Files
codex-market-app/electron/lib/memory.js
T
2026-07-15 18:01:56 +08:00

874 lines
35 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// memory.js —— 会话记忆:把 Codex 会话自动浓缩成「交接摘要」,边用边存。
// 分层记忆(Phase 0):
// 监视 ~/.codex/sessions 的 rollout-*.jsonl → 会话静默 idleSeconds 秒后自动摘要 →
// ① 存进 SQLite(userData/memory.db,历史可检索、只增不减 = 单一真源)
// ② 从库里【确定性聚合】出「项目核心记忆块」直接写进项目 AGENTS.md 标记段
// —— 关键:Codex 每次新会话必注入 AGENTS.md 全文,所以事实必须钉在这里,而不是放指针
// ③ HANDOFF.md 降级为「给人看的近期快照」;每会话摘要另 append 到 ~/.codex/memory/<hash>.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 = "<!-- codex-memory:start -->";
const AGENTS_END = "<!-- codex-memory: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/<hash>.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("<!-- 本文件由「Codex 插件市场 · 会话记忆」自动生成与覆盖,请勿手动编辑 -->");
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,
};