Files
codex-market-app/electron/lib/memory.js
T

1065 lines
41 KiB
JavaScript
Raw Normal View History

2026-07-15 18:01:56 +08:00
// 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;
const deletedSessionIds = new Set();
2026-07-15 18:01:56 +08:00
// ---------------------------------------------------------------------------
// 配置(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 flushDbStrict() {
clearTimeout(persistTimer);
if (!db) return;
const file = dbFile();
const tmp = file + ".td-delete-" + process.pid + ".tmp";
const old = file + ".td-delete-" + process.pid + ".old";
fs.rmSync(old, { force: true });
const fd = fs.openSync(tmp, "w");
try {
fs.writeFileSync(fd, Buffer.from(db.export()));
fs.fsyncSync(fd);
} finally {
fs.closeSync(fd);
}
try {
if (fs.existsSync(file)) fs.renameSync(file, old);
try {
fs.renameSync(tmp, file);
} catch (e) {
if (fs.existsSync(old)) fs.renameSync(old, file);
throw e;
}
try { fs.rmSync(old, { force: true }); } catch {}
} finally {
fs.rmSync(tmp, { force: true });
}
}
2026-07-15 18:01:56 +08:00
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 线程或读不到元信息 → 跳过
if (deletedSessionIds.has(item.id)) return;
2026-07-15 18:01:56 +08:00
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();
if (deletedSessionIds.has(item.id)) return;
2026-07-15 18:01:56 +08:00
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);
}
function removeProjectCore(cwd) {
const f = path.join(cwd, "AGENTS.md");
let text = "";
try { text = fs.readFileSync(f, "utf-8"); } catch { return; }
const si = text.indexOf(AGENTS_START);
const ei = text.indexOf(AGENTS_END);
if (si < 0 || ei <= si) return;
const next = text.slice(0, si) + text.slice(ei + AGENTS_END.length);
writeFileAtomic(f, next);
}
function removeArchiveSession(cwd, id) {
if (!cwd || !id) return;
const f = archivePath(cwd);
let text = "";
try { text = fs.readFileSync(f, "utf-8"); } catch { return; }
const lines = text.split("\n");
const kept = [];
let dropping = false;
for (const line of lines) {
const header = line.match(/^## .* \[([0-9a-f-]{36})\]\s*$/i);
if (header) dropping = header[1] === id;
if (!dropping) kept.push(line);
}
const next = kept.join("\n");
if (next.trim()) writeFileAtomic(f, next);
else fs.rmSync(f, { force: true });
}
function refreshScopeAfterDelete(cwd, id) {
db.run("DELETE FROM core_decisions WHERE scope = ? AND session_id = ?", [cwd, id]);
const latest = all("SELECT * FROM sessions WHERE cwd = ? ORDER BY ts DESC LIMIT 1", [cwd])[0];
if (!latest) {
db.run("DELETE FROM core WHERE scope = ?", [cwd]);
return 0;
}
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`,
[cwd, clip(latest.summary, 300), JSON.stringify(parseArr(latest.next_steps).slice(0, 6)), JSON.stringify(parseArr(latest.files).slice(0, 8)), new Date().toISOString()]
);
return 1;
}
function removeGeneratedHandoff(cwd) {
const file = handoffPath(cwd);
let text = "";
try { text = fs.readFileSync(file, "utf-8"); } catch { return; }
if (text.startsWith("<!-- 本文件由「Codex 插件市场 · 会话记忆」自动生成与覆盖,请勿手动编辑 -->")) {
fs.rmSync(file, { force: true });
}
}
function canWriteGeneratedHandoff(cwd) {
let text = "";
try { text = fs.readFileSync(handoffPath(cwd), "utf-8"); } catch { return true; }
return text.startsWith("<!-- 本文件由「Codex 插件市场 · 会话记忆」自动生成与覆盖,请勿手动编辑 -->");
}
2026-07-15 18:01:56 +08:00
// 新版首启:先把已有 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();
const markSeen = () => {
// 标记已存在会话需要递归扫描 ~/.codex/sessions,启动时可延后,避免阻塞主进程响应点击。
try {
for (const it of sessions.listConversations({ includeSubagent: true })) seenRollouts.add(it.path);
} catch {}
};
2026-07-15 18:01:56 +08:00
// 监视器常开(总开关只控制是否处理),这样开关切换无需重启
try {
const sessionsDir = sessions.getSessionsDir();
fs.mkdirSync(sessionsDir, { recursive: true });
watcher = fs.watch(sessionsDir, { recursive: true }, (_ev, name) => {
2026-07-15 18:01:56 +08:00
if (!name || !/rollout-.*\.jsonl$/.test(String(name))) return;
const full = path.join(sessionsDir, String(name));
2026-07-15 18:01:56 +08:00
// 「开新会话即刻结算上一个」:首次见到某 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);
if (opts.startupScan === false) return;
const startupScan = () => {
if (!loadCfg().enabled) return;
markSeen();
backfill().catch((e) => log("会话记忆:启动扫描失败 " + (e.message || e)));
};
const delay = Number(opts.startupDelayMs) || 0;
if (delay > 0) setTimeout(startupScan, delay);
else startupScan();
2026-07-15 18:01:56 +08:00
}
function stop() {
try {
if (watcher) watcher.close();
} catch {}
watcher = null;
clearInterval(sweepTimer);
flushDb(); // 退出前把内存库落盘
}
function restartWatching() {
stop();
dirty.clear();
seenRollouts.clear();
queue.length = 0;
start();
}
2026-07-15 18:01:56 +08:00
// ---------------------------------------------------------------------------
// 给 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();
}
async function clearAllSessions() {
await ensureDb();
const rows = all("SELECT id, cwd, rollout_path FROM sessions");
const scopes = Array.from(new Set(rows.map((row) => row.cwd).filter(Boolean)));
for (const row of rows) {
deletedSessionIds.add(String(row.id || ""));
if (row.rollout_path) {
dirty.delete(row.rollout_path);
seenRollouts.delete(row.rollout_path);
}
}
queue.length = 0;
const snapshot = Buffer.from(db.export());
try {
db.run("BEGIN");
db.run("DELETE FROM sessions");
// 保留 pinned=1 的长期身份/偏好事实;清掉由会话产生的普通决策与项目摘要。
db.run("DELETE FROM core_decisions WHERE COALESCE(pinned,0)<>1");
db.run("DELETE FROM core");
db.run("COMMIT");
flushDbStrict();
} catch (e) {
try { db.run("ROLLBACK"); } catch {}
try { db.close(); } catch {}
db = new SQLmod.Database(snapshot);
throw e;
}
for (const scope of scopes) {
try { fs.rmSync(archivePath(scope), { force: true }); } catch {}
try { removeProjectCore(scope); } catch (e) { log("清空会话记忆块失败:" + (e.message || e)); }
try { removeGeneratedHandoff(scope); } catch (e) { log("清空会话交接文件失败:" + (e.message || e)); }
}
notify();
return { count: rows.length, scopes: scopes.length };
}
async function deleteSession(id, cwd) {
deletedSessionIds.add(String(id || ""));
await ensureDb();
const rows = all("SELECT cwd, rollout_path FROM sessions WHERE id = ?", [id]);
for (const row of rows) {
if (row.rollout_path) {
dirty.delete(row.rollout_path);
seenRollouts.delete(row.rollout_path);
for (let i = queue.length - 1; i >= 0; i--) if (queue[i] === row.rollout_path) queue.splice(i, 1);
}
}
const storedScopes = Array.from(new Set(rows.map((row) => row.cwd).filter(Boolean)));
const scopes = storedScopes.length ? storedScopes : (cwd ? [cwd] : []);
const snapshot = Buffer.from(db.export());
try {
db.run("BEGIN");
db.run("DELETE FROM sessions WHERE id = ?", [id]);
for (const scope of scopes) refreshScopeAfterDelete(scope, id);
db.run("COMMIT");
flushDbStrict();
} catch (e) {
try { db.run("ROLLBACK"); } catch {}
try { db.close(); } catch {}
db = new SQLmod.Database(snapshot);
throw e;
}
for (const scope of scopes) removeArchiveSession(scope, id);
for (const scope of scopes) {
if (!scope) continue;
try { if (!fs.statSync(scope).isDirectory()) continue; } catch { continue; }
try {
if (all("SELECT 1 FROM sessions WHERE cwd = ? LIMIT 1", [scope]).length) {
writeProjectCore(scope);
if (canWriteGeneratedHandoff(scope)) writeHandoff(scope);
} else {
removeProjectCore(scope);
removeGeneratedHandoff(scope);
}
} catch (e) { log("会话记忆派生文件清理失败:" + (e.message || e)); }
}
notify();
return { id, scopes };
}
2026-07-15 18:01:56 +08:00
module.exports = {
start,
stop,
restartWatching,
2026-07-15 18:01:56 +08:00
statusInfo,
list,
setEnabled,
toggleProject,
resummarize,
deleteRow,
deleteSession,
clearAllSessions,
2026-07-15 18:01:56 +08:00
handoffPath,
renderProjectCore,
writeProjectCore,
semanticCompact,
applyCompactOps,
};