1028 lines
40 KiB
JavaScript
1028 lines
40 KiB
JavaScript
// 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();
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// 配置(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 });
|
||
}
|
||
}
|
||
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;
|
||
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;
|
||
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 插件市场 · 会话记忆」自动生成与覆盖,请勿手动编辑 -->");
|
||
}
|
||
|
||
// 新版首启:先把已有 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 {}
|
||
};
|
||
// 监视器常开(总开关只控制是否处理),这样开关切换无需重启
|
||
try {
|
||
const sessionsDir = sessions.getSessionsDir();
|
||
fs.mkdirSync(sessionsDir, { recursive: true });
|
||
watcher = fs.watch(sessionsDir, { recursive: true }, (_ev, name) => {
|
||
if (!name || !/rollout-.*\.jsonl$/.test(String(name))) return;
|
||
const full = path.join(sessionsDir, 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);
|
||
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();
|
||
}
|
||
|
||
function stop() {
|
||
try {
|
||
if (watcher) watcher.close();
|
||
} catch {}
|
||
watcher = null;
|
||
clearInterval(sweepTimer);
|
||
flushDb(); // 退出前把内存库落盘
|
||
}
|
||
|
||
function restartWatching() {
|
||
stop();
|
||
dirty.clear();
|
||
seenRollouts.clear();
|
||
queue.length = 0;
|
||
start();
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// 给 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 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 };
|
||
}
|
||
|
||
module.exports = {
|
||
start,
|
||
stop,
|
||
restartWatching,
|
||
statusInfo,
|
||
list,
|
||
setEnabled,
|
||
toggleProject,
|
||
resummarize,
|
||
deleteRow,
|
||
deleteSession,
|
||
handoffPath,
|
||
renderProjectCore,
|
||
writeProjectCore,
|
||
semanticCompact,
|
||
applyCompactOps,
|
||
};
|