thincoder 0.12.60 → 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 (171) hide show
  1. package/CHANGELOG.md +49 -2
  2. package/README.md +8 -6
  3. package/bin/thincoder.mjs +38 -124
  4. package/package.json +3 -2
  5. package/src/abort-provenance.mjs +116 -0
  6. package/src/acp/bridge.mjs +38 -17
  7. package/src/acp.mjs +19 -6
  8. package/src/advisor/citations.mjs +83 -21
  9. package/src/advisor/compaction.mjs +174 -0
  10. package/src/advisor/loop.mjs +293 -0
  11. package/src/advisor/messages.mjs +36 -134
  12. package/src/advisor/project-context.mjs +194 -0
  13. package/src/advisor/repos.mjs +17 -40
  14. package/src/advisor/run.mjs +124 -329
  15. package/src/advisor/truncate.mjs +57 -0
  16. package/src/advisor.mjs +3 -2
  17. package/src/agent/completion.mjs +1 -1
  18. package/src/agent/dispatch.mjs +47 -12
  19. package/src/agent/helpers.mjs +71 -13
  20. package/src/agent/record-results.mjs +13 -5
  21. package/src/agent/relay-prefix.mjs +39 -0
  22. package/src/agent/run-stages.mjs +24 -7
  23. package/src/agent/setup-reminders.mjs +16 -9
  24. package/src/agent/setup.mjs +92 -128
  25. package/src/agent/spawn-child.mjs +43 -11
  26. package/src/agent-tools/advisor-async.mjs +70 -180
  27. package/src/agent-tools/advisor-settle.mjs +231 -0
  28. package/src/agent-tools/advisor.mjs +69 -20
  29. package/src/agent-tools/async-settle.mjs +13 -0
  30. package/src/agent-tools/batch-segment.mjs +195 -0
  31. package/src/agent-tools/consult.mjs +28 -10
  32. package/src/agent-tools/design-token.mjs +14 -1
  33. package/src/agent-tools/digest-budget.mjs +76 -0
  34. package/src/agent-tools/eng.mjs +3 -3
  35. package/src/agent-tools/escalate-async.mjs +22 -13
  36. package/src/agent-tools/read-history.mjs +31 -6
  37. package/src/agent-tools/review-streak.mjs +93 -0
  38. package/src/agent-tools/settings.mjs +130 -17
  39. package/src/agent-tools/subagent-actions.mjs +18 -6
  40. package/src/agent-tools/subagent-async.mjs +66 -14
  41. package/src/agent-tools/subagent-panel.mjs +22 -15
  42. package/src/agent-tools/subagent-run.mjs +10 -7
  43. package/src/agent-tools/subagent-scheduler.mjs +57 -8
  44. package/src/agent-tools/subagent-spawn.mjs +69 -16
  45. package/src/agent-tools/subagent.mjs +175 -49
  46. package/src/agent-tools/verify.mjs +13 -34
  47. package/src/agent-tools.mjs +1 -0
  48. package/src/agent.mjs +42 -21
  49. package/src/cli/distill-command.mjs +2 -2
  50. package/src/cli/make-agent.mjs +23 -7
  51. package/src/cli/memory-command.mjs +2 -2
  52. package/src/cli/setup-wizard.mjs +29 -9
  53. package/src/completions.mjs +114 -0
  54. package/src/config-migrate.mjs +70 -0
  55. package/src/config.mjs +132 -63
  56. package/src/context.mjs +12 -1
  57. package/src/conventions.mjs +223 -0
  58. package/src/crash-reports.mjs +35 -8
  59. package/src/expand-home.mjs +16 -0
  60. package/src/generate-title.mjs +9 -4
  61. package/src/heap-watch.mjs +88 -0
  62. package/src/hooks.mjs +7 -3
  63. package/src/ledger.mjs +227 -0
  64. package/src/memory/code-index.mjs +9 -3
  65. package/src/memory/code-sync.mjs +77 -36
  66. package/src/memory/core.mjs +8 -9
  67. package/src/memory/delete.mjs +2 -0
  68. package/src/memory/docs.mjs +17 -11
  69. package/src/memory/file-walk.mjs +109 -0
  70. package/src/memory/scan.mjs +95 -0
  71. package/src/memory/schema.mjs +15 -3
  72. package/src/model-ref.mjs +66 -0
  73. package/src/model-specs.mjs +42 -8
  74. package/src/prompt-overlays.mjs +73 -16
  75. package/src/prompts/advisor-design.md +19 -9
  76. package/src/prompts/advisor-round1.md +8 -2
  77. package/src/prompts/advisor-round2.md +14 -3
  78. package/src/prompts/advisor-round3.md +14 -3
  79. package/src/prompts/common.md +115 -0
  80. package/src/prompts/consult-base.md +2 -0
  81. package/src/prompts/discipline-engineering.md +258 -0
  82. package/src/prompts/discipline-normal.md +185 -0
  83. package/src/prompts/persona-coder.md +21 -0
  84. package/src/prompts/persona-eng-coder.md +37 -0
  85. package/src/prompts/persona-eng-designer.md +60 -0
  86. package/src/prompts/persona-engineering.md +55 -0
  87. package/src/prompts/persona-explore.md +15 -0
  88. package/src/prompts/persona-normal.md +27 -0
  89. package/src/prompts/persona-plan.md +26 -0
  90. package/src/provider/anthropic.mjs +4 -4
  91. package/src/provider/core.mjs +13 -32
  92. package/src/provider/errors.mjs +26 -1
  93. package/src/provider/google.mjs +5 -6
  94. package/src/provider/index.mjs +2 -1
  95. package/src/provider/list-models.mjs +93 -0
  96. package/src/provider/rate.mjs +2 -1
  97. package/src/provider/responses.mjs +5 -3
  98. package/src/provider/sse.mjs +3 -4
  99. package/src/proxy.mjs +9 -14
  100. package/src/session-gc.mjs +9 -2
  101. package/src/session-guard.mjs +12 -0
  102. package/src/session-segments.mjs +100 -0
  103. package/src/session-slots.mjs +10 -2
  104. package/src/session-store.mjs +441 -0
  105. package/src/session.mjs +137 -99
  106. package/src/text-budget.mjs +46 -0
  107. package/src/token-ttl.mjs +2 -1
  108. package/src/tools/{system.mjs → bash.mjs} +6 -243
  109. package/src/tools/file.mjs +30 -10
  110. package/src/tools/git.md +1 -1
  111. package/src/tools/git.mjs +15 -34
  112. package/src/tools/index.mjs +4 -2
  113. package/src/tools/ops.mjs +20 -7
  114. package/src/tools/question.md +1 -0
  115. package/src/tools/question.mjs +26 -0
  116. package/src/tools/read.md +1 -1
  117. package/src/tools/read_image.md +1 -1
  118. package/src/tools/search.mjs +236 -0
  119. package/src/traces/trace-store.mjs +195 -64
  120. package/src/tui/agent-turn.mjs +32 -13
  121. package/src/tui/ansi.mjs +2 -0
  122. package/src/tui/clipboard.mjs +7 -1
  123. package/src/tui/cmd-advisor.mjs +3 -2
  124. package/src/tui/cmd-clear.mjs +2 -0
  125. package/src/tui/cmd-config.mjs +108 -37
  126. package/src/tui/cmd-eng.mjs +11 -27
  127. package/src/tui/cmd-exit.mjs +6 -8
  128. package/src/tui/cmd-model.mjs +14 -12
  129. package/src/tui/cmd-new.mjs +5 -1
  130. package/src/tui/cmd-reindex.mjs +7 -0
  131. package/src/tui/cmd-session.mjs +8 -4
  132. package/src/tui/cmd-submodel.mjs +8 -5
  133. package/src/tui/cmd-undo.mjs +4 -3
  134. package/src/tui/display-budget.mjs +184 -0
  135. package/src/tui/index.mjs +69 -40
  136. package/src/tui/key-handler-search.mjs +9 -1
  137. package/src/tui/key-handler.mjs +61 -17
  138. package/src/tui/key-modes.mjs +86 -8
  139. package/src/tui/layout.mjs +18 -10
  140. package/src/tui/ledger-surface.mjs +69 -0
  141. package/src/tui/model-catalog.mjs +89 -0
  142. package/src/tui/model-picker.mjs +498 -0
  143. package/src/tui/mouse.mjs +47 -10
  144. package/src/tui/pickers.mjs +28 -410
  145. package/src/tui/render-frame.mjs +38 -16
  146. package/src/tui/render-loop.mjs +2 -0
  147. package/src/tui/render-segments.mjs +5 -19
  148. package/src/tui/render.mjs +37 -5
  149. package/src/tui/slash-commands.mjs +2 -2
  150. package/src/tui/startup.mjs +45 -13
  151. package/src/tui/subagent-blocks.mjs +70 -90
  152. package/src/tui/subagent-children.mjs +125 -67
  153. package/src/tui/subagent-freeze.mjs +48 -45
  154. package/src/tui/subagent-panel.mjs +21 -66
  155. package/src/tui/suspension-drive.mjs +31 -83
  156. package/src/tui/tool-args.mjs +9 -4
  157. package/src/tui/tool-display.mjs +20 -5
  158. package/src/tui/tool-events.mjs +76 -30
  159. package/src/tui/tui-lifecycle.mjs +27 -7
  160. package/src/tui/wizard.mjs +52 -18
  161. package/src/tui/wrapped-spawn.mjs +54 -0
  162. package/src/prompts/coder.md +0 -13
  163. package/src/prompts/discipline.md +0 -84
  164. package/src/prompts/eng-coder.md +0 -19
  165. package/src/prompts/engineering-sub.md +0 -14
  166. package/src/prompts/engineering.md +0 -87
  167. package/src/prompts/explore.md +0 -12
  168. package/src/prompts/main.md +0 -34
  169. package/src/prompts/methodology-template.md +0 -38
  170. package/src/prompts/plan.md +0 -9
  171. package/src/prompts/system.md +0 -44
@@ -0,0 +1,16 @@
1
+ /**
2
+ * expand-home.mjs — 配置路径字段家目录展开器(第 29 批 HOME-EXPANSION,2026-09-11)。
3
+ * `loadConfig()` 单一规范化点(设计 MEMORY.md §9.3a):纯函数、零依赖、home 可注入。
4
+ */
5
+
6
+ import { homedir } from "node:os"
7
+ import { join } from "node:path"
8
+
9
+ /** 展开配置路径字段的前缀 `~`(`~` / `~/` / `~\`)为主目录绝对路径;不识别形态原样返回。
10
+ * home 第二参 = 测试注入缝(生产缺省 homedir())。 */
11
+ export function expandHome(p, home = homedir()) {
12
+ if (typeof p !== "string" || !p.startsWith("~")) return p // 非字符串 / 非 ~ 前缀 → 原样
13
+ if (p === "~") return home // 裸 ~ = 主目录
14
+ if (p[1] !== "/" && p[1] !== "\\") return p // ~user 等非分隔符 → 原样(不猜用户)
15
+ return join(home, p.slice(2).replaceAll("\\", "/")) // 余段分隔符归一(跨端统一)
16
+ }
@@ -46,7 +46,7 @@ export async function generateTitle(userContent, provider) {
46
46
  const url = `${provider.baseURL.replace(/\/+$/, "")}${chatPath}`
47
47
  const opts = {
48
48
  method: "POST",
49
- headers: { "Content-Type": "application/json", Authorization: `Bearer ${provider.apiKey}` },
49
+ headers: { ...(provider.headers ?? {}), "Content-Type": "application/json", Authorization: `Bearer ${provider.apiKey}` }, // 定制头展开(PROVIDER.md §21)——定制头在前、内置头在后:内置头胜出
50
50
  body,
51
51
  signal: AbortSignal.timeout(10000),
52
52
  }
@@ -69,9 +69,14 @@ export async function generateTitle(userContent, provider) {
69
69
  export async function ensureSessionTitle(agent) {
70
70
  if (agent.title) return agent.title
71
71
  try {
72
- const firstUser = (agent._fullHistory ?? agent.history).find(
73
- (m) => m.role === "user" && typeof m.content === "string" && !m.content.startsWith("[System reminder:"),
74
- )
72
+ // TUI-OOM-ROOTCAUSE 批(SESSION.md §14.3.3):绑定态首条 user 消息在记录存储(段 1)——
73
+ // 内存窗口可能已滑过它(store.firstUserMessage 首扫一次并缓存);未绑定回退内存查找。
74
+ let firstUser = agent._recordStore?.firstUserMessage?.() ?? null
75
+ if (!firstUser) {
76
+ firstUser = (agent._fullHistory ?? agent.history).find(
77
+ (m) => m.role === "user" && typeof m.content === "string" && !m.content.startsWith("[System reminder:"),
78
+ )
79
+ }
75
80
  if (firstUser) {
76
81
  const title = await generateTitle(firstUser.content, agent.provider)
77
82
  if (title) agent.title = title
@@ -0,0 +1,88 @@
1
+ /**
2
+ * heap-watch.mjs — 堆遥测/看门狗(TUI-OOM-ROOTCAUSE 批——CRASH-REPORTS.md §8)。
3
+ *
4
+ * 事故会话堆爬升 ~19 分钟无任何事前信号(「炸了才知道」);本模块补「事前预警」:
5
+ * 60s 采样 + 堆上限比例双档(70% / 85%)边缘触发 → stderr 行 + TUI 行(订阅制)+
6
+ * 事件日志。**只看不治**(不强制 GC、不阻断、不触发快照——D-HW2)。
7
+ *
8
+ * 关口(§8.3):阈值 = `heapUsed / v8.getHeapStatistics().heap_size_limit`(比例——可移植,
9
+ * 无绝对 MB 常量);双档边缘触发(每档一次/进程——不重复刷屏);零常态开销
10
+ * (60s 一次 memoryUsage(),无输出——D-HW5)。
11
+ *
12
+ * 开关单点:`THINCODER_HEAP_WATCH`——关值集合 {`0`,`false`,`off`,`no`}(trim + 大小写
13
+ * 不敏感)→ 不启动;未设/空串/其他值 → 启动(默认开——与 F3② 同约定)。
14
+ *
15
+ * 注入缝(N5 可测):`sample` / `heapLimit` / `timer`——测试以假实现 + `checkNow()`
16
+ * 直驱(零等待);失败面全吞(采样抛错不阻断——样本失败静默跳过本次)。
17
+ */
18
+ import { getHeapStatistics } from "node:v8"
19
+ import { logEvent } from "./log.mjs"
20
+
21
+ /** 关值集合(单点判定——§3.2 同约定复用)。 */
22
+ export const HEAP_WATCH_OFF = new Set(["0", "false", "off", "no"])
23
+
24
+ /** 开关判定(单点——grep 面:仅本模块读 env.THINCODER_HEAP_WATCH)。 */
25
+ export function heapWatchEnabled(env = process.env) {
26
+ const v = String(env.THINCODER_HEAP_WATCH ?? "").trim().toLowerCase()
27
+ return !HEAP_WATCH_OFF.has(v)
28
+ }
29
+
30
+ // 订阅表(TUI 行接线——§8.3 订阅制;模块级:武装在 bin、订阅在 startTUI)
31
+ const _subscribers = new Set()
32
+
33
+ /** 订阅预警行(返回退订函数)。 */
34
+ export function onHeapWarn(cb) {
35
+ if (typeof cb !== "function") return () => {}
36
+ _subscribers.add(cb)
37
+ return () => _subscribers.delete(cb)
38
+ }
39
+
40
+ /** 预警行格式化(逐字——§8.3;U/L 一位小数、P 取整)。 */
41
+ export function heapWarnLine(used, limit, ratio) {
42
+ const gb = (n) => (n / 1024 / 1024 / 1024).toFixed(1)
43
+ return `[heap] warning: heapUsed ${gb(used)} GB / ${gb(limit)} GB heap limit (${Math.round(ratio * 100)}%) — long session; consider /new to reset context`
44
+ }
45
+
46
+ /**
47
+ * 启动看门狗(返回 `{ stop(), checkNow() }`——checkNow 直驱一次采样并返回本轮新发预警行)。
48
+ * 关值(§8.3)→ 不注册定时器(返回惰性句柄)。
49
+ */
50
+ export function startHeapWatch({
51
+ intervalMs = 60_000,
52
+ ratios = [0.7, 0.85],
53
+ sample = process.memoryUsage,
54
+ heapLimit = () => getHeapStatistics().heap_size_limit,
55
+ env = process.env,
56
+ timer = setInterval,
57
+ } = {}) {
58
+ const warned = new Set()
59
+ const checkNow = () => {
60
+ const out = []
61
+ try {
62
+ const used = sample()?.heapUsed ?? 0
63
+ const limit = heapLimit()
64
+ if (!(limit > 0)) return out
65
+ const ratio = used / limit
66
+ for (const r of ratios) {
67
+ if (ratio >= r && !warned.has(r)) {
68
+ warned.add(r) // 每档一次/进程
69
+ const line = heapWarnLine(used, limit, ratio)
70
+ out.push(line)
71
+ try { console.error(line) } catch { /* 输出失败不阻断 */ } // stderr(headless 主面——§8.3)
72
+ for (const cb of _subscribers) { try { cb(line) } catch { /* 订阅者失败不阻断 */ } }
73
+ try { logEvent("heap-warn", { kind: "heap-warn", used, limit, ratio: Number(ratio.toFixed(3)) }) } catch { /* 日志失败不阻断(D-HW4) */ }
74
+ }
75
+ }
76
+ } catch { /* 失败面全吞(N5——采样/上限抛错不阻断、状态保持) */ }
77
+ return out
78
+ }
79
+ if (!heapWatchEnabled(env)) return { stop() {}, checkNow }
80
+ const handle = timer(checkNow, intervalMs)
81
+ try { handle?.unref?.() } catch { /* unref 失败不阻断(一次性命令自然退出) */ }
82
+ return {
83
+ stop() {
84
+ try { clearInterval(handle) } catch { /* 已停/不可清——静默(尽力面) */ }
85
+ },
86
+ checkNow,
87
+ }
88
+ }
package/src/hooks.mjs CHANGED
@@ -8,7 +8,7 @@
8
8
  * PreToolUse — before each tool call (can block execution)
9
9
  * PostToolUse — after successful tool execution
10
10
  * PostToolUseFailure — after failed tool execution
11
- * Notification — generic notification (triggered by agent)
11
+ * Stop — main-session run end (no tool name; block is meaningless)
12
12
  *
13
13
  * Each hook: { matcher?, command, args?, timeout?, action? }
14
14
  * matcher: regex against tool name (default: match all)
@@ -29,9 +29,11 @@ export async function runHooks(event, ctx) {
29
29
  if (!hooks?.length) return true
30
30
 
31
31
  for (const hook of hooks) {
32
- if (hook.matcher) {
32
+ // Matcher = tool-name regex: tool events always carry a toolName; no-tool-name
33
+ // events (Stop) ignore it — a configured matcher must not silently never fire.
34
+ if (hook.matcher && ctx.toolName != null) {
33
35
  try {
34
- if (!new RegExp(hook.matcher).test(ctx.toolName ?? "")) continue
36
+ if (!new RegExp(hook.matcher).test(ctx.toolName)) continue
35
37
  } catch { /* invalid regex → skip */ }
36
38
  }
37
39
 
@@ -48,6 +50,8 @@ async function runOneHook(event, hook, ctx) {
48
50
  toolArgs: ctx.toolArgs ?? null,
49
51
  result: ctx.result ?? null,
50
52
  error: ctx.error?.message ?? null,
53
+ // Event-specific fields (Stop: turn / reason) — existing callers pass no extra ⇒ payload unchanged.
54
+ ...(ctx.extra ?? {}),
51
55
  timestamp: new Date().toISOString(),
52
56
  })
53
57
 
package/src/ledger.mjs ADDED
@@ -0,0 +1,227 @@
1
+ /**
2
+ * ledger.mjs — 台账(需求池 / 技术待办)单源读取面(LEDGER-SURFACE 批——设计档 §2.30.3)。
3
+ *
4
+ * 数字单源(F7):解析(`scanGroups`)/ 计数 / 老化 / 阈值一处实现——机检器
5
+ * (`scripts/check-ledger.mjs`)与显示面(CLI TUI `src/tui/ledger-surface.mjs`)共用本口径;
6
+ * VSC 端为独立实现、语义同源(不跨仓 import)。
7
+ * 口径(需求档 §1.18):计数 = `##` 组内 `- [ ]` 未决条目;老化 = 技术组无 `触发=` 且行龄
8
+ * > 30 天(行龄未知不计);阈值 = 池 ≥ 3 或任一板块 ≥ 2;可动作 = 老化 > 0 或阈值达成。
9
+ * 行文本逐字契约 = 设计档 §2.30.3.3(本模块只产出文本,色 / 态归各端显示面)。
10
+ */
11
+ import { mkdirSync, opendirSync, readFileSync, renameSync, statSync, writeFileSync } from "node:fs"
12
+ import { basename, dirname, join, resolve } from "node:path"
13
+ import { execFileSync } from "node:child_process"
14
+ import { configDir } from "./config.mjs"
15
+
16
+ /** 老化阈值(天——口径 = 需求档 §1.18;`days` 参数可覆盖)。 */
17
+ export const AGING_DAYS = 30
18
+ /** 「可开批」阈值:同一板块(池条目指针所指需求档文档名)未决 ≥ 2。 */
19
+ export const THRESHOLD_BOARD = 2
20
+ /** 「可开批」阈值:需求池未决 ≥ 3。 */
21
+ export const THRESHOLD_POOL = 3
22
+ /** 刷新周期(N2 实现常量;VSC 端另有换项目事件触发)。 */
23
+ export const REFRESH_MS = 120000
24
+ /** 变化行去重档(跨会话、跨端共享——需求档 §1.18 F5;configDir 先例 = crash-reports 同区)。 */
25
+ export const NOTIFY_FILE = join(configDir, "ledger-notify.json")
26
+ /** 空族提示(仅命令面——运行时面静默,N1 / 设计档 §2.30.3.3)。 */
27
+ export const EMPTY_FAMILY_LINE = "台账:未发现台账(docs/TODO.md)。"
28
+
29
+ const LEDGER_REL = join("docs", "TODO.md")
30
+ const H2_RE = /^##\s+(.*)$/
31
+ const DECL_RE = /((\d+)\s*条)/
32
+ const OPEN_ENTRY_RE = /^- \[ \]\s+/
33
+ const TRIGGER_RE = /触发\s*=\s*([^·\n]*)/
34
+ const BOARD_REF_RE = /requirements\/([A-Za-z0-9_.-]+\.md)/
35
+
36
+ /** `##` 组扫描(与机检器 L2/L3 同源——解析唯一实现):[{name,line,declared,entries:[{line,text}]}]。 */
37
+ export function scanGroups(lines) {
38
+ const groups = []
39
+ lines.forEach((l, i) => {
40
+ const h = H2_RE.exec(l)
41
+ if (h) groups.push({ name: h[1].trim(), line: i + 1, declared: Number((DECL_RE.exec(h[1]) ?? [])[1] ?? NaN), entries: [] })
42
+ else if (groups.length && OPEN_ENTRY_RE.test(l)) groups[groups.length - 1].entries.push({ line: i + 1, text: l })
43
+ })
44
+ return groups
45
+ }
46
+
47
+ /** 条目归一化文本 = 条目键(去 `- [ ] ` 前缀 + 去首尾空白 + 连续空白折叠单空格)。
48
+ * 位置无关(位移 / 他条编辑稳定);自身文本变更 = 键变(最坏一次重报)。§2.30.3.2。 */
49
+ export function normalizeEntry(text) {
50
+ return text.replace(/^- \[[ x]\]\s+/, "").trim().replace(/\s+/g, " ")
51
+ }
52
+
53
+ /** 条目标题:首个 `**…**` 段;无粗体段 → 归一化文本前 20 字(超出加 `…`)。§2.30.3.3 L3。 */
54
+ export function entryTitle(text) {
55
+ const m = /\*\*(.+?)\*\*/.exec(text)
56
+ if (m) return m[1]
57
+ const norm = normalizeEntry(text)
58
+ return norm.length > 20 ? norm.slice(0, 20) + "…" : norm
59
+ }
60
+
61
+ const isFile = (p) => { try { return statSync(p).isFile() } catch { return false } }
62
+
63
+ /** 向上(含自身)最近的含台账目录 → {root, ledger} / null(F4——CLI 锚 = cwd)。 */
64
+ export function findProject(anchor) {
65
+ let dir = resolve(anchor)
66
+ for (;;) {
67
+ const ledger = join(dir, LEDGER_REL)
68
+ if (isFile(ledger)) return { root: dir, ledger }
69
+ const parent = dirname(dir)
70
+ if (parent === dir) return null
71
+ dir = parent
72
+ }
73
+ }
74
+
75
+ /** 同级枚举上限(N2 成本有界:父目录超此规模 = 缓存 / 临时等非仓族形态 → 退化空集,只显当前项目)。 */
76
+ export const MAX_SIBLING_SCAN = 100
77
+
78
+ /** 目录下含台账的直接子目录(目录名升序;不可读 → [];**惰性枚举 + 上限**——大目录零全量扫描)。 */
79
+ function ledgerChildren(dir) {
80
+ const out = []
81
+ let handle
82
+ try { handle = opendirSync(dir) } catch { return out }
83
+ try {
84
+ let n = 0
85
+ for (;;) {
86
+ const ent = handle.readSync()
87
+ if (!ent) break
88
+ if (++n > MAX_SIBLING_SCAN) return []
89
+ if (!ent.isDirectory()) continue
90
+ const root = join(dir, ent.name)
91
+ const ledger = join(root, LEDGER_REL)
92
+ if (isFile(ledger)) out.push({ root, ledger })
93
+ }
94
+ } catch { /* 读中断 → 已得子集(尽力而为) */ }
95
+ finally { try { handle.closeSync() } catch { /* 已关 */ } }
96
+ return out.sort((a, b) => (a.root < b.root ? -1 : a.root > b.root ? 1 : 0))
97
+ }
98
+
99
+ /** 项目族发现:{current, projects}——projects = current + 其同级含台账目录(**发现序:current 在前**,
100
+ * 其余按目录名升序);current 缺(容器目录,K6)→ 上下文目录向上最近「含台账子目录」者取其子目录。 */
101
+ export function discoverFamily(anchor) {
102
+ const current = findProject(anchor)
103
+ if (current) {
104
+ const siblings = ledgerChildren(dirname(current.root)).filter((p) => p.root !== current.root)
105
+ return { current, projects: [current, ...siblings] }
106
+ }
107
+ let dir = resolve(anchor)
108
+ for (;;) {
109
+ const kids = ledgerChildren(dir)
110
+ if (kids.length) return { current: null, projects: kids }
111
+ const parent = dirname(dir)
112
+ if (parent === dir) return { current: null, projects: [] }
113
+ dir = parent
114
+ }
115
+ }
116
+
117
+ /** 行龄 Map:全档一次 `git blame --porcelain` → Map<行号, 天>;非 git / 不可判定 → null(零假阳降级)。 */
118
+ export function blameAges(abs) {
119
+ let out
120
+ try {
121
+ out = execFileSync("git", ["blame", "--porcelain", "--", abs], {
122
+ cwd: dirname(abs), encoding: "utf8", stdio: ["ignore", "pipe", "ignore"], maxBuffer: 32 * 1024 * 1024,
123
+ })
124
+ } catch { return null }
125
+ const times = new Map(), ages = new Map()
126
+ const now = Date.now() / 1000
127
+ let sha = null, line = 0
128
+ for (const l of out.split("\n")) {
129
+ const h = /^([0-9a-f^]{7,40})\s+\d+\s+(\d+)(?:\s+\d+)?$/.exec(l)
130
+ if (h) { sha = h[1]; line = Number(h[2]); continue }
131
+ if (l.startsWith("author-time ")) { times.set(sha, Number(l.slice(12))); continue }
132
+ if (l.startsWith("\t")) {
133
+ const t = times.get(sha)
134
+ ages.set(line, t != null ? (now - t) / 86400 : null)
135
+ }
136
+ }
137
+ return ages
138
+ }
139
+
140
+ /** 单项目汇总 → {name,root,ledger,pool,tech,aged,agedKeys,agedTitles,thresholdReached,boards,actionable}。
141
+ * `ageOf(abs)` → Map<行号,天>|null(注入面——确定性用例);**无候选条目不调 ageOf**(N2)。 */
142
+ export function summarizeLedger(project, { days = AGING_DAYS, ageOf = blameAges } = {}) {
143
+ const groups = scanGroups(readFileSync(project.ledger, "utf8").split("\n"))
144
+ const poolEntries = groups.filter((g) => g.name.includes("需求池")).flatMap((g) => g.entries)
145
+ const techEntries = groups.filter((g) => g.name.includes("技术")).flatMap((g) => g.entries)
146
+ const candidates = techEntries.filter((e) => !TRIGGER_RE.test(e.text))
147
+ const ages = candidates.length ? ageOf(project.ledger) : null
148
+ const aged = candidates.filter((e) => { const a = ages?.get(e.line); return a != null && a > days })
149
+ const boards = new Map()
150
+ for (const e of poolEntries) {
151
+ const m = BOARD_REF_RE.exec(e.text)
152
+ if (m) boards.set(m[1], (boards.get(m[1]) ?? 0) + 1)
153
+ }
154
+ const thresholdReached = poolEntries.length >= THRESHOLD_POOL || [...boards.values()].some((n) => n >= THRESHOLD_BOARD)
155
+ return {
156
+ name: basename(project.root), root: project.root, ledger: project.ledger,
157
+ pool: poolEntries.length, tech: techEntries.length, aged: aged.length,
158
+ agedKeys: aged.map((e) => normalizeEntry(e.text)), agedTitles: aged.map((e) => entryTitle(e.text)),
159
+ thresholdReached, boards, actionable: aged.length > 0 || thresholdReached,
160
+ }
161
+ }
162
+
163
+ /** L1 状态标记(逐字——§2.30.3.3)。 */
164
+ export function formatMarker(scan) {
165
+ return scan ? `台账 ${scan.pool}·${scan.tech}` : null
166
+ }
167
+
168
+ /** L2 明细行(逐字——`(老化 <n>)` 恒显;阈值达成加 ` — 可开批`)。 */
169
+ export function formatDetailLine(scan) {
170
+ return `台账 ${scan.name}:需求池 ${scan.pool} · 技术待办 ${scan.tech}(老化 ${scan.aged})${scan.thresholdReached ? " — 可开批" : ""}`
171
+ }
172
+
173
+ /** L3 变化行·老化(titles = 新增老化条目标题;> 3 条时第三项后接 `;…`)。 */
174
+ export function formatAgingLine(scan, titles) {
175
+ return `台账变化:${scan.name} 老化首次越线 ${titles.length} 条(超 30 天未处置):${titles.slice(0, 3).join(";")}${titles.length > 3 ? ";…" : ""}`
176
+ }
177
+
178
+ /** L4 变化行·阈值。 */
179
+ export function formatThresholdLine(scan) {
180
+ return `台账变化:${scan.name} 需求池达阈值(${scan.pool} 条)— 可开批`
181
+ }
182
+
183
+ /** 明细行集 = {current} ∪ {可动作项目}(发现序——current 在前,其余按目录名升序;同项去重)。 */
184
+ export function detailScans(scans, current) {
185
+ const out = []
186
+ if (current) out.push(current)
187
+ for (const s of scans) if (s !== current && s.actionable) out.push(s)
188
+ return out
189
+ }
190
+
191
+ /** 变化行规划(纯函数):{lines:[{text,warn}], next:{aged,threshold}}(去重口径 §2.30.3.2)。 */
192
+ export function planChangeLines(prev, scan) {
193
+ const p = prev && typeof prev === "object" ? prev : {}
194
+ const before = new Set(Array.isArray(p.aged) ? p.aged : [])
195
+ const freshTitles = scan.agedKeys.map((k, i) => (before.has(k) ? null : scan.agedTitles[i])).filter((t) => t != null)
196
+ const lines = []
197
+ if (freshTitles.length) lines.push({ text: formatAgingLine(scan, freshTitles), warn: true })
198
+ if (scan.thresholdReached && !p.threshold) lines.push({ text: formatThresholdLine(scan), warn: true })
199
+ return { lines, next: { aged: scan.agedKeys, threshold: scan.thresholdReached } }
200
+ }
201
+
202
+ /** 去重档键 = 台账绝对路径·正斜杠 + **盘符大写**(跨端同规则——CLI 的 `process.cwd()` 盘符大写、
203
+ * VSC 的 `uri.fsPath` 小写(`session-slots.mjs` `normalizeCwd` 同一契约——session / checkpoint /
204
+ * trace 跨端共享即赖此);CLI 写的键 VSC 须逐字认得)。 */
205
+ export function notifyKey(ledger) {
206
+ return resolve(ledger).replace(/\\/g, "/").replace(/^([a-z]):/, (_, d) => `${d.toUpperCase()}:`)
207
+ }
208
+
209
+ /** 去重档读(缺失 / 坏 JSON → 空态——N1 降级不崩)。 */
210
+ export function loadNotifyState(file = NOTIFY_FILE) {
211
+ try {
212
+ const j = JSON.parse(readFileSync(file, "utf8"))
213
+ if (j && typeof j === "object" && j.ledgers && typeof j.ledgers === "object") return { version: 1, ledgers: j.ledgers }
214
+ } catch { /* 缺失 / 坏档 → 空态 */ }
215
+ return { version: 1, ledgers: {} }
216
+ }
217
+
218
+ /** 去重档写(temp + rename 防撕裂;写失败静默返回 false——唯一写面,仅送达后调)。 */
219
+ export function saveNotifyState(file, state) {
220
+ try {
221
+ mkdirSync(dirname(file), { recursive: true })
222
+ const tmp = `${file}.tmp-${process.pid}`
223
+ writeFileSync(tmp, JSON.stringify(state), "utf8")
224
+ renameSync(tmp, file)
225
+ return true
226
+ } catch { return false }
227
+ }
@@ -8,14 +8,20 @@ import { segmentCJK, CODE_EXTS, DOC_EXTS, SKIP_DIRS, BIG_FILE_LINES } from "./sc
8
8
  export function detectLanguage(filename) {
9
9
  const ext = filename.slice(filename.lastIndexOf(".")).toLowerCase()
10
10
  const map = {
11
- ".mjs": "javascript", ".js": "javascript", ".jsx": "jsx", ".ts": "typescript", ".tsx": "tsx",
11
+ ".mjs": "javascript", ".js": "javascript", ".cjs": "javascript", ".jsx": "jsx",
12
+ ".ts": "typescript", ".tsx": "tsx", ".mts": "typescript", ".cts": "typescript",
12
13
  ".py": "python", ".rs": "rust", ".go": "go", ".java": "java",
13
14
  ".c": "c", ".h": "c", ".cpp": "cpp", ".hpp": "cpp",
14
- ".rb": "ruby", ".swift": "swift", ".kt": "kotlin",
15
+ ".rb": "ruby", ".swift": "swift", ".kt": "kotlin", ".dart": "dart", ".lua": "lua",
16
+ ".cs": "csharp", ".fs": "fsharp", ".fsx": "fsharp",
17
+ ".clj": "clojure", ".cljs": "clojure", ".ex": "elixir", ".exs": "elixir",
18
+ ".erl": "erlang", ".hrl": "erlang", ".scala": "scala", ".groovy": "groovy",
19
+ ".pl": "perl", ".pm": "perl", ".r": "r", ".jl": "julia", ".zig": "zig",
20
+ ".ps1": "powershell", ".proto": "protobuf", ".graphql": "graphql", ".tf": "terraform", ".hcl": "hcl",
15
21
  ".sh": "bash", ".bash": "bash", ".sql": "sql",
16
22
  ".yaml": "yaml", ".yml": "yaml", ".toml": "toml", ".json": "json",
17
23
  ".css": "css", ".html": "html", ".vue": "vue", ".svelte": "svelte",
18
- ".md": "markdown", ".mdc": "markdown",
24
+ ".md": "markdown", ".mdc": "markdown", ".mdx": "markdown", ".org": "org", ".wiki": "wiki", ".tex": "tex",
19
25
  }
20
26
  return map[ext] ?? ext.slice(1)
21
27
  }
@@ -4,13 +4,31 @@
4
4
  import { readFile, stat } from "node:fs/promises"
5
5
  import { join, relative } from "node:path"
6
6
  import { embed, cosine, toBlob, fromBlob } from "../embedding.mjs"
7
- import { CODE_EXTS, DOC_EXTS, SKIP_DIRS, MAX_CODE_FILE_BYTES, MAX_DOC_FILE_BYTES } from "./schema.mjs"
7
+ import { scanVectors, createTopK } from "./scan.mjs"
8
+ import { CODE_EXTS, DOC_EXTS, MAX_CODE_FILE_BYTES, MAX_DOC_FILE_BYTES } from "./schema.mjs"
8
9
  import { buildFtsQuery, ensureEmbeddings, EMBED_TEXT_MAX_LEN } from "./core.mjs"
9
10
  import { detectLanguage, _upsertCodeFile, _upsertDocFile, yieldTick } from "./code-index.mjs"
11
+ import { walkProjectFiles, isSkippedRelPath, extensionOf, createUnlistedTally, MAX_WALK_FILES } from "./file-walk.mjs"
12
+ import { loadConventions } from "../conventions.mjs"
13
+ import { logEvent } from "../log.mjs"
10
14
 
11
15
  const DIFF_FULL_SYNC_THRESHOLD = 200
12
16
  const CODE_EMBED_BATCH = 64
13
17
 
18
+ /**
19
+ * Index extension sets = built-in tables ∪ project declaration
20
+ * (`.thincoder/conventions.json` → index.codeExtensions / index.docExtensions;
21
+ * PORTABILITY PO-9/§3.7 — a project may declare extensions this product does not
22
+ * ship a default for). Declaration only ADDS (union), never removes.
23
+ */
24
+ export function indexExtensions(dir) {
25
+ const conv = loadConventions(dir)
26
+ return {
27
+ code: new Set([...CODE_EXTS, ...conv.index.codeExtensions]),
28
+ doc: new Set([...DOC_EXTS, ...conv.index.docExtensions]),
29
+ }
30
+ }
31
+
14
32
  /**
15
33
  * git-driven incremental indexing: use git diff to find files changed since
16
34
  * the last index, and only rebuild FTS5 chunks for those files (vectors are untouched).
@@ -19,6 +37,7 @@ const CODE_EMBED_BATCH = 64
19
37
  */
20
38
  export async function gitSync(memory, dir, { onProgress } = {}) {
21
39
  const { execFile: _execFile } = await import("node:child_process")
40
+ const { code: codeExts, doc: docExts } = indexExtensions(dir)
22
41
  const gitRun = (args) => new Promise((resolve, reject) => {
23
42
  _execFile("git", args, { cwd: dir, encoding: "utf8", timeout: 10000, windowsHide: true }, (err, stdout) => {
24
43
  if (err) reject(err); else resolve(stdout)
@@ -55,17 +74,16 @@ export async function gitSync(memory, dir, { onProgress } = {}) {
55
74
  for (let i = 0; i < diffOut.length; i++) {
56
75
  const rel = diffOut[i].replaceAll("\\", "/")
57
76
  const abs = join(dir, rel)
58
- const ext = rel.slice(rel.lastIndexOf(".")).toLowerCase()
77
+ const ext = extensionOf(rel)
59
78
 
60
- const pathDirs = rel.split("/")
61
- if (pathDirs.some((d) => SKIP_DIRS.has(d) || d.startsWith("."))) continue
79
+ if (isSkippedRelPath(rel)) continue
62
80
 
63
- if (!CODE_EXTS.has(ext) && !DOC_EXTS.has(ext)) { skipped++; continue }
81
+ if (!codeExts.has(ext) && !docExts.has(ext)) { skipped++; continue }
64
82
 
65
83
  try {
66
84
  const text = await readFile(abs, "utf8")
67
85
  const lines = text.split("\n")
68
- if (CODE_EXTS.has(ext)) {
86
+ if (codeExts.has(ext)) {
69
87
  const lang = detectLanguage(abs)
70
88
  let mtimeMs = 0
71
89
  try { mtimeMs = Math.floor((await stat(abs)).mtimeMs) } catch { /* new file */ }
@@ -79,7 +97,7 @@ export async function gitSync(memory, dir, { onProgress } = {}) {
79
97
  } catch (e) {
80
98
  const isDeleted = e.code === "ENOENT"
81
99
  if (isDeleted) {
82
- if (CODE_EXTS.has(ext)) memory.db.prepare(`DELETE FROM code_chunks WHERE origin = ? AND path = ?`).run(dir, rel)
100
+ if (codeExts.has(ext)) memory.db.prepare(`DELETE FROM code_chunks WHERE origin = ? AND path = ?`).run(dir, rel)
83
101
  else memory.db.prepare(`DELETE FROM doc_chunks WHERE origin = ? AND path = ?`).run(dir, rel)
84
102
  removed++
85
103
  } else {
@@ -104,16 +122,27 @@ export async function gitSync(memory, dir, { onProgress } = {}) {
104
122
 
105
123
  /**
106
124
  * List project files matching the given extensions.
107
- * Only indexes git repos — if dir isn't inside a git worktree, returns [].
108
- * Uses `git ls-files --cached --others --exclude-standard` to get the file list
109
- * (tracked + untracked-not-ignored, respecting .gitignore).
110
- * Returns an array of { abs, rel } pairs (abs = full path, rel = path relative to dir).
125
+ * Preferred source = git (`git ls-files --cached --others --exclude-standard`:
126
+ * tracked + untracked-not-ignored, .gitignore respected). Projects WITHOUT git
127
+ * fall back to a filesystem walk (PORTABILITY FR15/P8) — before, the index was
128
+ * silently empty there. The walk cannot honour .gitignore (no git → no such
129
+ * concept) and skips the shared SKIP_DIRS/dot-directory set instead.
130
+ * Returns { entries, unlisted }: `entries` = { abs, rel } pairs (rel relative to
131
+ * dir); `unlisted` = files whose extension is in NO index list (count + sample),
132
+ * the visible signal behind "my .xyz files are not searchable" (PORTABILITY PO-9).
133
+ * `truncated` = the non-git walk hit its file cap (capped trees are logged, never
134
+ * silently indexed-partial). opts.maxFiles = the walk cap (test seam).
111
135
  */
112
- export async function listProjectFiles(dir, exts) {
136
+ export async function listProjectFiles(dir, exts, { maxFiles } = {}) {
113
137
  const { execFile: _execFile } = await import("node:child_process")
114
138
  const { join: joinPath } = await import("node:path")
139
+ const { code, doc } = indexExtensions(dir)
140
+ const tally = createUnlistedTally(new Set([...code, ...doc]))
115
141
 
116
- // Only index git repos — if this isn't one, return empty
142
+ const files = []
143
+ const finish = (truncated = false) => ({ entries: files, unlisted: tally.result(), truncated })
144
+
145
+ // Git listing when available; a non-git project falls through to the walk.
117
146
  let gitTop
118
147
  try {
119
148
  gitTop = (await new Promise((resolve, reject) => {
@@ -121,10 +150,17 @@ export async function listProjectFiles(dir, exts) {
121
150
  (err, stdout) => { if (err) reject(err); else resolve(stdout.trim()) })
122
151
  })).replace(/\\/g, "/")
123
152
  } catch {
124
- return [] // not a git repo → nothing to index
153
+ // Not a git repo → filesystem walk (FR15: the index must still be usable).
154
+ const walked = await walkProjectFiles(dir, exts, { knownExts: new Set([...code, ...doc]), ...(maxFiles !== undefined ? { maxFiles } : {}) })
155
+ files.push(...walked.files)
156
+ // The walk's cap must stay visible end-to-end (file-walk.mjs: "never silently
157
+ // dropped") — a capped listing says so in the log, not only in a discarded flag.
158
+ if (walked.truncated) {
159
+ logEvent("index:truncated", { dir, files: walked.files.length, maxFiles: maxFiles ?? MAX_WALK_FILES })
160
+ }
161
+ return { entries: files, unlisted: walked.unlisted, truncated: walked.truncated }
125
162
  }
126
163
 
127
- const files = []
128
164
  try {
129
165
  const raw = await new Promise((resolve, reject) => {
130
166
  _execFile("git", ["ls-files", "--cached", "--others", "--exclude-standard"],
@@ -134,16 +170,15 @@ export async function listProjectFiles(dir, exts) {
134
170
  for (const line of raw.trim().split("\n")) {
135
171
  const p = line.trim()
136
172
  if (!p) continue
137
- const ext = p.slice(p.lastIndexOf(".")).toLowerCase()
138
- if (!exts.has(ext)) continue
139
- const abs = joinPath(dir, p)
140
173
  const rel = p.replace(/\\/g, "/")
141
- if (rel.split("/").some((seg) => SKIP_DIRS.has(seg) || seg.startsWith("."))) continue
142
- files.push({ abs, rel })
174
+ if (isSkippedRelPath(rel)) continue
175
+ const ext = extensionOf(rel)
176
+ if (!ext || !exts.has(ext)) { tally.note(rel); continue }
177
+ files.push({ abs: joinPath(dir, rel), rel })
143
178
  }
144
179
  } catch { /* ls-files failed */ }
145
180
 
146
- return files
181
+ return finish()
147
182
  }
148
183
 
149
184
 
@@ -152,7 +187,8 @@ export async function listProjectFiles(dir, exts) {
152
187
  * Incremental by mtime — only rebuilds chunks for files that have changed.
153
188
  */
154
189
  export async function codeSync(memory, dir, { onProgress } = {}) {
155
- const entries = await listProjectFiles(dir, CODE_EXTS)
190
+ const { code: exts } = indexExtensions(dir)
191
+ const { entries, unlisted } = await listProjectFiles(dir, exts)
156
192
  const files = [] // { abs, rel, mtimeMs }
157
193
  let overSizeSkipped = 0
158
194
  for (const { abs, rel } of entries) {
@@ -206,7 +242,10 @@ export async function codeSync(memory, dir, { onProgress } = {}) {
206
242
 
207
243
  onProgress?.({ phase: "done", total: files.length, updated, removed, skipped, failed, overSizeSkipped })
208
244
  markIndexedCommit(memory, dir)
209
- return { updated, removed, skipped, failed, errors, total: files.length, overSizeSkipped }
245
+ if (unlisted.count > 0) {
246
+ logEvent("index:unlisted", { dir, kind: "code", count: unlisted.count, exts: unlisted.exts.map((e) => e.ext) })
247
+ }
248
+ return { updated, removed, skipped, failed, errors, total: files.length, overSizeSkipped, unlistedExts: unlisted }
210
249
  }
211
250
 
212
251
  /** Record current HEAD as the index anchor (gitSync incremental diff baseline); silently skip non-git repos */
@@ -253,11 +292,12 @@ export async function codeSearch(memory, query, { limit = 5 } = {}) {
253
292
  console.error(`[code] query embedding failed, falling back to FTS-only: ${e.message}`)
254
293
  return ftsList.slice(0, limit)
255
294
  }
256
- const rows = memory.db.prepare(`SELECT rowid, embedding FROM code_chunks WHERE embedding IS NOT NULL ${vecOriginFilter}`).all(...originParams)
257
- const vecList = rows
258
- .map((r) => ({ rowid: r.rowid, score: cosine(qvec, fromBlob(r.embedding)) }))
259
- .sort((a, b) => b.score - a.score)
260
- .slice(0, Math.max(limit * 4, 20))
295
+ // TUI-OOM-ROOTCAUSE(MEMORY.md §10.3):分块扫描 + 有界 top-K(原全表 .all()——峰值 = 块 + K)
296
+ const top = createTopK(Math.max(limit * 4, 20))
297
+ scanVectors(memory.db, `SELECT rowid, embedding FROM code_chunks WHERE embedding IS NOT NULL ${vecOriginFilter}`, originParams, {
298
+ onRow: (r) => top.push({ id: r.rowid, rowid: r.rowid, score: cosine(qvec, fromBlob(r.embedding)) }),
299
+ })
300
+ const vecList = top.list().map((c) => ({ rowid: c.id, score: c.score }))
261
301
 
262
302
  const K = 60
263
303
  const scores = new Map()
@@ -338,32 +378,33 @@ export function codeSearchTool(memory) {
338
378
  * Single-file incremental reindex: called after write/edit/delete, only rebuilds this one path.
339
379
  */
340
380
  export async function reindexFile(memory, cwd, absPath) {
341
- const ext = absPath.slice(absPath.lastIndexOf(".")).toLowerCase()
381
+ const ext = extensionOf(absPath)
342
382
  const rel = relative(cwd, absPath).replaceAll("\\", "/")
343
383
  if (rel === ".." || rel.startsWith("../")) return
344
- const dirs = rel.split("/").slice(0, -1)
345
- if (dirs.some((d) => SKIP_DIRS.has(d) || d.startsWith("."))) return
384
+ if (isSkippedRelPath(rel)) return
385
+ // Declared extensions count for the single-file path too (union with built-ins).
386
+ const { code: codeExts, doc: docExts } = indexExtensions(cwd)
346
387
 
347
388
  // skip oversized files (minified bundles, test fixtures, generated code)
348
- const maxBytes = CODE_EXTS.has(ext) ? MAX_CODE_FILE_BYTES : DOC_EXTS.has(ext) ? MAX_DOC_FILE_BYTES : 0
389
+ const maxBytes = codeExts.has(ext) ? MAX_CODE_FILE_BYTES : docExts.has(ext) ? MAX_DOC_FILE_BYTES : 0
349
390
  if (maxBytes > 0) {
350
391
  try { const st = await stat(absPath); if (st.size > maxBytes) return } catch { /* can't stat, proceed */ }
351
392
  }
352
393
 
353
394
  let text
354
395
  try { text = await readFile(absPath, "utf8") } catch {
355
- if (CODE_EXTS.has(ext)) memory.db.prepare(`DELETE FROM code_chunks WHERE origin = ? AND path = ?`).run(cwd, rel)
356
- else if (DOC_EXTS.has(ext)) memory.db.prepare(`DELETE FROM doc_chunks WHERE origin = ? AND path = ?`).run(cwd, rel)
396
+ if (codeExts.has(ext)) memory.db.prepare(`DELETE FROM code_chunks WHERE origin = ? AND path = ?`).run(cwd, rel)
397
+ else if (docExts.has(ext)) memory.db.prepare(`DELETE FROM doc_chunks WHERE origin = ? AND path = ?`).run(cwd, rel)
357
398
  return
358
399
  }
359
400
  const lines = text.split("\n")
360
401
 
361
- if (CODE_EXTS.has(ext)) {
402
+ if (codeExts.has(ext)) {
362
403
  const lang = detectLanguage(absPath)
363
404
  let mtimeMs = 0
364
405
  try { mtimeMs = Math.floor((await stat(absPath)).mtimeMs) } catch { /* new file */ }
365
406
  _upsertCodeFile(memory, cwd, rel, lines, lang, mtimeMs)
366
- } else if (DOC_EXTS.has(ext)) {
407
+ } else if (docExts.has(ext)) {
367
408
  let mtimeMs = 0
368
409
  try { mtimeMs = Math.floor((await stat(absPath)).mtimeMs) } catch { /* new file */ }
369
410
  _upsertDocFile(memory, cwd, rel, lines, mtimeMs)