thincoder 0.12.61 → 0.12.62

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.
Files changed (57) hide show
  1. package/CHANGELOG.md +25 -1
  2. package/bin/thincoder.mjs +12 -3
  3. package/package.json +1 -1
  4. package/src/acp.mjs +13 -5
  5. package/src/agent/run-stages.mjs +3 -1
  6. package/src/agent/spawn-child.mjs +17 -2
  7. package/src/agent-tools/async-settle.mjs +13 -0
  8. package/src/agent-tools/consult.mjs +5 -0
  9. package/src/agent-tools/escalate-async.mjs +6 -0
  10. package/src/agent-tools/read-history.mjs +18 -3
  11. package/src/agent-tools/subagent-actions.mjs +3 -0
  12. package/src/agent-tools/subagent-run.mjs +1 -1
  13. package/src/agent-tools/subagent-spawn.mjs +6 -0
  14. package/src/agent.mjs +4 -0
  15. package/src/context.mjs +12 -1
  16. package/src/crash-reports.mjs +31 -9
  17. package/src/generate-title.mjs +8 -3
  18. package/src/heap-watch.mjs +88 -0
  19. package/src/ledger.mjs +227 -0
  20. package/src/memory/code-sync.mjs +7 -5
  21. package/src/memory/core.mjs +8 -9
  22. package/src/memory/docs.mjs +7 -5
  23. package/src/memory/scan.mjs +95 -0
  24. package/src/prompts/advisor-design.md +1 -1
  25. package/src/prompts/advisor-round1.md +1 -1
  26. package/src/prompts/advisor-round2.md +1 -1
  27. package/src/prompts/advisor-round3.md +1 -1
  28. package/src/prompts/discipline-engineering.md +51 -10
  29. package/src/prompts/discipline-normal.md +10 -4
  30. package/src/prompts/persona-eng-designer.md +6 -1
  31. package/src/prompts/persona-engineering.md +1 -0
  32. package/src/session-gc.mjs +9 -2
  33. package/src/session-guard.mjs +12 -0
  34. package/src/session-segments.mjs +100 -0
  35. package/src/session-slots.mjs +3 -0
  36. package/src/session-store.mjs +441 -0
  37. package/src/session.mjs +78 -61
  38. package/src/text-budget.mjs +46 -0
  39. package/src/traces/trace-store.mjs +195 -64
  40. package/src/tui/cmd-clear.mjs +2 -0
  41. package/src/tui/cmd-new.mjs +5 -1
  42. package/src/tui/cmd-session.mjs +8 -4
  43. package/src/tui/display-budget.mjs +184 -0
  44. package/src/tui/index.mjs +31 -3
  45. package/src/tui/key-handler-search.mjs +9 -1
  46. package/src/tui/ledger-surface.mjs +69 -0
  47. package/src/tui/render-frame.mjs +7 -1
  48. package/src/tui/startup.mjs +45 -13
  49. package/src/tui/subagent-blocks.mjs +1 -0
  50. package/src/tui/subagent-children.mjs +86 -14
  51. package/src/tui/subagent-freeze.mjs +8 -2
  52. package/src/tui/suspension-drive.mjs +3 -1
  53. package/src/tui/tool-args.mjs +5 -2
  54. package/src/tui/tool-display.mjs +16 -2
  55. package/src/tui/tool-events.mjs +53 -14
  56. package/src/tui/tui-lifecycle.mjs +9 -2
  57. package/src/tui/wrapped-spawn.mjs +21 -5
@@ -12,6 +12,7 @@ You are the **product manager and flow orchestrator** of this engineering-mode s
12
12
  **batch record** (`batches/*.md`).
13
13
  - **Yours**: requirement discussion + registration, batch close-out, batch record §1/§4/§6, content-level verification of the design
14
14
  (is the design right? does it cover the requirements?), reminding the user to fire the review, and delegating implementation.
15
+ - **The ledger is yours**: the requirement-pool / tech-backlog ledger (record + status advance + physical writes; subagents never declare ledger files in `files`).
15
16
  - **NOT yours**: the requirements doc / design doc — that is **eng-designer**'s writing surface (revisions included; single writer).
16
17
  You do NOT write implementation code yourself — implementation is done by `eng-coder` subagents only.
17
18
  You are the lead engineer: you see the full picture, you coordinate complex work, and you are ultimately responsible for the result.
@@ -19,7 +19,7 @@
19
19
  * import,启动钩子(session.mjs resumeSlot 包装)只一行调用。
20
20
  */
21
21
 
22
- import { readdirSync, readFileSync, statSync, unlinkSync, existsSync } from "node:fs"
22
+ import { readdirSync, readFileSync, statSync, unlinkSync, existsSync, rmSync } from "node:fs"
23
23
  import { join, dirname, basename } from "node:path"
24
24
  import { configDir } from "./config.mjs"
25
25
  import { sessionPath, isProcessAlive } from "./session-slots.mjs"
@@ -153,7 +153,14 @@ export function deleteColdCwd(hash, { dir = sessionsDir(), now = Date.now(), ali
153
153
  if (!target) return { ok: false, reason: "not-cold", deleted: [] }
154
154
  const deleted = []
155
155
  for (const name of target.files) {
156
- try { unlinkSync(join(dir, name)); deleted.push(name) } catch { /* 占用/竞态——跳过 */ }
156
+ const p = join(dir, name)
157
+ try {
158
+ // §14.3.8(TUI-OOM-ROOTCAUSE):目录项(记录存储 sidecar `{prefix}.N.d`)递归删——
159
+ // 现 unlinkSync 对目录静默跳过(旧实现漏删 sidecar);数据文件维持 unlink。
160
+ if (statSync(p).isDirectory()) rmSync(p, { recursive: true, force: true })
161
+ else unlinkSync(p)
162
+ deleted.push(name)
163
+ } catch { /* 占用/竞态——跳过 */ }
157
164
  }
158
165
  return { ok: true, deleted }
159
166
  }
@@ -11,6 +11,17 @@
11
11
 
12
12
  import { existsSync, readFileSync, renameSync, statSync } from "node:fs"
13
13
  import { basename } from "node:path"
14
+ // §14.3.8①(修正轮 #1):.bak 轮转对**非本会话活动绑定面**联动改名 sidecar(`{p}.d → {bak}.d`)。
15
+ import { RECORD_DIR_SUFFIX } from "./session-store.mjs"
16
+
17
+ /** 轮转时随迁记录存储(.bak 路径联动)——本会话活动绑定面跳过(防拔掉活动存储)。 */
18
+ function moveSidecar(src, dst, agent) {
19
+ const dir = src + RECORD_DIR_SUFFIX
20
+ if (agent?._recordStore?.dir === dir) return // 本会话活动绑定面——原地保留(§14.3.8①)
21
+ try {
22
+ if (existsSync(dir)) renameSync(dir, dst + RECORD_DIR_SUFFIX)
23
+ } catch { /* 随迁失败不阻断轮转(现场原地保留——读侧身份核验兜底) */ }
24
+ }
14
25
 
15
26
  export function guardForeignSlotFile(agent, p, slot) {
16
27
  try {
@@ -29,6 +40,7 @@ export function guardForeignSlotFile(agent, p, slot) {
29
40
  if (diskIsNewer || diskForeign || (diskStart && diskStart !== myStart)) {
30
41
  const bak = `${p}.bak-${Date.now()}`
31
42
  renameSync(p, bak)
43
+ moveSidecar(p, bak, agent)
32
44
  console.error(`[session] slot ${slot} holds ${diskIsNewer ? `a newer-version file (v${disk.version})` : diskForeign ? `a foreign-cwd file (${disk.cwd})` : `another session (start ${diskStart}, ours ${myStart})`} — preserved as ${basename(bak)}`)
33
45
  agent._slotMtime = st.mtimeMs
34
46
  return bak
@@ -0,0 +1,100 @@
1
+ /**
2
+ * session-segments.mjs — 记录存储的段文件原语 + 人读线条目形态(TUI-OOM-ROOTCAUSE 批)。
3
+ *
4
+ * 机制契约全文 = docs/design/SESSION.md §14.3(D2 单一权威源);本文件 = 「段 IO + 条目
5
+ * 形态」叶子层(`session-store.mjs` 拆分产物——503+ 行越 500 硬限,§14.5 表列模块按职责
6
+ * 拆分;store 继续 re-export 全部公开名,调用面零改)。零项目内依赖(仅 `node:`)。
7
+ */
8
+
9
+ import { readdirSync, readFileSync } from "node:fs"
10
+
11
+ /** 常量单源(§14.3.2):每段消息数 / sidecar 目录后缀。 */
12
+ export const RECORD_SEG_MESSAGES = 100
13
+ export const RECORD_DIR_SUFFIX = ".d"
14
+
15
+ /** 观测计数(测试缝——AC-RS9「恢复只读末段」以段读次数断言)。 */
16
+ export const _storeStats = { segmentReads: 0, segmentWrites: 0, appendedEntries: 0, catchUpRetries: 0 }
17
+
18
+ const LEGACY_TRANSIENT_PREFIXES = [
19
+ "[System reminder: working directory snapshot:",
20
+ "[Relevant memories from previous sessions",
21
+ ]
22
+
23
+ /** legacy transient 判定(旧进程写进人读线的机器注入——落盘/展示均跳过)。 */
24
+ export function isLegacyTransient(m) {
25
+ return (
26
+ m?.role === "user" &&
27
+ typeof m.content === "string" &&
28
+ LEGACY_TRANSIENT_PREFIXES.some((p) => m.content.startsWith(p))
29
+ )
30
+ }
31
+
32
+ /** Slim the HUMAN line for storage(自 session.mjs 迁出——§14.5):机器线(contextHistory)
33
+ * 逐字节保持。Copy-on-write ONLY(两线经 pushReal 共享对象引用):tool args → 300 字符头
34
+ * 截断 / tool content → 500 / 多模态 user → 保留 text、丢 image_url。 */
35
+ export function slimForDisplay(m) {
36
+ if (m && Array.isArray(m.content)) {
37
+ const textParts = m.content.filter((p) => p?.type !== "image_url")
38
+ if (textParts.length === m.content.length) return m
39
+ return { ...m, content: textParts }
40
+ }
41
+ if (m && m.role === "assistant" && Array.isArray(m.tool_calls)) {
42
+ let changed = false
43
+ const tool_calls = m.tool_calls.map((tc) => {
44
+ const args = tc.function?.arguments
45
+ if (typeof args === "string" && args.length > 300) {
46
+ changed = true
47
+ return { ...tc, function: { ...tc.function, arguments: args.slice(0, 300) + "…" } }
48
+ }
49
+ return tc
50
+ })
51
+ return changed ? { ...m, tool_calls } : m
52
+ }
53
+ if (m && m.role === "tool" && typeof m.content === "string" && m.content.length > 500) {
54
+ return { ...m, content: m.content.slice(0, 500) + "\n… (truncated for storage)" }
55
+ }
56
+ return m
57
+ }
58
+
59
+ /** 记录条目过滤(与 saveSession 同源):transient 机器注入与 legacy-transient 前缀不进记录。 */
60
+ export function shouldAppend(m) {
61
+ return !!m && typeof m === "object" && !m.transient && !isLegacyTransient(m)
62
+ }
63
+
64
+ /** 真实 user 消息(turnCount/firstMessage 口径——同 session-slots.extractSlotMeta)。 */
65
+ export function isRealUserMsg(m) {
66
+ return m?.role === "user" && typeof m.content === "string" && !m.content.startsWith("[System reminder:")
67
+ }
68
+
69
+ /** sidecar 目录路径(`{slot 文件路径}.d`——同目录同前缀)。 */
70
+ export function recordDirOf(slotFile) { return slotFile + RECORD_DIR_SUFFIX }
71
+
72
+ /** 段文件名(六位零填充)。 */
73
+ export function segName(n) { return `seg-${String(n).padStart(6, "0")}.jsonl` }
74
+
75
+ /** 段号升序表(readdir——不读内容)。 */
76
+ export function listSegments(dir) {
77
+ let names
78
+ try { names = readdirSync(dir) } catch { return [] }
79
+ const out = []
80
+ for (const n of names) {
81
+ const m = /^seg-(\d+)\.jsonl$/.exec(n)
82
+ if (m) out.push(Number(m[1]))
83
+ }
84
+ return out.sort((a, b) => a - b)
85
+ }
86
+
87
+ /** 读段(原始行——去尾空行);读失败按空段容忍(读路径尽力面)。 */
88
+ export function readSegmentLines(path) {
89
+ _storeStats.segmentReads++
90
+ let text
91
+ try { text = readFileSync(path, "utf8") } catch { return [] }
92
+ const lines = text.split("\n")
93
+ if (lines.length > 0 && lines[lines.length - 1] === "") lines.pop()
94
+ return lines
95
+ }
96
+
97
+ /** 逐行容错解析:半行/损坏行 → undefined(调用方跳过——不抛、不自动修复)。 */
98
+ export function tryParse(line) {
99
+ try { return JSON.parse(line) } catch { return undefined }
100
+ }
@@ -18,6 +18,8 @@ import { migrateHashLength } from "./session-migrate.mjs"
18
18
  // legacy 过滤)属 session.mjs(500 行内不迁移)——静态环仅此一处:函数声明实例化期已初始化、
19
19
  // 只函数体内运行时使用(环安全);VS Code 端 session-slots ↔ session-io 同构镜像。
20
20
  import { loadSlotFile, isLegacyTransient } from "./session.mjs"
21
+ // TUI-OOM-ROOTCAUSE 批(SESSION.md §14.3.8):删槽联动记录存储(store 零项目内依赖——无环)。
22
+ import { unlinkRecordStore } from "./session-store.mjs"
21
23
 
22
24
  let currentSessionId = null
23
25
 
@@ -405,6 +407,7 @@ export function deleteSlot(cwd, slot) {
405
407
  delete m.slots[n]
406
408
  delete m.slotSessions?.[n] // orphan session-id entries bloat the manifest forever
407
409
  try { unlinkSync(slotPath(cwd, n)) } catch { /* missing file is fine */ }
410
+ unlinkRecordStore(slotPath(cwd, n)) // §14.3.8:删槽连带删除记录存储(sidecar)
408
411
  if (m.active === n) delete m.active
409
412
  // setActive: true —— 显式表达"删到 active 时 active 置空"的意图(saveManifest 默认
410
413
  // 保留 fresh.active,2026-09-01 会诊三家 🟡)
@@ -0,0 +1,441 @@
1
+ /**
2
+ * session-store.mjs — 人读线记录存储:磁盘为准 + 内存窗口(TUI-OOM-ROOTCAUSE 批)。
3
+ * 机制契约全文 = docs/design/SESSION.md §14.3(D2 单一权威源);本文件 = 实现。
4
+ * 形态:`{slot 文件路径}.d/` + `meta.json` + `seg-000001.jsonl`(行 = slimForDisplay 后的
5
+ * 消息 JSON——与槽 JSON `history` 元素逐字节同形;投影零转换,§14.3.1)。
6
+ * 依赖:零项目内依赖(仅 `node:`)——路径由 `slotFile` 传入;session.mjs / session-slots.mjs /
7
+ * session-guard.mjs 单向引本模块。窗口驱逐在 pushReal(context.mjs)、追加单点同 pushReal。
8
+ * 段 IO 原语与条目形态(`slimForDisplay` 等)拆住 `session-segments.mjs`(越 500 硬限的
9
+ * 职责拆分——本档 re-export 全部公开名,调用面零改)。
10
+ */
11
+
12
+ import {
13
+ appendFileSync, closeSync, existsSync, mkdirSync, openSync, readFileSync,
14
+ renameSync, rmSync, truncateSync, unlinkSync, writeFileSync, writeSync,
15
+ } from "node:fs"
16
+ import { basename, dirname, join } from "node:path"
17
+ import {
18
+ RECORD_SEG_MESSAGES, recordDirOf, segName, listSegments, readSegmentLines, tryParse,
19
+ isLegacyTransient, slimForDisplay, shouldAppend, isRealUserMsg, _storeStats,
20
+ } from "./session-segments.mjs"
21
+
22
+ // 公开名 re-export(调用面零改——§14.3.3 接口面)
23
+ export {
24
+ RECORD_SEG_MESSAGES, RECORD_DIR_SUFFIX, recordDirOf, isLegacyTransient, slimForDisplay, _storeStats,
25
+ } from "./session-segments.mjs"
26
+
27
+ /** 内存窗口(条数——与首屏 INITIAL_HISTORY_MESSAGES 同值——单一概念,§14.3.2)。 */
28
+ export const RECORD_WINDOW_MESSAGES = 200
29
+
30
+ const META_NAME = "meta.json"
31
+
32
+ class RecordStore {
33
+ constructor(slotFile) {
34
+ this.slotFile = slotFile
35
+ this.dir = recordDirOf(slotFile)
36
+ this.agent = null
37
+ this.identity = null // meta.identity(会话身份锚 = sessionStart)
38
+ this.degraded = false // 追加失败即停(§14.4 D-R4①)
39
+ this.memTotal = 0 // pushReal 计数(含未落盘条——缺口 = memTotal − total,D-R4②)
40
+ this._segCount = this._lastSegN = this._lastSegLines = 0 // 末段段号(= 段文件数)+ 行数
41
+ this._metaReady = false
42
+ this._lastSegCache = null // { n, lines }——bind 期复用(恢复只读末段:不重复读同一段)
43
+ this._partialSeg = 0 // 带未写完半行的段号(崩溃现场——首写前先补行终止符,防粘连)
44
+ this._counts = { turnCount: 0, firstMessage: "" }
45
+ this._sawRealUser = false
46
+ this._firstUser = undefined // undefined = 未扫;null = 无(首扫一次并缓存)
47
+ }
48
+
49
+ /** 绑定对账(§14.3.4):**先验身份、后比计数**。返回 this。 */
50
+ bind(agent, identity, baseHistory) {
51
+ this.agent = agent
52
+ const base = (Array.isArray(baseHistory) ? baseHistory : []).filter(shouldAppend)
53
+ const site = identity ?? null
54
+ let meta = this._readMeta()
55
+ this._scan()
56
+ const hasSidecar = meta !== null || this._segCount > 0
57
+ if (hasSidecar) {
58
+ this.identity = meta?.identity ?? null
59
+ const bothEmpty = !this.identity && !site
60
+ const same = !!(this.identity && site && this.identity === site)
61
+ if (!bothEmpty && !same) {
62
+ // 陈旧/孤儿 sidecar——不得采纳(槽号回收/轮转/改名/对端占用同槽的跨会话污染面):
63
+ // 改名 `.stale-<epochms>` 保留现场,按现场重建(§14.3.8 ②③)——身份回到现场身份
64
+ // (否则空 base 路径不重物化时,新建 sidecar 会把旧会话身份写进 meta——内审 #1)。
65
+ this._quarantine()
66
+ this.identity = site
67
+ meta = null
68
+ }
69
+ } else {
70
+ this.identity = site // 无 sidecar:创建时写入现场身份(可 null——待固化)
71
+ }
72
+ const storeTotal = this.total()
73
+ if (meta?.degraded === true || storeTotal < base.length) {
74
+ // ② 计数对账——以 JSON 为准重建(降级标记 / JSON 更长:VSC 追加、sidecar 缺失、首次迁移)
75
+ this.identity = site
76
+ this._materialize(base)
77
+ } else {
78
+ // 以段为准(崩溃后未保存消息可见)——计数 = JSON 全量 + JSON 之后的已落盘尾部
79
+ this._countOver(base)
80
+ if (storeTotal > base.length) this._countOver(this._range(base.length, storeTotal))
81
+ }
82
+ this.memTotal = this.total()
83
+ return this
84
+ }
85
+
86
+ _readMeta() {
87
+ try {
88
+ const p = join(this.dir, META_NAME)
89
+ if (!existsSync(p)) return null
90
+ const m = JSON.parse(readFileSync(p, "utf8"))
91
+ return m && typeof m === "object" ? m : null
92
+ } catch { return null }
93
+ }
94
+
95
+ _writeMeta() {
96
+ const meta = { v: 1, segSize: RECORD_SEG_MESSAGES, identity: this.identity ?? null }
97
+ if (this.degraded) meta.degraded = true
98
+ try {
99
+ mkdirSync(this.dir, { recursive: true })
100
+ const tmp = join(this.dir, META_NAME) + ".tmp"
101
+ writeFileSync(tmp, JSON.stringify(meta), "utf8")
102
+ renameSync(tmp, join(this.dir, META_NAME))
103
+ } catch {
104
+ try { writeFileSync(join(this.dir, META_NAME), JSON.stringify(meta), "utf8") } catch { /* 尽力面 */ }
105
+ }
106
+ }
107
+
108
+ /** meta 同步(身份补写 / 降级置位时重写——小文件原子写)。 */
109
+ syncMeta() {
110
+ try { this._writeMeta(); this._metaReady = true } catch { /* 尽力面 */ }
111
+ }
112
+
113
+ /** 陈旧/孤儿现场保留:目录改名 `.stale-<epochms>`(与 .bak/.corrupted 同族)后按现场重建。 */
114
+ _quarantine() {
115
+ const dst = `${this.dir}.stale-${Date.now()}`
116
+ try {
117
+ renameSync(this.dir, dst)
118
+ console.error(`[session] stale record store preserved as ${basename(dst)} (identity ${this.identity ?? "null"} ≠ session)`)
119
+ } catch { /* 改名失败(占用)——仍按现场重建(materialize 覆盖) */ }
120
+ this._resetState()
121
+ this.identity = null // 隔离后不再持有旧身份(新 sidecar 待写现场身份——bind 内随即置回 site)
122
+ this.degraded = false
123
+ }
124
+
125
+ /** 定位态 + 计数态归零(改名/重建/扫描共用;counts=false 保留计数——扫描路径)。 */
126
+ _resetState(counts = true) {
127
+ this._segCount = this._lastSegN = this._lastSegLines = 0
128
+ this._metaReady = false
129
+ this._lastSegCache = null
130
+ this._partialSeg = 0
131
+ if (!counts) return
132
+ this._counts = { turnCount: 0, firstMessage: "" }
133
+ this._sawRealUser = false
134
+ this._firstUser = undefined
135
+ }
136
+
137
+ /** 目录扫描:段数 + 末段行数(半行 = 末段最后一行解析失败 → 视为未写完、忽略)。 */
138
+ _scan() {
139
+ const segs = listSegments(this.dir)
140
+ this._resetState(false)
141
+ if (segs.length === 0) return
142
+ this._segCount = this._lastSegN = segs[segs.length - 1]
143
+ const lines = readSegmentLines(join(this.dir, segName(this._lastSegN)))
144
+ const complete = lines.length > 0 && tryParse(lines[lines.length - 1]) === undefined ? lines.length - 1 : lines.length
145
+ this._lastSegLines = complete
146
+ // 末尾半行(崩溃现场):记段号——下一次追加先补行终止符(不自动修复内容,仅不粘连新记录)
147
+ this._partialSeg = lines.length > complete ? this._lastSegN : 0
148
+ this._lastSegCache = { n: this._lastSegN, lines: lines.slice(0, complete) }
149
+ }
150
+
151
+ /** 末段缓存取段(bind 期不重复读同一段)。 */
152
+ _segmentLines(n) {
153
+ if (this._lastSegCache && this._lastSegCache.n === n) return this._lastSegCache.lines
154
+ return readSegmentLines(join(this.dir, segName(n)))
155
+ }
156
+
157
+ _countOver(msgs) {
158
+ for (const m of msgs) this._countLine(m)
159
+ }
160
+
161
+ /** 以 JSON 为准重建整个 sidecar(rm 目录 → 重新物化——§14.3.4 规则 2)。 */
162
+ _materialize(base) {
163
+ try { rmSync(this.dir, { recursive: true, force: true }) } catch { /* 尽力面 */ }
164
+ this._resetState()
165
+ this.degraded = false
166
+ this.memTotal = 0
167
+ if (base.length === 0) return // 两者皆空 → 新目录懒创建(首 append 建目录/写 meta)
168
+ mkdirSync(this.dir, { recursive: true })
169
+ this._writeMeta()
170
+ this._metaReady = true
171
+ const buf = []
172
+ for (const m of base) {
173
+ this._countLine(m)
174
+ buf.push(JSON.stringify(slimForDisplay(m)))
175
+ if (buf.length >= RECORD_SEG_MESSAGES) this._flushSeg(buf)
176
+ }
177
+ this._flushSeg(buf)
178
+ this.memTotal = this.total()
179
+ }
180
+
181
+ /** 段落盘(物化路径——整段一次写出)。 */
182
+ _flushSeg(lines) {
183
+ if (lines.length === 0) return
184
+ const n = this._lastSegN + 1
185
+ writeFileSync(join(this.dir, segName(n)), lines.join("\n") + "\n", "utf8")
186
+ this._lastSegN = n
187
+ this._lastSegLines = lines.length
188
+ this._segCount = n
189
+ this._lastSegCache = null
190
+ _storeStats.segmentWrites++
191
+ lines.length = 0
192
+ }
193
+
194
+ /** 已落盘条数:(段数−1) × segSize + 末段行数(末段空文件按 0 行计)。 */
195
+ total() {
196
+ if (this._segCount <= 0) return 0
197
+ return (this._segCount - 1) * RECORD_SEG_MESSAGES + this._lastSegLines
198
+ }
199
+
200
+ /** 绝对区间 [lo, hi) 的连续切片(逐段读——只触碰覆盖区间段)。 */
201
+ _range(lo, hi) {
202
+ const out = []
203
+ const total = this.total()
204
+ if (hi <= lo || total <= 0) return out
205
+ const firstSeg = Math.floor(lo / RECORD_SEG_MESSAGES) + 1
206
+ const lastSeg = Math.floor((hi - 1) / RECORD_SEG_MESSAGES) + 1
207
+ for (let s = firstSeg; s <= lastSeg; s++) {
208
+ const lines = this._segmentLines(s)
209
+ const segBase = (s - 1) * RECORD_SEG_MESSAGES
210
+ for (let k = 0; k < lines.length; k++) {
211
+ const idx = segBase + k
212
+ if (idx < lo || idx >= hi || idx >= total) continue
213
+ const msg = tryParse(lines[k])
214
+ if (msg !== undefined) out.push(msg)
215
+ }
216
+ }
217
+ return out
218
+ }
219
+
220
+ /** 尾部 n 条(恢复窗口:只读末段及其前置段)。 */
221
+ tail(n) {
222
+ const total = this.total()
223
+ return this._range(Math.max(0, total - n), total)
224
+ }
225
+
226
+ /** 区间取页 + ±1 页沿(§14.3.6——页沿上下文供渲染层跨页回合标签/工具结果配对)。 */
227
+ page(start, end, { margin = 1 } = {}) {
228
+ const total = this.total()
229
+ const lo = Math.max(0, start - margin)
230
+ const hi = Math.min(total, end + margin)
231
+ return { messages: this._range(lo, hi), base: lo }
232
+ }
233
+
234
+ /** 方向流式迭代(read_history 用——'newest' 自尾向前 / 'oldest' 自首向后)。 */
235
+ *iterate(direction = "newest") {
236
+ const total = this.total()
237
+ if (total <= 0) return
238
+ const segs = []
239
+ for (let s = 1; s <= this._segCount; s++) segs.push(s)
240
+ const order = direction === "oldest" ? segs : [...segs].reverse()
241
+ for (const s of order) {
242
+ const lines = this._segmentLines(s)
243
+ const segBase = (s - 1) * RECORD_SEG_MESSAGES
244
+ for (let k = 0; k < lines.length; k++) {
245
+ const idx = direction === "oldest" ? segBase + k : segBase + (lines.length - 1 - k)
246
+ if (idx >= total) continue // 半行(末段未写完)不计
247
+ const msg = tryParse(lines[idx - segBase])
248
+ if (msg !== undefined) yield msg
249
+ }
250
+ }
251
+ }
252
+
253
+ /** 首条真实 user 消息(标题回退——首扫一次并缓存;生成器惰性:命中即停,不扫后续段)。 */
254
+ firstUserMessage() {
255
+ if (this._firstUser !== undefined) return this._firstUser
256
+ this._firstUser = null
257
+ for (const m of this.iterate("oldest")) {
258
+ if (isRealUserMsg(m)) { this._firstUser = m; break }
259
+ }
260
+ return this._firstUser
261
+ }
262
+
263
+ /** 槽摘要计数(manifest digest——不物化 history):{total, userReal, firstMessage}。 */
264
+ counters() {
265
+ return { total: this.total(), userReal: this._counts.turnCount, firstMessage: this._counts.firstMessage }
266
+ }
267
+
268
+ /** 追加一条真实消息(§14.3.5 单点 = pushReal;同步;独立 try/catch——失败置 degraded + 停写)。 */
269
+ append(msg) {
270
+ if (!shouldAppend(msg)) return false
271
+ this.memTotal++
272
+ if (this.degraded) return false
273
+ try {
274
+ this._writeLine(msg)
275
+ return true
276
+ } catch (e) {
277
+ this.degraded = true
278
+ console.error(`[session] record store append failed (${e.message}) — writing stopped (SESSION.md §14.4 D-R4)`)
279
+ return false
280
+ }
281
+ }
282
+
283
+ /** 半行现场修复(写路径,代码评审 #1):截掉末尾未写完的半行——恢复「物理行序 = 逻辑
284
+ * 序」不变式(读侧按计数索引;半行占位会使后续记录的物理位置与计数错位)。半行对应
285
+ * 的消息从未被确认(崩溃中止的写入)——丢弃即读侧「视为未写完、忽略」的落盘面。 */
286
+ _dropPartialTail() {
287
+ if (!this._partialSeg || this._partialSeg !== this._lastSegN) return
288
+ this._partialSeg = 0
289
+ this._lastSegCache = null
290
+ try {
291
+ const p = join(this.dir, segName(this._lastSegN))
292
+ const buf = readFileSync(p)
293
+ truncateSync(p, buf.lastIndexOf(0x0a) + 1) // −1(无完整行)→ 截为 0
294
+ } catch { /* 尽力面:截断失败仍按读侧半行容错跳过 */ }
295
+ }
296
+
297
+ /** 单条落盘(成功路径——失败向上抛,由 append/catchUp 统一处置)。 */
298
+ _writeLine(msg) {
299
+ mkdirSync(this.dir, { recursive: true })
300
+ this._dropPartialTail() // 先清末尾半行(若在)——防新记录与半行错位/粘连
301
+ // 身份固化:创建时为空 → 现场身份补写(只在跃迁时重写 meta——逐条追加不重复写)
302
+ const hadIdentity = this.identity != null
303
+ if (!hadIdentity && this.agent?._sessionStart) this.identity = this.agent._sessionStart
304
+ if (!this._metaReady || (!hadIdentity && this.identity != null)) {
305
+ this._writeMeta()
306
+ this._metaReady = true
307
+ }
308
+ if (this._lastSegN === 0) {
309
+ this._lastSegN = 1
310
+ this._lastSegLines = 0
311
+ this._segCount = 1
312
+ } else if (this._lastSegLines >= RECORD_SEG_MESSAGES) {
313
+ this._lastSegN++ // 轮转:末段满 segSize → 下一次追加新建下一段
314
+ this._lastSegLines = 0
315
+ this._segCount = this._lastSegN
316
+ }
317
+ appendFileSync(join(this.dir, segName(this._lastSegN)), JSON.stringify(slimForDisplay(msg)) + "\n", "utf8")
318
+ _storeStats.appendedEntries++
319
+ this._lastSegLines++
320
+ this._lastSegCache = null
321
+ this._countLine(msg)
322
+ }
323
+
324
+ _countLine(m) {
325
+ if (!isRealUserMsg(m)) return
326
+ this._counts.turnCount++
327
+ if (!this._sawRealUser) {
328
+ this._sawRealUser = true
329
+ this._counts.firstMessage = String(m.content).slice(0, 80)
330
+ }
331
+ }
332
+
333
+ /** D-R4② 追赶重试(保存时点):缺口 ⊆ 窗口 → 重试追加;窗口已滑过缺口 → 不补写(不写洞)。 */
334
+ catchUp() {
335
+ if (!this.degraded) return true
336
+ _storeStats.catchUpRetries++
337
+ const gap = this.memTotal - this.total()
338
+ if (gap <= 0) { this.degraded = false; return true }
339
+ const win = (this.agent?._fullHistory ?? []).filter(shouldAppend)
340
+ if (gap > win.length) return false // 缺口已滑出窗口——保持降级(不写洞)
341
+ const missing = win.slice(win.length - gap)
342
+ try {
343
+ for (const m of missing) this._writeLine(m)
344
+ } catch (e) {
345
+ console.error(`[session] record store catch-up failed: ${e.message}`)
346
+ return false
347
+ }
348
+ this.degraded = false
349
+ return true
350
+ }
351
+
352
+ /** 降级置位(追赶失败——投影照 store 前缀 + meta.degraded 标记 + stderr 一行诊断)。 */
353
+ markDegraded() {
354
+ this.degraded = true
355
+ console.error("[session] record store degraded — projection written from the stored prefix; unflushed messages beyond the window are unrecoverable (SESSION.md §14.4 D-R4)")
356
+ this.syncMeta()
357
+ }
358
+ }
359
+
360
+ /** 绑定记录存储到 agent(`agent._recordStore`)+ 身份核验/对账 + 窗口置位(§14.3.4)。 */
361
+ export function bindRecordStore(agent, { slotFile, identity = null, baseHistory = [] } = {}) {
362
+ const store = new RecordStore(slotFile)
363
+ store.bind(agent, identity, baseHistory)
364
+ agent._recordStore = store
365
+ agent._historyWindow = RECORD_WINDOW_MESSAGES
366
+ return store
367
+ }
368
+
369
+ /** 解绑(模式 F / /new 重置——内存窗口随之关闭,人读线回到全量数组语义)。 */
370
+ export function unbindRecordStore(agent) {
371
+ agent._recordStore = null
372
+ agent._historyWindow = null
373
+ }
374
+
375
+ /** 删槽联动(deleteSlot / 冷前缀清理调用)。 */
376
+ export function unlinkRecordStore(slotFile) {
377
+ try { rmSync(recordDirOf(slotFile), { recursive: true, force: true }) } catch { /* 尽力面 */ }
378
+ }
379
+
380
+ /**
381
+ * 槽 JSON 投影(流式拼接——§14.3.3 表 3 候选 1):`history` = 各段原文拼接(峰值 O(单段));
382
+ * 原子写(.tmp + rename——与 writeSessionFile 同族)。降级态先追赶(D-R4②)。
383
+ */
384
+ export function saveProjectedSlot(agent, p, fields, contextHistory) {
385
+ const store = agent?._recordStore
386
+ if (!store) return false
387
+ store.catchUp()
388
+ if (store.degraded) {
389
+ store.markDegraded()
390
+ } else {
391
+ if (store.identity == null && agent._sessionStart) store.identity = agent._sessionStart // 身份固化(规则 3)
392
+ store.syncMeta()
393
+ }
394
+ mkdirSync(dirname(p), { recursive: true })
395
+ const tmp = `${p}.tmp`
396
+ const fd = openSync(tmp, "w")
397
+ const w = (s) => writeSync(fd, s)
398
+ try {
399
+ w("{")
400
+ let first = true
401
+ for (const [k, v] of Object.entries(fields)) {
402
+ const encoded = v === undefined ? undefined : JSON.stringify(v)
403
+ if (encoded === undefined) continue // JSON.stringify 语义:undefined 字段省略(不产非法 JSON)
404
+ if (!first) w(",")
405
+ first = false
406
+ w(JSON.stringify(k) + ":" + encoded)
407
+ }
408
+ if (!first) w(",")
409
+ w(`${JSON.stringify("history")}:[`)
410
+ let wrote = false
411
+ for (let s = 1; s <= store._segCount; s++) {
412
+ let lines
413
+ try { lines = readSegmentLines(join(store.dir, segName(s))) } catch { lines = [] }
414
+ for (const line of lines) {
415
+ // 半行容忍(§14.3.2——读路径逐行容错):崩溃在 append 中途留下的末尾半行/损坏行
416
+ // 不得拼进投影——否则槽 JSON 变成非法 JSON(下次 loadSlotFile 整体读不出)。
417
+ if (line === "" || tryParse(line) === undefined) continue
418
+ if (wrote) w(",")
419
+ wrote = true
420
+ w(line)
421
+ }
422
+ }
423
+ w("]")
424
+ w("," + JSON.stringify("contextHistory") + ":" + JSON.stringify(contextHistory ?? []))
425
+ w("}")
426
+ } finally {
427
+ try { closeSync(fd) } catch { /* ignore */ }
428
+ }
429
+ try {
430
+ renameSync(tmp, p)
431
+ } catch {
432
+ try { unlinkSync(p) } catch { /* missing is fine */ }
433
+ try {
434
+ renameSync(tmp, p)
435
+ try { unlinkSync(tmp) } catch { /* ignore */ }
436
+ } catch {
437
+ writeFileSync(p, readFileSync(tmp, "utf8"), "utf8")
438
+ }
439
+ }
440
+ return true
441
+ }