/** * session-vcs:会话落盘存档,按 agent 归档(移植自旧内容仓库 extensions/session-vcs.ts)。 * * - session_start:启动 15 分钟定时归档,并迁移旧平铺档——把 /sessions/ 直属的 * *.jsonl 移入 sessions/_legacy/(renameSync,幂等;目录不存在跳过,单个失败容错继续) * - /dpi-save [name]:立即保存当前会话,可创建命名保存点 * - /dpi-record on|off|status:存档开关,写入 dpi 配置 * * 会话自愈(坏消息清理): * 网关 400/429 失败或用户中断(abort)时,pi 会把 content: [] 的空 assistant * 消息写入会话文件;此后每次请求都带上它,Anthropic 协议拒绝空消息 → 之后 * 每一轮都 400,会话"死亡"。这里在两个时机自动清理: * - 定时归档或 /dpi-save:归档前清理当前会话文件,归档进仓库的也是干净版 * - session_start(new/resume/fork):清理被替换下去的 previousSessionFile * 另有 /session-repair 手动修复当前会话(修的是磁盘文件,重进会话生效)。 * * 未绑定内容仓库时静默跳过。agent 名 /^[\w-]+$/ 白名单校验防路径穿越,非法 * 回退 _unknown;全部容错,绝不抛异常阻断 pi。 */ import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent"; import { hostname } from "node:os"; import { existsSync, mkdirSync, readFileSync, readdirSync, renameSync, statSync, unlinkSync, writeFileSync, } from "node:fs"; import { basename, join } from "node:path"; import { gitAuthOpts, loadConfig, saveConfig } from "../src/config.ts"; import { gitHashObject, gitIn, gitUpdateIndexCacheInfo } from "../src/git.ts"; import { extractFirstUser, extractLatestTimestamp } from "../src/sessions-shared.ts"; import { chooseArchivePath } from "../src/session-archive-path.ts"; import { readSaveState, writeSaveState } from "../src/save-state.ts"; import { setSessionMetaInIndex, setSessionNameInIndex } from "../src/session-index.ts"; import { errMsg } from "../src/common.ts"; import { registerDpiCommand } from "../src/command-alias.ts"; /** * 清理会话文件中的空 assistant 坏消息(content: []),有改动才写回。 * 返回删除条数;文件缺失/损坏/无坏消息返回 0,绝不抛异常。 * 判定不看 stopReason:正常 assistant 消息不会有空 content,空即坏消息。 * 导出供单元测试。 */ export function repairSessionFile(file: string): number { try { if (!file || !existsSync(file)) return 0; const lines = readFileSync(file, "utf-8").split("\n"); const kept: string[] = []; let removed = 0; for (const ln of lines) { if (ln.trim() === "") { kept.push(ln); continue; } let entry: { type?: string; message?: { role?: string; content?: unknown } }; try { entry = JSON.parse(ln) as typeof entry; } catch { kept.push(ln); // 无法解析的行原样保留,绝不误删 continue; } const msg = entry.message; if ( entry.type === "message" && msg?.role === "assistant" && Array.isArray(msg.content) && msg.content.length === 0 ) { removed += 1; continue; } kept.push(ln); } if (removed > 0) writeFileSync(file, kept.join("\n"), "utf-8"); return removed; } catch { return 0; // 修复失败不阻断任何流程 } } // 存档根目录 /sessions:未绑定内容仓库时返回 null function sessionsRoot(): string | null { const cfg = loadConfig(); return cfg.repoUrl ? join(cfg.repoPath, "sessions") : null; } // 当前 agent 的存档子目录名(白名单校验,非法回退 _unknown) function archiveAgentName(): string { const { currentAgent } = loadConfig(); return /^[\w-]+$/.test(currentAgent) ? currentAgent : "_unknown"; } /** 一次性迁移:sessions/ 直属的平铺 *.jsonl → sessions/_legacy/(幂等,逐步容错) */ function migrateLegacySessions(root: string): void { try { if (!existsSync(root)) return; // 存档目录不存在:跳过 const flat = readdirSync(root, { withFileTypes: true }).filter( (e) => e.isFile() && e.name.endsWith(".jsonl"), ); if (flat.length === 0) return; const legacy = join(root, "_legacy"); mkdirSync(legacy, { recursive: true }); for (const e of flat) { try { renameSync(join(root, e.name), join(legacy, e.name)); } catch { // 单个失败容错继续(下次 session_start 再迁) } } } catch { // 迁移失败不阻断会话启动 } } export default function (pi: ExtensionAPI) { // 定时归档:会话开着也每 15 分钟归档一次(人们不会频繁退出会话,退出时归档 // 等于长期不同步)。复用 recordSessions 开关;启动时立即补归档一次(上次未归档的)。 // 归档 = hash-object + update-index 直写 git(sessions/ 不在工作区)+ commit + push。 const ARCHIVE_INTERVAL = 15 * 60 * 1000; let lastArchivedKey = ""; // 会话文件 mtime:size,避免空归档 let archiveTimer: ReturnType | null = null; interface ArchiveResult { commit: string; // commit hash(前 8 位) blob: string; // blob hash(前 8 位) relPath: string; // sessions//.jsonl pushed: boolean; // 推送成功 upToDate?: boolean; // force 但内容无变化(无名字时) branched?: boolean; // 分叉:归档到了新路径(别机改过同一会话) } async function archiveSession( ctx: ExtensionContext, opts: { force?: boolean; name?: string; onStage?: (stage: string) => void } = {}, ): Promise { const file = ctx.sessionManager.getSessionFile(); const stage = (st: string) => opts.onStage?.(st); const cfg = loadConfig(); if (!cfg.recordSessions) return null; if (!file || !existsSync(file)) return null; const st = statSync(file); const key = `${st.mtimeMs}:${st.size}`; if (!opts.force && key === lastArchivedKey) return null; // 定时路径:无变化不空提交 repairSessionFile(file); stage("hashing session…"); let blob: string; let tempSavePath = ""; if (opts.name) { const content = `${readFileSync(file, "utf-8")}${JSON.stringify({ type: "session_info", name: opts.name })}\n`; tempSavePath = join(cfg.repoPath, ".git", `save-${Date.now()}.tmp`); writeFileSync(tempSavePath, content, "utf-8"); blob = await gitHashObject(cfg.repoPath, tempSavePath, { noAuth: true, timeoutMs: 8000 }); } else { blob = await gitHashObject(cfg.repoPath, file, { noAuth: true, timeoutMs: 8000 }); } const basePath = `sessions/${archiveAgentName()}/${basename(file)}`; const state = readSaveState(); const sessionKey = basename(file); const prior = state.archives?.[sessionKey] ?? (state.lastArchive?.session === sessionKey ? state.lastArchive : undefined); let relPath = prior?.path ?? basePath; let branched = relPath !== basePath; // 分叉检测:第一次发现远端变更时创建一个副本;后续定时保存继续写该副本。 try { await gitIn(cfg.repoPath, ["fetch", "origin"], gitAuthOpts()); const { stdout: remoteBlob } = await gitIn( cfg.repoPath, ["rev-parse", `origin/main:${basePath}`], { noAuth: true, timeoutMs: 8000 }, ); const branchName = `${hostname()}_${sessionKey}`; const chosen = chooseArchivePath({ basePath, session: basename(file), previousSession: sessionKey, previousPath: relPath, previousBlob: prior?.blob, currentBlob: blob, remoteBlob: remoteBlob.trim(), branchPath: `sessions/${archiveAgentName()}/${branchName}`, }); relPath = chosen.path; branched = chosen.branched; } catch { // fetch/对比失败:沿用已持久化路径,避免继续制造副本 } if (tempSavePath) { try { unlinkSync(tempSavePath); } catch { // 清理失败静默 } } await gitUpdateIndexCacheInfo(cfg.repoPath, relPath, blob, { noAuth: true, timeoutMs: 8000, }); if (opts.name) setSessionNameInIndex(cfg.repoPath, relPath, opts.name); // 名字索引同步 // 元数据索引(列表显示):大小 + 首条消息摘要 + 最后更新时间(本地提取,零 blob 拉取) const sessionText = readFileSync(file, "utf-8"); setSessionMetaInIndex(cfg.repoPath, relPath, { size: st.size, first: extractFirstUser(sessionText), updatedAt: extractLatestTimestamp(sessionText), }); // force 但内容无变化(无名字):已是最新,无需 commit/push if (opts.force && !opts.name) { try { const { stdout } = await gitIn(cfg.repoPath, ["rev-parse", `HEAD:${relPath}`], { noAuth: true, timeoutMs: 8000, }); if (stdout.trim() === blob) { return { commit: "", blob: blob.slice(0, 8), relPath, pushed: false, upToDate: true }; } } catch { // HEAD 无此路径(首次归档):正常提交 } } await gitIn(cfg.repoPath, ["add", "session-index.json"], { noAuth: true, timeoutMs: 8000 }); stage("committing…"); await gitIn( cfg.repoPath, ["commit", "-m", opts.name ? `save session ${opts.name}` : "[sync] archive session"], { noAuth: true, timeoutMs: 8000 }, ); // 验证:commit 存在 + blob 在 commit tree 里 const { stdout: headOut } = await gitIn(cfg.repoPath, ["rev-parse", "HEAD"], { noAuth: true, timeoutMs: 8000, }); const commit = headOut.trim(); const { stdout: blobInTree } = await gitIn( cfg.repoPath, ["rev-parse", `HEAD:${relPath}`], { noAuth: true, timeoutMs: 8000 }, ); const verified = blobInTree.trim() === blob; let pushed = false; try { stage("pushing…"); await gitIn(cfg.repoPath, ["push"], gitAuthOpts(15000)); // 推送(私有仓库带 token) const { stdout: remoteOut } = await gitIn(cfg.repoPath, ["rev-parse", "origin/main"], { noAuth: true, timeoutMs: 8000, }); pushed = remoteOut.trim() === commit; // 推送后远端指向本 commit } catch { pushed = false; // 推送失败(本地已存,待补推) } try { await gitIn(cfg.repoPath, ["gc", "--auto"], { noAuth: true, timeoutMs: 8000 }); // 轻量 gc(阈值内 no-op) } catch { // gc 失败静默 } lastArchivedKey = key; writeSaveState({ lastArchive: { time: new Date().toISOString(), session: sessionKey, result: verified && pushed ? "committed" : "copied", blob, path: relPath, }, archives: { ...(state.archives ?? {}), [sessionKey]: { path: relPath, blob }, }, }); if (!verified) throw new Error("commit verification failed"); return { commit: commit.slice(0, 8), blob: blob.slice(0, 8), relPath, pushed, branched, }; } pi.on("session_start", async (event, ctx) => { try { if (event.previousSessionFile) repairSessionFile(event.previousSessionFile); } catch { // 自愈失败静默 } const root = sessionsRoot(); if (!root) return; migrateLegacySessions(root); // 启动 15 分钟定时归档(与 /dpi-save 同一实现;无启动补归档——最简设计) if (archiveTimer) clearInterval(archiveTimer); lastArchivedKey = ""; archiveTimer = setInterval(() => void archiveSession(ctx).catch(() => {}), ARCHIVE_INTERVAL); }); pi.on("session_shutdown", () => { if (archiveTimer) { clearInterval(archiveTimer); archiveTimer = null; } }); registerDpiCommand(pi, "dpi-session-repair", { description: "Repair session file: clean empty assistant messages (400/429/abort leftovers)", handler: async (_args, ctx) => { try { const file = ctx.sessionManager.getSessionFile(); if (!file) { ctx.ui.notify("No session file to repair", "warning"); return; } const removed = repairSessionFile(file); ctx.ui.notify( removed > 0 ? `Cleaned ${removed} bad messages. Current session memory still holds them; exit and resume to apply` : "Session file healthy, nothing to repair", "info", ); } catch (e) { ctx.ui.notify(`Repair failed: ${e instanceof Error ? e.message : String(e)}`, "error"); } }, }); // /dpi-save:主动存档(Ctrl+S 式)——立即保存当前会话,带参数即命名保存点。 // 完整反馈:转圈 → 验证 commit/blob/推送 → 成功显示位置与 hash,失败显示失败步骤。 registerDpiCommand(pi, "dpi-save", { description: "Save current session to archive now; with a name = named savepoint", handler: async (args, ctx) => { const name = (args ?? "").trim(); const t0 = Date.now(); try { ctx.ui.setStatus("dpi-save", "saving…"); // suckless 风格:底栏实时状态行 const result = await archiveSession(ctx, { force: true, name: name || undefined, onStage: (st) => ctx.ui.setStatus("dpi-save", st), }); if (!result) { ctx.ui.notify("Save failed ✗ — nothing to save (no active session?)", "error"); return; } if (result.upToDate) { ctx.ui.notify(`Saved in ${((Date.now() - t0) / 1000).toFixed(1)}s — already up to date`, "info"); return; } const lines = [ `Saved ✓ in ${((Date.now() - t0) / 1000).toFixed(1)}s`, ` commit ${result.commit} · blob ${result.blob}`, ` ${result.relPath}`, result.pushed ? " pushed to origin/main ✓ (visible on other machines)" : " ⚠ committed locally, push pending (will retry)", ]; if (result.branched) { lines.push(" ⚠ fork detected: another machine changed this session — saved as a new archive (both kept)"); } if (name) lines.push(` name: ${name}`); ctx.ui.notify(lines.join("\n"), "info"); } catch (e) { ctx.ui.notify(`Save failed ✗ in ${((Date.now() - t0) / 1000).toFixed(1)}s: ${errMsg(e)}`, "error"); } finally { ctx.ui.setStatus("dpi-save", undefined); // 清除状态行 } }, }); registerDpiCommand(pi, "dpi-record", { description: "Session archive toggle: /dpi-record on|off|status", handler: async (args, ctx) => { const sub = (args ?? "").trim().toLowerCase(); if (sub === "on" || sub === "off") { saveConfig({ recordSessions: sub === "on" }); ctx.ui.notify(`Session archiving ${sub === "on" ? "enabled" : "disabled"}`, "info"); return; } if (sub === "status" || sub === "") { ctx.ui.notify( `Session archiving: ${loadConfig().recordSessions ? "on" : "off"}`, "info", ); return; } ctx.ui.notify("Usage: /dpi-record on|off|status", "warning"); }, }); }