tianshu-harness 3.23.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +189 -0
- package/README.en.md +1066 -0
- package/README.ja.md +1214 -0
- package/README.ko.md +1216 -0
- package/README.md +309 -0
- package/completions/rivet.bash +118 -0
- package/completions/rivet.fish +110 -0
- package/completions/rivet.ps1 +230 -0
- package/completions/rivet.zsh +187 -0
- package/dist/account-OAEKORZE.js +47 -0
- package/dist/account-login-V6FOLHJR.js +65 -0
- package/dist/account-status-KHNPPAFL.js +23 -0
- package/dist/agent/worker-process/child.js +407 -0
- package/dist/browser-cli-FLNY5ZHI.js +19 -0
- package/dist/browser-readiness-LWVGSBD5.js +12 -0
- package/dist/build-backends-5KXKR2S4.js +13 -0
- package/dist/build-options-66Y4ZE7C.js +9 -0
- package/dist/bundled-skills/brainstorming/SKILL.md +139 -0
- package/dist/bundled-skills/visual-acceptance/SKILL.md +64 -0
- package/dist/chain-7MAMFXZ3.js +10 -0
- package/dist/checkpoint-MND4DUIX.js +33 -0
- package/dist/chunk-2E6CK2RC.js +392 -0
- package/dist/chunk-2EBWGAXD.js +51 -0
- package/dist/chunk-2HDP7RMR.js +119 -0
- package/dist/chunk-2HKD2UFM.js +1000 -0
- package/dist/chunk-2VYRLULP.js +51 -0
- package/dist/chunk-2XN7PM4N.js +125 -0
- package/dist/chunk-35H7GVNR.js +66 -0
- package/dist/chunk-3C3R7JXY.js +77 -0
- package/dist/chunk-3VGD2FXB.js +118 -0
- package/dist/chunk-424AP7FM.js +534 -0
- package/dist/chunk-4J5TZGOE.js +3707 -0
- package/dist/chunk-4QJ3Y5JM.js +898 -0
- package/dist/chunk-4U2ET7ZY.js +1326 -0
- package/dist/chunk-4XPFLICE.js +876 -0
- package/dist/chunk-5B2OCU5P.js +31707 -0
- package/dist/chunk-5GGOGLSN.js +73 -0
- package/dist/chunk-5JRZPLI6.js +251 -0
- package/dist/chunk-5TQ3JYBU.js +397 -0
- package/dist/chunk-66JPZSGX.js +55 -0
- package/dist/chunk-6HSVXHCZ.js +764 -0
- package/dist/chunk-6S5FPUS7.js +21 -0
- package/dist/chunk-722LQO64.js +131 -0
- package/dist/chunk-73NJ7IT7.js +354 -0
- package/dist/chunk-7FW3I7WC.js +95 -0
- package/dist/chunk-7KUCBNLB.js +173 -0
- package/dist/chunk-7RDOV3DF.js +201 -0
- package/dist/chunk-A5MNH4ER.js +79 -0
- package/dist/chunk-ADIVZ2D5.js +15 -0
- package/dist/chunk-AE3P3F6W.js +366 -0
- package/dist/chunk-AERM3VGP.js +280 -0
- package/dist/chunk-AEWHC4WM.js +246 -0
- package/dist/chunk-AOVQ7VIF.js +169 -0
- package/dist/chunk-AOYHP3WI.js +12411 -0
- package/dist/chunk-B3YEBABJ.js +7160 -0
- package/dist/chunk-BCQXEZPJ.js +154 -0
- package/dist/chunk-BKTQAYO2.js +418 -0
- package/dist/chunk-BSECIPAQ.js +80 -0
- package/dist/chunk-BUIMGNDD.js +52 -0
- package/dist/chunk-BXHV3GSB.js +10320 -0
- package/dist/chunk-C6ZVZCPP.js +139 -0
- package/dist/chunk-CEZNQVWM.js +82 -0
- package/dist/chunk-CGFZGR7G.js +1492 -0
- package/dist/chunk-CGOADM2X.js +337 -0
- package/dist/chunk-CI4R6P2B.js +24 -0
- package/dist/chunk-D2TCGRSX.js +73 -0
- package/dist/chunk-D4PTYFOG.js +154 -0
- package/dist/chunk-D54ZWG4T.js +717 -0
- package/dist/chunk-DEBI37QB.js +145 -0
- package/dist/chunk-DWROHPXP.js +1051 -0
- package/dist/chunk-DZ6UOJ5D.js +92 -0
- package/dist/chunk-EBJTVRQN.js +81 -0
- package/dist/chunk-EGEI2RQA.js +1167 -0
- package/dist/chunk-EHHUASO5.js +1305 -0
- package/dist/chunk-EPKTIOED.js +41 -0
- package/dist/chunk-ER5JW2QZ.js +5681 -0
- package/dist/chunk-ETHFACAB.js +315 -0
- package/dist/chunk-EXUE5W2F.js +112 -0
- package/dist/chunk-EXXICUIW.js +73 -0
- package/dist/chunk-FFQSZJOO.js +169 -0
- package/dist/chunk-FHFKPGDO.js +85 -0
- package/dist/chunk-FW3QKMFX.js +158 -0
- package/dist/chunk-GD4ZNZ7W.js +30 -0
- package/dist/chunk-GHVMWF2I.js +581 -0
- package/dist/chunk-GMKAXSYQ.js +297 -0
- package/dist/chunk-HBVF6BWO.js +325 -0
- package/dist/chunk-HV4YGVMS.js +379 -0
- package/dist/chunk-ILBCKAV6.js +89 -0
- package/dist/chunk-J4U4AW46.js +440 -0
- package/dist/chunk-J6ESSM47.js +1791 -0
- package/dist/chunk-JBZHIC6T.js +2309 -0
- package/dist/chunk-JCFWOULR.js +245 -0
- package/dist/chunk-JRF5L6OD.js +131 -0
- package/dist/chunk-JSS75KCG.js +2014 -0
- package/dist/chunk-JSXWIWWJ.js +225 -0
- package/dist/chunk-JYV7I2QH.js +98 -0
- package/dist/chunk-KIOXTQQL.js +1596 -0
- package/dist/chunk-KWVTXT5B.js +89 -0
- package/dist/chunk-KX22RD6R.js +33 -0
- package/dist/chunk-LA6IPTGH.js +709 -0
- package/dist/chunk-LH5VCGLB.js +1796 -0
- package/dist/chunk-LQQ6XJ2Y.js +269 -0
- package/dist/chunk-MJQ55WZZ.js +65 -0
- package/dist/chunk-ML3UKMH3.js +40 -0
- package/dist/chunk-MOZPLHSU.js +5526 -0
- package/dist/chunk-NGEWJ4DF.js +89 -0
- package/dist/chunk-NH6VIOAR.js +211 -0
- package/dist/chunk-O5VZQKCB.js +26106 -0
- package/dist/chunk-OHSV42QB.js +748 -0
- package/dist/chunk-OO3TAHO5.js +4070 -0
- package/dist/chunk-OTNHSAGN.js +38282 -0
- package/dist/chunk-OUWAC7AQ.js +442 -0
- package/dist/chunk-OXMIV4WE.js +270 -0
- package/dist/chunk-P5KJURUR.js +229 -0
- package/dist/chunk-P6GJOVO6.js +307 -0
- package/dist/chunk-QARAGUXX.js +23 -0
- package/dist/chunk-R43HJCDX.js +2711 -0
- package/dist/chunk-RAXBAWRN.js +210 -0
- package/dist/chunk-RK3JHWPT.js +39 -0
- package/dist/chunk-RWA23GMM.js +24 -0
- package/dist/chunk-S4K4L6IM.js +179 -0
- package/dist/chunk-SAXK6LVF.js +124 -0
- package/dist/chunk-SEPGEJNK.js +1607 -0
- package/dist/chunk-SUJERP5I.js +285 -0
- package/dist/chunk-T3ED6BY2.js +913 -0
- package/dist/chunk-THKDVSEC.js +90 -0
- package/dist/chunk-TI4GSEL5.js +52 -0
- package/dist/chunk-TPI73L57.js +164 -0
- package/dist/chunk-TVHW77C7.js +338 -0
- package/dist/chunk-TWNHOSDT.js +1073 -0
- package/dist/chunk-UFMN76QD.js +347 -0
- package/dist/chunk-UHGLFRZE.js +205 -0
- package/dist/chunk-UTZYPKBF.js +15231 -0
- package/dist/chunk-UXNLELW6.js +25 -0
- package/dist/chunk-UXXS53EA.js +166 -0
- package/dist/chunk-V5DHFGI7.js +171 -0
- package/dist/chunk-V5YIPMHV.js +2353 -0
- package/dist/chunk-WMUWEO2C.js +754 -0
- package/dist/chunk-WOE3D7JM.js +945 -0
- package/dist/chunk-X2IK5LVR.js +2592 -0
- package/dist/chunk-XU2YXCDL.js +459 -0
- package/dist/chunk-YJG5HIX5.js +203 -0
- package/dist/chunk-Z3QE7TZM.js +72 -0
- package/dist/chunk-Z5Z3JHJY.js +64 -0
- package/dist/chunk-ZDIFBJFT.js +168 -0
- package/dist/chunk-ZGGCEXDC.js +176 -0
- package/dist/chunk-ZMZCZNKU.js +263 -0
- package/dist/chunk-ZNC5T65N.js +17 -0
- package/dist/chunk-ZO3I3A7O.js +308 -0
- package/dist/chunk-ZQKXBUB7.js +217 -0
- package/dist/chunk-ZTRINFM2.js +78 -0
- package/dist/chunk-ZU7JZCVP.js +193 -0
- package/dist/chunk-ZZAWNALS.js +350 -0
- package/dist/cli/entry.js +54 -0
- package/dist/connector-L5QQTR6Y.js +26 -0
- package/dist/context-62DC6EC3.js +31 -0
- package/dist/council-convene-NJWPNBDU.js +46 -0
- package/dist/create-agent-config-ODSQWNVC.js +55 -0
- package/dist/default-registry-2PU3BTAU.js +68 -0
- package/dist/deliver-task-KONV4C4M.js +57 -0
- package/dist/delivery-gate-v2-F2YVMVGK.js +27 -0
- package/dist/domain-knowledge-store-DDZ3F4J7.js +11 -0
- package/dist/fetch-core-ZPPYDSZH.js +14 -0
- package/dist/galaxy-K72BBDQN.js +91 -0
- package/dist/goal-criteria-SCALAU2D.js +141 -0
- package/dist/goal-persist-XWY2GDQX.js +19 -0
- package/dist/goal-tracker-FXMJ2GVK.js +11 -0
- package/dist/headless-LSB6I7PT.js +162 -0
- package/dist/headless-coordinator-VDPDMVUT.js +90 -0
- package/dist/inject-U55SMB6A.js +39 -0
- package/dist/login-flow-CD4ZGXOC.js +59 -0
- package/dist/logs-cli-SQVYFMMN.js +283 -0
- package/dist/loop-ULZYBDA3.js +76 -0
- package/dist/main.js +4064 -0
- package/dist/manager-ROZBCWPA.js +171 -0
- package/dist/manager-Z7XFFUOM.js +10670 -0
- package/dist/meridian-indexer-ZIW363L7.js +21 -0
- package/dist/native-resolver-PN34UIVV.js +13 -0
- package/dist/node_modules/better-sqlite3/lib/database.js +90 -0
- package/dist/node_modules/better-sqlite3/lib/index.js +3 -0
- package/dist/node_modules/better-sqlite3/lib/methods/aggregate.js +43 -0
- package/dist/node_modules/better-sqlite3/lib/methods/backup.js +67 -0
- package/dist/node_modules/better-sqlite3/lib/methods/function.js +31 -0
- package/dist/node_modules/better-sqlite3/lib/methods/inspect.js +7 -0
- package/dist/node_modules/better-sqlite3/lib/methods/pragma.js +12 -0
- package/dist/node_modules/better-sqlite3/lib/methods/serialize.js +16 -0
- package/dist/node_modules/better-sqlite3/lib/methods/table.js +189 -0
- package/dist/node_modules/better-sqlite3/lib/methods/transaction.js +78 -0
- package/dist/node_modules/better-sqlite3/lib/methods/wrappers.js +54 -0
- package/dist/node_modules/better-sqlite3/lib/sqlite-error.js +20 -0
- package/dist/node_modules/better-sqlite3/lib/util.js +12 -0
- package/dist/node_modules/better-sqlite3/package.json +59 -0
- package/dist/ownership-ledger-E3WIEKPL.js +9 -0
- package/dist/package.json +3 -0
- package/dist/path-grants-XRRXHXG7.js +40 -0
- package/dist/paths-LVV5IMQN.js +49 -0
- package/dist/plan-fact-anchors-LMVQZCW5.js +13 -0
- package/dist/plan-store-ODLTTPFB.js +17 -0
- package/dist/plan-templates-5FPFBOL6.js +109 -0
- package/dist/plugin-loader-ZZKX7ZE6.js +22 -0
- package/dist/prefix-budget-62OV5HQC.js +13 -0
- package/dist/project-trust-5WS5MOMU.js +31 -0
- package/dist/provider-cli-ALDQXIT7.js +37 -0
- package/dist/providers-XQYTAVPK.js +11 -0
- package/dist/proxy-resolver-V2Y6534D.js +15 -0
- package/dist/recovery-cli-KPGY6FY3.js +106 -0
- package/dist/review-discipline-MNSYCST5.js +55 -0
- package/dist/review-router-BVQZW3MX.js +18 -0
- package/dist/seed-capsules/seed-capsule-attack-methodology.md +62 -0
- package/dist/seed-capsules/seed-capsule-changgeng.md +31 -0
- package/dist/seed-capsules/seed-capsule-design-aesthetics.md +38 -0
- package/dist/seed-capsules/seed-capsule-diagnostic-ladder.md +54 -0
- package/dist/seed-capsules/seed-capsule-fu.md +39 -0
- package/dist/seed-capsules/seed-capsule-huagai.md +73 -0
- package/dist/seed-capsules/seed-capsule-kaiyang.md +65 -0
- package/dist/seed-capsules/seed-capsule-knowledge-work.md +40 -0
- package/dist/seed-capsules/seed-capsule-tanlang.md +47 -0
- package/dist/seed-capsules/seed-capsule-tianfu.md +23 -0
- package/dist/seed-capsules/seed-capsule-tianliang.md +63 -0
- package/dist/seed-capsules/seed-capsule-tianquan.md +32 -0
- package/dist/seed-capsules/seed-capsule-tianxuan-ccr.md +22 -0
- package/dist/seed-capsules/seed-capsule-tianxuan.md +64 -0
- package/dist/seed-capsules/seed-capsule-yaoguang.md +87 -0
- package/dist/serve-X2R2UTLF.js +170 -0
- package/dist/serve-agent-KHSU6RVV.js +965 -0
- package/dist/session-persist-HUUC772H.js +37 -0
- package/dist/session-registry-6XUD5C4Y.js +15 -0
- package/dist/skill-loader-CK3RMO47.js +48 -0
- package/dist/skills/cli-harness-sop.md +207 -0
- package/dist/spawn-git-4HFU65AH.js +29 -0
- package/dist/sse-EYUTBWUK.js +512 -0
- package/dist/starflow-NOFBMPFU.js +30 -0
- package/dist/streamableHttp-V67CNJMO.js +447 -0
- package/dist/task-contract-6XHBNJQK.js +32 -0
- package/dist/task-ledger-3K4TJIER.js +9 -0
- package/dist/team-orchestrate-S6NHZP7X.js +64 -0
- package/dist/title-extract-QX4XGCYN.js +50 -0
- package/dist/todo-QDXGRZZP.js +31 -0
- package/dist/todo-store-V3TTO5AH.js +12 -0
- package/dist/tool-preset-LU5BYEDZ.js +23 -0
- package/dist/tool-tiers-GD742YBN.js +23 -0
- package/dist/turndown.es-OI55K6C3.js +17373 -0
- package/dist/verification-attribution-EKG4CNUV.js +13 -0
- package/dist/volatile-git-MBTST7RA.js +15 -0
- package/dist/wave-checkpoint-XLIIHXQA.js +30 -0
- package/dist/web-cli-DUG4I35O.js +233 -0
- package/dist/web-search-3IK542AA.js +34 -0
- package/dist/workers/cpu-worker.js +45 -0
- package/dist/workflow-runner-HX7UBAYG.js +297 -0
- package/dist/worktree-baseline-O5NCU6M2.js +9 -0
- package/package.json +122 -0
- package/scripts/check-mobile-env.mjs +76 -0
- package/scripts/fetch-native-sqlite.js +392 -0
- package/scripts/prune-installed-wasms.js +45 -0
- package/scripts/tree-sitter-wasm-keep.js +40 -0
|
@@ -0,0 +1,1796 @@
|
|
|
1
|
+
#!/usr/bin/env -S node --expose-gc --max-old-space-size=4096
|
|
2
|
+
import { createRequire as __rivetCreateRequire } from 'node:module'; const require = __rivetCreateRequire(import.meta.url);
|
|
3
|
+
import {
|
|
4
|
+
parseFrozenSnapshotData
|
|
5
|
+
} from "./chunk-6S5FPUS7.js";
|
|
6
|
+
import {
|
|
7
|
+
normalizeOaiMessage,
|
|
8
|
+
normalizeOaiMessages
|
|
9
|
+
} from "./chunk-EXXICUIW.js";
|
|
10
|
+
import {
|
|
11
|
+
stableStringify
|
|
12
|
+
} from "./chunk-QARAGUXX.js";
|
|
13
|
+
import {
|
|
14
|
+
writeFileAtomicAsync,
|
|
15
|
+
writeFileAtomicSync
|
|
16
|
+
} from "./chunk-A5MNH4ER.js";
|
|
17
|
+
import {
|
|
18
|
+
init_paths,
|
|
19
|
+
sessionsDir
|
|
20
|
+
} from "./chunk-BCQXEZPJ.js";
|
|
21
|
+
import {
|
|
22
|
+
init_esm_shims
|
|
23
|
+
} from "./chunk-5GGOGLSN.js";
|
|
24
|
+
|
|
25
|
+
// src/agent/session-persist.ts
|
|
26
|
+
init_esm_shims();
|
|
27
|
+
import { existsSync as existsSync6, mkdirSync as mkdirSync2, readFileSync as readFileSync5, unlinkSync as unlinkSync2, rmSync, readdirSync, statSync as statSync2 } from "fs";
|
|
28
|
+
init_paths();
|
|
29
|
+
import { isAbsolute, join as join5, relative, resolve } from "path";
|
|
30
|
+
|
|
31
|
+
// src/context/session-memory.ts
|
|
32
|
+
init_esm_shims();
|
|
33
|
+
import { existsSync, readFileSync } from "fs";
|
|
34
|
+
import { join } from "path";
|
|
35
|
+
import { createHash } from "crypto";
|
|
36
|
+
|
|
37
|
+
// src/validation.ts
|
|
38
|
+
init_esm_shims();
|
|
39
|
+
var SESSION_ID_RE = /^[a-zA-Z0-9_-]{1,128}$/;
|
|
40
|
+
function isValidSessionId(id) {
|
|
41
|
+
return SESSION_ID_RE.test(id);
|
|
42
|
+
}
|
|
43
|
+
function assertValidSessionId(id) {
|
|
44
|
+
if (!isValidSessionId(id)) throw new Error(`Invalid sessionId: ${id}`);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// src/context/session-memory.ts
|
|
48
|
+
function memoryPath(dir, sessionId) {
|
|
49
|
+
assertValidSessionId(sessionId);
|
|
50
|
+
return join(dir, `${sessionId}.memory.json`);
|
|
51
|
+
}
|
|
52
|
+
function idFor(input) {
|
|
53
|
+
return createHash("sha256").update(`${input.createdAt}:${input.source}:${input.text}`).digest("hex").slice(0, 12);
|
|
54
|
+
}
|
|
55
|
+
function loadSessionMemory(dir, sessionId) {
|
|
56
|
+
const filePath = memoryPath(dir, sessionId);
|
|
57
|
+
if (!existsSync(filePath)) return { sessionId, entries: [] };
|
|
58
|
+
try {
|
|
59
|
+
return JSON.parse(readFileSync(filePath, "utf-8"));
|
|
60
|
+
} catch {
|
|
61
|
+
return { sessionId, entries: [] };
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
function appendSessionMemory(dir, sessionId, input) {
|
|
65
|
+
const state = loadSessionMemory(dir, sessionId);
|
|
66
|
+
const duplicate = state.entries.some((entry2) => entry2.text === input.text && entry2.source === input.source);
|
|
67
|
+
if (duplicate) return state;
|
|
68
|
+
const entry = { id: idFor(input), ...input };
|
|
69
|
+
const next = { sessionId, entries: [...state.entries, entry].slice(-50) };
|
|
70
|
+
writeFileAtomicSync(memoryPath(dir, sessionId), JSON.stringify(next, null, 2) + "\n");
|
|
71
|
+
return next;
|
|
72
|
+
}
|
|
73
|
+
function escapeXml(text) {
|
|
74
|
+
return text.replaceAll("&", "&").replaceAll("<", "<").replaceAll(">", ">").replaceAll('"', """);
|
|
75
|
+
}
|
|
76
|
+
function buildSessionMemoryBlock(state) {
|
|
77
|
+
if (state.entries.length === 0) return "";
|
|
78
|
+
const entries = state.entries.map((entry) => `<entry id="${entry.id}" created_at="${entry.createdAt}" source="${entry.source}">${escapeXml(entry.text)}</entry>`);
|
|
79
|
+
return `<session-memory session_id="${state.sessionId}">
|
|
80
|
+
${entries.join("\n")}
|
|
81
|
+
</session-memory>`;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// src/context/claim-store.ts
|
|
85
|
+
init_esm_shims();
|
|
86
|
+
import { appendFileSync, existsSync as existsSync3, mkdirSync, readFileSync as readFileSync2, statSync, unlinkSync } from "fs";
|
|
87
|
+
import { appendFile, rename, writeFile } from "fs/promises";
|
|
88
|
+
import { join as join3 } from "path";
|
|
89
|
+
|
|
90
|
+
// src/context/claims.ts
|
|
91
|
+
init_esm_shims();
|
|
92
|
+
import { createHash as createHash2 } from "crypto";
|
|
93
|
+
function normalizeClaimText(text) {
|
|
94
|
+
return text.trim().replace(/\s+/g, " ").toLowerCase();
|
|
95
|
+
}
|
|
96
|
+
function claimIdFor(proposal) {
|
|
97
|
+
return createHash2("sha256").update(JSON.stringify({
|
|
98
|
+
kind: proposal.kind,
|
|
99
|
+
scope: proposal.scope,
|
|
100
|
+
text: normalizeClaimText(proposal.text),
|
|
101
|
+
sessionId: proposal.source.sessionId
|
|
102
|
+
})).digest("hex").slice(0, 12);
|
|
103
|
+
}
|
|
104
|
+
function kindFromAnchor(anchor) {
|
|
105
|
+
if (anchor.kind === "user_constraint") return "user_constraint";
|
|
106
|
+
if (anchor.kind === "user_preference") return "user_preference";
|
|
107
|
+
if (anchor.kind === "decision") return "decision";
|
|
108
|
+
if (anchor.kind === "verification") return "verification_fact";
|
|
109
|
+
if (anchor.kind === "error") return "failure_pattern";
|
|
110
|
+
return "file_observation";
|
|
111
|
+
}
|
|
112
|
+
function confidenceFromAnchor(anchor) {
|
|
113
|
+
if (anchor.kind === "user_constraint") return 0.9;
|
|
114
|
+
if (anchor.kind === "decision") return 0.82;
|
|
115
|
+
if (anchor.kind === "verification") return 0.88;
|
|
116
|
+
return 0.7;
|
|
117
|
+
}
|
|
118
|
+
function claimProposalFromAnchor(anchor, meta) {
|
|
119
|
+
return {
|
|
120
|
+
kind: kindFromAnchor(anchor),
|
|
121
|
+
scope: "session",
|
|
122
|
+
text: anchor.text,
|
|
123
|
+
confidence: confidenceFromAnchor(anchor),
|
|
124
|
+
fitness: anchor.salience,
|
|
125
|
+
source: {
|
|
126
|
+
actor: meta.actor,
|
|
127
|
+
sessionId: meta.sessionId,
|
|
128
|
+
turn: meta.turn,
|
|
129
|
+
eventId: meta.eventId
|
|
130
|
+
},
|
|
131
|
+
evidence: [{
|
|
132
|
+
id: `${meta.eventId}:anchor`,
|
|
133
|
+
kind: meta.actor === "user" ? "user_message" : "assistant_message",
|
|
134
|
+
summary: anchor.text,
|
|
135
|
+
createdAt: meta.createdAt
|
|
136
|
+
}],
|
|
137
|
+
createdAt: meta.createdAt,
|
|
138
|
+
tags: ["anchor", anchor.kind]
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
function createClaimFromProposal(proposal) {
|
|
142
|
+
return {
|
|
143
|
+
id: claimIdFor(proposal),
|
|
144
|
+
kind: proposal.kind,
|
|
145
|
+
scope: proposal.scope,
|
|
146
|
+
status: "active",
|
|
147
|
+
text: proposal.text,
|
|
148
|
+
confidence: proposal.confidence,
|
|
149
|
+
fitness: proposal.fitness,
|
|
150
|
+
source: proposal.source,
|
|
151
|
+
evidence: [...proposal.evidence],
|
|
152
|
+
consumers: [],
|
|
153
|
+
counterevidence: [],
|
|
154
|
+
createdAt: proposal.createdAt,
|
|
155
|
+
lastUsedAt: proposal.createdAt,
|
|
156
|
+
expiresAt: proposal.expiresAt,
|
|
157
|
+
tags: [...proposal.tags]
|
|
158
|
+
};
|
|
159
|
+
}
|
|
160
|
+
function isPromptEligibleClaim(claim, now = Date.now()) {
|
|
161
|
+
if (claim.expiresAt !== void 0 && claim.expiresAt <= now) return false;
|
|
162
|
+
return claim.status === "active" || claim.status === "durable_candidate" || claim.status === "durable";
|
|
163
|
+
}
|
|
164
|
+
function checkpointClaims(claims, now = Date.now(), lastEventSeq) {
|
|
165
|
+
const alive = claims.filter((c) => {
|
|
166
|
+
if (c.status === "stale" || c.status === "quarantined") return false;
|
|
167
|
+
if (c.expiresAt !== void 0 && c.expiresAt <= now) return false;
|
|
168
|
+
return true;
|
|
169
|
+
});
|
|
170
|
+
return {
|
|
171
|
+
version: 1,
|
|
172
|
+
createdAt: now,
|
|
173
|
+
...lastEventSeq !== void 0 ? { lastEventSeq } : {},
|
|
174
|
+
claims: alive
|
|
175
|
+
};
|
|
176
|
+
}
|
|
177
|
+
function loadClaimSnapshot(snapshot, now = Date.now()) {
|
|
178
|
+
if (snapshot.version !== 1) return [];
|
|
179
|
+
return snapshot.claims.map((claim) => ({
|
|
180
|
+
...claim,
|
|
181
|
+
lastUsedAt: now
|
|
182
|
+
}));
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
// src/context/promotion.ts
|
|
186
|
+
init_esm_shims();
|
|
187
|
+
import { existsSync as existsSync2 } from "fs";
|
|
188
|
+
import { join as join2 } from "path";
|
|
189
|
+
function canRecallClaim(claim, cwd) {
|
|
190
|
+
if (!cwd) return true;
|
|
191
|
+
const filePaths = claim.evidence.filter((e) => e.path !== void 0).map((e) => e.path);
|
|
192
|
+
if (filePaths.length === 0) return true;
|
|
193
|
+
return filePaths.some((p) => existsSync2(join2(cwd, p)));
|
|
194
|
+
}
|
|
195
|
+
function evaluatePromotion(claim, now = Date.now()) {
|
|
196
|
+
if (!isPromptEligibleClaim(claim, now)) return null;
|
|
197
|
+
if (claim.counterevidence.length > 0) return null;
|
|
198
|
+
if (claim.status === "active") {
|
|
199
|
+
if (new Set(claim.consumers.map((c) => c.id)).size < 3) return null;
|
|
200
|
+
return "durable_candidate";
|
|
201
|
+
}
|
|
202
|
+
if (claim.status === "durable_candidate") {
|
|
203
|
+
const age = now - claim.createdAt;
|
|
204
|
+
if (age < 10 * 6e4) return null;
|
|
205
|
+
if (new Set(claim.consumers.map((c) => c.id)).size < 5) return null;
|
|
206
|
+
return "durable";
|
|
207
|
+
}
|
|
208
|
+
return null;
|
|
209
|
+
}
|
|
210
|
+
function claimHasFileEvidence(claim, path) {
|
|
211
|
+
if (claim.kind !== "file_observation" && claim.kind !== "verification_fact") return false;
|
|
212
|
+
return claim.evidence.some((evidence) => evidence.path === path);
|
|
213
|
+
}
|
|
214
|
+
function countClaimsByStatus(claims) {
|
|
215
|
+
return claims.reduce((counts, c) => {
|
|
216
|
+
if (c.status === "active") return { ...counts, active: counts.active + 1 };
|
|
217
|
+
if (c.status === "stale") return { ...counts, stale: counts.stale + 1 };
|
|
218
|
+
if (c.status === "conflicted") return { ...counts, conflicted: counts.conflicted + 1 };
|
|
219
|
+
if (c.status === "durable") return { ...counts, durable: counts.durable + 1 };
|
|
220
|
+
if (c.status === "durable_candidate") return { ...counts, durableCandidate: counts.durableCandidate + 1 };
|
|
221
|
+
if (c.status === "quarantined") return { ...counts, quarantined: counts.quarantined + 1 };
|
|
222
|
+
return counts;
|
|
223
|
+
}, { active: 0, stale: 0, conflicted: 0, durable: 0, durableCandidate: 0, quarantined: 0, recallBlocked: 0 });
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
// src/context/claim-store.ts
|
|
227
|
+
var MAX_CONSUMERS_PER_CLAIM = 50;
|
|
228
|
+
var MAX_ACTIVE_CLAIMS = 50;
|
|
229
|
+
var DEFAULT_CHECKPOINT_EVERY_EVENTS = 500;
|
|
230
|
+
var DEFAULT_MAX_TRANSIENT_WRITE_RETRIES = 8;
|
|
231
|
+
var ContextClaimStore = class _ContextClaimStore {
|
|
232
|
+
path;
|
|
233
|
+
sessionId;
|
|
234
|
+
cachedEvents = null;
|
|
235
|
+
lastFileSize = -1;
|
|
236
|
+
cachedClaims = null;
|
|
237
|
+
lastProcessedLineCount = 0;
|
|
238
|
+
snapshotPath;
|
|
239
|
+
// ⚠️ Single-writer assumption: seq is per-instance, not coordinated across processes.
|
|
240
|
+
// If multiple processes append to the same .claims.jsonl concurrently, seq may collide.
|
|
241
|
+
// This is acceptable because claim-store is session-scoped and each session has exactly one writer.
|
|
242
|
+
nextSeq = 1;
|
|
243
|
+
checkpointing = false;
|
|
244
|
+
checkpointEveryEvents;
|
|
245
|
+
maxTransientWriteRetries;
|
|
246
|
+
constructor(dir, sessionId, options = {}) {
|
|
247
|
+
assertValidSessionId(sessionId);
|
|
248
|
+
this.sessionId = sessionId;
|
|
249
|
+
this.checkpointEveryEvents = options.checkpointEveryEvents ?? DEFAULT_CHECKPOINT_EVERY_EVENTS;
|
|
250
|
+
this.maxTransientWriteRetries = options.maxTransientWriteRetries ?? DEFAULT_MAX_TRANSIENT_WRITE_RETRIES;
|
|
251
|
+
mkdirSync(dir, { recursive: true });
|
|
252
|
+
this.path = join3(dir, `${this.sessionId}.claims.jsonl`);
|
|
253
|
+
this.snapshotPath = join3(dir, `${this.sessionId}.claims.snapshot.json`);
|
|
254
|
+
}
|
|
255
|
+
get eventCount() {
|
|
256
|
+
return this.readEvents().length;
|
|
257
|
+
}
|
|
258
|
+
appendEvent(event) {
|
|
259
|
+
if (!this.cachedEvents) {
|
|
260
|
+
if (existsSync3(this.path)) this.readEvents();
|
|
261
|
+
const checkpointSnapshot = this.loadFromCheckpoint();
|
|
262
|
+
if (checkpointSnapshot) this.nextSeq = Math.max(this.nextSeq, checkpointSnapshot.lastEventSeq + 1);
|
|
263
|
+
if (!this.cachedEvents) this.cachedEvents = [];
|
|
264
|
+
if (this.lastFileSize < 0) this.lastFileSize = 0;
|
|
265
|
+
}
|
|
266
|
+
const withSeq = { ...event, seq: event.seq ?? this.nextSeq };
|
|
267
|
+
const line = JSON.stringify(withSeq) + "\n";
|
|
268
|
+
const bytes = Buffer.byteLength(line);
|
|
269
|
+
this.pendingLines.push(line);
|
|
270
|
+
this.pendingBytes += bytes;
|
|
271
|
+
this.kickWriteChain();
|
|
272
|
+
this.nextSeq = Math.max(this.nextSeq, (withSeq.seq ?? 0) + 1);
|
|
273
|
+
this.cachedEvents.push(withSeq);
|
|
274
|
+
this.lastFileSize += bytes;
|
|
275
|
+
if (!this.checkpointing && this.checkpointEveryEvents !== void 0 && this.checkpointEveryEvents > 0 && this.cachedEvents !== null && this.cachedEvents.length >= this.checkpointEveryEvents) {
|
|
276
|
+
this.checkpointQueued = true;
|
|
277
|
+
this.kickWriteChain();
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
// ── 写链(claims.jsonl 唯一写者)─────────────────────────────────────
|
|
281
|
+
pendingLines = [];
|
|
282
|
+
/** 在途未写字节数:readEvents 外部修改检测的「磁盘合法滞后」基线。 */
|
|
283
|
+
pendingBytes = 0;
|
|
284
|
+
writeChain = { running: false, again: false };
|
|
285
|
+
checkpointQueued = false;
|
|
286
|
+
/** 重试不会改变结果的写入错误码(路径不存在 / 不是目录 / 只读文件系统)。 */
|
|
287
|
+
static PERMANENT_WRITE_ERROR_CODES = /* @__PURE__ */ new Set([
|
|
288
|
+
"ENOENT",
|
|
289
|
+
"ENOTDIR",
|
|
290
|
+
"EROFS",
|
|
291
|
+
"EISDIR"
|
|
292
|
+
]);
|
|
293
|
+
/** 撞上永久性错误后置起:停止自动重试。继续重试只会留下永不终止的 250ms
|
|
294
|
+
* 定时器链,使事件循环永远非空——node --test 因此既不打印汇总也不退出
|
|
295
|
+
* (实测:context-injection.test.ts 的 finally 删掉本 store 的目录后整批挂死)。
|
|
296
|
+
* 新 kick(新数据 / flushWrites)时清位,允许环境恢复后再试。 */
|
|
297
|
+
permanentWriteFailure = false;
|
|
298
|
+
/** 最近一次写失败(诊断用,停链时读取输出现场日志)。 */
|
|
299
|
+
lastWriteError = null;
|
|
300
|
+
/** 短暂性错误(EACCES/EPERM/EBUSY——AV/EDR 秒级锁会自愈,及未知码)的连续
|
|
301
|
+
* 失败计数:成功写入即清零;达 maxTransientWriteRetries 才转永久停链。
|
|
302
|
+
* 一次 EACCES 即停链会让锁释放后滞留行永久留在内存(agent-16)。 */
|
|
303
|
+
transientWriteRetries = 0;
|
|
304
|
+
/** 停链诊断「每实例至多一次」的独立闸:不复用 lastWriteError 判定——
|
|
305
|
+
* checkpoint 段异常(kickWriteChain 的 catch)也会赋 lastWriteError,
|
|
306
|
+
* 复用会让「首次真停链」静默无日志。 */
|
|
307
|
+
stallLogged = false;
|
|
308
|
+
/** 诊断读取点:最近一次的写失败(写链停链原因 / checkpoint 段异常),
|
|
309
|
+
* 供排障与测试断言使用(lastWriteError 的真实消费点)。 */
|
|
310
|
+
getLastWriteError() {
|
|
311
|
+
return this.lastWriteError;
|
|
312
|
+
}
|
|
313
|
+
/** 写链停链的现场记录:置状态 + 首次停链输出一次诊断(写链停了 = 滞留行留
|
|
314
|
+
* 在内存,值得在日志里可见;后续重试失败不再重复打)。 */
|
|
315
|
+
stallWriteChain(err) {
|
|
316
|
+
const error = err instanceof Error ? err : new Error(String(err));
|
|
317
|
+
this.permanentWriteFailure = true;
|
|
318
|
+
this.lastWriteError = error;
|
|
319
|
+
if (!this.stallLogged) {
|
|
320
|
+
this.stallLogged = true;
|
|
321
|
+
console.error(
|
|
322
|
+
`[claim-store] \u5199\u94FE\u505C\u94FE\uFF08${error.message}\uFF09\u2014\u2014\u6EDE\u7559\u884C\u4FDD\u7559\u5728\u5185\u5B58\uFF0C\u73AF\u5883\u6062\u590D\u540E\u7531\u65B0\u4E8B\u4EF6 / flushWrites \u91CD\u8BD5`
|
|
323
|
+
);
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
kickWriteChain() {
|
|
327
|
+
if (this.writeChain.running) {
|
|
328
|
+
this.writeChain.again = true;
|
|
329
|
+
return;
|
|
330
|
+
}
|
|
331
|
+
this.permanentWriteFailure = false;
|
|
332
|
+
this.transientWriteRetries = 0;
|
|
333
|
+
queueMicrotask(() => {
|
|
334
|
+
if (this.writeChain.running) return;
|
|
335
|
+
this.writeChain.running = true;
|
|
336
|
+
void this.runWriteChain().catch((err) => {
|
|
337
|
+
this.lastWriteError = err instanceof Error ? err : new Error(String(err));
|
|
338
|
+
});
|
|
339
|
+
});
|
|
340
|
+
}
|
|
341
|
+
async runWriteChain() {
|
|
342
|
+
try {
|
|
343
|
+
for (; ; ) {
|
|
344
|
+
this.writeChain.again = false;
|
|
345
|
+
const lines = this.pendingLines;
|
|
346
|
+
const wantCheckpoint = this.checkpointQueued;
|
|
347
|
+
this.checkpointQueued = false;
|
|
348
|
+
if (lines.length === 0 && !wantCheckpoint) break;
|
|
349
|
+
if (lines.length > 0) {
|
|
350
|
+
this.pendingLines = [];
|
|
351
|
+
const text = lines.join("");
|
|
352
|
+
try {
|
|
353
|
+
await appendFile(this.path, text, "utf-8");
|
|
354
|
+
this.pendingBytes -= Buffer.byteLength(text);
|
|
355
|
+
this.transientWriteRetries = 0;
|
|
356
|
+
} catch (err) {
|
|
357
|
+
this.pendingLines = [...lines, ...this.pendingLines];
|
|
358
|
+
const code = err.code ?? "";
|
|
359
|
+
if (_ContextClaimStore.PERMANENT_WRITE_ERROR_CODES.has(code)) {
|
|
360
|
+
this.stallWriteChain(err);
|
|
361
|
+
return;
|
|
362
|
+
}
|
|
363
|
+
this.transientWriteRetries++;
|
|
364
|
+
if (this.transientWriteRetries >= this.maxTransientWriteRetries) {
|
|
365
|
+
this.stallWriteChain(err);
|
|
366
|
+
return;
|
|
367
|
+
}
|
|
368
|
+
await new Promise((r) => setTimeout(r, 250));
|
|
369
|
+
continue;
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
if (wantCheckpoint) await this.checkpointOnChain();
|
|
373
|
+
if (!this.writeChain.again && this.pendingLines.length === 0 && !this.checkpointQueued) break;
|
|
374
|
+
}
|
|
375
|
+
} finally {
|
|
376
|
+
this.writeChain.running = false;
|
|
377
|
+
this.writeChain.again = false;
|
|
378
|
+
if (!this.permanentWriteFailure && (this.pendingLines.length > 0 || this.checkpointQueued)) {
|
|
379
|
+
this.kickWriteChain();
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
/** checkpoint 的链内异步版:快照由内存态派生(cachedEvents/cachedClaims 在
|
|
384
|
+
* appendEvent 时同步更新),文件写全部异步,截断与后续 append 由链保序。 */
|
|
385
|
+
async checkpointOnChain(now = Date.now()) {
|
|
386
|
+
this.checkpointing = true;
|
|
387
|
+
try {
|
|
388
|
+
const snapshot = checkpointClaims(this.listClaims(), now, this.maxEventSeq(this.readEvents()));
|
|
389
|
+
const tmpSnapshot = `${this.snapshotPath}.tmp`;
|
|
390
|
+
await writeFile(tmpSnapshot, JSON.stringify(snapshot, null, 2) + "\n", "utf-8");
|
|
391
|
+
await rename(tmpSnapshot, this.snapshotPath);
|
|
392
|
+
const tmpLog = `${this.path}.tmp`;
|
|
393
|
+
await writeFile(tmpLog, "", "utf-8");
|
|
394
|
+
await rename(tmpLog, this.path);
|
|
395
|
+
this.cachedEvents = [];
|
|
396
|
+
this.cachedClaims = loadClaimSnapshot(snapshot, now);
|
|
397
|
+
this.lastProcessedLineCount = 0;
|
|
398
|
+
this.lastFileSize = 0;
|
|
399
|
+
this.pendingBytes = 0;
|
|
400
|
+
} finally {
|
|
401
|
+
this.checkpointing = false;
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
/** 等写链排空(测试/收口用)。 */
|
|
405
|
+
async flushWrites(timeoutMs = 1e4) {
|
|
406
|
+
this.kickWriteChain();
|
|
407
|
+
const deadline = Date.now() + timeoutMs;
|
|
408
|
+
let requeued = false;
|
|
409
|
+
for (; ; ) {
|
|
410
|
+
if (!this.writeChain.running && this.pendingLines.length === 0 && !this.checkpointQueued) return;
|
|
411
|
+
if (!this.writeChain.running && (this.pendingLines.length > 0 || this.checkpointQueued)) {
|
|
412
|
+
if (requeued) return;
|
|
413
|
+
requeued = true;
|
|
414
|
+
this.kickWriteChain();
|
|
415
|
+
}
|
|
416
|
+
if (Date.now() > deadline) return;
|
|
417
|
+
await new Promise((r) => setTimeout(r, 10));
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
propose(proposal) {
|
|
421
|
+
const claim = createClaimFromProposal(proposal);
|
|
422
|
+
const existing = this.listClaims().find((current) => current.id === claim.id);
|
|
423
|
+
if (existing) return existing;
|
|
424
|
+
this.appendEvent({
|
|
425
|
+
type: "claim_proposed",
|
|
426
|
+
eventId: `${proposal.source.eventId}:claim:${claim.id}`,
|
|
427
|
+
createdAt: proposal.createdAt,
|
|
428
|
+
claim
|
|
429
|
+
});
|
|
430
|
+
this.evictExcessActiveClaims();
|
|
431
|
+
return claim;
|
|
432
|
+
}
|
|
433
|
+
updateClaimStatus(id, status, reason) {
|
|
434
|
+
const current = this.listClaims().find((claim) => claim.id === id);
|
|
435
|
+
if (!current) return null;
|
|
436
|
+
this.appendEvent({
|
|
437
|
+
type: "claim_status_changed",
|
|
438
|
+
eventId: `${id}:status:${status}:${Date.now()}`,
|
|
439
|
+
createdAt: Date.now(),
|
|
440
|
+
claimId: id,
|
|
441
|
+
status,
|
|
442
|
+
reason
|
|
443
|
+
});
|
|
444
|
+
return this.listClaims().find((claim) => claim.id === id) ?? null;
|
|
445
|
+
}
|
|
446
|
+
recordClaimUsed(id, input) {
|
|
447
|
+
const current = this.listClaims().find((claim) => claim.id === id);
|
|
448
|
+
if (!current) return null;
|
|
449
|
+
this.appendEvent({
|
|
450
|
+
type: "claim_used",
|
|
451
|
+
eventId: `${id}:used:${input.consumerId}:${input.usedAt}`,
|
|
452
|
+
createdAt: input.usedAt,
|
|
453
|
+
claimId: id,
|
|
454
|
+
consumerId: input.consumerId,
|
|
455
|
+
consumerKind: input.consumerKind
|
|
456
|
+
});
|
|
457
|
+
return this.listClaims().find((claim) => claim.id === id) ?? null;
|
|
458
|
+
}
|
|
459
|
+
boostFitness(id, delta, cap) {
|
|
460
|
+
const claim = this.listClaims().find((c) => c.id === id);
|
|
461
|
+
if (!claim) return null;
|
|
462
|
+
const newFitness = Math.min(claim.fitness + delta, cap);
|
|
463
|
+
this.appendEvent({
|
|
464
|
+
type: "claim_boosted",
|
|
465
|
+
eventId: `${id}:boost:${Date.now()}`,
|
|
466
|
+
createdAt: Date.now(),
|
|
467
|
+
claimId: id,
|
|
468
|
+
fitness: newFitness
|
|
469
|
+
});
|
|
470
|
+
return { ...claim, fitness: newFitness };
|
|
471
|
+
}
|
|
472
|
+
listClaims(filter = {}) {
|
|
473
|
+
return this.projectClaims().filter((claim) => {
|
|
474
|
+
if (filter.status && !filter.status.includes(claim.status)) return false;
|
|
475
|
+
if (filter.kind && !filter.kind.includes(claim.kind)) return false;
|
|
476
|
+
if (filter.scope && !filter.scope.includes(claim.scope)) return false;
|
|
477
|
+
return true;
|
|
478
|
+
});
|
|
479
|
+
}
|
|
480
|
+
listActiveClaims(now = Date.now()) {
|
|
481
|
+
return this.listClaims().filter((claim) => isPromptEligibleClaim(claim, now));
|
|
482
|
+
}
|
|
483
|
+
listClaimsByFileEvidence(path) {
|
|
484
|
+
return this.listClaims().filter((claim) => claimHasFileEvidence(claim, path));
|
|
485
|
+
}
|
|
486
|
+
getStatusCounts() {
|
|
487
|
+
return countClaimsByStatus(this.listClaims());
|
|
488
|
+
}
|
|
489
|
+
markClaimsStaleForFile(path, reason) {
|
|
490
|
+
const changed = [];
|
|
491
|
+
for (const claim of this.listClaimsByFileEvidence(path)) {
|
|
492
|
+
if (claim.status === "stale" || claim.status === "quarantined") continue;
|
|
493
|
+
const updated = this.updateClaimStatus(claim.id, "stale", reason);
|
|
494
|
+
if (updated) changed.push(updated);
|
|
495
|
+
}
|
|
496
|
+
return changed;
|
|
497
|
+
}
|
|
498
|
+
promoteEligibleClaims(now = Date.now(), cwd) {
|
|
499
|
+
const promoted = [];
|
|
500
|
+
for (const claim of this.listClaims()) {
|
|
501
|
+
const next = evaluatePromotion(claim, now);
|
|
502
|
+
if (!next) continue;
|
|
503
|
+
if (!canRecallClaim(claim, cwd)) {
|
|
504
|
+
this.updateClaimStatus(claim.id, "stale", "recall-gate: evidence files no longer exist");
|
|
505
|
+
continue;
|
|
506
|
+
}
|
|
507
|
+
const updated = this.updateClaimStatus(claim.id, next, "promotion threshold met");
|
|
508
|
+
if (updated) promoted.push(updated);
|
|
509
|
+
}
|
|
510
|
+
this.evictExcessActiveClaims();
|
|
511
|
+
return promoted;
|
|
512
|
+
}
|
|
513
|
+
evictExcessActiveClaims() {
|
|
514
|
+
const evictable = this.listActiveClaims().filter((c) => c.status !== "durable");
|
|
515
|
+
if (evictable.length <= MAX_ACTIVE_CLAIMS) return;
|
|
516
|
+
const toEvict = [...evictable].sort((a, b) => a.createdAt - b.createdAt).slice(0, evictable.length - MAX_ACTIVE_CLAIMS);
|
|
517
|
+
for (const claim of toEvict) {
|
|
518
|
+
this.updateClaimStatus(claim.id, "stale", "evicted-overflow");
|
|
519
|
+
}
|
|
520
|
+
}
|
|
521
|
+
exportSession() {
|
|
522
|
+
const disk = existsSync3(this.path) ? readFileSync2(this.path, "utf-8") : "";
|
|
523
|
+
return disk + this.pendingLines.join("");
|
|
524
|
+
}
|
|
525
|
+
/**
|
|
526
|
+
* Checkpoint: write current claims state as a snapshot, then truncate JSONL.
|
|
527
|
+
* Follows Redis 7.0 Base+Incr pattern:
|
|
528
|
+
* - Snapshot = full projected state (base)
|
|
529
|
+
* - JSONL = incremental events after snapshot (incr)
|
|
530
|
+
* - Load = read snapshot + replay incr events
|
|
531
|
+
*/
|
|
532
|
+
checkpoint(now = Date.now()) {
|
|
533
|
+
if (this.writeChain.running) {
|
|
534
|
+
this.checkpointQueued = true;
|
|
535
|
+
this.kickWriteChain();
|
|
536
|
+
const snapshot = checkpointClaims(this.listClaims(), now, this.maxEventSeq(this.readEvents()));
|
|
537
|
+
return { snapshotPath: this.snapshotPath, claimCount: snapshot.claims.length, truncatedPath: this.path };
|
|
538
|
+
}
|
|
539
|
+
if (this.pendingLines.length > 0) {
|
|
540
|
+
try {
|
|
541
|
+
appendFileSync(this.path, this.pendingLines.join(""), "utf-8");
|
|
542
|
+
this.pendingLines = [];
|
|
543
|
+
this.pendingBytes = 0;
|
|
544
|
+
} catch {
|
|
545
|
+
}
|
|
546
|
+
}
|
|
547
|
+
this.checkpointing = true;
|
|
548
|
+
try {
|
|
549
|
+
const snapshot = checkpointClaims(this.listClaims(), now, this.maxEventSeq(this.readEvents()));
|
|
550
|
+
writeFileAtomicSync(this.snapshotPath, JSON.stringify(snapshot, null, 2) + "\n");
|
|
551
|
+
writeFileAtomicSync(this.path, "");
|
|
552
|
+
this.cachedEvents = [];
|
|
553
|
+
this.cachedClaims = loadClaimSnapshot(snapshot, now);
|
|
554
|
+
this.lastProcessedLineCount = 0;
|
|
555
|
+
this.lastFileSize = 0;
|
|
556
|
+
return { snapshotPath: this.snapshotPath, claimCount: snapshot.claims.length, truncatedPath: this.path };
|
|
557
|
+
} finally {
|
|
558
|
+
this.checkpointing = false;
|
|
559
|
+
}
|
|
560
|
+
}
|
|
561
|
+
/**
|
|
562
|
+
* Load claims from checkpoint snapshot. Incremental JSONL events are replayed
|
|
563
|
+
* by projectClaims() after this base state is loaded.
|
|
564
|
+
*/
|
|
565
|
+
loadFromCheckpoint(now = Date.now()) {
|
|
566
|
+
if (!existsSync3(this.snapshotPath)) return null;
|
|
567
|
+
try {
|
|
568
|
+
const raw = readFileSync2(this.snapshotPath, "utf-8");
|
|
569
|
+
const snapshot = JSON.parse(raw);
|
|
570
|
+
return {
|
|
571
|
+
claims: loadClaimSnapshot(snapshot, now),
|
|
572
|
+
// Old snapshots predate watermarks and were always paired with a
|
|
573
|
+
// truncated JSONL in production. Treat them as base state only.
|
|
574
|
+
lastEventSeq: snapshot.lastEventSeq ?? 0
|
|
575
|
+
};
|
|
576
|
+
} catch {
|
|
577
|
+
return null;
|
|
578
|
+
}
|
|
579
|
+
}
|
|
580
|
+
/** Delete checkpoint snapshot file (for testing or cleanup). */
|
|
581
|
+
deleteCheckpoint() {
|
|
582
|
+
if (existsSync3(this.snapshotPath)) {
|
|
583
|
+
unlinkSync(this.snapshotPath);
|
|
584
|
+
}
|
|
585
|
+
}
|
|
586
|
+
static loadDurableClaims(dir, sessionId) {
|
|
587
|
+
if (!existsSync3(join3(dir, `${sessionId}.claims.jsonl`)) && !existsSync3(join3(dir, `${sessionId}.claims.snapshot.json`))) return [];
|
|
588
|
+
const store = new _ContextClaimStore(dir, sessionId);
|
|
589
|
+
return store.listClaims().filter((c) => c.status === "durable");
|
|
590
|
+
}
|
|
591
|
+
readEvents() {
|
|
592
|
+
if (this.cachedEvents) {
|
|
593
|
+
if (!existsSync3(this.path)) {
|
|
594
|
+
return this.lastFileSize === this.pendingBytes ? this.cachedEvents : [];
|
|
595
|
+
}
|
|
596
|
+
const size = statSync(this.path).size;
|
|
597
|
+
if (size === this.lastFileSize - this.pendingBytes) return this.cachedEvents;
|
|
598
|
+
} else if (!existsSync3(this.path)) {
|
|
599
|
+
return [];
|
|
600
|
+
}
|
|
601
|
+
const content = readFileSync2(this.path, "utf-8");
|
|
602
|
+
this.lastFileSize = Buffer.byteLength(content);
|
|
603
|
+
const events = content.split("\n").filter((line) => line.trim().length > 0).flatMap((line) => {
|
|
604
|
+
try {
|
|
605
|
+
return [JSON.parse(line)];
|
|
606
|
+
} catch {
|
|
607
|
+
return [];
|
|
608
|
+
}
|
|
609
|
+
});
|
|
610
|
+
this.cachedEvents = events;
|
|
611
|
+
this.nextSeq = Math.max(1, this.maxEventSeq(events) + 1);
|
|
612
|
+
return events;
|
|
613
|
+
}
|
|
614
|
+
maxEventSeq(events) {
|
|
615
|
+
return events.reduce((max, event, index) => Math.max(max, event.seq ?? index + 1), 0);
|
|
616
|
+
}
|
|
617
|
+
projectClaims() {
|
|
618
|
+
const events = this.readEvents();
|
|
619
|
+
if (this.cachedClaims && this.lastProcessedLineCount === events.length) {
|
|
620
|
+
return this.cachedClaims;
|
|
621
|
+
}
|
|
622
|
+
if (this.cachedClaims && this.lastProcessedLineCount < events.length) {
|
|
623
|
+
const newEvents = events.slice(this.lastProcessedLineCount);
|
|
624
|
+
const map = new Map(this.cachedClaims.map((c) => [c.id, c]));
|
|
625
|
+
this.applyEventsToMap(map, newEvents);
|
|
626
|
+
this.cachedClaims = [...map.values()];
|
|
627
|
+
this.lastProcessedLineCount = events.length;
|
|
628
|
+
return this.cachedClaims;
|
|
629
|
+
}
|
|
630
|
+
const checkpointSnapshot = this.loadFromCheckpoint();
|
|
631
|
+
if (checkpointSnapshot) {
|
|
632
|
+
const claims2 = new Map(checkpointSnapshot.claims.map((c) => [c.id, c]));
|
|
633
|
+
this.nextSeq = Math.max(this.nextSeq, checkpointSnapshot.lastEventSeq + 1, this.maxEventSeq(events) + 1);
|
|
634
|
+
const replayEvents = events.filter((event, index) => (event.seq ?? index + 1) > checkpointSnapshot.lastEventSeq);
|
|
635
|
+
this.applyEventsToMap(claims2, replayEvents);
|
|
636
|
+
this.cachedClaims = [...claims2.values()];
|
|
637
|
+
this.lastProcessedLineCount = events.length;
|
|
638
|
+
return this.cachedClaims;
|
|
639
|
+
}
|
|
640
|
+
const claims = /* @__PURE__ */ new Map();
|
|
641
|
+
this.applyEventsToMap(claims, events);
|
|
642
|
+
this.cachedClaims = [...claims.values()];
|
|
643
|
+
this.lastProcessedLineCount = events.length;
|
|
644
|
+
return this.cachedClaims;
|
|
645
|
+
}
|
|
646
|
+
applyEventsToMap(claims, events) {
|
|
647
|
+
for (const event of events) {
|
|
648
|
+
if (event.type === "claim_proposed") {
|
|
649
|
+
if (!claims.has(event.claim.id)) {
|
|
650
|
+
claims.set(event.claim.id, event.claim);
|
|
651
|
+
}
|
|
652
|
+
continue;
|
|
653
|
+
}
|
|
654
|
+
if (event.type === "claim_status_changed") {
|
|
655
|
+
const claim = claims.get(event.claimId);
|
|
656
|
+
if (!claim) continue;
|
|
657
|
+
const counterevidence = event.status === "active" ? claim.counterevidence : [...claim.counterevidence, {
|
|
658
|
+
id: event.eventId,
|
|
659
|
+
kind: "tool_result",
|
|
660
|
+
summary: event.reason,
|
|
661
|
+
createdAt: event.createdAt
|
|
662
|
+
}];
|
|
663
|
+
claims.set(event.claimId, { ...claim, status: event.status, counterevidence });
|
|
664
|
+
continue;
|
|
665
|
+
}
|
|
666
|
+
if (event.type === "claim_used") {
|
|
667
|
+
const claim = claims.get(event.claimId);
|
|
668
|
+
if (!claim) continue;
|
|
669
|
+
const newConsumers = [...claim.consumers, {
|
|
670
|
+
id: event.consumerId,
|
|
671
|
+
kind: event.consumerKind,
|
|
672
|
+
usedAt: event.createdAt
|
|
673
|
+
}];
|
|
674
|
+
const cappedConsumers = newConsumers.length > MAX_CONSUMERS_PER_CLAIM ? newConsumers.slice(-MAX_CONSUMERS_PER_CLAIM) : newConsumers;
|
|
675
|
+
claims.set(event.claimId, {
|
|
676
|
+
...claim,
|
|
677
|
+
lastUsedAt: event.createdAt,
|
|
678
|
+
consumers: cappedConsumers
|
|
679
|
+
});
|
|
680
|
+
continue;
|
|
681
|
+
}
|
|
682
|
+
if (event.type === "claim_boosted") {
|
|
683
|
+
const claim = claims.get(event.claimId);
|
|
684
|
+
if (!claim) continue;
|
|
685
|
+
claims.set(event.claimId, { ...claim, fitness: event.fitness });
|
|
686
|
+
continue;
|
|
687
|
+
}
|
|
688
|
+
}
|
|
689
|
+
}
|
|
690
|
+
};
|
|
691
|
+
|
|
692
|
+
// src/agent/checksum.ts
|
|
693
|
+
init_esm_shims();
|
|
694
|
+
import { createHash as createHash3 } from "crypto";
|
|
695
|
+
function computeLineChecksum(jsonLine) {
|
|
696
|
+
const hash = createHash3("sha256").update(jsonLine).digest("hex");
|
|
697
|
+
return hash.slice(0, 16);
|
|
698
|
+
}
|
|
699
|
+
function appendChecksum(jsonLine) {
|
|
700
|
+
const checksum = computeLineChecksum(jsonLine);
|
|
701
|
+
return `${jsonLine}|${checksum}`;
|
|
702
|
+
}
|
|
703
|
+
function verifyAndExtract(line) {
|
|
704
|
+
const trimmed = line.trim();
|
|
705
|
+
if (!trimmed) {
|
|
706
|
+
return { valid: false, json: "", isLegacy: false, error: "Empty line" };
|
|
707
|
+
}
|
|
708
|
+
const lastPipe = trimmed.lastIndexOf("|");
|
|
709
|
+
if (lastPipe === -1) {
|
|
710
|
+
return { valid: true, json: trimmed, isLegacy: true };
|
|
711
|
+
}
|
|
712
|
+
const jsonPart = trimmed.slice(0, lastPipe);
|
|
713
|
+
const storedChecksum = trimmed.slice(lastPipe + 1);
|
|
714
|
+
if (!/^[0-9a-f]{16}$/.test(storedChecksum)) {
|
|
715
|
+
return { valid: true, json: trimmed, isLegacy: true };
|
|
716
|
+
}
|
|
717
|
+
try {
|
|
718
|
+
JSON.parse(jsonPart);
|
|
719
|
+
} catch {
|
|
720
|
+
return { valid: true, json: trimmed, isLegacy: true };
|
|
721
|
+
}
|
|
722
|
+
const computedChecksum = computeLineChecksum(jsonPart);
|
|
723
|
+
if (computedChecksum !== storedChecksum) {
|
|
724
|
+
return {
|
|
725
|
+
valid: false,
|
|
726
|
+
json: jsonPart,
|
|
727
|
+
isLegacy: false,
|
|
728
|
+
error: `Checksum mismatch: expected ${computedChecksum}, got ${storedChecksum}`
|
|
729
|
+
};
|
|
730
|
+
}
|
|
731
|
+
return { valid: true, json: jsonPart, isLegacy: false };
|
|
732
|
+
}
|
|
733
|
+
function verifyLines(lines) {
|
|
734
|
+
let invalidCount = 0;
|
|
735
|
+
let legacyCount = 0;
|
|
736
|
+
const validLines = [];
|
|
737
|
+
for (const line of lines) {
|
|
738
|
+
const result = verifyAndExtract(line);
|
|
739
|
+
if (result.valid) {
|
|
740
|
+
validLines.push(result.json);
|
|
741
|
+
if (result.isLegacy) legacyCount++;
|
|
742
|
+
} else {
|
|
743
|
+
invalidCount++;
|
|
744
|
+
}
|
|
745
|
+
}
|
|
746
|
+
return { validLines, invalidCount, legacyCount };
|
|
747
|
+
}
|
|
748
|
+
|
|
749
|
+
// src/agent/session-transcript-codec.ts
|
|
750
|
+
init_esm_shims();
|
|
751
|
+
import { constants, zstdCompressSync, zstdDecompressSync } from "zlib";
|
|
752
|
+
var ZSTD_MAGIC_LE = 4247762216;
|
|
753
|
+
var CHECKSUM_OPTIONS = {
|
|
754
|
+
params: { [constants.ZSTD_c_checksumFlag]: 1 }
|
|
755
|
+
};
|
|
756
|
+
function scanZstdFrames(buffer) {
|
|
757
|
+
const frames = [];
|
|
758
|
+
let offset = 0;
|
|
759
|
+
while (offset < buffer.length) {
|
|
760
|
+
const start = offset;
|
|
761
|
+
if (buffer.length - offset < 4) return { frames, tornStart: start };
|
|
762
|
+
if (buffer.readUInt32LE(offset) !== ZSTD_MAGIC_LE) {
|
|
763
|
+
throw new Error(`corrupt session transcript: invalid frame magic at byte ${offset}`);
|
|
764
|
+
}
|
|
765
|
+
offset += 4;
|
|
766
|
+
if (offset === buffer.length) return { frames, tornStart: start };
|
|
767
|
+
const descriptor = buffer.readUInt8(offset);
|
|
768
|
+
offset += 1;
|
|
769
|
+
if ((descriptor & 24) !== 0) {
|
|
770
|
+
throw new Error(`corrupt session transcript: reserved frame-header bit at byte ${offset - 1}`);
|
|
771
|
+
}
|
|
772
|
+
const contentSizeFlag = descriptor >>> 6;
|
|
773
|
+
const singleSegment = (descriptor & 32) !== 0;
|
|
774
|
+
const checksum = (descriptor & 4) !== 0;
|
|
775
|
+
const dictionaryFlag = descriptor & 3;
|
|
776
|
+
const dictionaryBytes = dictionaryFlag === 3 ? 4 : dictionaryFlag;
|
|
777
|
+
const contentSizeBytes = contentSizeFlag === 0 ? singleSegment ? 1 : 0 : 1 << contentSizeFlag;
|
|
778
|
+
const remainingHeaderBytes = (singleSegment ? 0 : 1) + dictionaryBytes + contentSizeBytes;
|
|
779
|
+
if (buffer.length - offset < remainingHeaderBytes) return { frames, tornStart: start };
|
|
780
|
+
offset += remainingHeaderBytes;
|
|
781
|
+
for (; ; ) {
|
|
782
|
+
if (buffer.length - offset < 3) return { frames, tornStart: start };
|
|
783
|
+
const blockHeader = buffer.readUIntLE(offset, 3);
|
|
784
|
+
offset += 3;
|
|
785
|
+
const lastBlock = (blockHeader & 1) !== 0;
|
|
786
|
+
const blockType = blockHeader >>> 1 & 3;
|
|
787
|
+
const blockSize = blockHeader >>> 3;
|
|
788
|
+
if (blockType === 3) {
|
|
789
|
+
throw new Error(`corrupt session transcript: reserved block type at byte ${offset - 3}`);
|
|
790
|
+
}
|
|
791
|
+
const payloadBytes = blockType === 1 ? 1 : blockSize;
|
|
792
|
+
if (buffer.length - offset < payloadBytes) return { frames, tornStart: start };
|
|
793
|
+
offset += payloadBytes;
|
|
794
|
+
if (lastBlock) break;
|
|
795
|
+
}
|
|
796
|
+
if (checksum) {
|
|
797
|
+
if (buffer.length - offset < 4) return { frames, tornStart: start };
|
|
798
|
+
offset += 4;
|
|
799
|
+
}
|
|
800
|
+
frames.push({ start, end: offset });
|
|
801
|
+
}
|
|
802
|
+
return { frames };
|
|
803
|
+
}
|
|
804
|
+
function isZstdFrameStream(buffer) {
|
|
805
|
+
return buffer.length >= 4 && buffer.readUInt32LE(0) === ZSTD_MAGIC_LE;
|
|
806
|
+
}
|
|
807
|
+
function encodeBatch(text) {
|
|
808
|
+
if (text.length === 0) return Buffer.alloc(0);
|
|
809
|
+
return zstdCompressSync(Buffer.from(text, "utf-8"), CHECKSUM_OPTIONS);
|
|
810
|
+
}
|
|
811
|
+
function decodeTranscriptText(buffer) {
|
|
812
|
+
if (buffer.length === 0) return "";
|
|
813
|
+
if (!isZstdFrameStream(buffer)) return buffer.toString("utf-8");
|
|
814
|
+
const { frames } = scanZstdFrames(buffer);
|
|
815
|
+
if (frames.length === 0) return "";
|
|
816
|
+
const parts = [];
|
|
817
|
+
for (const frame of frames) {
|
|
818
|
+
const plain = zstdDecompressSync(buffer.subarray(frame.start, frame.end));
|
|
819
|
+
parts.push(plain.toString("utf-8"));
|
|
820
|
+
}
|
|
821
|
+
return parts.join("");
|
|
822
|
+
}
|
|
823
|
+
|
|
824
|
+
// src/agent/session-batch-writer.ts
|
|
825
|
+
init_esm_shims();
|
|
826
|
+
import { appendFileSync as appendFileSync2, copyFileSync, existsSync as existsSync4, readFileSync as readFileSync3 } from "fs";
|
|
827
|
+
import { appendFile as appendFile2, open } from "fs/promises";
|
|
828
|
+
import { join as join4 } from "path";
|
|
829
|
+
var SessionBatchWriter = class {
|
|
830
|
+
constructor(filePath, backupDirProvider) {
|
|
831
|
+
this.filePath = filePath;
|
|
832
|
+
this.backupDirProvider = backupDirProvider;
|
|
833
|
+
}
|
|
834
|
+
filePath;
|
|
835
|
+
backupDirProvider;
|
|
836
|
+
/** Write-behind batch: JSONL text waiting for the next flush. */
|
|
837
|
+
pendingBuffer = "";
|
|
838
|
+
flushTimer = null;
|
|
839
|
+
flushPromise = null;
|
|
840
|
+
/** Set once the transcript file is known to be zstd-frame format. */
|
|
841
|
+
codecReady = false;
|
|
842
|
+
/**
|
|
843
|
+
* Queue a JSONL line into the batch. The batch is flushed by a 200ms timer,
|
|
844
|
+
* an explicit flush barrier, or compaction — whichever comes first.
|
|
845
|
+
*
|
|
846
|
+
* Exception: the first line of a brand-new session flushes synchronously so
|
|
847
|
+
* the session file exists immediately, and a crash within the first 200ms
|
|
848
|
+
* cannot lose the entire session.
|
|
849
|
+
*/
|
|
850
|
+
enqueueLine(line) {
|
|
851
|
+
this.pendingBuffer += line;
|
|
852
|
+
if (this.flushTimer === null) {
|
|
853
|
+
if (!this.codecReady && !existsSync4(this.filePath)) {
|
|
854
|
+
this.flushSync();
|
|
855
|
+
return;
|
|
856
|
+
}
|
|
857
|
+
this.flushTimer = setTimeout(() => {
|
|
858
|
+
this.flushTimer = null;
|
|
859
|
+
void this.flush().catch((err) => {
|
|
860
|
+
console.error("[session-persist] batch flush failed:", err);
|
|
861
|
+
});
|
|
862
|
+
}, 200);
|
|
863
|
+
this.flushTimer.unref?.();
|
|
864
|
+
}
|
|
865
|
+
}
|
|
866
|
+
/**
|
|
867
|
+
* Flush barrier: cancel the timer, drain the pending batch into one
|
|
868
|
+
* checksummed zstd frame append + datasync. Concurrent callers share the
|
|
869
|
+
* in-flight drain promise; a drain that arrives while one is already in
|
|
870
|
+
* flight still drains whatever queued in the meantime (the awaited promise
|
|
871
|
+
* only covers the batch it started with).
|
|
872
|
+
*
|
|
873
|
+
* On write failure the batch is put back at the head of the buffer so the
|
|
874
|
+
* next flush / shutdown drain retries it — a failed append must not
|
|
875
|
+
* permanently drop queued lines (ENOSPC etc.).
|
|
876
|
+
*/
|
|
877
|
+
async flush() {
|
|
878
|
+
if (this.flushTimer !== null) {
|
|
879
|
+
clearTimeout(this.flushTimer);
|
|
880
|
+
this.flushTimer = null;
|
|
881
|
+
}
|
|
882
|
+
if (this.flushPromise !== null) return this.flushPromise;
|
|
883
|
+
this.flushPromise = this.drainPending().finally(() => {
|
|
884
|
+
this.flushPromise = null;
|
|
885
|
+
});
|
|
886
|
+
return this.flushPromise;
|
|
887
|
+
}
|
|
888
|
+
async drainPending() {
|
|
889
|
+
while (this.pendingBuffer.length > 0) {
|
|
890
|
+
const text = this.pendingBuffer;
|
|
891
|
+
this.pendingBuffer = "";
|
|
892
|
+
try {
|
|
893
|
+
await this.writeBatch(text);
|
|
894
|
+
} catch (err) {
|
|
895
|
+
this.pendingBuffer = text + this.pendingBuffer;
|
|
896
|
+
throw err;
|
|
897
|
+
}
|
|
898
|
+
}
|
|
899
|
+
}
|
|
900
|
+
/** Synchronous flush variant for the sync compaction paths. */
|
|
901
|
+
flushSync() {
|
|
902
|
+
if (this.flushTimer !== null) {
|
|
903
|
+
clearTimeout(this.flushTimer);
|
|
904
|
+
this.flushTimer = null;
|
|
905
|
+
}
|
|
906
|
+
const text = this.pendingBuffer;
|
|
907
|
+
this.pendingBuffer = "";
|
|
908
|
+
if (text.length > 0) {
|
|
909
|
+
this.ensureCodecFormat();
|
|
910
|
+
const frame = encodeBatch(text);
|
|
911
|
+
if (frame.length > 0) appendFileSync2(this.filePath, frame, { mode: 384 });
|
|
912
|
+
}
|
|
913
|
+
}
|
|
914
|
+
/**
|
|
915
|
+
* Merge the on-disk transcript text with lines still queued in the batch —
|
|
916
|
+
* in-process readers must see un-flushed lines (append followed by load
|
|
917
|
+
* without a flush is valid).
|
|
918
|
+
*/
|
|
919
|
+
mergePending(onDiskText) {
|
|
920
|
+
return this.pendingBuffer.length > 0 ? onDiskText + this.pendingBuffer : onDiskText;
|
|
921
|
+
}
|
|
922
|
+
async writeBatch(text) {
|
|
923
|
+
this.ensureCodecFormat();
|
|
924
|
+
const frame = encodeBatch(text);
|
|
925
|
+
if (frame.length === 0) return;
|
|
926
|
+
await appendFile2(this.filePath, frame, { mode: 384 });
|
|
927
|
+
await this.fdatasyncQuiet();
|
|
928
|
+
}
|
|
929
|
+
/**
|
|
930
|
+
* One-time migration: on the first write, a legacy plain-text transcript is
|
|
931
|
+
* backed up and transcoded to a single zstd frame so subsequent appends can
|
|
932
|
+
* stay frame-aligned. New files and already-compressed files are untouched.
|
|
933
|
+
*/
|
|
934
|
+
ensureCodecFormat() {
|
|
935
|
+
if (this.codecReady) return;
|
|
936
|
+
this.codecReady = true;
|
|
937
|
+
if (!existsSync4(this.filePath)) return;
|
|
938
|
+
const head = readFileSync3(this.filePath);
|
|
939
|
+
if (isZstdFrameStream(head)) return;
|
|
940
|
+
try {
|
|
941
|
+
copyFileSync(this.filePath, join4(this.backupDirProvider(), this.filePath.split("/").pop() + ".pre-zstd"));
|
|
942
|
+
} catch {
|
|
943
|
+
}
|
|
944
|
+
const frame = encodeBatch(head.toString("utf-8"));
|
|
945
|
+
writeFileAtomicSync(this.filePath, frame.length > 0 ? frame : Buffer.alloc(0));
|
|
946
|
+
}
|
|
947
|
+
/** Force appended data to disk without blocking the event loop. */
|
|
948
|
+
async fdatasyncQuiet() {
|
|
949
|
+
try {
|
|
950
|
+
const fh = await open(this.filePath, "a");
|
|
951
|
+
try {
|
|
952
|
+
await fh.datasync();
|
|
953
|
+
} finally {
|
|
954
|
+
await fh.close();
|
|
955
|
+
}
|
|
956
|
+
} catch {
|
|
957
|
+
}
|
|
958
|
+
}
|
|
959
|
+
};
|
|
960
|
+
|
|
961
|
+
// src/agent/session-metadata.ts
|
|
962
|
+
init_esm_shims();
|
|
963
|
+
import { existsSync as existsSync5, readFileSync as readFileSync4 } from "fs";
|
|
964
|
+
var SessionMetadataStore = class {
|
|
965
|
+
constructor(metadataPath) {
|
|
966
|
+
this.metadataPath = metadataPath;
|
|
967
|
+
}
|
|
968
|
+
metadataPath;
|
|
969
|
+
/** In-memory cache: null = not loaded, undefined = no file on disk. */
|
|
970
|
+
cached = null;
|
|
971
|
+
/** Set when in-memory metadata diverges from disk. */
|
|
972
|
+
dirty = false;
|
|
973
|
+
load() {
|
|
974
|
+
if (this.cached !== null) return this.cached;
|
|
975
|
+
if (!existsSync5(this.metadataPath)) {
|
|
976
|
+
this.cached = void 0;
|
|
977
|
+
return void 0;
|
|
978
|
+
}
|
|
979
|
+
try {
|
|
980
|
+
this.cached = JSON.parse(readFileSync4(this.metadataPath, "utf-8"));
|
|
981
|
+
} catch {
|
|
982
|
+
this.cached = void 0;
|
|
983
|
+
}
|
|
984
|
+
return this.cached;
|
|
985
|
+
}
|
|
986
|
+
/** Synchronously durable write (initMetadata / external writers). */
|
|
987
|
+
write(metadata) {
|
|
988
|
+
this.cached = metadata;
|
|
989
|
+
this.dirty = false;
|
|
990
|
+
writeFileAtomicSync(this.metadataPath, JSON.stringify(metadata, null, 2) + "\n");
|
|
991
|
+
}
|
|
992
|
+
/**
|
|
993
|
+
* Upsert fields in memory only. Merge semantics: sessionId/createdAt win
|
|
994
|
+
* from explicit arguments, updatedAt always advances, tokenUsage merges
|
|
995
|
+
* nested instead of replacing. Disk write rides the batch flush cadence.
|
|
996
|
+
*/
|
|
997
|
+
update(patch, sessionId) {
|
|
998
|
+
const existing = this.load();
|
|
999
|
+
const merged = {
|
|
1000
|
+
compactEvents: existing?.compactEvents ?? [],
|
|
1001
|
+
...existing,
|
|
1002
|
+
...patch,
|
|
1003
|
+
// These must win over ...existing/...patch — place them last:
|
|
1004
|
+
// - sessionId is authoritative from the caller
|
|
1005
|
+
// - createdAt is set once at creation and preserved thereafter
|
|
1006
|
+
// - updatedAt always advances to now (the whole point of the field;
|
|
1007
|
+
// spreading ...existing after it would freeze it at creation time)
|
|
1008
|
+
sessionId,
|
|
1009
|
+
createdAt: existing?.createdAt ?? Date.now(),
|
|
1010
|
+
updatedAt: Date.now(),
|
|
1011
|
+
// Preserve nested objects by merging, not replacing
|
|
1012
|
+
tokenUsage: existing?.tokenUsage || patch.tokenUsage ? { prompt: 0, completion: 0, total: 0, ...existing?.tokenUsage, ...patch.tokenUsage } : void 0
|
|
1013
|
+
};
|
|
1014
|
+
this.cached = merged;
|
|
1015
|
+
this.dirty = true;
|
|
1016
|
+
}
|
|
1017
|
+
/** Persist in-memory metadata when dirty (batch-flush cadence). */
|
|
1018
|
+
flush() {
|
|
1019
|
+
if (!this.dirty) return;
|
|
1020
|
+
try {
|
|
1021
|
+
writeFileAtomicSync(this.metadataPath, JSON.stringify(this.cached ?? {}, null, 2) + "\n");
|
|
1022
|
+
} catch (err) {
|
|
1023
|
+
this.dirty = true;
|
|
1024
|
+
throw err;
|
|
1025
|
+
}
|
|
1026
|
+
this.dirty = false;
|
|
1027
|
+
}
|
|
1028
|
+
};
|
|
1029
|
+
|
|
1030
|
+
// src/agent/session-persist.ts
|
|
1031
|
+
init_paths();
|
|
1032
|
+
function legacyMessageToOaiMessages(message) {
|
|
1033
|
+
if (typeof message.content === "string") {
|
|
1034
|
+
return [{ role: message.role, content: message.content }];
|
|
1035
|
+
}
|
|
1036
|
+
if (message.role === "user") {
|
|
1037
|
+
const text2 = message.content.filter((block) => block.type === "text").map((block) => block.text).join("");
|
|
1038
|
+
const toolMessages = message.content.filter((block) => block.type === "tool_result").map((block) => ({ role: "tool", tool_call_id: block.tool_use_id, content: block.content }));
|
|
1039
|
+
return [
|
|
1040
|
+
...text2 ? [{ role: "user", content: text2 }] : [],
|
|
1041
|
+
...toolMessages
|
|
1042
|
+
];
|
|
1043
|
+
}
|
|
1044
|
+
const text = message.content.filter((block) => block.type === "text").map((block) => block.text).join("");
|
|
1045
|
+
const reasoning = message.content.filter((block) => block.type === "thinking").map((block) => block.thinking).join("");
|
|
1046
|
+
const toolCalls = message.content.filter((block) => block.type === "tool_use").map((block) => ({
|
|
1047
|
+
id: block.id,
|
|
1048
|
+
type: "function",
|
|
1049
|
+
function: { name: block.name, arguments: stableStringify(block.input) }
|
|
1050
|
+
}));
|
|
1051
|
+
const assistant = {
|
|
1052
|
+
role: "assistant",
|
|
1053
|
+
content: text || (toolCalls.length === 0 ? "" : null),
|
|
1054
|
+
...reasoning ? { reasoning_content: reasoning } : {},
|
|
1055
|
+
...toolCalls.length > 0 ? { tool_calls: toolCalls } : {}
|
|
1056
|
+
};
|
|
1057
|
+
return [assistant];
|
|
1058
|
+
}
|
|
1059
|
+
function getSessionDir(cwd) {
|
|
1060
|
+
return sessionsDir(cwd);
|
|
1061
|
+
}
|
|
1062
|
+
function ensureDir(dir) {
|
|
1063
|
+
if (!existsSync6(dir)) {
|
|
1064
|
+
mkdirSync2(dir, { recursive: true });
|
|
1065
|
+
}
|
|
1066
|
+
}
|
|
1067
|
+
var MAX_SESSION_MESSAGE_JSON_CHARS = 1e5;
|
|
1068
|
+
function truncateString(value, maxChars) {
|
|
1069
|
+
if (value.length <= maxChars) return value;
|
|
1070
|
+
const marker = `
|
|
1071
|
+
<session-message-truncated original_chars="${value.length}" kept_chars="${maxChars}" />`;
|
|
1072
|
+
const keep = Math.max(0, maxChars - marker.length);
|
|
1073
|
+
return value.slice(0, keep) + marker;
|
|
1074
|
+
}
|
|
1075
|
+
function capJsonValue(value, maxChars) {
|
|
1076
|
+
if (typeof value === "string") return truncateString(value, maxChars);
|
|
1077
|
+
if (Array.isArray(value)) return value.map((item) => capJsonValue(item, maxChars));
|
|
1078
|
+
if (value && typeof value === "object") {
|
|
1079
|
+
const capped = {};
|
|
1080
|
+
for (const [key, child] of Object.entries(value)) {
|
|
1081
|
+
capped[key] = capJsonValue(child, maxChars);
|
|
1082
|
+
}
|
|
1083
|
+
return capped;
|
|
1084
|
+
}
|
|
1085
|
+
return value;
|
|
1086
|
+
}
|
|
1087
|
+
function serializeSessionMessage(message, maxChars = MAX_SESSION_MESSAGE_JSON_CHARS) {
|
|
1088
|
+
return serializeSessionJsonValue(message, maxChars, () => ({
|
|
1089
|
+
role: message.role,
|
|
1090
|
+
content: truncateString(JSON.stringify(message), maxChars)
|
|
1091
|
+
}));
|
|
1092
|
+
}
|
|
1093
|
+
function serializeOaiSessionMessage(message, maxChars = MAX_SESSION_MESSAGE_JSON_CHARS) {
|
|
1094
|
+
const normalized = normalizeOaiMessage(message);
|
|
1095
|
+
return serializeSessionJsonValue(normalized, maxChars, () => ({
|
|
1096
|
+
role: normalized.role,
|
|
1097
|
+
content: truncateString(JSON.stringify(normalized), maxChars),
|
|
1098
|
+
...normalized.role === "tool" ? { tool_call_id: normalized.tool_call_id } : {}
|
|
1099
|
+
}));
|
|
1100
|
+
}
|
|
1101
|
+
function serializeSessionJsonValue(message, maxChars, fallback) {
|
|
1102
|
+
let json = JSON.stringify(message);
|
|
1103
|
+
if (json.length <= maxChars) return json;
|
|
1104
|
+
const capped = capJsonValue(message, Math.max(1e3, Math.floor(maxChars * 0.8)));
|
|
1105
|
+
json = JSON.stringify(capped);
|
|
1106
|
+
if (json.length <= maxChars) return json;
|
|
1107
|
+
return JSON.stringify(fallback());
|
|
1108
|
+
}
|
|
1109
|
+
function isOaiMessage(value) {
|
|
1110
|
+
if (!value || typeof value !== "object") return false;
|
|
1111
|
+
const msg = value;
|
|
1112
|
+
if (msg.role === "system") return typeof msg.content === "string";
|
|
1113
|
+
if (msg.role === "user") return typeof msg.content === "string";
|
|
1114
|
+
if (msg.role === "assistant") return typeof msg.content === "string" || msg.content === null;
|
|
1115
|
+
if (msg.role === "tool") return typeof msg.tool_call_id === "string" && typeof msg.content === "string";
|
|
1116
|
+
return false;
|
|
1117
|
+
}
|
|
1118
|
+
function parseSessionLine(line) {
|
|
1119
|
+
const parsed = JSON.parse(line);
|
|
1120
|
+
if (parsed.type === "compact_start" || parsed.type === "compact_end" || parsed.type === "model_switch") {
|
|
1121
|
+
return null;
|
|
1122
|
+
}
|
|
1123
|
+
return parsed;
|
|
1124
|
+
}
|
|
1125
|
+
function readHistoricalTranscript(filePath) {
|
|
1126
|
+
if (!existsSync6(filePath)) return [];
|
|
1127
|
+
let lines;
|
|
1128
|
+
try {
|
|
1129
|
+
lines = decodeTranscriptText(readFileSync5(filePath)).trim().split("\n").filter(Boolean);
|
|
1130
|
+
} catch {
|
|
1131
|
+
return [];
|
|
1132
|
+
}
|
|
1133
|
+
const messages = [];
|
|
1134
|
+
for (const line of verifyLines(lines).validLines) {
|
|
1135
|
+
try {
|
|
1136
|
+
const parsed = parseSessionLine(line);
|
|
1137
|
+
if (parsed && (parsed.role === "user" || parsed.role === "assistant") && typeof parsed.content === "string") {
|
|
1138
|
+
messages.push({ role: parsed.role, content: parsed.content });
|
|
1139
|
+
}
|
|
1140
|
+
} catch {
|
|
1141
|
+
}
|
|
1142
|
+
}
|
|
1143
|
+
return messages;
|
|
1144
|
+
}
|
|
1145
|
+
var SessionPersist = class _SessionPersist {
|
|
1146
|
+
filePath;
|
|
1147
|
+
metadataPath;
|
|
1148
|
+
frozenPath;
|
|
1149
|
+
sessionId;
|
|
1150
|
+
cwd;
|
|
1151
|
+
/** Write-behind batch writer (zstd frames + 200ms batching + torn recovery). */
|
|
1152
|
+
batchWriter;
|
|
1153
|
+
/** Metadata store (in-memory cache + batch-flush cadence). */
|
|
1154
|
+
metaStore;
|
|
1155
|
+
/** Public getter for testing file-path-dependent integrations. */
|
|
1156
|
+
getFilePath() {
|
|
1157
|
+
return this.filePath;
|
|
1158
|
+
}
|
|
1159
|
+
constructor(sessionId, cwd) {
|
|
1160
|
+
assertValidSessionId(sessionId);
|
|
1161
|
+
this.cwd = cwd;
|
|
1162
|
+
ensureDir(getSessionDir(cwd));
|
|
1163
|
+
this.sessionId = sessionId;
|
|
1164
|
+
this.filePath = join5(getSessionDir(cwd), `${sessionId}.jsonl`);
|
|
1165
|
+
this.metadataPath = join5(getSessionDir(cwd), `${sessionId}.meta.json`);
|
|
1166
|
+
this.frozenPath = join5(getSessionDir(cwd), `${sessionId}.frozen.json`);
|
|
1167
|
+
this.batchWriter = new SessionBatchWriter(this.filePath, () => this.getBackupDir());
|
|
1168
|
+
this.metaStore = new SessionMetadataStore(this.metadataPath);
|
|
1169
|
+
}
|
|
1170
|
+
getBackupDir() {
|
|
1171
|
+
const dir = join5(getSessionDir(this.cwd), this.sessionId, "backups");
|
|
1172
|
+
ensureDir(dir);
|
|
1173
|
+
return dir;
|
|
1174
|
+
}
|
|
1175
|
+
/** Append a single message to the session file (checksummed batch path). */
|
|
1176
|
+
async append(message) {
|
|
1177
|
+
const line = serializeSessionMessage(message) + "\n";
|
|
1178
|
+
this.batchWriter.enqueueLine(line);
|
|
1179
|
+
}
|
|
1180
|
+
/** Flush barrier: drain the write-behind batch into a zstd frame on disk. */
|
|
1181
|
+
async flushSessionBuffer() {
|
|
1182
|
+
await this.batchWriter.flush();
|
|
1183
|
+
this.metaStore.flush();
|
|
1184
|
+
}
|
|
1185
|
+
/** Read the transcript file as JSONL text (zstd frames or legacy passthrough). */
|
|
1186
|
+
readTranscriptText() {
|
|
1187
|
+
const onDisk = existsSync6(this.filePath) ? decodeTranscriptText(readFileSync5(this.filePath)) : "";
|
|
1188
|
+
return this.batchWriter.mergePending(onDisk);
|
|
1189
|
+
}
|
|
1190
|
+
/** Load all messages from the session file (with checksum validation) */
|
|
1191
|
+
load() {
|
|
1192
|
+
return this.loadWithChecksum();
|
|
1193
|
+
}
|
|
1194
|
+
/** Append an OpenAI-native message with checksum (queued into the batch).
|
|
1195
|
+
*
|
|
1196
|
+
* `flush: true` drains the batch after enqueue — used for durability-critical
|
|
1197
|
+
* records (assistant tool_calls / tool results / user turns) so a hard crash
|
|
1198
|
+
* cannot lose a completed tool result while its disk side effect survives.
|
|
1199
|
+
*/
|
|
1200
|
+
async appendOaiWithChecksum(message, options) {
|
|
1201
|
+
const json = serializeOaiSessionMessage(message);
|
|
1202
|
+
const line = appendChecksum(json) + "\n";
|
|
1203
|
+
this.batchWriter.enqueueLine(line);
|
|
1204
|
+
if (options?.flush) await this.batchWriter.flush();
|
|
1205
|
+
}
|
|
1206
|
+
/**
|
|
1207
|
+
* Append a model-switch event to the session transcript.
|
|
1208
|
+
*
|
|
1209
|
+
* Written as a checksummed `type: 'model_switch'` line so it survives
|
|
1210
|
+
* checksum verification, but parseSessionLine skips it on replay (same
|
|
1211
|
+
* pattern as compact_start/compact_end) — it's an audit breadcrumb, not
|
|
1212
|
+
* part of the conversation history. Lets a session JSONL show exactly
|
|
1213
|
+
* when/what the model changed mid-session.
|
|
1214
|
+
*/
|
|
1215
|
+
appendModelSwitch(event) {
|
|
1216
|
+
const line = appendChecksum(JSON.stringify({
|
|
1217
|
+
type: "model_switch",
|
|
1218
|
+
t: Date.now(),
|
|
1219
|
+
from: event.from,
|
|
1220
|
+
to: event.to,
|
|
1221
|
+
provider: event.provider
|
|
1222
|
+
})) + "\n";
|
|
1223
|
+
this.batchWriter.enqueueLine(line);
|
|
1224
|
+
}
|
|
1225
|
+
/** Load messages in OpenAI-native format, migrating legacy rows on read. */
|
|
1226
|
+
loadOai() {
|
|
1227
|
+
const content = this.readTranscriptText();
|
|
1228
|
+
const lines = content.trim().split("\n").filter(Boolean);
|
|
1229
|
+
const { validLines } = verifyLines(lines);
|
|
1230
|
+
const messages = [];
|
|
1231
|
+
for (const line of validLines) {
|
|
1232
|
+
try {
|
|
1233
|
+
const parsed = parseSessionLine(line);
|
|
1234
|
+
if (!parsed) continue;
|
|
1235
|
+
if (isOaiMessage(parsed)) {
|
|
1236
|
+
messages.push(parsed);
|
|
1237
|
+
} else {
|
|
1238
|
+
messages.push(...legacyMessageToOaiMessages(parsed).map((message) => JSON.parse(JSON.stringify(message))));
|
|
1239
|
+
}
|
|
1240
|
+
} catch {
|
|
1241
|
+
}
|
|
1242
|
+
}
|
|
1243
|
+
const normalized = normalizeOaiMessages(messages);
|
|
1244
|
+
const { messages: repaired, hadOrphans, strippedWriteTool } = this.repairOrphanToolCalls(normalized);
|
|
1245
|
+
if (hadOrphans) {
|
|
1246
|
+
repaired.unshift({
|
|
1247
|
+
role: "system",
|
|
1248
|
+
content: strippedWriteTool ? "<system-reminder>The previous session was interrupted mid-turn. A write/edit tool call from the last assistant message was stripped because its result was not recorded \u2014 but the interruption may have happened either before OR after the file was actually written, so the file may or may not already contain the intended changes. Do NOT blindly re-run the write. First verify the file's current state with read_file or grep, then only write what is still missing. This is host-process interruption recovery, NOT a tool malfunction \u2014 the write tools remain fully functional; keep using them normally instead of bash workarounds.</system-reminder>" : "<system-reminder>The previous session was interrupted mid-turn. Some tool calls from the last assistant message were stripped because their results were not recorded. Re-run any read-only/search steps you still need; verify state before assuming any side effects took hold.</system-reminder>"
|
|
1249
|
+
});
|
|
1250
|
+
}
|
|
1251
|
+
return repaired.map(normalizeOaiMessage);
|
|
1252
|
+
}
|
|
1253
|
+
/** Tools whose orphan recovery must be non-destructive: the file may already
|
|
1254
|
+
* hold the intended changes, so the model must verify before re-writing. */
|
|
1255
|
+
static WRITE_TOOL_NAMES = /* @__PURE__ */ new Set([
|
|
1256
|
+
"write_file",
|
|
1257
|
+
"edit_file",
|
|
1258
|
+
"hash_edit",
|
|
1259
|
+
"ast_edit",
|
|
1260
|
+
"apply_patch"
|
|
1261
|
+
]);
|
|
1262
|
+
/** 压#7: Remove orphan tool_use/tool_result pairs left by corrupted/missing lines.
|
|
1263
|
+
*
|
|
1264
|
+
* Orphan tool_use entries occur when the stream delivered a complete
|
|
1265
|
+
* tool_calls block but the process was killed before the tool executed
|
|
1266
|
+
* (force-kill, power loss, or stream error after tool_use commit). The tool
|
|
1267
|
+
* was NEVER run — any files it would have created/modified do NOT exist.
|
|
1268
|
+
* Returns whether any orphans were stripped so the caller can warn the model
|
|
1269
|
+
* not to assume side effects from the removed tools, plus whether any stripped
|
|
1270
|
+
* orphan was a write/edit tool (so the warning can be non-destructive: the
|
|
1271
|
+
* file may already hold the change, verify before re-running). */
|
|
1272
|
+
repairOrphanToolCalls(messages) {
|
|
1273
|
+
const toolCallIds = /* @__PURE__ */ new Set();
|
|
1274
|
+
const toolResultIndices = /* @__PURE__ */ new Map();
|
|
1275
|
+
for (let i = 0; i < messages.length; i++) {
|
|
1276
|
+
const msg = messages[i];
|
|
1277
|
+
if (msg.role === "assistant" && msg.tool_calls) {
|
|
1278
|
+
for (const tc of msg.tool_calls) {
|
|
1279
|
+
if (tc.id) toolCallIds.add(tc.id);
|
|
1280
|
+
}
|
|
1281
|
+
}
|
|
1282
|
+
if (msg.role === "tool" && msg.tool_call_id) {
|
|
1283
|
+
toolResultIndices.set(msg.tool_call_id, i);
|
|
1284
|
+
}
|
|
1285
|
+
}
|
|
1286
|
+
const orphanResultIdx = /* @__PURE__ */ new Set();
|
|
1287
|
+
for (const [id, idx] of toolResultIndices) {
|
|
1288
|
+
if (!toolCallIds.has(id)) orphanResultIdx.add(idx);
|
|
1289
|
+
}
|
|
1290
|
+
const result = [];
|
|
1291
|
+
let hadOrphans = false;
|
|
1292
|
+
let strippedWriteTool = false;
|
|
1293
|
+
const noteStripped = (tc) => {
|
|
1294
|
+
if (_SessionPersist.WRITE_TOOL_NAMES.has(tc.function?.name ?? "")) strippedWriteTool = true;
|
|
1295
|
+
};
|
|
1296
|
+
for (let i = 0; i < messages.length; i++) {
|
|
1297
|
+
const msg = messages[i];
|
|
1298
|
+
if (orphanResultIdx.has(i)) {
|
|
1299
|
+
hadOrphans = true;
|
|
1300
|
+
continue;
|
|
1301
|
+
}
|
|
1302
|
+
if (msg.role === "assistant" && msg.tool_calls) {
|
|
1303
|
+
const valid = msg.tool_calls.filter((tc) => tc.id && toolResultIndices.has(tc.id));
|
|
1304
|
+
const orphaned = msg.tool_calls.filter((tc) => !(tc.id && toolResultIndices.has(tc.id)));
|
|
1305
|
+
if (valid.length === 0 && !msg.content) {
|
|
1306
|
+
hadOrphans = true;
|
|
1307
|
+
orphaned.forEach(noteStripped);
|
|
1308
|
+
continue;
|
|
1309
|
+
}
|
|
1310
|
+
if (valid.length !== msg.tool_calls.length) {
|
|
1311
|
+
hadOrphans = true;
|
|
1312
|
+
orphaned.forEach(noteStripped);
|
|
1313
|
+
result.push({ ...msg, tool_calls: valid });
|
|
1314
|
+
continue;
|
|
1315
|
+
}
|
|
1316
|
+
}
|
|
1317
|
+
result.push(msg);
|
|
1318
|
+
}
|
|
1319
|
+
return { messages: result, hadOrphans, strippedWriteTool };
|
|
1320
|
+
}
|
|
1321
|
+
/** Audit breadcrumb types: skipped on replay (parseSessionLine), preserved across rewrites. */
|
|
1322
|
+
static AUDIT_LINE_TYPES = /* @__PURE__ */ new Set(["compact_start", "compact_end", "model_switch"]);
|
|
1323
|
+
/**
|
|
1324
|
+
* Collect audit breadcrumb lines from the current file, re-checksummed for a
|
|
1325
|
+
* full rewrite. Rewrites (compact / compactOai / compactOaiAsync) regenerate
|
|
1326
|
+
* the file from in-memory messages only, and audit lines never enter memory
|
|
1327
|
+
* (parseSessionLine skips them on replay) — without this, the first rewrite
|
|
1328
|
+
* after appendModelSwitch silently destroys the audit trail.
|
|
1329
|
+
*/
|
|
1330
|
+
collectAuditLines() {
|
|
1331
|
+
if (!existsSync6(this.filePath)) return [];
|
|
1332
|
+
try {
|
|
1333
|
+
const lines = this.readTranscriptText().trim().split("\n").filter(Boolean);
|
|
1334
|
+
const { validLines } = verifyLines(lines);
|
|
1335
|
+
const audit = [];
|
|
1336
|
+
for (const json of validLines) {
|
|
1337
|
+
try {
|
|
1338
|
+
const parsed = JSON.parse(json);
|
|
1339
|
+
if (typeof parsed.type === "string" && _SessionPersist.AUDIT_LINE_TYPES.has(parsed.type)) {
|
|
1340
|
+
audit.push(appendChecksum(json));
|
|
1341
|
+
}
|
|
1342
|
+
} catch {
|
|
1343
|
+
}
|
|
1344
|
+
}
|
|
1345
|
+
return audit;
|
|
1346
|
+
} catch {
|
|
1347
|
+
return [];
|
|
1348
|
+
}
|
|
1349
|
+
}
|
|
1350
|
+
/** Compact the session file with the given messages (with checksums) */
|
|
1351
|
+
compact(messages) {
|
|
1352
|
+
this.batchWriter.flushSync();
|
|
1353
|
+
const audit = this.collectAuditLines();
|
|
1354
|
+
const content = [...audit, ...messages.map((m) => appendChecksum(serializeSessionMessage(m)))].join("\n") + "\n";
|
|
1355
|
+
writeFileAtomicSync(this.filePath, encodeBatch(content));
|
|
1356
|
+
}
|
|
1357
|
+
/** Compact the session file with OAI-format messages */
|
|
1358
|
+
compactOai(messages) {
|
|
1359
|
+
this.batchWriter.flushSync();
|
|
1360
|
+
const audit = this.collectAuditLines();
|
|
1361
|
+
const content = [...audit, ...messages.map((m) => appendChecksum(serializeOaiSessionMessage(m)))].join("\n") + "\n";
|
|
1362
|
+
writeFileAtomicSync(this.filePath, encodeBatch(content));
|
|
1363
|
+
}
|
|
1364
|
+
/** Async atomic compaction — avoids blocking the agent loop on full rewrites (S13). */
|
|
1365
|
+
async compactOaiAsync(messages) {
|
|
1366
|
+
await this.flushSessionBuffer();
|
|
1367
|
+
const audit = this.collectAuditLines();
|
|
1368
|
+
const content = [...audit, ...messages.map((m) => appendChecksum(serializeOaiSessionMessage(m)))].join("\n") + "\n";
|
|
1369
|
+
await writeFileAtomicAsync(this.filePath, encodeBatch(content));
|
|
1370
|
+
}
|
|
1371
|
+
/** Delete the session file */
|
|
1372
|
+
delete() {
|
|
1373
|
+
try {
|
|
1374
|
+
unlinkSync2(this.filePath);
|
|
1375
|
+
} catch {
|
|
1376
|
+
}
|
|
1377
|
+
}
|
|
1378
|
+
/**
|
|
1379
|
+
* 带校验和的 append(入队批处理)
|
|
1380
|
+
*/
|
|
1381
|
+
async appendWithChecksum(message) {
|
|
1382
|
+
const json = serializeSessionMessage(message);
|
|
1383
|
+
const line = appendChecksum(json) + "\n";
|
|
1384
|
+
this.batchWriter.enqueueLine(line);
|
|
1385
|
+
}
|
|
1386
|
+
/**
|
|
1387
|
+
* 带校验和的 load(向后兼容)
|
|
1388
|
+
*/
|
|
1389
|
+
loadWithChecksum() {
|
|
1390
|
+
const content = this.readTranscriptText();
|
|
1391
|
+
const lines = content.trim().split("\n").filter(Boolean);
|
|
1392
|
+
const { validLines, invalidCount } = verifyLines(lines);
|
|
1393
|
+
if (invalidCount > 0) {
|
|
1394
|
+
}
|
|
1395
|
+
return validLines.map((line) => {
|
|
1396
|
+
try {
|
|
1397
|
+
const parsed = parseSessionLine(line);
|
|
1398
|
+
if (!parsed) return null;
|
|
1399
|
+
return parsed;
|
|
1400
|
+
} catch {
|
|
1401
|
+
return null;
|
|
1402
|
+
}
|
|
1403
|
+
}).filter(Boolean);
|
|
1404
|
+
}
|
|
1405
|
+
/**
|
|
1406
|
+
* 写入 compact 开始标记
|
|
1407
|
+
*/
|
|
1408
|
+
/**
|
|
1409
|
+
* 写入 compact 结束标记
|
|
1410
|
+
*/
|
|
1411
|
+
/**
|
|
1412
|
+
* 检测 incomplete compact
|
|
1413
|
+
* @returns 是否检测到 incomplete compact
|
|
1414
|
+
*/
|
|
1415
|
+
/** Get the session file path */
|
|
1416
|
+
getPath() {
|
|
1417
|
+
return this.filePath;
|
|
1418
|
+
}
|
|
1419
|
+
writeMetadata(metadata) {
|
|
1420
|
+
this.metaStore.write(metadata);
|
|
1421
|
+
this.refreshListCacheEntry();
|
|
1422
|
+
}
|
|
1423
|
+
/** Upsert specific metadata fields without overwriting others */
|
|
1424
|
+
updateMetadata(patch) {
|
|
1425
|
+
this.metaStore.update(patch, this.sessionId);
|
|
1426
|
+
this.refreshListCacheEntry();
|
|
1427
|
+
}
|
|
1428
|
+
/**
|
|
1429
|
+
* 把本会话的最新 metadata 增量并入会话列表缓存(纯内存,无磁盘 I/O),
|
|
1430
|
+
* 而不是整表失效。此前每次 append 都 invalidateListCache——append 监听器
|
|
1431
|
+
* 每条消息都会走 updateMetadata,而 loadPrevHandoff 在每个 LLM round 都读
|
|
1432
|
+
* 列表,活跃会话期间缓存命中率恒为 0,每轮全量 readdirSync + 逐会话重读。
|
|
1433
|
+
* 增量 upsert 保持同样的新鲜度语义:新建/更新立即可见、updatedAt 排序即时
|
|
1434
|
+
* 重排;TTL 不动,外部进程的文件变化仍按原 60s 窗口被吸收。
|
|
1435
|
+
*/
|
|
1436
|
+
refreshListCacheEntry() {
|
|
1437
|
+
const meta = this.metaStore.load();
|
|
1438
|
+
if (meta === void 0) return;
|
|
1439
|
+
_SessionPersist.upsertListCacheEntry(this.cwd, _SessionPersist.buildListEntry(this.sessionId, meta));
|
|
1440
|
+
}
|
|
1441
|
+
/** Initialize metadata for a new session if not already present */
|
|
1442
|
+
initMetadata(init) {
|
|
1443
|
+
if (existsSync6(this.metadataPath)) return;
|
|
1444
|
+
this.writeMetadata({
|
|
1445
|
+
sessionId: this.sessionId,
|
|
1446
|
+
createdAt: Date.now(),
|
|
1447
|
+
updatedAt: Date.now(),
|
|
1448
|
+
compactEvents: [],
|
|
1449
|
+
status: "active",
|
|
1450
|
+
turnCount: 0,
|
|
1451
|
+
toolCallCount: 0,
|
|
1452
|
+
tokenUsage: { prompt: 0, completion: 0, total: 0 },
|
|
1453
|
+
...init
|
|
1454
|
+
});
|
|
1455
|
+
}
|
|
1456
|
+
loadMetadata() {
|
|
1457
|
+
return this.metaStore.load();
|
|
1458
|
+
}
|
|
1459
|
+
/**
|
|
1460
|
+
* 冻结前缀快照落盘(`<id>.frozen.json`)——resume 缓存继承的写侧。
|
|
1461
|
+
* 由 PromptEngine 的 onFrozenSnapshotCommit 钩子在每个 user 边界触发(低频)。
|
|
1462
|
+
*/
|
|
1463
|
+
writeFrozenSnapshot(data) {
|
|
1464
|
+
writeFileAtomicSync(this.frozenPath, JSON.stringify(data));
|
|
1465
|
+
}
|
|
1466
|
+
/**
|
|
1467
|
+
* 读回冻结前缀快照(resume 缓存继承的读侧)。
|
|
1468
|
+
* 不存在 / 坏 JSON / 形状校验失败一律 undefined——调用方降级为全量重建。
|
|
1469
|
+
*/
|
|
1470
|
+
readFrozenSnapshot() {
|
|
1471
|
+
if (!existsSync6(this.frozenPath)) return void 0;
|
|
1472
|
+
try {
|
|
1473
|
+
return parseFrozenSnapshotData(JSON.parse(readFileSync5(this.frozenPath, "utf-8")));
|
|
1474
|
+
} catch {
|
|
1475
|
+
return void 0;
|
|
1476
|
+
}
|
|
1477
|
+
}
|
|
1478
|
+
loadMemory() {
|
|
1479
|
+
return loadSessionMemory(getSessionDir(this.cwd), this.sessionId);
|
|
1480
|
+
}
|
|
1481
|
+
appendMemory(input) {
|
|
1482
|
+
return appendSessionMemory(getSessionDir(this.cwd), this.sessionId, input);
|
|
1483
|
+
}
|
|
1484
|
+
buildMemoryBlock() {
|
|
1485
|
+
return buildSessionMemoryBlock(this.loadMemory());
|
|
1486
|
+
}
|
|
1487
|
+
getSessionMemoryState() {
|
|
1488
|
+
const memory = this.loadMemory();
|
|
1489
|
+
if (memory.entries.length === 0) return void 0;
|
|
1490
|
+
const block = buildSessionMemoryBlock(memory);
|
|
1491
|
+
return {
|
|
1492
|
+
path: join5(getSessionDir(this.cwd), `${this.sessionId}.memory.json`),
|
|
1493
|
+
lastSummarizedRoundIndex: -1,
|
|
1494
|
+
lastUpdatedAt: memory.entries[memory.entries.length - 1]?.createdAt ?? Date.now(),
|
|
1495
|
+
digest: block.length > 200 ? block.slice(0, 197) + "..." : block,
|
|
1496
|
+
stale: false,
|
|
1497
|
+
tokenEstimate: block.length
|
|
1498
|
+
};
|
|
1499
|
+
}
|
|
1500
|
+
/** Create a claim store for the current session. */
|
|
1501
|
+
createClaimStore() {
|
|
1502
|
+
return new ContextClaimStore(getSessionDir(this.cwd), this.sessionId);
|
|
1503
|
+
}
|
|
1504
|
+
/** Load durable claims from the most recent previous main session.
|
|
1505
|
+
* listMainSessions 排除 worker-/带点伪 id(`<id>.claims`)并按 updatedAt 降序——
|
|
1506
|
+
* listSessions + sort().pop() 会选中伪条目(恒返回 [])或非最近会话。 */
|
|
1507
|
+
loadPreviousDurableClaims() {
|
|
1508
|
+
const previous = _SessionPersist.listMainSessions(this.cwd).find((s) => s.id !== this.sessionId);
|
|
1509
|
+
return previous ? ContextClaimStore.loadDurableClaims(getSessionDir(this.cwd), previous.id) : [];
|
|
1510
|
+
}
|
|
1511
|
+
/** Inject durable claims from previous session into a claim store with confidence decay.
|
|
1512
|
+
* A4: cross-session pollution gate — only inject claims that intersect with current
|
|
1513
|
+
* project files AND were created within 7 days. */
|
|
1514
|
+
injectDurableClaims(store, cwd) {
|
|
1515
|
+
const now = Date.now();
|
|
1516
|
+
const TTL_MS = 7 * 24 * 60 * 60 * 1e3;
|
|
1517
|
+
const durableClaims = this.loadPreviousDurableClaims();
|
|
1518
|
+
for (const claim of durableClaims) {
|
|
1519
|
+
if (now - claim.createdAt > TTL_MS) continue;
|
|
1520
|
+
if (cwd) {
|
|
1521
|
+
const fileEvidence = claim.evidence.filter((e) => e.path);
|
|
1522
|
+
if (fileEvidence.length > 0) {
|
|
1523
|
+
const hasRelevantFile = fileEvidence.some((e) => {
|
|
1524
|
+
const abs = resolve(cwd, e.path);
|
|
1525
|
+
const rel = relative(cwd, abs);
|
|
1526
|
+
return rel === "" || !rel.startsWith("..") && !isAbsolute(rel);
|
|
1527
|
+
});
|
|
1528
|
+
if (!hasRelevantFile) continue;
|
|
1529
|
+
}
|
|
1530
|
+
}
|
|
1531
|
+
store.propose({
|
|
1532
|
+
kind: claim.kind,
|
|
1533
|
+
scope: claim.scope,
|
|
1534
|
+
text: claim.text,
|
|
1535
|
+
confidence: claim.confidence * 0.9,
|
|
1536
|
+
fitness: claim.fitness,
|
|
1537
|
+
source: { ...claim.source, eventId: `resume:${claim.id}` },
|
|
1538
|
+
evidence: claim.evidence,
|
|
1539
|
+
createdAt: Date.now(),
|
|
1540
|
+
tags: [...claim.tags, "resumed"]
|
|
1541
|
+
});
|
|
1542
|
+
}
|
|
1543
|
+
}
|
|
1544
|
+
/** Write structured handoff text for this session. */
|
|
1545
|
+
writeHandoff(text) {
|
|
1546
|
+
writeFileAtomicSync(this.getHandoffPath(), text);
|
|
1547
|
+
}
|
|
1548
|
+
/** 会话归档交接文档路径(`<id>.handoff.md`)——loadPrevHandoff 注入管线认的位置。 */
|
|
1549
|
+
getHandoffPath() {
|
|
1550
|
+
return join5(getSessionDir(this.cwd), `${this.sessionId}.handoff.md`);
|
|
1551
|
+
}
|
|
1552
|
+
/**
|
|
1553
|
+
* Load the most relevant previous session's handoff text.
|
|
1554
|
+
* Routes by domain if both sessions have a domain tag; otherwise falls back
|
|
1555
|
+
* to the most recently updated session. Returns null if none found.
|
|
1556
|
+
*/
|
|
1557
|
+
static loadPrevHandoff(cwd, currentSessionId, currentDomain) {
|
|
1558
|
+
const sessions = _SessionPersist.listSessionsWithMetadata(cwd).filter((s) => s.id !== currentSessionId);
|
|
1559
|
+
if (sessions.length === 0) return null;
|
|
1560
|
+
let candidates = sessions;
|
|
1561
|
+
if (currentDomain) {
|
|
1562
|
+
const sameDomain = sessions.filter((s) => s.domain === currentDomain);
|
|
1563
|
+
if (sameDomain.length > 0) candidates = sameDomain;
|
|
1564
|
+
}
|
|
1565
|
+
const prev = candidates[0];
|
|
1566
|
+
if (!prev) return null;
|
|
1567
|
+
const handoffPath = join5(getSessionDir(cwd), `${prev.id}.handoff.md`);
|
|
1568
|
+
if (!existsSync6(handoffPath)) return null;
|
|
1569
|
+
try {
|
|
1570
|
+
return readFileSync5(handoffPath, "utf-8");
|
|
1571
|
+
} catch {
|
|
1572
|
+
return null;
|
|
1573
|
+
}
|
|
1574
|
+
}
|
|
1575
|
+
/** List all session files */
|
|
1576
|
+
static listSessions(cwd) {
|
|
1577
|
+
const dir = getSessionDir(cwd);
|
|
1578
|
+
ensureDir(dir);
|
|
1579
|
+
try {
|
|
1580
|
+
return readdirSync(dir).filter((f) => f.endsWith(".jsonl")).map((f) => f.replace(".jsonl", ""));
|
|
1581
|
+
} catch {
|
|
1582
|
+
return [];
|
|
1583
|
+
}
|
|
1584
|
+
}
|
|
1585
|
+
/**
|
|
1586
|
+
* Cache for listSessionsWithMetadata — avoids re-reading hundreds of session
|
|
1587
|
+
* meta files on every user boundary when cross-session handoff is requested.
|
|
1588
|
+
* TTL: 60s. Writes upsert the session's own entry incrementally
|
|
1589
|
+
* (refreshListCacheEntry) so the hot append path never cold-rows the cache.
|
|
1590
|
+
* Keyed by cwd since sessions are per-project.
|
|
1591
|
+
*/
|
|
1592
|
+
static _listCache = /* @__PURE__ */ new Map();
|
|
1593
|
+
static LIST_CACHE_TTL_MS = 6e4;
|
|
1594
|
+
static invalidateListCache() {
|
|
1595
|
+
_SessionPersist._listCache.clear();
|
|
1596
|
+
}
|
|
1597
|
+
/** 移除单会话的缓存条目(硬删链——文件删除后条目不得在重建中复活)。 */
|
|
1598
|
+
static removeListCacheEntry(id) {
|
|
1599
|
+
for (const cached of _SessionPersist._listCache.values()) {
|
|
1600
|
+
const idx = cached.data.findIndex((e) => e.id === id);
|
|
1601
|
+
if (idx >= 0) cached.data.splice(idx, 1);
|
|
1602
|
+
}
|
|
1603
|
+
}
|
|
1604
|
+
/** 统一的列表条目形状——全量构建与增量 upsert 共用,防两处漂移。 */
|
|
1605
|
+
static buildListEntry(id, meta) {
|
|
1606
|
+
return {
|
|
1607
|
+
id,
|
|
1608
|
+
sessionId: id,
|
|
1609
|
+
createdAt: meta?.createdAt ?? 0,
|
|
1610
|
+
updatedAt: meta?.updatedAt ?? 0,
|
|
1611
|
+
compactEvents: meta?.compactEvents ?? [],
|
|
1612
|
+
...meta
|
|
1613
|
+
};
|
|
1614
|
+
}
|
|
1615
|
+
/**
|
|
1616
|
+
* 缓存命中时把单会话条目原位并入列表。不替换数组、不刷新 ts:
|
|
1617
|
+
* 命中路径的契约本来就是"同一 cwd 的多次 list 返回同一数组",写入原位生效
|
|
1618
|
+
* 即是该契约下的新鲜度语义;ts 不动则 TTL 仍按首次构建计时,外部进程的
|
|
1619
|
+
* 文件变化照旧在 60s 窗口内被吸收。
|
|
1620
|
+
*/
|
|
1621
|
+
static upsertListCacheEntry(cwd, entry) {
|
|
1622
|
+
const cached = _SessionPersist._listCache.get(cwd);
|
|
1623
|
+
if (!cached) return;
|
|
1624
|
+
const idx = cached.data.findIndex((e) => e.id === entry.id);
|
|
1625
|
+
if (idx >= 0) cached.data[idx] = { ...cached.data[idx], ...entry };
|
|
1626
|
+
else cached.data.push(entry);
|
|
1627
|
+
cached.data.sort((a, b) => (b.updatedAt ?? 0) - (a.updatedAt ?? 0));
|
|
1628
|
+
}
|
|
1629
|
+
/** List sessions with metadata, sorted by updatedAt descending (most recent first) */
|
|
1630
|
+
static listSessionsWithMetadata(cwd) {
|
|
1631
|
+
const now = Date.now();
|
|
1632
|
+
const cached = _SessionPersist._listCache.get(cwd);
|
|
1633
|
+
if (cached && now - cached.ts < _SessionPersist.LIST_CACHE_TTL_MS) {
|
|
1634
|
+
return cached.data;
|
|
1635
|
+
}
|
|
1636
|
+
const ids = _SessionPersist.listSessions(cwd);
|
|
1637
|
+
const results = [];
|
|
1638
|
+
for (const id of ids) {
|
|
1639
|
+
try {
|
|
1640
|
+
const p = new _SessionPersist(id, cwd);
|
|
1641
|
+
results.push(_SessionPersist.buildListEntry(id, p.loadMetadata()));
|
|
1642
|
+
} catch {
|
|
1643
|
+
}
|
|
1644
|
+
}
|
|
1645
|
+
results.sort((a, b) => (b.updatedAt ?? 0) - (a.updatedAt ?? 0));
|
|
1646
|
+
_SessionPersist._listCache.set(cwd, { ts: now, data: results });
|
|
1647
|
+
return results;
|
|
1648
|
+
}
|
|
1649
|
+
/**
|
|
1650
|
+
* Main user-facing sessions only: excludes worker sub-sessions (`worker-*`)
|
|
1651
|
+
* and non-transcript artifacts whose id carries a dotted suffix
|
|
1652
|
+
* (`<id>.claims`, `<id>.memory`, `<id>.snapshot`). Sorted by updatedAt desc.
|
|
1653
|
+
*/
|
|
1654
|
+
static listMainSessions(cwd) {
|
|
1655
|
+
return _SessionPersist.listSessionsWithMetadata(cwd).filter((s) => !s.id.startsWith("worker-") && !s.id.includes("."));
|
|
1656
|
+
}
|
|
1657
|
+
/**
|
|
1658
|
+
* Resolve a user-supplied session reference (full id or short prefix) to a
|
|
1659
|
+
* single full session id. Exact match wins; otherwise prefix-match across
|
|
1660
|
+
* main sessions. Returns the resolved id, an ambiguous candidate list, or
|
|
1661
|
+
* null when nothing matches. The id = log id = resume id are the same value.
|
|
1662
|
+
*/
|
|
1663
|
+
static resolveSessionId(cwd, ref) {
|
|
1664
|
+
const ref0 = ref.trim();
|
|
1665
|
+
if (!ref0) return null;
|
|
1666
|
+
const sessions = _SessionPersist.listMainSessions(cwd);
|
|
1667
|
+
const exact = sessions.find((s) => s.id === ref0);
|
|
1668
|
+
if (exact) return { id: exact.id };
|
|
1669
|
+
const matches = sessions.filter((s) => s.id.startsWith(ref0));
|
|
1670
|
+
if (matches.length === 1) return { id: matches[0].id };
|
|
1671
|
+
if (matches.length > 1) return { ambiguous: matches.map((s) => s.id) };
|
|
1672
|
+
return null;
|
|
1673
|
+
}
|
|
1674
|
+
/**
|
|
1675
|
+
* Render the session list for CLI `--list` and TUI `/sessions`. One row per
|
|
1676
|
+
* main session, numbered to match `listMainSessions` ordering so the index is
|
|
1677
|
+
* stable and aligned with prefix-based resume.
|
|
1678
|
+
*/
|
|
1679
|
+
static formatSessionList(cwd, currentId) {
|
|
1680
|
+
const sessions = _SessionPersist.listMainSessions(cwd);
|
|
1681
|
+
if (sessions.length === 0) return "\u6CA1\u6709\u5386\u53F2\u4F1A\u8BDD\u3002";
|
|
1682
|
+
return sessions.map((s, i) => {
|
|
1683
|
+
const marker = s.id === currentId ? " \u2190 \u5F53\u524D" : "";
|
|
1684
|
+
const when = formatRelativeTime(s.updatedAt ?? 0);
|
|
1685
|
+
const turns = s.turnCount ?? 0;
|
|
1686
|
+
const model = s.model ?? "?";
|
|
1687
|
+
const domain = s.domain ? ` ${s.domain}` : "";
|
|
1688
|
+
const title = (s.title ?? "").replace(/\s+/g, " ").trim().slice(0, 50);
|
|
1689
|
+
return `${String(i + 1).padStart(2)}. ${s.id.slice(0, 8)} ${when} ${turns}\u8F6E ${model}${domain} ${title}${marker}`;
|
|
1690
|
+
}).join("\n");
|
|
1691
|
+
}
|
|
1692
|
+
};
|
|
1693
|
+
function formatExitSummary(meta, sessionId) {
|
|
1694
|
+
const turns = meta?.turnCount ?? 0;
|
|
1695
|
+
if (turns <= 0) return null;
|
|
1696
|
+
const short = sessionId.slice(0, 8);
|
|
1697
|
+
const title = (meta?.title ?? "").replace(/\s+/g, " ").trim().slice(0, 60);
|
|
1698
|
+
const head = `\u4F1A\u8BDD\u5DF2\u4FDD\u5B58: ${short} \xB7 ${turns}\u8F6E${title ? ` \xB7 \u201C${title}\u201D` : ""}`;
|
|
1699
|
+
return `\u2726 \u540E\u4F1A\u6709\u671F \u2014 \u661F\u8F68\u5DF2\u5B58\u6863
|
|
1700
|
+
${head}
|
|
1701
|
+
\u6062\u590D: rivet --continue\uFF08\u6700\u8FD1\u4F1A\u8BDD\uFF09\u6216 rivet --resume ${short}
|
|
1702
|
+
\u7F13\u5B58\u6210\u672C\uFF1A\u5C3D\u5FEB\u56DE\u8FDE \u2248 \u7EE7\u627F\u51BB\u7ED3\u951A\u70B9\uFF08\u53EA\u8BFB\u7F13\u5B58\u4EF7\uFF09\uFF1B\u95F4\u9694\u8FC7\u4E45\u7F13\u5B58\u8FC7\u671F \u2192 \u5168\u91CF\u91CD\u5EFA\u4E00\u6B21\u524D\u7F00\uFF08\u957F\u4F1A\u8BDD\u6570\u5143\u7EA7\uFF09\u3002`;
|
|
1703
|
+
}
|
|
1704
|
+
function shouldAutoWriteHandoff(existingMtimeMs, sessionStartMs) {
|
|
1705
|
+
if (existingMtimeMs === null) return true;
|
|
1706
|
+
return existingMtimeMs <= sessionStartMs;
|
|
1707
|
+
}
|
|
1708
|
+
function formatRelativeTime(ts) {
|
|
1709
|
+
if (!ts) return "\u672A\u77E5";
|
|
1710
|
+
const diff = Date.now() - ts;
|
|
1711
|
+
if (diff < 0) return "\u521A\u521A";
|
|
1712
|
+
const sec = Math.floor(diff / 1e3);
|
|
1713
|
+
if (sec < 60) return "\u521A\u521A";
|
|
1714
|
+
const min = Math.floor(sec / 60);
|
|
1715
|
+
if (min < 60) return `${min}\u5206\u949F\u524D`;
|
|
1716
|
+
const hr = Math.floor(min / 60);
|
|
1717
|
+
if (hr < 24) return `${hr}\u5C0F\u65F6\u524D`;
|
|
1718
|
+
const day = Math.floor(hr / 24);
|
|
1719
|
+
if (day < 30) return `${day}\u5929\u524D`;
|
|
1720
|
+
const mon = Math.floor(day / 30);
|
|
1721
|
+
if (mon < 12) return `${mon}\u4E2A\u6708\u524D`;
|
|
1722
|
+
return `${Math.floor(mon / 12)}\u5E74\u524D`;
|
|
1723
|
+
}
|
|
1724
|
+
var MAX_SESSIONS = 50;
|
|
1725
|
+
function evictOldSessions(keepSessionId, cwd) {
|
|
1726
|
+
return evictOldSessionsInternal(getSessionDir(cwd), keepSessionId, MAX_SESSIONS);
|
|
1727
|
+
}
|
|
1728
|
+
function evictOldSessionsInternal(dir, keepSessionId, limit) {
|
|
1729
|
+
ensureDir(dir);
|
|
1730
|
+
let sessions;
|
|
1731
|
+
try {
|
|
1732
|
+
sessions = readdirSync(dir).filter((f) => f.endsWith(".jsonl")).map((f) => f.replace(".jsonl", "")).filter((id) => !id.startsWith("worker-") && !id.includes("."));
|
|
1733
|
+
} catch {
|
|
1734
|
+
return [];
|
|
1735
|
+
}
|
|
1736
|
+
if (sessions.length <= limit) return [];
|
|
1737
|
+
const withMtime = sessions.map((id) => {
|
|
1738
|
+
let mtime = 0;
|
|
1739
|
+
try {
|
|
1740
|
+
mtime = statSync2(join5(dir, `${id}.jsonl`)).mtimeMs;
|
|
1741
|
+
} catch {
|
|
1742
|
+
}
|
|
1743
|
+
return { id, mtime };
|
|
1744
|
+
});
|
|
1745
|
+
withMtime.sort((a, b) => a.mtime - b.mtime);
|
|
1746
|
+
const toEvict = withMtime.filter(({ id }) => id !== keepSessionId).slice(0, sessions.length - limit).map(({ id }) => id);
|
|
1747
|
+
for (const id of toEvict) removeSessionFilesIn(dir, id);
|
|
1748
|
+
return toEvict;
|
|
1749
|
+
}
|
|
1750
|
+
function removeSessionFilesIn(dir, id) {
|
|
1751
|
+
try {
|
|
1752
|
+
unlinkSync2(join5(dir, `${id}.jsonl`));
|
|
1753
|
+
} catch {
|
|
1754
|
+
}
|
|
1755
|
+
try {
|
|
1756
|
+
unlinkSync2(join5(dir, `${id}.meta.json`));
|
|
1757
|
+
} catch {
|
|
1758
|
+
}
|
|
1759
|
+
try {
|
|
1760
|
+
unlinkSync2(join5(dir, `${id}.memory.json`));
|
|
1761
|
+
} catch {
|
|
1762
|
+
}
|
|
1763
|
+
try {
|
|
1764
|
+
unlinkSync2(join5(dir, `${id}.claims.jsonl`));
|
|
1765
|
+
} catch {
|
|
1766
|
+
}
|
|
1767
|
+
try {
|
|
1768
|
+
unlinkSync2(join5(dir, `${id}.frozen.json`));
|
|
1769
|
+
} catch {
|
|
1770
|
+
}
|
|
1771
|
+
try {
|
|
1772
|
+
rmSync(join5(dir, id), { recursive: true, force: true });
|
|
1773
|
+
} catch {
|
|
1774
|
+
}
|
|
1775
|
+
}
|
|
1776
|
+
function deleteSessionFiles(cwd, id) {
|
|
1777
|
+
removeSessionFilesIn(getSessionDir(cwd), id);
|
|
1778
|
+
SessionPersist.removeListCacheEntry(id);
|
|
1779
|
+
}
|
|
1780
|
+
|
|
1781
|
+
export {
|
|
1782
|
+
claimProposalFromAnchor,
|
|
1783
|
+
verifyAndExtract,
|
|
1784
|
+
decodeTranscriptText,
|
|
1785
|
+
getSessionDir,
|
|
1786
|
+
MAX_SESSION_MESSAGE_JSON_CHARS,
|
|
1787
|
+
serializeSessionMessage,
|
|
1788
|
+
serializeOaiSessionMessage,
|
|
1789
|
+
readHistoricalTranscript,
|
|
1790
|
+
SessionPersist,
|
|
1791
|
+
formatExitSummary,
|
|
1792
|
+
shouldAutoWriteHandoff,
|
|
1793
|
+
evictOldSessions,
|
|
1794
|
+
evictOldSessionsInternal,
|
|
1795
|
+
deleteSessionFiles
|
|
1796
|
+
};
|