// memory.js —— 会话记忆:把 Codex 会话自动浓缩成「交接摘要」,边用边存。 // 分层记忆(Phase 0): // 监视 ~/.codex/sessions 的 rollout-*.jsonl → 会话静默 idleSeconds 秒后自动摘要 → // ① 存进 SQLite(userData/memory.db,历史可检索、只增不减 = 单一真源) // ② 从库里【确定性聚合】出「项目核心记忆块」直接写进项目 AGENTS.md 标记段 // —— 关键:Codex 每次新会话必注入 AGENTS.md 全文,所以事实必须钉在这里,而不是放指针 // ③ HANDOFF.md 降级为「给人看的近期快照」;每会话摘要另 append 到 ~/.codex/memory/.md 供 grep 检索 // 摘要优先走「路由中转」里可用分组的 LLM;没配分组或调用失败则退化为本地规则提取。 // 核心块的渲染是【确定性】的(每次从库重新投影,非在上一版上叠加)→ 天然无「传话游戏」漂移。 const { app } = require("electron"); const fs = require("fs"); const path = require("path"); const os = require("os"); const crypto = require("crypto"); const initSqlJs = require("sql.js"); const sessions = require("./sessions"); const router = require("./router"); const AGENTS_START = ""; const AGENTS_END = ""; // —— 运行时状态 —— let db = null; let SQLmod = null; let log = () => {}; let notify = () => {}; let watcher = null; let sweepTimer = null; let persistTimer = null; const dirty = new Map(); // rollout 文件 → 最后一次变化的时间戳 const seenRollouts = new Set(); // 已见过的 rollout 文件(判定「新会话开始」) const queue = []; let working = false; let cfg = null; // --------------------------------------------------------------------------- // 配置(userData/memory.json) // --------------------------------------------------------------------------- function cfgFile() { return path.join(app.getPath("userData"), "memory.json"); } function loadCfg() { if (cfg) return cfg; let raw = {}; try { raw = JSON.parse(fs.readFileSync(cfgFile(), "utf-8")); } catch {} cfg = { enabled: true, // 总开关 disabledProjects: [], // 关闭写入的项目 cwd 列表(记忆库照常记录,只是不写文件) idleSeconds: 20, // 会话静默多少秒后视为「一段落」并摘要(另有「开新会话即刻结算上一个」触发) backfillDays: 7, // 启动回填:只补最近 N 天的旧会话,防止首次启动海量调用 handoffSessions: 4, // HANDOFF.md 里保留最近几次会话 // —— 项目核心记忆块(写进 AGENTS.md,每轮注入)的预算与容量 —— coreMaxLines: 55, // 核心块最多行数 coreMaxChars: 1400, // 核心块最多字符 coreDecisions: 12, // 核心块保留最近多少条去重决策 coreTopics: 12, // 历史话题索引保留最近多少条 archiveEnabled: true, // 是否把每会话摘要 append 到 ~/.codex/memory/.md 供 grep 检索 semanticCompact: true, // 是否用 LLM 做语义去重/取代(仅在配了中转分组时生效) compactMinItems: 6, // 活跃条目达到多少条才值得跑一次语义整合 ...raw, }; // 兼容旧配置:老版本存的是 idleMinutes if (typeof raw.idleMinutes === "number" && typeof raw.idleSeconds !== "number") { cfg.idleSeconds = Math.max(10, Math.round(raw.idleMinutes * 60)); } return cfg; } function saveCfg() { fs.writeFileSync(cfgFile(), JSON.stringify(cfg, null, 2)); } // --------------------------------------------------------------------------- // SQLite(sql.js:内存库 + 落盘持久化) // --------------------------------------------------------------------------- function dbFile() { return path.join(app.getPath("userData"), "memory.db"); } async function ensureDb() { if (db) return db; if (!SQLmod) { const wasm = require.resolve("sql.js/dist/sql-wasm.wasm"); SQLmod = await initSqlJs({ wasmBinary: fs.readFileSync(wasm) }); } try { db = new SQLmod.Database(fs.readFileSync(dbFile())); } catch { db = new SQLmod.Database(); } db.run(`CREATE TABLE IF NOT EXISTS sessions ( id TEXT PRIMARY KEY, ts TEXT, project TEXT, cwd TEXT, topic TEXT, summary TEXT, decisions TEXT, next_steps TEXT, files TEXT, rollout_path TEXT, rollout_size INTEGER, engine TEXT, updated_at TEXT )`); // —— Phase 1:结构化核心记忆 —— db.run(`CREATE TABLE IF NOT EXISTS meta (k TEXT PRIMARY KEY, v TEXT)`); // 每个 scope(项目 cwd)一行滚动状态 db.run(`CREATE TABLE IF NOT EXISTS core ( scope TEXT PRIMARY KEY, state TEXT, next_steps TEXT, files TEXT, updated_at TEXT )`); // 决策/长期事实:一条一行。pinned=1 的长期事实(身份/偏好)永不淘汰 db.run(`CREATE TABLE IF NOT EXISTS core_decisions ( id INTEGER PRIMARY KEY AUTOINCREMENT, scope TEXT, text TEXT, norm TEXT, pinned INTEGER DEFAULT 0, status TEXT DEFAULT 'active', session_id TEXT, ts TEXT )`); return db; } function persistDb() { clearTimeout(persistTimer); persistTimer = setTimeout(flushDb, 500); } function flushDb() { clearTimeout(persistTimer); if (!db) return; try { fs.writeFileSync(dbFile(), Buffer.from(db.export())); } catch (e) { log("会话记忆:记忆库写盘失败 " + (e.message || e)); } } function all(sql, params = []) { const rows = []; const st = db.prepare(sql); st.bind(params); while (st.step()) rows.push(st.getAsObject()); st.free(); return rows; } function parseArr(s) { try { const a = JSON.parse(s); return Array.isArray(a) ? a.map(String).filter(Boolean) : []; } catch { return []; } } // --------------------------------------------------------------------------- // 摘要生成 // --------------------------------------------------------------------------- function clip(s, n) { s = String(s || "").replace(/\s+/g, " ").trim(); return s.length > n ? s.slice(0, n) + "…" : s; } // 把整段对话压缩成给 LLM 看的精简文本(控制 token:单条截断 + 总长掐头留尾) function compressConv(conv) { const parts = []; for (const m of conv.messages) { if (m.role === "user") parts.push("用户: " + clip(m.text, 600)); else if (m.role === "assistant") parts.push("助手: " + clip(m.text, 400)); } let text = parts.join("\n"); if (text.length > 16000) { text = text.slice(0, 6000) + "\n……(中间已省略)……\n" + text.slice(-10000); } return text; } const SUMMARY_PROMPT = (convText) => `你是"会话交接摘要器"。下面是用户与 AI 编程助手(Codex)在某个项目里的对话记录(已压缩,可能有省略)。 请只输出一个 JSON 对象(不要代码块、不要其它文字),字段: { "topic": "一句话主题,不超过30字", "summary": "摘要,不超过200字。只写结论与当前状态,不写过程;要让没看过对话的人能接着干活", "decisions": ["已确定的关键决策,每条不超过40字,没有就空数组"], "next_steps": ["明确的下一步待办,每条不超过40字,没有就空数组"], "files": ["对话中涉及的重要文件路径,最多8个"] } 对话记录: <<< ${convText} >>>`; // 调 LLM(复用路由中转的可用分组;chat / responses 两种协议都支持) async function llmSummarize(convText) { const g = router.llmGroup(); if (!g) throw new Error("未配置可用的中转分组"); const base = g.baseUrl.replace(/\/$/, ""); const prompt = SUMMARY_PROMPT(convText); const ctrl = new AbortController(); const timer = setTimeout(() => ctrl.abort(), 90000); try { let content = ""; const headers = { "content-type": "application/json", authorization: "Bearer " + g.key, "User-Agent": "codex-market", }; if (g.mode === "chat") { let ep; if (/\/chat\/completions$/.test(base)) ep = base; else if (/\/v\d+$/.test(base)) ep = base + "/chat/completions"; else ep = base + "/v1/chat/completions"; const res = await fetch(ep, { method: "POST", signal: ctrl.signal, headers, body: JSON.stringify({ model: g.model, messages: [{ role: "user", content: prompt }], stream: false }), }); if (!res.ok) throw new Error("HTTP " + res.status); const j = await res.json(); content = (j.choices && j.choices[0] && j.choices[0].message && j.choices[0].message.content) || ""; } else { const ep = /\/v\d+$/.test(base) ? base + "/responses" : base + "/v1/responses"; const res = await fetch(ep, { method: "POST", signal: ctrl.signal, headers, body: JSON.stringify({ model: g.model, input: prompt, stream: false }), }); if (!res.ok) throw new Error("HTTP " + res.status); const j = await res.json(); content = j.output_text || ""; if (!content && Array.isArray(j.output)) { for (const it of j.output) { if (it.type !== "message") continue; for (const c of it.content || []) if (c.type === "output_text") content += c.text || ""; } } } const m = String(content).match(/\{[\s\S]*\}/); if (!m) throw new Error("LLM 未返回 JSON"); const d = JSON.parse(m[0]); return { topic: clip(d.topic, 60), summary: String(d.summary || "").trim().slice(0, 600), decisions: (Array.isArray(d.decisions) ? d.decisions : []).map((x) => clip(x, 80)).filter(Boolean).slice(0, 8), next_steps: (Array.isArray(d.next_steps) ? d.next_steps : []).map((x) => clip(x, 80)).filter(Boolean).slice(0, 8), files: (Array.isArray(d.files) ? d.files : []).map(String).filter(Boolean).slice(0, 8), engine: "llm:" + (g.name || g.model || ""), }; } finally { clearTimeout(timer); } } // 本地兜底:不调外部服务的规则提取(质量粗糙但可用) function localSummarize(item, conv) { const users = conv.messages.filter((m) => m.role === "user"); const asst = conv.messages.filter((m) => m.role === "assistant"); const blob = conv.messages.map((m) => m.text).join("\n"); const files = Array.from( new Set(blob.match(/[\w./~-]+\.(?:js|ts|tsx|jsx|py|json|md|toml|yml|yaml|css|html|sh|go|rs|java|sql)\b/g) || []) ).slice(0, 8); const summary = [ "首条需求:" + clip((users[0] && users[0].text) || item.title, 200), asst.length ? "最后进展:" + clip(asst[asst.length - 1].text, 260) : "", ] .filter(Boolean) .join("\n"); return { topic: clip(item.title || (users[0] && users[0].text) || "(无标题)", 60), summary, decisions: [], next_steps: [], files, engine: "local", }; } // --------------------------------------------------------------------------- // 摘要一条会话 → 落库 → 写 HANDOFF / AGENTS // --------------------------------------------------------------------------- async function summarizeFile(file) { const items = sessions.listConversations({ includeSubagent: false }); const item = items.find((i) => i.path === file); if (!item || !item.id) return; // 子 agent 线程或读不到元信息 → 跳过 const conv = sessions.readConversation(file); if (!conv.messages.some((m) => m.role === "user")) return; // 空会话不摘要 let r; try { r = await llmSummarize(compressConv(conv)); } catch (e) { log(`会话记忆:LLM 摘要失败(${e.message || e}),改用本地提取`); r = localSummarize(item, conv); } await ensureDb(); db.run( `INSERT INTO sessions (id, ts, project, cwd, topic, summary, decisions, next_steps, files, rollout_path, rollout_size, engine, updated_at) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?) ON CONFLICT(id) DO UPDATE SET ts=excluded.ts, project=excluded.project, cwd=excluded.cwd, topic=excluded.topic, summary=excluded.summary, decisions=excluded.decisions, next_steps=excluded.next_steps, files=excluded.files, rollout_path=excluded.rollout_path, rollout_size=excluded.rollout_size, engine=excluded.engine, updated_at=excluded.updated_at`, [ item.id, item.ts || "", item.project || "", item.cwd || "", r.topic, r.summary, JSON.stringify(r.decisions), JSON.stringify(r.next_steps), JSON.stringify(r.files), file, item.size || 0, r.engine, new Date().toISOString(), ] ); // Phase 1:把本次摘要并入结构化 core(决策去重/淘汰、长期事实 pin、滚动状态) // Phase 2:有新决策进来时,跑一次 LLM 语义整合(近义合并 / 推翻作废,只输出操作,抗漂移) if (item.cwd) { try { const added = consolidateCore(item.cwd, item.ts, item.id, r.decisions, r.summary, r.next_steps, r.files); if (added > 0) await semanticCompact(item.cwd); } catch (e) { log("会话记忆:整合 core 失败 " + (e.message || e)); } } flushDb(); // 同步落盘:真源先持久,派生文件后写,避免 CLI/下次会话读到旧数据 if (projectWritable(item.cwd)) { try { writeProjectCore(item.cwd); // 核心块直接进 AGENTS.md(Codex 必读) writeHandoff(item.cwd); // 详细快照(L2) appendArchive(item.cwd, item, r); // grep 归档(L3,只增不删) } catch (e) { log("会话记忆:写核心/HANDOFF/归档失败 " + (e.message || e)); } } log(`会话记忆:已更新「${r.topic}」(${item.project || item.cwd || "未知项目"})`); notify(); } // 这个项目能不能写文件(总开关 + 项目开关 + 目录合法性) function projectWritable(cwd) { loadCfg(); if (!cfg.enabled) return false; if (!cwd || cwd === os.homedir() || cwd === "/") return false; // 家目录/根目录不写,避免制造垃圾 if (cfg.disabledProjects.includes(cwd)) return false; try { return fs.statSync(cwd).isDirectory(); } catch { return false; } } function handoffPath(cwd) { return path.join(cwd, "HANDOFF.md"); } // 原子写:先写 .tmp 再 rename,避免 Codex 启动时读到写了一半的半截文件 function writeFileAtomic(file, text) { const tmp = file + ".tmp"; fs.writeFileSync(tmp, text, "utf-8"); fs.renameSync(tmp, file); } // 项目 → grep 归档文件(放 ~/.codex/memory,不污染用户项目 git 树) function hashCwd(cwd) { return crypto.createHash("sha1").update(String(cwd)).digest("hex").slice(0, 12); } function archivePath(cwd) { const d = path.join(os.homedir(), ".codex", "memory"); fs.mkdirSync(d, { recursive: true }); return path.join(d, hashCwd(cwd) + ".md"); } // 每次会话摘要 append 到归档(只增不删,供模型 rg 检索长尾细节) function appendArchive(cwd, item, r) { if (!loadCfg().archiveEnabled) return; const when = (item.ts || "").slice(0, 16).replace("T", " "); const L = [`\n## ${when} · ${r.topic} [${item.id || ""}]`]; if (r.summary) L.push(r.summary); if ((r.decisions || []).length) L.push("决策:" + r.decisions.join(";")); if ((r.files || []).length) L.push("文件:" + r.files.join("、")); try { fs.appendFileSync(archivePath(cwd), L.join("\n") + "\n", "utf-8"); } catch {} } // 写 HANDOFF.md(L2):详细近期快照,给人看 / 被引导时读;核心已在 AGENTS.md,关键信息不依赖本文件 function writeHandoff(cwd) { const rows = all(`SELECT * FROM sessions WHERE cwd = ? ORDER BY ts DESC LIMIT ?`, [cwd, loadCfg().handoffSessions]); if (!rows.length) return; const L = []; L.push(""); L.push("# HANDOFF —— 会话交接快照(补充细节)"); L.push(""); L.push("> 核心记忆已写进本项目 `AGENTS.md`(Codex 每次会话自动读)。本文件是更详细的近期快照,供需要时参考;关键信息不依赖本文件。"); L.push(""); rows.forEach((r, i) => { const dec = parseArr(r.decisions); const next = parseArr(r.next_steps); const files = parseArr(r.files); const when = (r.ts || "").slice(0, 16).replace("T", " "); L.push(`## ${i === 0 ? "最近会话" : "更早会话"} · ${when} · ${r.topic}`); L.push(""); if (r.summary) L.push(r.summary, ""); if (dec.length) { L.push("**已确定的决策**:"); dec.forEach((d) => L.push(`- ${d}`)); L.push(""); } if (next.length) { L.push("**下一步**:"); next.forEach((d) => L.push(`- ${d}`)); L.push(""); } if (files.length) L.push(`**涉及文件**:${files.join("、")}`, ""); }); writeFileAtomic(handoffPath(cwd), L.join("\n")); } // 预算裁剪:首尾标记 + 检索指令行始终保留;超预算时优先砍末尾(历史话题),再砍靠后的内容 function enforceBudget(lines, maxLines, maxChars) { const start = lines[0]; const end = lines[lines.length - 1]; const retrieval = lines.find((l) => l.startsWith("> 回答涉及")) || ""; let body = lines.slice(1, lines.length - 1).filter((l) => l !== retrieval); const assemble = () => [start, ...body, retrieval, end].filter(Boolean).join("\n"); while (body.length && [start, ...body, retrieval, end].filter(Boolean).length > maxLines) body.pop(); while (body.length && assemble().length > maxChars) body.pop(); return assemble(); } // —— Phase 1:结构化核心记忆的增量整合(同步、确定性 → 无漂移、无 RMW 竞态窗口)—— function normText(s) { return String(s || "").toLowerCase().replace(/\s+/g, "").replace(/[。,、;:,.;:!??!「」『』()()]/g, ""); } // 命中即视为「长期事实」(身份 / 称呼 / 偏好 / 背景)→ pinned,永不因预算淘汰 const PINNED_RE = /(称呼|叫我|叫你|用户(是|叫|名|有|为|的身份)|我(是|叫)|偏好|以后(都|请|叫|按)|习惯|喜欢|沟通(风格|方式)?|风格|背景|经验|职业|身份|资深|专职)/; function classifyPinned(text) { return PINNED_RE.test(String(text || "")) ? 1 : 0; } // 把一次会话摘要并入结构化 core:决策去重新增 + 超预算淘汰(归档不删) + 更新滚动状态 function consolidateCore(scope, ts, sessionId, decisions, summary, nextSteps, files) { loadCfg(); const existing = all("SELECT norm FROM core_decisions WHERE scope = ? AND status='active'", [scope]).map((r) => r.norm); const norms = new Set(existing); let added = 0; for (const d of decisions || []) { const t = String(d || "").trim(); if (!t) continue; const n = normText(t); if (!n || norms.has(n)) continue; norms.add(n); added++; db.run("INSERT INTO core_decisions (scope,text,norm,pinned,status,session_id,ts) VALUES (?,?,?,?,'active',?,?)", [ scope, t, n, classifyPinned(t), sessionId || "", ts || "", ]); } // 淘汰:非 pinned 的 active 决策超预算 → 归档(最旧先归档;pinned 永不淘汰、仍在库可检索) const acts = all("SELECT id FROM core_decisions WHERE scope = ? AND status='active' AND pinned=0 ORDER BY ts DESC, id DESC", [scope]); if (acts.length > cfg.coreDecisions) { for (const d of acts.slice(cfg.coreDecisions)) db.run("UPDATE core_decisions SET status='archived' WHERE id = ?", [d.id]); } // 滚动状态(当前状态 / 下一步 / 关键文件)来自最近一次摘要 db.run( `INSERT INTO core (scope,state,next_steps,files,updated_at) VALUES (?,?,?,?,?) ON CONFLICT(scope) DO UPDATE SET state=excluded.state, next_steps=excluded.next_steps, files=excluded.files, updated_at=excluded.updated_at`, [scope, clip(summary, 300), JSON.stringify((nextSteps || []).slice(0, 6)), JSON.stringify((files || []).slice(0, 8)), new Date().toISOString()] ); return added; } // —— Phase 2:LLM 语义整合(只让 LLM 输出「操作」:哪些 id 是重复/被取代,不重写记忆文本 → 抗漂移)—— // 复用中转分组做一次 LLM 调用,返回原始文本 async function llmRaw(prompt) { const g = router.llmGroup(); if (!g) throw new Error("未配置可用的中转分组"); const base = g.baseUrl.replace(/\/$/, ""); const ctrl = new AbortController(); const timer = setTimeout(() => ctrl.abort(), 90000); const headers = { "content-type": "application/json", authorization: "Bearer " + g.key, "User-Agent": "codex-market" }; try { if (g.mode === "chat") { let ep; if (/\/chat\/completions$/.test(base)) ep = base; else if (/\/v\d+$/.test(base)) ep = base + "/chat/completions"; else ep = base + "/v1/chat/completions"; const res = await fetch(ep, { method: "POST", signal: ctrl.signal, headers, body: JSON.stringify({ model: g.model, messages: [{ role: "user", content: prompt }], stream: false }) }); if (!res.ok) throw new Error("HTTP " + res.status); const j = await res.json(); return (j.choices && j.choices[0] && j.choices[0].message && j.choices[0].message.content) || ""; } const ep = /\/v\d+$/.test(base) ? base + "/responses" : base + "/v1/responses"; const res = await fetch(ep, { method: "POST", signal: ctrl.signal, headers, body: JSON.stringify({ model: g.model, input: prompt, stream: false }) }); if (!res.ok) throw new Error("HTTP " + res.status); const j = await res.json(); let content = j.output_text || ""; if (!content && Array.isArray(j.output)) { for (const it of j.output) { if (it.type !== "message") continue; for (const c of it.content || []) if (c.type === "output_text") content += c.text || ""; } } return content; } finally { clearTimeout(timer); } } const COMPACT_PROMPT = (list) => `你是"记忆去重/整合器"。下面是某项目已记录的若干事实/决策条目(每条带数字 id)。请只输出一个 JSON 对象(不要代码块、不要其它文字): { "duplicates": [ { "keep": <保留的id>, "remove": [<被合并的同义id>...], "text": "<该组最简洁准确的规范表述>" } ], "superseded": [ { "old": <被后来推翻/过时的id>, "by": <取代它的新id> } ] } 规则: - 只合并【真正同义】的(如"称呼用户为小楼"与"用户身份称呼为小楼");拿不准就不要合并。 - superseded 只在明确"后者推翻前者"时给(如先"用 Redis"后"改用内存缓存");不确定不给。 - keep 选表述最清晰的那条;text 用该组最准确简洁的说法。 - 没有可合并/取代的,返回 {"duplicates":[],"superseded":[]}。 条目: <<< ${list} >>>`; // 对某项目的活跃条目做语义整合:LLM 只判断关系,代码按 id 执行合并/作废(归档不删) async function semanticCompact(scope) { loadCfg(); if (!cfg.semanticCompact || !router.llmGroup()) return; const rows = all("SELECT id, text FROM core_decisions WHERE scope = ? AND status='active' ORDER BY id ASC", [scope]); if (rows.length < cfg.compactMinItems) return; const idSet = new Set(rows.map((r) => r.id)); const list = rows.map((r) => `[${r.id}] ${r.text}`).join("\n"); let ops; try { const raw = await llmRaw(COMPACT_PROMPT(list)); const m = String(raw).match(/\{[\s\S]*\}/); if (!m) return; ops = JSON.parse(m[0]); } catch (e) { log("会话记忆:语义整合跳过(" + (e.message || e) + ")"); return; // 任何失败 → 保持现状,不动数据 } applyCompactOps(scope, ops, idSet); } // 执行 LLM 给的操作,严格校验 id 属于本 scope 的活跃条目,非法一律忽略 function applyCompactOps(scope, ops, idSet) { let changed = 0; const valid = (id) => Number.isInteger(id) && idSet.has(id); for (const g of (ops && ops.duplicates) || []) { if (!valid(g.keep)) continue; const removes = (g.remove || []).filter((id) => valid(id) && id !== g.keep); if (!removes.length) continue; if (g.text && String(g.text).trim()) { const t = String(g.text).trim(); db.run("UPDATE core_decisions SET text = ?, norm = ? WHERE id = ? AND scope = ?", [t, normText(t), g.keep, scope]); } for (const id of removes) { db.run("UPDATE core_decisions SET status='merged' WHERE id = ? AND scope = ?", [id, scope]); idSet.delete(id); changed++; } } for (const s of (ops && ops.superseded) || []) { if (!valid(s.old) || !valid(s.by) || s.old === s.by) continue; db.run("UPDATE core_decisions SET status='superseded', superseded_by = ? WHERE id = ? AND scope = ?", [s.by, s.old, scope]); idSet.delete(s.old); changed++; } if (changed) { flushDb(); log(`会话记忆:语义整合合并/作废 ${changed} 条`); } } // 一次性迁移:把已有 sessions 按时间正序 replay 进结构化 core(幂等,靠 meta 标记只跑一次) function migrateCoreFromSessions() { let done = false; try { done = all("SELECT v FROM meta WHERE k='core_migrated'").length > 0; } catch {} if (done) return; const rows = all("SELECT * FROM sessions WHERE cwd != '' ORDER BY ts ASC"); for (const r of rows) { consolidateCore(r.cwd, r.ts, r.id, parseArr(r.decisions), r.summary, parseArr(r.next_steps), parseArr(r.files)); } db.run("INSERT OR REPLACE INTO meta (k,v) VALUES ('core_migrated','1')"); flushDb(); } // 【核心】从结构化 core 表渲染「项目核心记忆块」。pinned 长期事实置顶且永不被预算裁掉。 function renderProjectCore(cwd) { loadCfg(); const c = all("SELECT * FROM core WHERE scope = ?", [cwd])[0]; const pinned = all("SELECT text FROM core_decisions WHERE scope = ? AND pinned=1 AND status='active' ORDER BY id ASC", [cwd]).map((r) => r.text); const decs = all("SELECT text FROM core_decisions WHERE scope = ? AND pinned=0 AND status='active' ORDER BY ts DESC, id DESC LIMIT ?", [cwd, cfg.coreDecisions]).map((r) => r.text); const topicRows = all("SELECT ts, topic FROM sessions WHERE cwd = ? ORDER BY ts DESC LIMIT ?", [cwd, cfg.coreTopics]); if (!c && !pinned.length && !decs.length && !topicRows.length) return null; const nextSteps = c ? parseArr(c.next_steps).slice(0, 6) : []; const files = c ? parseArr(c.files) : []; const L = []; L.push(AGENTS_START); L.push("## 项目记忆(Codex 插件市场自动维护 · 回答前据此 · 勿手动编辑本段)"); if (pinned.length) { L.push("**关于用户 / 长期事实(务必遵守)**:"); pinned.forEach((d) => L.push(`- ${d}`)); } if (c && c.state) L.push(`**当前状态**:${clip(c.state, 300)}`); if (decs.length) { L.push("**已定决策**:"); decs.forEach((d) => L.push(`- ${d}`)); } if (nextSteps.length) { L.push("**下一步**:"); nextSteps.forEach((d) => L.push(`- ${d}`)); } if (files.length) L.push(`**关键文件**:${files.join("、")}`); if (topicRows.length) { L.push("**历史话题(需其中细节时用下面命令检索)**:"); topicRows.forEach((r) => L.push(`- ${(r.ts || "").slice(0, 10)} ${r.topic}`)); } L.push(`> 回答涉及本段未列出的历史细节前,先运行 \`rg -n "关键词" ${archivePath(cwd)}\` 查证再答。`); L.push(AGENTS_END); return enforceBudget(L, cfg.coreMaxLines, cfg.coreMaxChars); } // 把核心记忆块写进项目 AGENTS.md:写前重读,只替换标记段,块外内容原样保留;标记残缺则放弃不硬盖 function writeProjectCore(cwd) { const core = renderProjectCore(cwd); if (!core) return; const f = path.join(cwd, "AGENTS.md"); let text = ""; try { text = fs.readFileSync(f, "utf-8"); } catch {} const si = text.indexOf(AGENTS_START); const ei = text.indexOf(AGENTS_END); let nt; if (si >= 0 && ei > si) { nt = text.slice(0, si) + core + text.slice(ei + AGENTS_END.length); } else if (si >= 0 || ei >= 0) { log("会话记忆:AGENTS.md 记忆标记不完整,跳过本次更新以免破坏文件"); return; } else { nt = (text ? text.trimEnd() + "\n\n" : "") + core + "\n"; } if (nt !== text) writeFileAtomic(f, nt); } // 新版首启:先把已有 sessions 迁移进结构化 core,再把所有已知项目 AGENTS.md 升级为核心块格式 function refreshAllCores() { try { migrateCoreFromSessions(); } catch (e) { log("会话记忆:core 迁移失败 " + (e.message || e)); } let cwds = []; try { cwds = all("SELECT DISTINCT cwd FROM sessions WHERE cwd != ''").map((r) => r.cwd); } catch { return; } for (const cwd of cwds) { if (!projectWritable(cwd)) continue; try { writeProjectCore(cwd); writeHandoff(cwd); } catch {} } } // --------------------------------------------------------------------------- // 监视 + 队列(串行处理,避免并发打爆中转 API) // --------------------------------------------------------------------------- function enqueue(file) { if (!queue.includes(file)) queue.push(file); pump(); } async function pump() { if (working) return; working = true; try { while (queue.length) { if (!loadCfg().enabled) { queue.length = 0; break; } const file = queue.shift(); try { await summarizeFile(file); } catch (e) { log("会话记忆:摘要失败 " + (e.message || e)); } } } finally { working = false; } } // 定时清点:静默超过 idleSeconds 的脏文件 → 入队摘要 function sweep() { const idleMs = loadCfg().idleSeconds * 1000; const now = Date.now(); for (const [file, t] of dirty) { if (now - t >= idleMs) { dirty.delete(file); enqueue(file); } } } // 立刻结算「除 exceptFile 外」所有待处理会话(用于「开新会话即刻把上一个存好」) function flushPending(exceptFile) { for (const [file] of dirty) { if (file === exceptFile) continue; dirty.delete(file); enqueue(file); } } // 启动回填:把上次运行后新增/长大的会话补摘要(限最近 N 天、单次最多 20 个) // 一次性:对所有已知项目跑语义整合,清理迁移进来的近义冗余(meta 标记,仅首次 Phase 2 启动跑) async function compactAllOnce() { loadCfg(); if (!cfg.semanticCompact || !router.llmGroup()) return; let done = false; try { done = all("SELECT v FROM meta WHERE k='compact_v1'").length > 0; } catch {} if (done) return; const cwds = all("SELECT DISTINCT cwd FROM sessions WHERE cwd != ''").map((r) => r.cwd); for (const cwd of cwds) { try { await semanticCompact(cwd); if (projectWritable(cwd)) writeProjectCore(cwd); } catch {} } db.run("INSERT OR REPLACE INTO meta (k,v) VALUES ('compact_v1','1')"); flushDb(); } async function backfill() { await ensureDb(); loadCfg(); if (!cfg.enabled) return; refreshAllCores(); // 首启即把已有项目 AGENTS.md 升级为核心块格式 compactAllOnce().catch(() => {}); // 后台一次性语义整合已有近义项 const cutoff = Date.now() - cfg.backfillDays * 86400000; const known = new Map(all("SELECT rollout_path, rollout_size FROM sessions").map((r) => [r.rollout_path, r.rollout_size])); const items = sessions.listConversations({ includeSubagent: false }); let queued = 0; let skippedOld = 0; for (const it of items) { const t = Date.parse(it.ts || ""); if (!t || t < cutoff) { if (!known.has(it.path)) skippedOld++; continue; } if (known.get(it.path) === it.size) continue; // 没变化 enqueue(it.path); if (++queued >= 20) break; } if (queued) log(`会话记忆:发现 ${queued} 个新增/有变化的会话,开始后台摘要…`); if (skippedOld) log(`会话记忆:${skippedOld} 个 ${cfg.backfillDays} 天前的旧会话未自动回填(可在「会话记忆」页对单条手动摘要)`); } function start(opts = {}) { if (opts.log) log = opts.log; if (opts.notify) notify = opts.notify; loadCfg(); // 先把已存在的会话标记为「已见」,这样只有启动后【新开】的会话才触发「即刻结算上一个」 try { for (const it of sessions.listConversations({ includeSubagent: true })) seenRollouts.add(it.path); } catch {} // 监视器常开(总开关只控制是否处理),这样开关切换无需重启 try { fs.mkdirSync(sessions.SESSIONS_DIR, { recursive: true }); watcher = fs.watch(sessions.SESSIONS_DIR, { recursive: true }, (_ev, name) => { if (!name || !/rollout-.*\.jsonl$/.test(String(name))) return; const full = path.join(sessions.SESSIONS_DIR, String(name)); // 「开新会话即刻结算上一个」:首次见到某 rollout 文件 = 新会话开始 → 立刻把其它待处理的(多半是刚关掉的那个)先存好 if (!seenRollouts.has(full)) { seenRollouts.add(full); flushPending(full); } dirty.set(full, Date.now()); }); } catch (e) { log("会话记忆:无法监视 sessions 目录 " + (e.message || e)); } sweepTimer = setInterval(sweep, 5000); backfill().catch((e) => log("会话记忆:启动扫描失败 " + (e.message || e))); } function stop() { try { if (watcher) watcher.close(); } catch {} watcher = null; clearInterval(sweepTimer); flushDb(); // 退出前把内存库落盘 } // --------------------------------------------------------------------------- // 给 UI 的接口 // --------------------------------------------------------------------------- async function statusInfo() { await ensureDb(); loadCfg(); const g = router.llmGroup(); const projects = all( `SELECT cwd, project, COUNT(*) AS n, MAX(ts) AS last FROM sessions WHERE cwd != '' GROUP BY cwd ORDER BY last DESC` ).map((p) => ({ cwd: p.cwd, project: p.project || path.basename(p.cwd || ""), count: p.n, last: p.last, enabled: !cfg.disabledProjects.includes(p.cwd), hasHandoff: fs.existsSync(handoffPath(p.cwd)), })); return { enabled: cfg.enabled, idleSeconds: cfg.idleSeconds, engine: g ? `LLM 摘要(分组「${g.name}」· ${g.model})` : "本地提取(未配置可用中转分组,质量有限)", total: (all("SELECT COUNT(*) AS n FROM sessions")[0] || { n: 0 }).n, pending: queue.length + dirty.size, projects, }; } async function list({ project = "", q = "", limit = 200 } = {}) { await ensureDb(); let sql = "SELECT * FROM sessions"; const cond = []; const params = []; if (project) { cond.push("cwd = ?"); params.push(project); } if (q) { cond.push("(topic LIKE ? OR summary LIKE ? OR decisions LIKE ? OR project LIKE ?)"); const s = `%${q}%`; params.push(s, s, s, s); } if (cond.length) sql += " WHERE " + cond.join(" AND "); sql += " ORDER BY ts DESC LIMIT ?"; params.push(limit); return all(sql, params).map((r) => ({ ...r, decisions: parseArr(r.decisions), next_steps: parseArr(r.next_steps), files: parseArr(r.files), })); } function setEnabled(on) { loadCfg(); cfg.enabled = !!on; saveCfg(); if (cfg.enabled) backfill().catch(() => {}); } function toggleProject(cwd, on) { loadCfg(); const set = new Set(cfg.disabledProjects); if (on) set.delete(cwd); else set.add(cwd); cfg.disabledProjects = Array.from(set); saveCfg(); if (on && projectWritable(cwd)) { try { writeProjectCore(cwd); writeHandoff(cwd); } catch {} } } async function resummarize(file) { await summarizeFile(file); } async function deleteRow(id) { await ensureDb(); db.run("DELETE FROM sessions WHERE id = ?", [id]); persistDb(); notify(); } module.exports = { start, stop, statusInfo, list, setEnabled, toggleProject, resummarize, deleteRow, handoffPath, renderProjectCore, writeProjectCore, semanticCompact, applyCompactOps, };