@thincoder/core 0.10.3 → 0.10.5

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 (172) hide show
  1. package/CHANGELOG.md +62 -0
  2. package/README.md +1 -1
  3. package/advisor/citations.mjs +29 -3
  4. package/advisor/compaction.mjs +31 -7
  5. package/advisor/history.mjs +8 -6
  6. package/advisor/loop.mjs +29 -27
  7. package/advisor/messages.mjs +2 -1
  8. package/advisor/run.mjs +9 -5
  9. package/advisor/timeline.mjs +26 -0
  10. package/advisor.mjs +4 -2
  11. package/agent/assemble.mjs +1 -1
  12. package/agent/chat-call.mjs +1 -1
  13. package/agent/child-marks.mjs +2 -2
  14. package/agent/completion.mjs +3 -1
  15. package/agent/digest-account.mjs +189 -0
  16. package/agent/dispatch-gates.mjs +8 -0
  17. package/agent/dispatch.mjs +39 -18
  18. package/agent/family-tools.mjs +27 -16
  19. package/agent/helpers.mjs +12 -3
  20. package/agent/light-round.mjs +66 -0
  21. package/agent/post-turn.mjs +8 -4
  22. package/agent/record-results.mjs +4 -0
  23. package/agent/run-stages.mjs +32 -7
  24. package/agent/run-start.mjs +50 -3
  25. package/agent/setup-reminders.mjs +1 -1
  26. package/agent/setup.mjs +18 -3
  27. package/agent/subagent-event.mjs +1 -1
  28. package/agent/suspension.mjs +37 -12
  29. package/agent/timers.mjs +56 -5
  30. package/agent/turn-loop.mjs +30 -5
  31. package/agent/write-gate.mjs +20 -32
  32. package/agent-tools/advisor-async.mjs +15 -7
  33. package/agent-tools/advisor-settle.mjs +2 -2
  34. package/agent-tools/advisor.mjs +34 -33
  35. package/agent-tools/async-discard.mjs +34 -0
  36. package/agent-tools/async-settle.mjs +32 -7
  37. package/agent-tools/audit-block.mjs +1 -1
  38. package/agent-tools/bash-async.mjs +1 -1
  39. package/agent-tools/batch-lifecycle.mjs +59 -17
  40. package/agent-tools/batch-paths.mjs +24 -4
  41. package/agent-tools/batch-skeleton.mjs +59 -43
  42. package/agent-tools/batch.mjs +39 -18
  43. package/agent-tools/browser-async.mjs +198 -0
  44. package/agent-tools/checkpoint.mjs +1 -1
  45. package/agent-tools/design-slots.mjs +232 -0
  46. package/agent-tools/design-token.mjs +20 -0
  47. package/agent-tools/digest-budget.mjs +1 -1
  48. package/agent-tools/escalate-async.mjs +4 -2
  49. package/agent-tools/manifest.mjs +193 -0
  50. package/agent-tools/parent-channel.mjs +7 -4
  51. package/agent-tools/review-facts.mjs +143 -16
  52. package/agent-tools/settings.mjs +1 -1
  53. package/agent-tools/skill.mjs +1 -1
  54. package/agent-tools/subagent-actions-query.mjs +13 -6
  55. package/agent-tools/subagent-async.mjs +31 -15
  56. package/agent-tools/subagent-panel.mjs +2 -1
  57. package/agent-tools/subagent-scheduler.mjs +8 -5
  58. package/agent-tools/subagent-spawn.mjs +30 -56
  59. package/agent-tools/subagent.mjs +21 -8
  60. package/agent-tools/timer.mjs +186 -12
  61. package/agent-tools/zero-write-watch.mjs +77 -0
  62. package/agent-tools.mjs +2 -1
  63. package/agent.mjs +2 -2
  64. package/attachments.mjs +1 -1
  65. package/config-io.mjs +18 -13
  66. package/config-migrate.mjs +13 -29
  67. package/config-presets.mjs +33 -29
  68. package/config.mjs +68 -25
  69. package/context-degrade.mjs +1 -1
  70. package/context-echo.mjs +1 -1
  71. package/context-push.mjs +2 -2
  72. package/context.mjs +1 -1
  73. package/conventions.mjs +28 -8
  74. package/declaration.mjs +18 -2
  75. package/escape.mjs +1 -1
  76. package/file-links.mjs +1 -1
  77. package/file-refs.mjs +1 -1
  78. package/history-window.mjs +1 -1
  79. package/i18n.mjs +7 -0
  80. package/ledger-cmd.mjs +62 -16
  81. package/ledger-db.mjs +6 -0
  82. package/ledger-surface.mjs +49 -5
  83. package/ledger-tools.mjs +158 -87
  84. package/ledger-variant-notice.mjs +47 -0
  85. package/ledger.mjs +11 -18
  86. package/log.mjs +1 -1
  87. package/manifest-discovery.mjs +1 -1
  88. package/manifest-schema.mjs +7 -5
  89. package/manifest.mjs +5 -3
  90. package/mcp/transport-stdio.mjs +1 -1
  91. package/mcp.mjs +1 -1
  92. package/memory/code-index.mjs +1 -1
  93. package/memory/code-search.mjs +1 -1
  94. package/memory/code-sync.mjs +1 -1
  95. package/memory/docs.mjs +6 -2
  96. package/memory/file-list.mjs +1 -1
  97. package/memory/file-walk.mjs +17 -5
  98. package/memory/maintain.mjs +410 -0
  99. package/memory/memory-tool.mjs +21 -6
  100. package/memory/schema.mjs +19 -7
  101. package/memory/sweep.mjs +8 -5
  102. package/memory/sync-tail.mjs +16 -10
  103. package/model-ref.mjs +16 -13
  104. package/model-specs.mjs +21 -9
  105. package/package.json +1 -1
  106. package/peer-instances.mjs +1 -1
  107. package/permission.mjs +33 -0
  108. package/process-probe.mjs +2 -2
  109. package/prompts/advisor-design.md +4 -2
  110. package/prompts/advisor-round1.md +1 -1
  111. package/prompts/advisor-round2.md +1 -1
  112. package/prompts/advisor-round3.md +1 -1
  113. package/prompts/common.md +38 -4
  114. package/prompts/consult-base.md +1 -1
  115. package/prompts/discipline-engineering.md +32 -15
  116. package/prompts/discipline-normal.md +46 -2
  117. package/prompts/persona-eng-designer.md +6 -6
  118. package/prompts/persona-engineering.md +18 -9
  119. package/prompts/persona-normal.md +2 -0
  120. package/prompts/subagent-base.md +3 -0
  121. package/provider/anthropic.mjs +14 -2
  122. package/provider/core.mjs +6 -0
  123. package/provider/errors.mjs +28 -3
  124. package/provider/google.mjs +22 -10
  125. package/provider/list-models.mjs +3 -2
  126. package/provider/responses-request.mjs +3 -1
  127. package/provider/responses.mjs +38 -4
  128. package/provider/sse.mjs +3 -0
  129. package/provider-flows.mjs +14 -15
  130. package/proxy-chunked.mjs +102 -0
  131. package/proxy-target.mjs +54 -0
  132. package/proxy-transport.mjs +298 -0
  133. package/proxy.mjs +17 -248
  134. package/session-gc.mjs +2 -2
  135. package/session-lifecycle.mjs +18 -14
  136. package/session-slot-claims.mjs +2 -2
  137. package/session-slots-manifest.mjs +1 -1
  138. package/session-slots.mjs +3 -3
  139. package/shell-identity.mjs +107 -0
  140. package/team-ledger.mjs +282 -0
  141. package/team.mjs +223 -0
  142. package/tool-docs/advisor.md +1 -1
  143. package/tool-docs/bash.md +2 -1
  144. package/tool-docs/batch.md +1 -1
  145. package/tool-docs/browser.md +48 -0
  146. package/tool-docs/delete.md +7 -5
  147. package/tool-docs/ledger.md +6 -0
  148. package/tool-docs/manifest.md +16 -0
  149. package/tool-docs/memory.md +1 -0
  150. package/tool-docs/notify_parent.md +5 -0
  151. package/tool-docs/process.md +4 -4
  152. package/tool-docs/question.md +1 -0
  153. package/tool-docs/subagent.md +14 -3
  154. package/tool-docs/team_ledger.md +9 -0
  155. package/tool-docs/timer.md +6 -0
  156. package/tool-docs/wait_for.md +1 -0
  157. package/tools/bash.mjs +9 -4
  158. package/tools/browser.mjs +161 -0
  159. package/tools/git.mjs +1 -1
  160. package/tools/index.mjs +4 -4
  161. package/tools/ops.mjs +26 -10
  162. package/tools/patch.mjs +8 -2
  163. package/tools/repomap.mjs +1 -1
  164. package/tools/write-path.mjs +8 -6
  165. package/traces/trace-cleanup.mjs +1 -1
  166. package/traces/trace-store.mjs +1 -1
  167. package/vision-reader.mjs +10 -16
  168. package/tool-docs/ledger_add.md +0 -1
  169. package/tool-docs/ledger_close.md +0 -1
  170. package/tool-docs/ledger_count.md +0 -1
  171. package/tool-docs/ledger_query.md +0 -1
  172. package/tool-docs/ledger_update.md +0 -1
@@ -0,0 +1,410 @@
1
+ /**
2
+ * memory/maintain.mjs — 索引自维护引擎(自愈轮 · MEMORY.md §6.14 面⑤ · 台账 #1096 · 2026-10-08)。
3
+ *
4
+ * 单引擎单入口(D-MEM33):`maintainMemory` = 自动拍 ∥ 工具同一单点。三动作(执行序 = ⑤-4):
5
+ * ① 鬼行 GC(F-S10 · ⑤-1)——全 origin 逐档探针(含停更者);判亡 = 原样 ∥ 归一形两路径皆
6
+ * ENOENT(仅 ENOENT——其余保留);origin 树亡短路(`treeState`);命中 > 0 才取备份
7
+ * (`takeBackup`——与 sweep 同判据同命名);单事务逐档删 + 写后回读(不过 ⇒ 回滚 ⇒ 零写)。
8
+ * ② 压实(F-S11 · ⑤-2)——`freelist ≥ 64 MiB` ∧ `freelist ∕ db ≥ 20%` 双式联立越阈才 `VACUUM`
9
+ * (后读 = GC 之后读 freelist);未越 ⇒ 零动作(N-S9);忙 ∥ 失败 ⇒ 跳过(fail-safe——不改数据)。
10
+ * ③ 轮转(F-S12 · ⑤-3)——严格名族 `<basename(dbPath)>.sweep-backup-<14 位戳>` 保最新 N
11
+ * (`config.memory.maintain.backupKeep`——缺省 2 · 钳 ≥ 1);非本族文件零触;失败跳过计数不抛。
12
+ *
13
+ * 安全封套(N-S8 · ⑤-6):同一引擎同一安全口径——备份先行 ∥ ENOENT-only ∥ 干跑读数随行
14
+ * (工具面缺省 = 干跑;自动拍 = 非干跑)∥ 写后回读。低峰不阻塞:鬼行扫描分片让出(`SCAN_YIELD_MS`
15
+ * 口径——探针循环读钟让出)∥ 写面短事务;`VACUUM` 单语句不可让出(已知界——恰一次全库重写;
16
+ * 升级路径 = worker,承面② 判据分支)。
17
+ *
18
+ * 复用(单源 = §6.14 引擎表):`sweep.mjs` 的 `probeOriginPath` ∥ `treeState` ∥ `takeBackup`
19
+ * (+ `backupPathFor`)∥ `originCounts`。`files` 表不涉(记忆层非项目文件);§6.11 ∥ §6.13 判据零改。
20
+ *
21
+ * 测试缝(L-⑥ 各分支所需):阈值参数(`??` 缺省核常量)∥ `vacuumFn` ∥ `probeFn` ∥ `yieldFn`
22
+ * (+ `nowFn` ∥ `yieldMs` ∥ `yieldCheckRows` ∥ `now` ∥ `backupKeep`);调度缝 =
23
+ * `_setMemoryMaintenanceDelayForTest`。调度(⑤-5)= `scheduleMemoryMaintenance`——每进程一次 ∥
24
+ * 延迟拍(缺省 3 s——先例 `session-index-pass.mjs`)∥ 异步非阻塞 ∥ 失败静默(+ `logEvent`)∥
25
+ * 库不在盘零动作(不建库——读面纪律)。
26
+ */
27
+ import { existsSync, readdirSync, statSync, unlinkSync } from "node:fs"
28
+ import { basename, dirname, join } from "node:path"
29
+ import { loadConfig } from "../config.mjs"
30
+ import { errText, logEvent } from "../log.mjs"
31
+ import { createMemory } from "./schema.mjs"
32
+ import { yieldTick } from "./code-index.mjs"
33
+ import { normalizeOrigin } from "./origin.mjs"
34
+ import { SCAN_YIELD_CHECK_ROWS, SCAN_YIELD_MS } from "./scan.mjs"
35
+ import { backupPathFor, originCounts, probeOriginPath, takeBackup, treeState } from "./sweep.mjs"
36
+
37
+ // ── 核常量(⑤-2 ∥ ⑤-3——缺省值 = 用户拍 §4 批准:64 MiB ∧ 20% ∥ N = 2;常量不声明面)────────
38
+
39
+ /** 压实下限:`freelist_bytes ≥ 此值`(64 MiB——防小库频压)。 */
40
+ export const MAINTAIN_FREELIST_MIN_BYTES = 64 * 1024 * 1024
41
+ /** 压实比值:`freelist_bytes ∕ db_bytes ≥ 此值`(20%——防大库小益)。 */
42
+ export const MAINTAIN_FREELIST_RATIO = 0.2
43
+ /** 轮转缺省保 N(`config.memory.maintain.backupKeep` 缺省值——引擎钳 ≥ 1)。 */
44
+ export const MAINTAIN_BACKUP_KEEP_DEFAULT = 2
45
+ /** 自动拍延迟缺省(⑤-5——启动窗外 3 s)。 */
46
+ export const MEMORY_MAINTENANCE_DELAY_MS = 3000
47
+
48
+ /** GC 表集(⑤-1):`code_chunks` ∥ `doc_chunks`——`files` 表不涉(记忆层非项目文件)。 */
49
+ const GC_TABLES = [["code_chunks", "code"], ["doc_chunks", "doc"]]
50
+ /** 报告面死档明细显示上限(总数仍在读数面)。 */
51
+ const GC_DISPLAY_CAP = 20
52
+
53
+ const groupKey = (origin, path) => `${origin}\u0001${path}`
54
+ const step = (action, status, reason, readings) => ({ action, status, reason: reason ?? null, readings })
55
+
56
+ /** 两路径态(原样 ∥ 归一形——`treeState` 的逐档版):alive 优先;两路径皆 ENOENT 才判亡;
57
+ * 其余错误 ⇒ unknown(保留——仅 ENOENT 判亡同律)。`probe` = 注入口(缺省 `probeOriginPath`)。 */
58
+ function twoPathState(probe, a, b) {
59
+ const sa = probe(a), sb = a === b ? sa : probe(b)
60
+ if (sa === "alive" || sb === "alive") return "alive"
61
+ return sa === "dead" && sb === "dead" ? "dead" : "unknown"
62
+ }
63
+
64
+ /** 鬼行扫描面 census:逐 `(origin, path)` 分组计数(两表并集——`files` 表不涉)。 */
65
+ function groupCensus(db) {
66
+ const map = new Map()
67
+ for (const [table, key] of GC_TABLES) {
68
+ for (const r of db.prepare(`SELECT origin, path, COUNT(*) AS n FROM ${table} GROUP BY origin, path`).all()) {
69
+ const k = groupKey(r.origin, r.path)
70
+ const cur = map.get(k) ?? { origin: r.origin, path: r.path, code: 0, doc: 0 }
71
+ cur[key] = Number(r.n)
72
+ map.set(k, cur)
73
+ }
74
+ }
75
+ return map
76
+ }
77
+
78
+ /** 鬼行扫描(零写 · ⑤-1):逐档探针 + 树亡短路;让出 = `SCAN_YIELD_MS` 口径(每
79
+ * `yieldCheckRows` 档读钟,过预算让出——「低峰不阻塞」半条)。返回 { groups, dead, keep, unknown, yields }。 */
80
+ async function scanGhosts(db, { probeFn, yieldFn, nowFn, yieldMs, yieldCheckRows }) {
81
+ const groups = [...groupCensus(db).values()]
82
+ .sort((a, b) => (a.origin < b.origin ? -1 : a.origin > b.origin ? 1 : a.path < b.path ? -1 : a.path > b.path ? 1 : 0))
83
+ const trees = new Map()
84
+ const dead = [], keep = [], unknown = []
85
+ let checked = 0, yields = 0, lastYieldAt = nowFn()
86
+ for (const g of groups) {
87
+ if (!trees.has(g.origin)) trees.set(g.origin, treeState(g.origin))
88
+ const state = trees.get(g.origin).dead
89
+ ? "dead" // 树亡短路(同一判据免逐档探针——⑤-1)
90
+ : twoPathState(probeFn, join(g.origin, g.path), join(normalizeOrigin(g.origin), g.path))
91
+ if (state === "dead") dead.push(g)
92
+ else if (state === "unknown") unknown.push(g)
93
+ else keep.push(g)
94
+ if (++checked >= yieldCheckRows) {
95
+ checked = 0
96
+ if (nowFn() - lastYieldAt >= yieldMs) { await yieldFn(); yields++; lastYieldAt = nowFn() }
97
+ }
98
+ }
99
+ return { groups, dead, keep, unknown, yields }
100
+ }
101
+
102
+ /**
103
+ * ⑤-1 鬼行 GC:`confirm:false` ⇒ 纯计划(零写 ∥ 零备份);`confirm:true` ⇒ 命中 > 0 先取备份
104
+ * (目标已存在 ∥ 判据不过 ⇒ 抛 ⇒ **零写**),再单事务逐档删 + 写后回读。
105
+ * 写后回读 = 命中档零行(逐档点查)∧ 其余 origin 逐键等前值(`originCounts` 差量——命中 origin
106
+ * 恰减其实删数;保留 ∥ 探针错档零变含于其中;`files` 表零变),不过 ⇒ 抛 ⇒ 回滚。
107
+ */
108
+ async function ghostGcStep(memory, { confirm, dbPath, now, probeFn, yieldFn, nowFn, yieldMs, yieldCheckRows }) {
109
+ const scan = await scanGhosts(memory.db, { probeFn, yieldFn, nowFn, yieldMs, yieldCheckRows })
110
+ const readings = {
111
+ scannedGroups: scan.groups.length,
112
+ deadGroups: scan.dead.length,
113
+ deadRows: scan.dead.reduce((n, g) => n + g.code + g.doc, 0),
114
+ keptGroups: scan.keep.length,
115
+ unknownGroups: scan.unknown.length,
116
+ yields: scan.yields,
117
+ deletedRows: null,
118
+ backupPath: null,
119
+ dead: scan.dead.map((g) => ({ origin: g.origin, path: g.path, code: g.code, doc: g.doc })),
120
+ }
121
+ if (scan.dead.length === 0) return step("gc", "skipped", "no ghost rows (all origins probed — nothing judged dead)", readings)
122
+ if (!confirm) return step("gc", "planned", null, readings)
123
+ if (typeof dbPath !== "string" || !dbPath || dbPath === ":memory:") {
124
+ throw new Error("memory maintain: gc requires a file dbPath (backup naming basis) — write refused without a rollback object")
125
+ }
126
+ readings.backupPath = takeBackup(memory.db, backupPathFor(dbPath, now)) // 命中 > 0 才取;抛 ⇒ 零写
127
+ const db = memory.db
128
+ const del = new Map() // origin → { code, doc }(实删数——回读差量基准)
129
+ db.exec("BEGIN IMMEDIATE")
130
+ try {
131
+ const before = originCounts(db)
132
+ for (const g of scan.dead) {
133
+ for (const [table, key] of GC_TABLES) {
134
+ const n = Number(db.prepare(`DELETE FROM ${table} WHERE origin = ? AND path = ?`).run(g.origin, g.path).changes)
135
+ const cur = del.get(g.origin) ?? { code: 0, doc: 0 }
136
+ cur[key] += n
137
+ del.set(g.origin, cur)
138
+ }
139
+ }
140
+ for (const g of scan.dead) { // ① 命中档零行
141
+ for (const [table] of GC_TABLES) {
142
+ const { n } = db.prepare(`SELECT COUNT(*) AS n FROM ${table} WHERE origin = ? AND path = ?`).get(g.origin, g.path)
143
+ if (Number(n) !== 0) throw new Error(`memory maintain read-back failed: ghost rows remain in ${table} (${g.origin} / ${g.path})`)
144
+ }
145
+ }
146
+ const after = originCounts(db) // ② 其余 origin 逐键等前值(差量 = 实删数)
147
+ for (const o of new Set([...before.keys(), ...after.keys()])) {
148
+ const b = before.get(o) ?? { code: 0, doc: 0, files: 0 }
149
+ const a = after.get(o) ?? { code: 0, doc: 0, files: 0 }
150
+ const d = del.get(o) ?? { code: 0, doc: 0 }
151
+ if (a.code !== b.code - d.code || a.doc !== b.doc - d.doc || a.files !== b.files) {
152
+ throw new Error(`memory maintain read-back failed: origin counts drifted beyond hits (${o})`)
153
+ }
154
+ }
155
+ db.exec("COMMIT")
156
+ } catch (e) {
157
+ try { db.exec("ROLLBACK") } catch { /* 已回滚 ∥ 事务未开 */ }
158
+ throw e
159
+ }
160
+ readings.deletedRows = [...del.values()].reduce((n, c) => n + c.code + c.doc, 0)
161
+ return step("gc", "executed", null, readings)
162
+ }
163
+
164
+ /** freelist 读数(⑤-2):`PRAGMA freelist_count × page_size` ∥ `statSync(dbPath)`(承 §4.10 读数面口径——
165
+ * freelist = 维护内部读数,不入可见面)。 */
166
+ function freelistReading(db, dbPath) {
167
+ const pageSize = Number(db.prepare("PRAGMA page_size").get()?.page_size ?? 0)
168
+ const count = Number(db.prepare("PRAGMA freelist_count").get()?.freelist_count ?? 0)
169
+ let dbBytes = null
170
+ try {
171
+ dbBytes = typeof dbPath === "string" && dbPath && dbPath !== ":memory:" ? statSync(dbPath).size : null
172
+ } catch { dbBytes = null }
173
+ return { freelistBytes: count * pageSize, dbBytes }
174
+ }
175
+
176
+ /**
177
+ * ⑤-2 压实:双式联立越阈(`freelist ≥ 64 MiB` ∧ `freelist ∕ db ≥ 20%`——阈值参数 `??` 缺省核常量)
178
+ * 才 `VACUUM`;未越 ⇒ 零动作零写(N-S9);干跑 ⇒ 零执行(越阈 = planned);忙 ∥ 失败 ⇒ 跳过
179
+ * (fail-safe——不改数据)。写后回读 = freelist 回落 ∧ 尺寸 ≤ 前。
180
+ */
181
+ async function vacuumStep(memory, { confirm, dbPath, vacuumFn, minBytes, ratio }) {
182
+ const before = freelistReading(memory.db, dbPath)
183
+ const readings = { ...before, minBytes, thresholdRatio: ratio, afterFreelistBytes: null, afterDbBytes: null, readback: null }
184
+ const ratioNow = before.dbBytes > 0 ? before.freelistBytes / before.dbBytes : null
185
+ const over = before.freelistBytes >= minBytes && ratioNow !== null && ratioNow >= ratio
186
+ if (!over) {
187
+ const now = ratioNow === null ? "?" : `${(ratioNow * 100).toFixed(1)}%`
188
+ return step("vacuum", "skipped",
189
+ `freelist ${fmtBytes(before.freelistBytes)} / db ${fmtBytes(before.dbBytes)} (${now}) under the line (${fmtBytes(minBytes)} ∧ ${(ratio * 100).toFixed(0)}%)`,
190
+ readings)
191
+ }
192
+ if (!confirm) return step("vacuum", "planned", null, readings)
193
+ try {
194
+ await vacuumFn(memory.db)
195
+ } catch (e) {
196
+ return step("vacuum", "skipped", `VACUUM failed — skipped (fail-safe, data unchanged): ${errText(e, 120)}`, readings)
197
+ }
198
+ const after = freelistReading(memory.db, dbPath)
199
+ readings.afterFreelistBytes = after.freelistBytes
200
+ readings.afterDbBytes = after.dbBytes
201
+ readings.readback = after.freelistBytes < before.freelistBytes && (after.dbBytes ?? 0) <= (before.dbBytes ?? 0) ? "ok" : "unexpected"
202
+ return step("vacuum", "executed", null, readings)
203
+ }
204
+
205
+ /** 轮转名族判定(⑤-3——严格名匹配,非前缀):`<basename>.sweep-backup-<14 位戳>`。 */
206
+ function familyRe(base) {
207
+ return new RegExp(`^${base.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}\\.sweep-backup-\\d{14}$`)
208
+ }
209
+
210
+ /**
211
+ * ⑤-3 轮转:族内保最新 N(钳 ≥ 1)枚,余者逐枚 `unlink`(失败跳过计数——不抛);非本族文件零触。
212
+ * 删除面纪律 = 引擎直落(不经 agent 工具面——分类门辖 agent 写面);干跑 ⇒ 零删 + 清单在。
213
+ */
214
+ function rotateStep({ confirm, dbPath, backupKeep }) {
215
+ const readings = { dir: null, keep: backupKeep, family: 0, kept: [], drop: [], deleted: [], skipped: [] }
216
+ if (typeof dbPath !== "string" || !dbPath || dbPath === ":memory:") {
217
+ return step("rotate", "skipped", "no file dbPath (the backup family has no naming basis)", readings)
218
+ }
219
+ const dir = dirname(dbPath)
220
+ readings.dir = dir
221
+ let names
222
+ try { names = readdirSync(dir) } catch (e) {
223
+ return step("rotate", "skipped", `directory unreadable: ${errText(e, 80)}`, readings)
224
+ }
225
+ const re = familyRe(basename(dbPath))
226
+ const family = names.filter((n) => re.test(n)).sort() // 同宽 14 位戳 ⇒ 字典序 = 时序
227
+ readings.family = family.length
228
+ readings.kept = family.slice(-backupKeep).reverse() // 最新在前
229
+ const drop = family.slice(0, Math.max(0, family.length - backupKeep))
230
+ readings.drop = [...drop]
231
+ if (drop.length === 0) return step("rotate", "skipped", `${family.length} backup(s) in family ≤ keep ${backupKeep}`, readings)
232
+ if (!confirm) return step("rotate", "planned", null, readings)
233
+ for (const n of drop) {
234
+ try { unlinkSync(join(dir, n)); readings.deleted.push(n) }
235
+ catch (e) { readings.skipped.push({ name: n, error: errText(e, 80) }) }
236
+ }
237
+ return step("rotate", "executed", null, readings)
238
+ }
239
+
240
+ /** 保 N 归一(钳 ≥ 1——F-S12 下限护栏);非有限值 ⇒ 缺省。 */
241
+ function normalizeBackupKeep(v) {
242
+ const n = Number(v)
243
+ if (!Number.isFinite(n)) return MAINTAIN_BACKUP_KEEP_DEFAULT
244
+ return Math.max(1, Math.floor(n))
245
+ }
246
+
247
+ /** `config.memory.maintain.backupKeep` 现读(读抛 ∥ 缺位 ⇒ 缺省 2——引擎侧单点)。 */
248
+ function configuredBackupKeep() {
249
+ try { return loadConfig()?.memory?.maintain?.backupKeep ?? MAINTAIN_BACKUP_KEEP_DEFAULT }
250
+ catch { return MAINTAIN_BACKUP_KEEP_DEFAULT }
251
+ }
252
+
253
+ /**
254
+ * 自维护单入口(自动拍 ∥ 工具同一单点——D-MEM33)。返回逐动作段报告:
255
+ * `{ dryRun, backupKeep, elapsedMs, steps: [{ action, status: executed|planned|skipped, reason, readings }] }`。
256
+ * `confirm:false`(缺省)= 干跑(零写——计划 + 读数);`confirm:true` = 执行(备份先行 ∥ 写后回读)。
257
+ */
258
+ export async function maintainMemory(memory, {
259
+ confirm = false,
260
+ dbPath = null,
261
+ backupKeep = null,
262
+ now = new Date(),
263
+ probeFn = probeOriginPath,
264
+ vacuumFn = null, // 缺省 = `exec` 形 `VACUUM`(执行观测 = 注入计数)
265
+ yieldFn = yieldTick,
266
+ nowFn = Date.now,
267
+ yieldMs = SCAN_YIELD_MS,
268
+ yieldCheckRows = SCAN_YIELD_CHECK_ROWS,
269
+ freelistMinBytes = null, // `??` 缺省核常量(⑤-2 测试缝)
270
+ freelistRatio = null,
271
+ } = {}) {
272
+ const t0 = nowFn()
273
+ const keep = normalizeBackupKeep(backupKeep ?? configuredBackupKeep())
274
+ const minBytes = freelistMinBytes ?? MAINTAIN_FREELIST_MIN_BYTES
275
+ const ratio = freelistRatio ?? MAINTAIN_FREELIST_RATIO
276
+ const file = typeof dbPath === "string" && dbPath ? dbPath : (typeof memory?.dbPath === "string" ? memory.dbPath : null)
277
+ const runVacuum = vacuumFn ?? ((db) => db.exec("VACUUM"))
278
+ const steps = [
279
+ await ghostGcStep(memory, { confirm, dbPath: file, now, probeFn, yieldFn, nowFn, yieldMs, yieldCheckRows }),
280
+ await vacuumStep(memory, { confirm, dbPath: file, vacuumFn: runVacuum, minBytes, ratio }),
281
+ rotateStep({ confirm, dbPath: file, backupKeep: keep }),
282
+ ]
283
+ return { dryRun: !confirm, backupKeep: keep, elapsedMs: nowFn() - t0, steps }
284
+ }
285
+
286
+ // ── 报告面(工具输出 = 逐动作段:执行 ∥ 跳过 + 理由 + 读数——§6.6.1 maintain 输出契约)────────────
287
+
288
+ /** 字节人类形(报告面)。 */
289
+ function fmtBytes(n) {
290
+ if (n === null || n === undefined) return "?"
291
+ if (Math.abs(n) >= 1024 * 1024) return `${(n / (1024 * 1024)).toFixed(1)} MiB`
292
+ if (Math.abs(n) >= 1024) return `${(n / 1024).toFixed(1)} KiB`
293
+ return `${n} B`
294
+ }
295
+
296
+ const statusTag = (s) => `${s.action}: ${s.status}${s.reason ? ` — ${s.reason}` : ""}`
297
+
298
+ function gcSection(s) {
299
+ const g = s.readings
300
+ const bits = [`scanned ${g.scannedGroups} group(s)`, `dead ${g.deadGroups} group(s) / ${g.deadRows} row(s)`, `kept ${g.keptGroups} group(s)`]
301
+ if (g.unknownGroups) bits.push(`probe-error kept ${g.unknownGroups}`)
302
+ if (g.deletedRows !== null) bits.push(`deleted ${g.deletedRows} row(s)`)
303
+ if (g.yields) bits.push(`yields ${g.yields}`)
304
+ const out = [`- ${statusTag(s)} · ${bits.join(" · ")}`]
305
+ for (const d of g.dead.slice(0, GC_DISPLAY_CAP)) out.push(` - ${d.origin} / ${d.path} — code=${d.code} doc=${d.doc}`)
306
+ if (g.dead.length > GC_DISPLAY_CAP) out.push(` - … ${g.dead.length - GC_DISPLAY_CAP} more dead group(s)`)
307
+ if (g.backupPath) out.push(` backup: ${g.backupPath}`)
308
+ return out
309
+ }
310
+
311
+ function vacuumSection(s) {
312
+ const out = [`- ${statusTag(s)}`]
313
+ const r = s.readings
314
+ if (s.status === "executed") {
315
+ out.push(` freelist ${fmtBytes(r.freelistBytes)} → ${fmtBytes(r.afterFreelistBytes)} · db ${fmtBytes(r.dbBytes)} → ${fmtBytes(r.afterDbBytes)} · read-back ${r.readback}`)
316
+ } else if (s.status === "planned") {
317
+ out.push(` freelist ${fmtBytes(r.freelistBytes)} / db ${fmtBytes(r.dbBytes)} — over the line (${fmtBytes(r.minBytes)} ∧ ${(r.thresholdRatio * 100).toFixed(0)}%)`)
318
+ }
319
+ return out
320
+ }
321
+
322
+ function rotateSection(s) {
323
+ const r = s.readings
324
+ const bits = [`family ${r.family} backup(s)`, `keep ${r.keep}`]
325
+ if (r.deleted.length) bits.push(`deleted ${r.deleted.length}`)
326
+ if (r.skipped.length) bits.push(`skipped ${r.skipped.length}`)
327
+ const out = [`- ${statusTag(s)} · ${bits.join(" · ")}`]
328
+ if (s.status === "planned") {
329
+ out.push(` would delete: ${r.drop.slice(0, GC_DISPLAY_CAP).join(", ")}${r.drop.length > GC_DISPLAY_CAP ? ` … (+${r.drop.length - GC_DISPLAY_CAP})` : ""}`)
330
+ }
331
+ for (const k of r.skipped) out.push(` skipped: ${k.name} — ${k.error}`)
332
+ return out
333
+ }
334
+
335
+ /** 人读报告(工具输出面):三动作段恒定在场(执行 ∥ 计划 ∥ 跳过 + 理由 + 读数)。 */
336
+ export function formatMaintainReport(report) {
337
+ const find = (a) => report.steps.find((s) => s.action === a)
338
+ return [
339
+ `memory maintain · ${report.dryRun ? "dry-run (no writes)" : "confirm (writes)"} · backupKeep=${report.backupKeep}`,
340
+ ...gcSection(find("gc")),
341
+ ...vacuumSection(find("vacuum")),
342
+ ...rotateSection(find("rotate")),
343
+ ].join("\n")
344
+ }
345
+
346
+ /** 有动作段数(可见面门——「有动作时一行」:planned ∥ executed 皆算)。 */
347
+ export function maintainActionCount(report) {
348
+ return report.steps.filter((s) => s.status === "planned" || s.status === "executed").length
349
+ }
350
+
351
+ /** 单行摘要(CLI 可见面 ∥ `logEvent` 摘要;零动作 ⇒ "no actions")。
352
+ * freelist 数值不入本行——⑤-6「freelist = 维护内部读数,不入可见面」的可见面枚举 = 本行 ∥ logEvent;
353
+ * 维护读数完整面 = 工具报告(⑤-4 ∥ §6.6.1 逐动作段读数)。 */
354
+ export function maintainLine(report) {
355
+ const find = (a) => report.steps.find((s) => s.action === a)
356
+ const gc = find("gc"), vacuum = find("vacuum"), rotate = find("rotate")
357
+ const bits = []
358
+ if (gc.status === "executed") bits.push(`gc -${gc.readings.deletedRows} row(s)`)
359
+ else if (gc.status === "planned") bits.push(`gc would delete ${gc.readings.deadRows} row(s)`)
360
+ if (vacuum.status === "executed") bits.push("vacuum executed")
361
+ else if (vacuum.status === "planned") bits.push("vacuum planned")
362
+ if (rotate.status === "executed") bits.push(`backups -${rotate.readings.deleted.length}`)
363
+ else if (rotate.status === "planned") bits.push(`backups would delete ${rotate.readings.drop.length}`)
364
+ return bits.length ? `[maintain] ${report.dryRun ? "dry-run · " : ""}${bits.join(" · ")}` : "[maintain] no actions"
365
+ }
366
+
367
+ // ── 调度(⑤-5:启动后延迟拍——每进程一次 ∥ 异步非阻塞 ∥ 失败静默 + logEvent)────────────────────
368
+
369
+ let maintenanceDelayMs = MEMORY_MAINTENANCE_DELAY_MS
370
+ let maintenanceScheduled = false
371
+
372
+ /** 测试缝(`_setSessionIndexPassDelayForTest` 同款):用例置短值即可点火(勿真等 3 s)。 */
373
+ export function _setMemoryMaintenanceDelayForTest(ms) { maintenanceDelayMs = ms }
374
+ /** 测试缝:一次性旗标复位(用例可重复点火)。 */
375
+ export function _resetMemoryMaintenanceScheduleForTest() { maintenanceScheduled = false }
376
+
377
+ /** `dbPath` 缺省现读(`config.memory.dbPath`——读抛 ∥ 缺位 ⇒ null ⇒ 拍零动作)。 */
378
+ function configuredDbPath() {
379
+ try {
380
+ const p = loadConfig()?.memory?.dbPath
381
+ return typeof p === "string" && p ? p : null
382
+ } catch { return null }
383
+ }
384
+
385
+ /**
386
+ * 启动后延迟拍(⑤-5):每进程一次 ∥ 延迟 `delayMs`(缺省 3 s——启动窗外)∥ 异步非阻塞 ∥
387
+ * 不 unref ∥ 失败静默(+ `logEvent`);**库不在盘 ⇒ 零动作**(不建库——读面纪律)。
388
+ * 自动拍写姿态 = **非干跑**(计划随行后执行 ∥ 备份先行)。`onReport` = 端侧可见面缝
389
+ * (CLI:有动作 ⇒ 一行;缺省零行——端面板零改)。返回 false = 此前已调度(每进程一次)。
390
+ */
391
+ export function scheduleMemoryMaintenance({ dbPath = null, delayMs = maintenanceDelayMs, onReport = null } = {}) {
392
+ if (maintenanceScheduled) return false
393
+ maintenanceScheduled = true
394
+ setTimeout(async () => {
395
+ let memory = null
396
+ try {
397
+ const file = typeof dbPath === "string" && dbPath ? dbPath : configuredDbPath()
398
+ if (!file || !existsSync(file)) return
399
+ memory = createMemory({ dbPath: file })
400
+ const report = await maintainMemory(memory, { confirm: true, dbPath: file })
401
+ logEvent("memory:maintain", { summary: maintainLine(report), ms: report.elapsedMs })
402
+ onReport?.(report)
403
+ } catch (error) {
404
+ logEvent("memory:maintain", { error: errText(error) }) // 失败静默(stderr 零输出)
405
+ } finally {
406
+ try { memory?.db?.close() } catch { /* 已关 ∥ 未开 */ }
407
+ }
408
+ }, delayMs)
409
+ return true
410
+ }
@@ -1,14 +1,15 @@
1
1
  /**
2
2
  * memory/memory-tool.mjs — memory agent 工具面(自 `memory/docs.mjs` 迁出 · 2026-10-01 core 拆分批 #755 ∥ #786)。
3
3
  * 内容 = `MEMORY_ACTIONS` ∥ `MEMORY_LAYERS` ∥ 校验 / 格式化小件 ∥ `memoryTools`(单一 `memory` 工具,
4
- * 五 action)+ 各 action 执行器(`execSearch` / `execPut` / `execList` / `execDelete` / `execDeleteSingle`
5
- * / `execClear`)——迁出块逐字;原档 `memory/docs.mjs` 经 `export { … } from` 转口保名
4
+ * 六 action)+ 各 action 执行器(`execSearch` / `execPut` / `execList` / `execDelete` / `execDeleteSingle`
5
+ * / `execClear` / `execMaintain`)——迁出块逐字;原档 `memory/docs.mjs` 经 `export { … } from` 转口保名
6
6
  * (消费链 `memory.mjs → docs.mjs → memory-tool.mjs` 零改)。
7
7
  */
8
8
  import { isAbsolute, join } from "node:path"
9
9
  import { put, search, putMarkdown, clearPersonal } from "./core.mjs"
10
10
  import { deleteByUid, matchMemoryRows, deleteWhere } from "./delete.mjs"
11
11
  import { commitAndPush } from "../git/gitmem.mjs"
12
+ import { formatMaintainReport, maintainMemory } from "./maintain.mjs" // F-S13:同一单点(自愈轮 §6.14 面⑤)
12
13
  import { DESC } from "../tools/shared.mjs" // #15 描述外置:文本单点 = tool-docs/memory.md
13
14
 
14
15
  // ---------------------------------------------------------------- agent tools
@@ -17,7 +18,7 @@ import { DESC } from "../tools/shared.mjs" // #15 描述外置:文本单点 =
17
18
  * VS Code face (`thincoder-vscode/src/memory-tool.mjs`); the description is a per-end form —
18
19
  * core text lives in `tool-docs/memory.md` (DESC() load; #15 外置). Layer VALUES per end
19
20
  * (VS Code has no team layer and rejects it with CLI guidance). */
20
- const MEMORY_ACTIONS = ["search", "put", "list", "delete", "clear"]
21
+ const MEMORY_ACTIONS = ["search", "put", "list", "delete", "clear", "maintain"]
21
22
  const MEMORY_LAYERS = ["personal", "project", "team"]
22
23
 
23
24
  function validateTypeFilter(type) {
@@ -39,11 +40,12 @@ function fmtDate(ts) {
39
40
  const listRowLine = (r) => `[${r.layer}] ${r.id} [${r.type}] ${r.title}(${fmtDate(r.ts)})`
40
41
 
41
42
  /**
42
- * Generate the memory agent tool — ONE `memory` tool with five actions (MEMORY.md §6 D-M1).
43
+ * Generate the memory agent tool — ONE `memory` tool with six actions (MEMORY.md §6 D-M1).
43
44
  * search/list are read-only actions (planMode pass / no permission ask — dispatch classifies
44
45
  * them action-level, same as subagent check/status); put keeps its side-effect permission
45
46
  * gate; batch delete/clear gate on confirm:true + layer inside the tool (direct-delete
46
- * ruling — the confirm parameter IS the gate) and stay non-readonly like the retired tools.
47
+ * ruling — the confirm parameter IS the gate) and stay non-readonly like the retired tools;
48
+ * maintain (index self-maintenance) gates on confirm:true inside the tool too — default = dry-run.
47
49
  * opts: { cwd, projectDir, author, team: { dir, name } | null }
48
50
  */
49
51
  export function memoryTools(memory, opts = {}) {
@@ -68,7 +70,7 @@ export function memoryTools(memory, opts = {}) {
68
70
  keyword: { type: "string", description: "list/delete batch: filter matching title/content" },
69
71
  id: { type: "string", description: "delete single: the entry id from put/search/list output" },
70
72
  limit: { type: "number", description: "Max rows: list 50 by default, search 5 by default" },
71
- confirm: { type: "boolean", description: "delete batch/clear: must be true — without it the tool refuses" },
73
+ confirm: { type: "boolean", description: "delete batch/clear: must be true — without it the tool refuses; maintain: true executes the maintenance run (omitted = dry-run)" },
72
74
  },
73
75
  required: ["action"],
74
76
  },
@@ -84,6 +86,7 @@ export function memoryTools(memory, opts = {}) {
84
86
  case "list": return execList(memory, args, dirs)
85
87
  case "delete": return execDelete(memory, args, dirs)
86
88
  case "clear": return execClear(memory, args)
89
+ case "maintain": return execMaintain(memory, args)
87
90
  }
88
91
  },
89
92
  },
@@ -227,3 +230,15 @@ function execClear(memory, args) {
227
230
  const n = clearPersonal(memory)
228
231
  return `Cleared personal memory (${n} entries deleted)`
229
232
  }
233
+
234
+ /** action maintain — index self-maintenance (MEMORY.md §6.14 面⑤ · F-S13): dry-run by default
235
+ * (plan + per-action readings, zero writes); `confirm:true` executes. Same single engine the
236
+ * automatic startup pass runs (`maintain.mjs` `maintainMemory` — no second implementation).
237
+ * No layer parameter (library-global operation — §6.6.1); output = per-action sections. */
238
+ async function execMaintain(memory, args) {
239
+ const report = await maintainMemory(memory, {
240
+ confirm: args.confirm === true,
241
+ dbPath: typeof memory?.dbPath === "string" ? memory.dbPath : null,
242
+ })
243
+ return formatMaintainReport(report)
244
+ }
package/memory/schema.mjs CHANGED
@@ -20,10 +20,10 @@ export const SQLITE_BUSY_TIMEOUT = 3000
20
20
  /** WAL 回收后的文件截断上界(§6.12 修法①——防复胀;实测曾达 587 MB)。 */
21
21
  export const WAL_SIZE_LIMIT_BYTES = 64 * 1024 * 1024
22
22
 
23
- /** 索引行预算(§6.14 面③ P2——每 origin 库内行数 code+doc 合计):WARN 线出**一行**可见警示
24
- * (≈0.5 s 趟量级);CAP 线起同步跳过后列文件(只停新增——存量行不失效)。B3 ∕ B4 复用 WARN 值。 */
25
- export const INDEX_ORIGIN_ROW_WARN = 20000
26
- export const INDEX_ORIGIN_ROW_CAP = 100000
23
+ /** 索引行预算(§6.14 面③ P2——每 origin 库内行数 code+doc 合计):WARN 线出一行中性信息
24
+ * (真实行数——F-S14 ①);CAP 线起同步跳过后列文件(只停新增——存量行不失效)。B3 ∕ B4 复用 WARN 值。 */
25
+ export const INDEX_ORIGIN_ROW_WARN = 100000
26
+ export const INDEX_ORIGIN_ROW_CAP = 300000
27
27
 
28
28
  // Code index: source file extensions. Curated DEFAULTS — a project can declare
29
29
  // more (union) through PROJECT-MANIFEST.json → index.codeExtensions
@@ -41,8 +41,10 @@ export const CODE_EXTS = new Set([
41
41
  // Doc index: markdown / plain text (separate index makes it easier for LLM to distinguish "design specs" from "existing code")
42
42
  // Same declaration rule as CODE_EXTS (index.docExtensions).
43
43
  export const DOC_EXTS = new Set([".md", ".mdc", ".mdx", ".txt", ".rst", ".adoc", ".org", ".wiki", ".tex"])
44
- // Directory names always skipped during code/doc indexing
45
- // NOTE: these are case-sensitive basename matches; add common platform-specific dirs
44
+ // Directory names skipped during code/doc indexing — family split (§6.14 面④ · 台账 #1081): junk/product
45
+ // family (`SKIP_DIRS_FOLD`) folds on win32 (that filesystem is case-insensitive — `Node_Modules` must
46
+ // still prune); position family (user/system dirs) stays exact on every platform; POSIX is exact
47
+ // throughout. `.git` is covered by the dot-segment rule (file-walk.mjs) — it belongs to no family.
46
48
  export const SKIP_DIRS = new Set([
47
49
  "node_modules", ".git", "dist", "build", ".turbo", "coverage",
48
50
  "__pycache__", ".venv", "venv", "target", ".next", ".nuxt", ".svelte-kit",
@@ -54,7 +56,17 @@ export const SKIP_DIRS = new Set([
54
56
  "Library", "go",
55
57
  // Other common non-code directories
56
58
  "Program Files", "Program Files (x86)", "Windows", "$Recycle.Bin",
59
+ // Generic ecosystem names (§6.14 面④ — exact basename, any depth; `bin` deliberately NOT included)
60
+ "vendor", "Pods", "bower_components", "third_party", "obj", "out",
57
61
  ])
62
+ /** Junk/product family full list (the win32 fold subset — matched only via `isSkippedRelPath`). */
63
+ export const SKIP_DIRS_FOLD = new Set([
64
+ "node_modules", "dist", "build", ".turbo", "coverage",
65
+ "__pycache__", ".venv", "venv", "target", ".next", ".nuxt", ".svelte-kit",
66
+ "vendor", "Pods", "bower_components", "third_party", "obj", "out",
67
+ ])
68
+ /** Directory-name prefixes (Bazel convenience dirs `bazel-<workspace>` — the workspace name rules out enumeration). */
69
+ export const SKIP_DIR_PREFIXES = ["bazel-"]
58
70
  // Files larger than these limits are skipped during bulk indexing
59
71
  // (minified bundles, test fixtures, generated code, etc.)
60
72
  export const MAX_CODE_FILE_BYTES = 1024 * 1024 // 1 MB
@@ -74,7 +86,7 @@ export function createMemory({ dbPath }) {
74
86
  // §6.12 WAL 卫生(TUI 假死批):① `journal_size_limit` = 回收后 WAL 文件截断上界(防复胀);
75
87
  // ② 开库**一次性** `wal_checkpoint(TRUNCATE)`——失败容忍(另一实例持读事务 ⇒ busy:静默跳过,
76
88
  // 不重试不报错)。边界写实:最坏等待上界 = 连接上的 `busy_timeout`(**不是**「不阻塞」;
77
- // `wal_checkpoint` 是否走 busy handler = unverified——判据面 = 批档 §2.5 T-W2)。
89
+ // `wal_checkpoint` 是否走 busy handler = unverified——判据 = 批档 §2.5 T-W2)。
78
90
  db.exec(`PRAGMA journal_size_limit = ${WAL_SIZE_LIMIT_BYTES}`)
79
91
  try { db.exec(`PRAGMA wal_checkpoint(TRUNCATE)`) } catch { /* busy / 非 WAL(内存库)⇒ 保持现状 */ }
80
92
 
package/memory/sweep.mjs CHANGED
@@ -37,14 +37,16 @@ export function probeOriginPath(p) {
37
37
  try { statSync(p); return "alive" } catch (e) { return e?.code === "ENOENT" ? "dead" : "unknown" }
38
38
  }
39
39
 
40
- /** 双路径树读数(原样 + 归一形):alive 优先;两路径皆死才判亡;任一探针错误 ⇒ unknown(保留)。 */
41
- function treeState(o) {
40
+ /** 双路径树读数(原样 + 归一形):alive 优先;两路径皆死才判亡;任一探针错误 ⇒ unknown(保留)。
41
+ * export 供 `maintain.mjs` 复用(§6.14 面⑤ 引擎表——鬼行 GC 树亡短路同谓词;零行为变)。 */
42
+ export function treeState(o) {
42
43
  const a = probeOriginPath(o), b = probeOriginPath(normalizeOrigin(o))
43
44
  return { alive: a === "alive" || b === "alive", dead: a === "dead" && b === "dead", unknown: a === "unknown" || b === "unknown" }
44
45
  }
45
46
 
46
- /** 逐(原样 origin)行数:Map<origin, { code, doc, files }>——三表并集。 */
47
- function originCounts(db) {
47
+ /** 逐(原样 origin)行数:Map<origin, { code, doc, files }>——三表并集。
48
+ * export 供 `maintain.mjs` 复用(§6.14 面⑤ 引擎表——写后回读基准;零行为变)。 */
49
+ export function originCounts(db) {
48
50
  const map = new Map()
49
51
  for (const { key, table } of TABLES) {
50
52
  for (const r of db.prepare(`SELECT origin, COUNT(*) AS n FROM ${table} GROUP BY origin`).all()) {
@@ -127,8 +129,9 @@ export function backupPathFor(dbPath, now = new Date()) {
127
129
  /**
128
130
  * 备份前置(`--confirm` 档且命中 > 0):`VACUUM INTO` + 判据(存在 ∧ 大小 > 0 ∧ `integrity_check` = ok)。
129
131
  * 目标已存在 / 判据不过 ⇒ 抛错(调用方在**任何写之前**收到 ⇒ 零写)。返回备份路径。
132
+ * export 供 `maintain.mjs` 复用(§6.14 面⑤ 引擎表——维护跑与 sweep 同判据同命名;零行为变)。
130
133
  */
131
- function takeBackup(db, path) {
134
+ export function takeBackup(db, path) {
132
135
  if (existsSync(path)) throw new Error(`memory sweep: backup target already exists (${path}) — aborted with zero writes`)
133
136
  db.exec(`VACUUM INTO '${path.replaceAll("'", "''")}'`)
134
137
  if (!existsSync(path) || statSync(path).size <= 0) throw new Error(`memory sweep: backup judgment failed (missing or empty: ${path}) — aborted with zero writes`)
@@ -3,9 +3,10 @@
3
3
  *
4
4
  * 两个同步入口(`codeSync` ∕ `docSync`)共用的收尾机制都住本档(拆分点 = 批档 §2.12 补块 3 缝
5
5
  * 「code-sync〔预算判定 + stale 收尾块〕」):
6
- * · **P2 行预算**(`createRowBudget`):基准 = 本 origin 库内行数(code+doc,开趟读一次);过
7
- * `INDEX_ORIGIN_ROW_WARN` 出一行可见警示(每趟至多一行)+ `logEvent`;过 `INDEX_ORIGIN_ROW_CAP`
8
- * 由调用方跳过列序后缀(**只停新增**——存量行不失效;跳过 = 不落行 ∕ 不记 mtime ⇒ 下趟重试)。
6
+ * · **P2 行预算**(`createRowBudget`):基准 = 本 origin 库内行数(code+doc,开趟读一次;`opts.baseOffset`
7
+ * = 测试注入缝——缺省 = 实读核计数);过 `INDEX_ORIGIN_ROW_WARN` 出一行中性信息(每 origin 每趟至多
8
+ * 一行;真实行数——F-S14 ①)+ `logEvent`;过 `INDEX_ORIGIN_ROW_CAP` 由调用方跳过列序后缀
9
+ * (**只停新增**——存量行不失效;跳过 = 不落行 ∕ 不记 mtime ⇒ 下趟重试)。
9
10
  * · **stale 收尾循环**(`sweepStaleRows`):同款时间片让出(§6.10 A2 口径——每 `SCAN_YIELD_CHECK_ROWS`
10
11
  * 行读钟,累计 ≥ `yieldMs` 即让出)+ 面① **收尾保护位**(`isExcludedRelPath` 命中路径短路——
11
12
  * 被排除但存在的路径存量行不因声明删除,收敛仅经 `sweep --path`;缺省 `[]` ⇒ 恒不命中)。
@@ -30,25 +31,30 @@ export function rowCountOfPath(memory, table, origin, rel) {
30
31
  return Number(memory.db.prepare(`SELECT COUNT(*) AS n FROM ${table} WHERE origin = ? AND path = ?`).get(origin, rel)?.n ?? 0)
31
32
  }
32
33
 
33
- /** §6.14 P2 行预算(单源——codeSync ∕ docSync 同用):`note()` 每趟至多一行 WARN 警示;`over()` =
34
- * 已到 CAP(调用方跳过后列文件);`add(delta)` = 本趟净增行数(新入 − 被替换——与「库内行数」同刻度)。 */
35
- export function createRowBudget(memory, origin, kind, dir) {
36
- const base = originRowCount(memory, origin)
34
+ /** §6.14 P2 行预算(单源——codeSync ∕ docSync 同用;`opts.baseOffset` = 测试注入缝,缺省 = 实读核计数):
35
+ * `note()` 每 origin 每趟至多一行中性信息(真实行数——F-S14 ①;CAP 越线另出一行优雅降级说明);
36
+ * `over()` = 已到 CAP(调用方跳过后列文件);`add(delta)` = 本趟净增行数(新入 − 被替换——与「库内行数」同刻度)。 */
37
+ export function createRowBudget(memory, origin, kind, dir, { baseOffset } = {}) {
38
+ const base = originRowCount(memory, origin) + (baseOffset ?? 0)
37
39
  let landed = 0, warned = false, capped = false
38
40
  const rows = () => base + landed
39
- const pruneHint = `declare index.excludePaths in PROJECT-MANIFEST.json, or prune: thincoder memory sweep --origin "${origin}" --path <sub>`
40
41
  return {
41
42
  add: (delta) => { landed += delta },
42
43
  over: () => rows() >= INDEX_ORIGIN_ROW_CAP,
43
44
  note() {
44
45
  if (!warned && rows() >= INDEX_ORIGIN_ROW_WARN) {
45
46
  warned = true
46
- console.warn(`[memory] index origin=${origin} rows≈${rows()} ≥ WARN ${INDEX_ORIGIN_ROW_WARN} — ${pruneHint}`)
47
+ console.warn(`[memory] index origin=${origin} rows≈${rows()} (code + doc)`)
47
48
  logEvent("index:budget", { dir, kind, rows: rows(), warn: INDEX_ORIGIN_ROW_WARN })
48
49
  }
49
50
  if (!capped && rows() >= INDEX_ORIGIN_ROW_CAP) {
50
51
  capped = true
51
- console.warn(`[memory] index origin=${origin} rows≈${rows()} ≥ CAP ${INDEX_ORIGIN_ROW_CAP} — skipping remaining files this pass (existing rows are kept)`)
52
+ console.warn(
53
+ `[memory] index origin=${origin} rows≈${rows()} — index is at its size limit: ` +
54
+ `new files are not being indexed for now (existing rows are kept and remain searchable). ` +
55
+ `Options: exclude paths from indexing (index.excludePaths in PROJECT-MANIFEST.json), or remove old rows ` +
56
+ `(thincoder memory sweep --origin "${origin}" --path <sub>)`
57
+ )
52
58
  logEvent("index:budget-cap", { dir, kind, rows: rows(), cap: INDEX_ORIGIN_ROW_CAP })
53
59
  }
54
60
  },