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
package/src/config.mjs CHANGED
@@ -1,6 +1,12 @@
1
1
  /**
2
2
  * config.mjs — configuration loading and saving
3
- * Multi-provider structure: providers[] + activeProvider
3
+ * Model-merge schema v2 (2026-09-10 MODEL-SELECTION): providers[] carry ONE default model
4
+ * per channel (`providers[].model` — the new-install seed / empty-slot fallback); the
5
+ * candidates list field models[] is gone — the available-model list is fetched from the
6
+ * provider at runtime (`GET /models`, PROVIDER.md §16). config.defaultModel (top level,
7
+ * "provider:model" composite) is the new-session starting point; activeProvider/activeModel
8
+ * are gone from config (session slots keep their own double fields). Legacy fields migrate
9
+ * on load (write-back failure never blocks startup).
4
10
  * Config file: ~/.thincoder/config.json
5
11
  * API key can fall back to environment variables (when not configured in providers).
6
12
  */
@@ -8,13 +14,27 @@
8
14
  import { chmodSync, copyFileSync, existsSync, mkdirSync, readFileSync, statSync, writeFileSync } from "node:fs"
9
15
  import { homedir } from "node:os"
10
16
  import { dirname, join } from "node:path"
17
+ import { parseModelRef, resolveRuntimeProvider, defaultModelReason } from "./model-ref.mjs"
18
+ // 老形态迁移核(M7 v2——纯函数零依赖——本文件超 500 行硬限拆分,VSC 同构文件)
19
+ import { migrateLegacyModelFields } from "./config-migrate.mjs"
20
+ import { expandHome } from "./expand-home.mjs"
21
+
22
+ export { parseModelRef, resolveRuntimeProvider, defaultModelReason, migrateLegacyModelFields }
11
23
 
12
24
  export const configDir = join(homedir(), ".thincoder")
13
25
  export const configPath = join(configDir, "config.json")
14
26
 
15
- /** Built-in provider presets: shared by /provider add <preset> and first-run wizard */
27
+ /** Test seam: override the config file location (mirrors thincoder-vscode config-io.mjs). */
28
+ let _pathOverride = null
29
+ export function _setConfigPathForTest(p) { _pathOverride = p }
30
+ export function _resetConfigPathForTest() { _pathOverride = null }
31
+ function cfgPath() { return _pathOverride ?? configPath }
32
+
33
+ /** Built-in provider presets: shared by /provider add <preset> and first-run wizard.
34
+ * MODEL-SELECTION v2 (2026-09-10): each preset carries exactly ONE default model
35
+ * (`model` — 新装启动种子/槽位空兜底; 候选清单不再预置——运行期从 provider 拉取). */
16
36
  export const PROVIDER_PRESETS = {
17
- deepseek: { baseURL: "https://api.deepseek.com", model: "deepseek-v4-pro", thinking: { type: "enabled" }, reasoningEffort: "max", maxTokens: 393216, desc: "DeepSeek" },
37
+ deepseek: { baseURL: "https://api.deepseek.com", model: "deepseek-flash", thinking: { type: "enabled" }, reasoningEffort: "max", maxTokens: 393216, desc: "DeepSeek" },
18
38
  kimi: { baseURL: "https://api.moonshot.cn/v1", model: "kimi-k3", thinking: null, reasoningEffort: "max", maxTokens: 131072, desc: "Kimi / Moonshot" },
19
39
  "kimi-code": { baseURL: "https://api.kimi.com/coding/v1", model: "k3", thinking: null, reasoningEffort: "max", maxTokens: 131072, desc: "Kimi For Coding (platform.kimi.com — sk-kimi- keys; NOT interchangeable with Moonshot)" },
20
40
  glm: { baseURL: "https://open.bigmodel.cn/api/paas/v4", model: "glm-5.2", thinking: { type: "enabled" }, reasoningEffort: "max", maxTokens: 128000, desc: "Zhipu GLM" },
@@ -37,7 +57,7 @@ export const PROVIDER_PRESETS = {
37
57
  }
38
58
 
39
59
  export const DEFAULTS = {
40
- activeModel: null, // optional: override provider.model (set via /model picker or /model provider:model)
60
+ defaultModel: null, // top-level "provider:model" composite — new-session starting point (F-1)
41
61
  agent: {
42
62
  maxTurns: 200,
43
63
  subagentTurns: 100,
@@ -54,15 +74,19 @@ export const DEFAULTS = {
54
74
  streamRules: [], // time-traveling stream rules: [{ pattern: "regex", message: "reminder", action: "abort"|"warn", repeat: "always"|"once" }]
55
75
  advisor: { guard: false }, // code review is always available; guard: true pushes completion back until reviewed (opt-in). Also accepts provider/model/thinking/reasoningEffort/timeoutMs overrides. Deprecated: enabled (2026-08-21)
56
76
  autoThink: false, // auto-classify task difficulty and set reasoning effort per-turn
57
- engineering: false, // strict methodology enforcement — read METHODOLOGY.md, design-before-code
58
- // Async subagent pool limits per role domain (AGENT-LOOP.md §24 D-24a/R14):
59
- // { engCoder, other } — eng-coder pool / other-role pool, defaults 4/4 (user
60
- // ruling "eng-coder 四路,其他 4 路"). Runtime-validated at every pool admission
61
- // (positive integer ≥1, invalid/absent keys fall back to 4 — settings tool and
62
- // the /config 并发池 menu write this key; change applies to the next spawn).
63
- // ⚠ 与 subagent-async.mjs ASYNC_POOL_LIMITS 逐键同值(运行时回退常量)——耦合锚
64
- // T-24a4 断言锁住——勿单侧改默认。
65
- poolLimits: { engCoder: 4, other: 4 },
77
+ engineering: false, // strict methodology enforcement — design-before-code (design review + user approval before code)
78
+ // Async pool limits (AGENT-LOOP.md §11.1 D-24a/R14 + §11.2 R13 — POOL-CONFIG-
79
+ // UNIFIED 2026-09-09): { engCoder, other, advisor } — eng-coder pool / other-role
80
+ // pool / advisor-review pool, defaults 4/4/4 (user ruling "eng-coder 四路,其他
81
+ // 4 路" + advisor 评审池并入同一可配体系——三池统一默认 4)。engCoder/other
82
+ // runtime-validated at every subagent pool admission (subagent-async
83
+ // resolvePoolLimits); advisor runtime-validated at every advisor launch
84
+ // (advisor-async advisorPoolLimitFor——F-2——两域读取器独立不共享)——非法/缺省
85
+ // 回退 4——settings tool 与 /config 并发池菜单写此键——变更下个 spawn/launch 生效。
86
+ // ⚠ 与 subagent-async.mjs ASYNC_POOL_LIMITS(两键)/advisor-async.mjs
87
+ // ADVISOR_POOL_LIMIT 逐键同值(运行时回退常量)——耦合锚 T-24a4 断言锁住——
88
+ // 勿单侧改默认。
89
+ poolLimits: { engCoder: 4, other: 4, advisor: 4 },
66
90
  },
67
91
  memory: {
68
92
  dbPath: join(configDir, "memory.db"),
@@ -78,7 +102,7 @@ export const DEFAULTS = {
78
102
  servers: [],
79
103
  },
80
104
  websearch: {
81
- provider: "tavily", // structured search API; empty apiKey → fall back to Bing HTML scraping
105
+ // Structured search via Tavily when a key is set — empty apiKey → Bing RSS/HTML fallback (zero-config).
82
106
  apiKey: "", // Tavily key (tvly-...) — optional
83
107
  },
84
108
  traces: {
@@ -90,8 +114,8 @@ export const DEFAULTS = {
90
114
  // Model capability table + spec lookup live in model-specs.mjs (2026-08-31
91
115
  // extract — config.mjs had grown past the 300-line advisory). Re-exported here
92
116
  // so the 23 existing importers keep their import paths.
93
- import { specForModel, providerSpec } from "./model-specs.mjs"
94
- export { specForModel, providerSpec }
117
+ import { specForModel, providerSpec, specMatch } from "./model-specs.mjs"
118
+ export { specForModel, providerSpec, specMatch }
95
119
 
96
120
 
97
121
  // Window utilization threshold: compacts at 60% context, reserving 40% headroom
@@ -144,6 +168,44 @@ export function resolveEnableThinking(provider, spec) {
144
168
  /** Module-level one-time warn dedupe for invalid providers[].context (PROVIDER.md §15 D-C1). */
145
169
  const warnedContextProviders = new Set()
146
170
 
171
+ /** F-4 (IKCDMR) 软失败清洗(D-S1 范式——loadConfig 是 CLI 启动砖点):consultModels 非法
172
+ * 条目过滤不 throw——非数组 → []、形状非法/未知渠道丢弃、超 5 截断(keep 前 5)。
173
+ * 返回 { keep, dropped }——dropped 供一次性警告;merged.agent.consultModelsFiltered 挂载
174
+ * 供后续首帧引导消费(数据层——本批文件面无消费方——同 providerInvalidReason 载体先例)。 */
175
+ function sanitizeConsultModels(cm, providerNames) {
176
+ if (cm === undefined || cm === null) return { keep: [], dropped: [] }
177
+ if (!Array.isArray(cm)) {
178
+ return { keep: [], dropped: [`agent.consultModels must be an array of { provider, model } entries (got ${typeof cm})`] }
179
+ }
180
+ const names = new Set(providerNames)
181
+ const keep = []
182
+ const dropped = []
183
+ for (const entry of cm) {
184
+ if (keep.length >= 5) { dropped.push(`over the 5-entry cap — dropped ${JSON.stringify(entry)}`); continue }
185
+ if (!entry || typeof entry !== "object" || typeof entry.provider !== "string" || !entry.provider.trim()
186
+ || typeof entry.model !== "string" || !entry.model.trim()) {
187
+ dropped.push(`invalid entry (expected { provider: string, model: string }) — got ${JSON.stringify(entry)}`)
188
+ continue
189
+ }
190
+ if (!names.has(entry.provider)) {
191
+ dropped.push(`entry "${entry.provider}:${entry.model}" references unknown provider "${entry.provider}" (available: ${[...names].join(", ") || "none"})`)
192
+ continue
193
+ }
194
+ keep.push(entry)
195
+ }
196
+ return { keep, dropped }
197
+ }
198
+
199
+ /** F-4:过滤警告——进程级一次性(loadConfig 同进程可多次调用:TUI 启动 + reloadConfig)。 */
200
+ let warnedConsultModels = false
201
+ function warnConsultModelsFiltered(dropped, path) {
202
+ if (warnedConsultModels || dropped.length === 0) return
203
+ warnedConsultModels = true
204
+ console.warn(`[config] agent.consultModels: ${dropped.length} invalid entr${dropped.length === 1 ? "y ignored" : "ies ignored"} (filtered — startup continues; no crash):\n` +
205
+ dropped.map((d) => ` - ${d}`).join("\n") +
206
+ `\n Fix: clean agent.consultModels in ${path} or use /config → consult/escalate pool menu.`)
207
+ }
208
+
147
209
  /**
148
210
  * Find provider by name in providers[].
149
211
  * Throws if name is non-empty but not found — a typo in activeProvider silently falling to the first provider would use the wrong key on the wrong endpoint.
@@ -187,29 +249,58 @@ function sanitizeProviderHeaders(p) {
187
249
  return p
188
250
  }
189
251
 
252
+ /** loadConfig 内联迁移核已迁 config-migrate.mjs(500 行硬限拆分——VSC 同构)——
253
+ * migrateLegacyModelFields 纯函数 + 本文件 import/写回编排(折中 C 见其头注释)。 */
190
254
  export function loadConfig() {
191
255
  let config = {}
192
- if (existsSync(configPath)) {
256
+ const path = cfgPath()
257
+ if (existsSync(path)) {
193
258
  try {
194
- config = JSON.parse(readFileSync(configPath, "utf8"))
259
+ config = JSON.parse(readFileSync(path, "utf8"))
195
260
  } catch (error) {
196
- throw new Error(`Config file is not valid JSON, check or delete it: ${configPath}\n ${error.message}`, { cause: error })
261
+ throw new Error(`Config file is not valid JSON, check or delete it: ${path}\n ${error.message}`, { cause: error })
262
+ }
263
+ }
264
+
265
+ // ── 迁移(M7 v2):检测老字段(models[] / active* / 垃圾值)→ 内存迁移态先行;写回失败绝不阻断(下次 load 重试——幂等)──
266
+ if (migrateLegacyModelFields(config)) {
267
+ try {
268
+ const r = writeConfigAtomic(path, migrateLegacyModelFields) // 磁盘 fresh raw 同变换
269
+ if (r.ok === false) console.warn(`[config] migration write-back skipped (${r.reason}) — memory state continues, retried on next load`)
270
+ } catch (e) {
271
+ console.warn(`[config] migration write-back failed — memory state continues, retried on next load: ${e.message}`)
197
272
  }
198
273
  }
199
274
 
200
275
  const merged = {
201
276
  ...DEFAULTS,
202
277
  ...config,
278
+ defaultModel: typeof config.defaultModel === "string" && config.defaultModel.trim() ? config.defaultModel : null,
203
279
  providers: Array.isArray(config.providers)
204
280
  ? config.providers.map((p) => sanitizeProviderHeaders({ ...p }))
205
281
  : [],
206
- activeProvider: config.activeProvider ?? "",
207
282
  agent: { ...DEFAULTS.agent, ...config.agent },
208
283
  memory: { ...DEFAULTS.memory, ...config.memory },
209
284
  embedding: { ...DEFAULTS.embedding, ...config.embedding },
210
285
  traces: { ...DEFAULTS.traces, ...config.traces },
211
286
  }
212
287
 
288
+ // 家目录展开(第 29 批):config 路径字段单一规范化点——只读归一(磁盘原文保留)
289
+ merged.memory.dbPath = expandHome(merged.memory.dbPath)
290
+ merged.memory.projectDir = expandHome(merged.memory.projectDir)
291
+ const team = merged.memory.team
292
+ if (team && typeof team === "object" && !Array.isArray(team) && team.dir !== undefined) {
293
+ merged.memory.team = { ...team, dir: expandHome(team.dir) } // 无 dir 键不注入(零键面变化)
294
+ }
295
+ merged.shell = expandHome(merged.shell)
296
+
297
+ // providers[].model 内存归一(v2 M3:非空字符串保留;非字符串/空串归一删除)——渠道默认模型
298
+ // 单值;无默认模型合法(模型选择经 /models 拉取候选——准入判据见 M8/M9)。
299
+ for (const p of merged.providers) {
300
+ if (typeof p.model === "string" && p.model.trim()) continue
301
+ if (p.model !== undefined) delete p.model
302
+ }
303
+
213
304
  // providers[].context (K units, PROVIDER.md §15 D-C1): positive integer only — invalid
214
305
  // values (0/negative/non-numeric) are IGNORED (spec value applies) with a ONE-TIME warn
215
306
  // per provider name (module-level dedupe, same precedent as warnedModels in model-specs.mjs).
@@ -223,27 +314,15 @@ export function loadConfig() {
223
314
  delete p.context
224
315
  }
225
316
 
226
- // Consult/escalate pool validation (CLI parity with the plugin): up to 5 candidates.
227
- const cm = merged.agent.consultModels
228
- if (cm !== undefined && !Array.isArray(cm)) {
229
- throw new Error(`agent.consultModels must be an array of { provider, model } entries (got ${typeof cm})`)
230
- }
231
- if (Array.isArray(cm) && cm.length > 5) {
232
- throw new Error(`agent.consultModels supports at most 5 models (got ${cm.length})`)
233
- }
234
- if (Array.isArray(cm)) {
235
- // Fail fast at load: a pool entry whose provider doesn't exist in providers[] fails
236
- // every consult/escalate call at runtime with a quiet error string (eats a turn).
237
- const providerNames = merged.providers.map((p) => p.name)
238
- for (const entry of cm) {
239
- if (!entry || typeof entry !== "object" || typeof entry.provider !== "string" || typeof entry.model !== "string") {
240
- throw new Error(`agent.consultModels entries must be { provider: string, model: string } objects (got ${JSON.stringify(entry)})`)
241
- }
242
- if (!providerNames.includes(entry.provider)) {
243
- throw new Error(`agent.consultModels entry "${entry.provider}:${entry.model}" references unknown provider "${entry.provider}" (available: ${providerNames.join(", ") || "none"})`)
244
- }
245
- }
246
- }
317
+ // F-4 (IKCDMR) consultModels 软失败化——D-S1 范式(defaultModel 无效同族):非法条目
318
+ // 过滤不 throw(去 startup brick——无修复入口的硬崩消);一次性启动警告内嵌修复指引
319
+ // (引导清条目载体——保留 discoverability);过滤记录挂 merged.agent.consultModelsFiltered
320
+ // (数据层——consumer-ready——首帧 UI 消费点同 D-S1 promptProviderIfInvalid 属后续批)。
321
+ // VSC loadAgentSettings 同规则(共享 config——双端锁步)。
322
+ const cmClean = sanitizeConsultModels(merged.agent.consultModels, merged.providers.map((p) => p.name))
323
+ warnConsultModelsFiltered(cmClean.dropped, path)
324
+ merged.agent.consultModels = cmClean.keep
325
+ if (cmClean.dropped.length) merged.agent.consultModelsFiltered = cmClean.dropped
247
326
 
248
327
  // Backward compatibility: promote root-level config fields to agent sub-object
249
328
  if (config.verifyGuard !== undefined) {
@@ -259,35 +338,24 @@ export function loadConfig() {
259
338
  // 保证 agent.config.proxy 永远是规范形态或 undefined
260
339
  merged.proxy = normalizeProxy(merged.proxy)
261
340
 
262
- // Get the currently active provider
263
- // 2026-09-02 Q1(SESSION.md §8):activeProvider 指向不存在的 provider 不再抛错——runtimeProvider
264
- // 置空对象,由 make-agent.mjs assembleAgent 后的校验打 `_providerInvalid` 标记 → TUI 引导重选 /
265
- // headless 报可读错误(原 findProvider throw 直接击穿 loadConfig → uncaughtException 退出)。
266
- // findProvider 的 throw 契约保留(advisor/run.mjs 等直接调用方仍依赖)。
267
- let active
268
- try {
269
- active = findProvider(merged.providers, merged.activeProvider)
270
- } catch {
271
- active = {}
272
- }
273
-
274
- // Build runtime provider object (for agent.provider usage)
275
- const runtimeProvider = { ...active }
276
-
277
- // activeModel overrides provider's default model (config only)
278
- if (merged.activeModel) runtimeProvider.model = merged.activeModel
279
- merged.activeModel = merged.activeModel || null // normalize for agent.activeModel
341
+ // Runtime provider = config.defaultModel 复合解析(F-2——resolveRuntimeProvider)。
342
+ // 无效/未设 → {} + providerInvalidReason(D-S1 处置不 throw——make-agent 打 _providerInvalid
343
+ // 标记 → TUI 首帧弹选择 / headless 报可读错误;同 2026-09-02 Q1 语义——不复用 findProvider
344
+ // throw 契约——findProvider 保留给 advisor/run.mjs 等直接调用方)。
345
+ merged.provider = resolveRuntimeProvider(merged.providers, merged.defaultModel)
346
+ merged.providerInvalidReason = merged.provider.name
347
+ ? null
348
+ : defaultModelReason(merged.providers, merged.defaultModel)
280
349
 
281
350
  // Compaction threshold follows the model (provider-level context override honored — providerSpec)
282
351
  const explicitThreshold = config.agent?.compactThreshold
283
- const { value, auto } = resolveCompactThreshold(explicitThreshold, runtimeProvider)
352
+ const { value, auto } = resolveCompactThreshold(explicitThreshold, merged.provider)
284
353
  merged.agent.compactThreshold = value
285
354
  merged.agent.compactThresholdAuto = auto
286
355
 
287
356
  // Write back to merged for convenient access by upper layers
288
- merged.provider = runtimeProvider
289
357
  // fetch 超时可配置(2026-09-01:agent.fetchTimeoutMs——provider/core.mjs effectiveFetchTimeoutMs 消费)
290
- runtimeProvider.fetchTimeoutMs = Number.isFinite(merged.agent?.fetchTimeoutMs) && merged.agent.fetchTimeoutMs > 0
358
+ merged.provider.fetchTimeoutMs = Number.isFinite(merged.agent?.fetchTimeoutMs) && merged.agent.fetchTimeoutMs > 0
291
359
  ? merged.agent.fetchTimeoutMs : undefined
292
360
  merged.providersList = merged.providers
293
361
  merged.advisor = { ...merged.agent.advisor } // promote for consistent access (decoupled copy)
@@ -344,7 +412,8 @@ function readMcpSection(path = configPath) {
344
412
  if (!existsSync(path)) return { ok: true, servers: [] }
345
413
  const raw = JSON.parse(readFileSync(path, "utf8"))
346
414
  const servers = raw?.mcp?.servers
347
- if (servers !== undefined && !Array.isArray(servers)) return { ok: true, servers: [] }
415
+ // 非数组 = 畸形磁盘配置(2.3 代码正确性批)——ok:false 走调用方既有畸形回退(reloadMcpFromDisk 早退)
416
+ if (servers !== undefined && !Array.isArray(servers)) return { ok: false, error: "mcp.servers must be an array" }
348
417
  return { ok: true, servers: Array.isArray(servers) ? servers : [] }
349
418
  } catch (error) {
350
419
  return { ok: false, error: error?.message ?? String(error) }
package/src/context.mjs CHANGED
@@ -163,7 +163,7 @@ function tightenTailByBudget(history, start, floorStart, budgetTokens) {
163
163
  * pushReal — the single entry point for REAL conversation messages.
164
164
  * A real message (user input, assistant reply, tool result, multimodal image) is appended to BOTH:
165
165
  * agent.history — the machine context (compaction shrinks this)
166
- * agent._fullHistory — the NEVER-COMPACTED human-readable record (persistence source)
166
+ * agent._fullHistory — the human-readable record (persistence source)
167
167
  * Machine-only messages ([System reminder:...], compaction notes, task/plan/checkpoint re-injections)
168
168
  * are pushed directly to agent.history WITHOUT going through here, so they never enter _fullHistory.
169
169
  * The two lines are written independently at the source — no after-the-fact delta sync.
@@ -171,11 +171,22 @@ function tightenTailByBudget(history, start, floorStart, budgetTokens) {
171
171
  * point covers every real message. Pre-existing ts (e.g. from another end writing the shared slot)
172
172
  * is preserved; restored old messages keep no ts rather than getting a misleading backdate (D-S3).
173
173
  * ts is a LOCAL-ONLY field — the send layer strips it before any provider request (T-S3).
174
+ *
175
+ * TUI-OOM-ROOTCAUSE 批(SESSION.md §14.3.5)——人读线内存有界 + 磁盘为准:
176
+ * ① `agent._recordStore?.append(msg)`:记录同步追加(磁盘为准——append-only sidecar);
177
+ * ② 窗口驱逐:绑定态(agent._historyWindow = 200)下 _fullHistory 只保最近窗口条——
178
+ * 更早内容仅存磁盘(翻页/检索/保存从盘按需读)。未绑定(模式 F)不驱逐(零回归)。
179
+ * 追加失败不阻断回合(独立 try/catch——尽力面 N-S6;store 内部另置 degraded 并停写)。
174
180
  */
175
181
  export function pushReal(agent, msg) {
176
182
  if (!Array.isArray(agent._fullHistory)) agent._fullHistory = []
177
183
  if (msg && msg.ts === undefined) msg.ts = Date.now()
178
184
  agent._fullHistory.push(msg)
185
+ try { agent._recordStore?.append(msg) } catch { /* 尽力面:落盘失败不阻断回合(N-S6) */ }
186
+ const win = agent._historyWindow
187
+ if (win > 0 && agent._fullHistory.length > win) {
188
+ agent._fullHistory.splice(0, agent._fullHistory.length - win)
189
+ }
179
190
  agent.history.push(msg)
180
191
  }
181
192
 
@@ -0,0 +1,223 @@
1
+ /**
2
+ * conventions.mjs — the single authority for code / doc / temp path classification,
3
+ * plus the project convention declaration surface (`.thincoder/conventions.json`).
4
+ *
5
+ * Why one module: engineering-mode gates and guards (design gate, review-doc gate,
6
+ * mutation accounting, verify fast path) each carried their own copy of the
7
+ * "what counts as product code" predicate — anchored `^src/` regexes, `docs/`
8
+ * prefix checks, component regexes. Each copy drifted, and each hardcoded THIS
9
+ * repository's layout: a project whose code lives outside `src/` slipped through
10
+ * the design gate silently (PORTABILITY FR12 / PO-10). One classifier + one
11
+ * declaration file = one truth.
12
+ *
13
+ * Defaults are DATA (`DEFAULT_CODE_PATHS`) — overridable per project through the
14
+ * declaration file (§4.1 schema). Missing file → pure defaults (no noise);
15
+ * corrupt/unreadable file → defaults + console.warn + a log event (never crash,
16
+ * never swallow — PORTABILITY FR10).
17
+ *
18
+ * Classification vocabulary (PORTABILITY design §3.1):
19
+ * code — inside a declared code segment (default: the path segment `src`), or
20
+ * not a documentation extension; doc — documentation extension outside
21
+ * any code segment; temp — tmp-* name or .tmp/.temp extension.
22
+ */
23
+ import { readFileSync } from "node:fs"
24
+ import { join, resolve } from "node:path"
25
+ import { logEvent } from "./log.mjs"
26
+
27
+ /** Default code-path segments (data, not logic — a project may replace them). */
28
+ export const DEFAULT_CODE_PATHS = ["src"]
29
+
30
+ /** Project declaration file, relative to the project root. */
31
+ export const CONVENTIONS_REL_PATH = ".thincoder/conventions.json"
32
+
33
+ /** Documentation predicate (moved here verbatim from advisor/repos.mjs — one copy). */
34
+ const DOC_FILE = /(?:^|[/\\])(?:LICENSE|NOTICE|CHANGELOG|AUTHORS)(?:\.\w+)?$|\.(?:md|markdown|mdx|txt|rst|adoc)$/i
35
+
36
+ /** Temp/scratch predicate (moved here verbatim from advisor/repos.mjs). */
37
+ const TEMP_FILE = /(?:^|[/\\])tmp-[^/\\]+$|\.(?:tmp|temp)$/i
38
+
39
+ /** True when a path is a throwaway temp file (tmp-* name or .tmp/.temp extension). */
40
+ export function isTempPath(p) {
41
+ return TEMP_FILE.test(p ?? "")
42
+ }
43
+
44
+ /** Path → segments (both separators accepted; absolute and relative alike). */
45
+ function segmentsOf(p) {
46
+ return String(p ?? "").replace(/\\/g, "/").split("/").filter(Boolean)
47
+ }
48
+
49
+ /**
50
+ * True when the path contains a declared code segment sequence at any depth.
51
+ * Segment matching (not a prefix anchor) is what closes the nested-layout hole:
52
+ * `packages/foo/src/x.md` is product code, not a document. Comparison is
53
+ * case-insensitive — on case-insensitive filesystems `Src/x.mjs` is the same
54
+ * directory, and the gate must not be bypassable by casing.
55
+ */
56
+ function hasCodeSegment(p, conv) {
57
+ const parts = segmentsOf(p).map((s) => s.toLowerCase())
58
+ const wanted = conv?.codePaths ?? DEFAULT_CODE_PATHS
59
+ for (const entry of wanted) {
60
+ const want = segmentsOf(entry).map((s) => s.toLowerCase())
61
+ if (want.length === 0) continue
62
+ for (let i = 0; i + want.length <= parts.length; i++) {
63
+ if (want.every((seg, j) => parts[i + j] === seg)) return true
64
+ }
65
+ }
66
+ return false
67
+ }
68
+
69
+ /** "code" | "doc" | "temp" — the single classification decision.
70
+ * Precedence: code segment first (src/** stays product code even when the name
71
+ * looks scratch — the pre-existing unconditional-src rule), then temp, then a
72
+ * documentation extension, else code (anything not doc/temp is product code). */
73
+ export function classifyPath(p, conv) {
74
+ const s = String(p ?? "")
75
+ if (hasCodeSegment(s, conv)) return "code"
76
+ if (TEMP_FILE.test(s)) return "temp"
77
+ if (DOC_FILE.test(s)) return "doc"
78
+ return "code"
79
+ }
80
+
81
+ /** True when the path is product code (see classifyPath for the precedence). */
82
+ export function isCodePath(p, conv) {
83
+ return classifyPath(p, conv) === "code"
84
+ }
85
+
86
+ /** True when the path is a documentation file — a doc extension that does NOT
87
+ * live inside a declared code segment (src/prompts/*.md is product code). */
88
+ export function isDocPath(p, conv) {
89
+ const s = String(p ?? "")
90
+ return DOC_FILE.test(s) && !hasCodeSegment(s, conv)
91
+ }
92
+
93
+ // ─────────────────────────────────────────────────────────────────────────────
94
+ // Declaration loading (cached per project root — `clearConventionsCache()` is
95
+ // the test seam; declaration files change rarely and only at session scope).
96
+ // ─────────────────────────────────────────────────────────────────────────────
97
+
98
+ function normalizeExtensions(v) {
99
+ if (!Array.isArray(v)) return []
100
+ const out = []
101
+ for (const e of v) {
102
+ if (typeof e !== "string") continue
103
+ const t = e.trim().toLowerCase()
104
+ if (!t) continue
105
+ const ext = t.startsWith(".") ? t : `.${t}`
106
+ if (!out.includes(ext)) out.push(ext)
107
+ }
108
+ return out
109
+ }
110
+
111
+ /** Declared code paths replace the default (replacement, not union — §4.1). */
112
+ function normalizeCodePaths(v) {
113
+ if (!Array.isArray(v)) return null
114
+ const out = []
115
+ for (const e of v) {
116
+ if (typeof e !== "string") continue
117
+ const s = e.trim().replace(/\\/g, "/").replace(/^\.\//, "").replace(/\/+$/, "")
118
+ if (!s || out.includes(s)) continue
119
+ out.push(s)
120
+ }
121
+ return out.length > 0 ? out : null
122
+ }
123
+
124
+ function normalizeString(v) {
125
+ return typeof v === "string" && v.trim() ? v.trim() : ""
126
+ }
127
+
128
+ /** Per-key type check for recognized keys (present but wrong type). §3.2/§4.1: a
129
+ * type error degrades WITH a warning — never silently (the fallback semantics stay
130
+ * per-key; only the visibility is added here). */
131
+ function typeErrorsOf(raw) {
132
+ const isObj = (v) => v !== undefined && v !== null && typeof v === "object" && !Array.isArray(v)
133
+ const strArray = (v) => Array.isArray(v) && v.every((x) => typeof x === "string")
134
+ const errs = []
135
+ if (raw.codePaths !== undefined && !strArray(raw.codePaths)) errs.push("codePaths must be an array of strings")
136
+ const idx = raw.index
137
+ if (idx !== undefined && !isObj(idx)) errs.push("index must be an object")
138
+ else if (isObj(idx)) {
139
+ for (const k of ["codeExtensions", "docExtensions"]) {
140
+ if (idx[k] !== undefined && !strArray(idx[k])) errs.push(`index.${k} must be an array of strings`)
141
+ }
142
+ }
143
+ const adv = raw.advisor
144
+ if (adv !== undefined && !isObj(adv)) errs.push("advisor must be an object")
145
+ else if (isObj(adv)) {
146
+ for (const k of ["docMap", "standardsDoc"]) {
147
+ if (adv[k] !== undefined && typeof adv[k] !== "string") errs.push(`advisor.${k} must be a string`)
148
+ }
149
+ }
150
+ return errs
151
+ }
152
+
153
+ function buildConventions(raw) {
154
+ const codePaths = normalizeCodePaths(raw?.codePaths)
155
+ const codeExtensions = normalizeExtensions(raw?.index?.codeExtensions)
156
+ const docExtensions = normalizeExtensions(raw?.index?.docExtensions)
157
+ const docMap = normalizeString(raw?.advisor?.docMap)
158
+ const standardsDoc = normalizeString(raw?.advisor?.standardsDoc)
159
+ // `declared` = the declaration actually took effect (at least one recognized key
160
+ // honored) — the design-gate hint reads it to decide whether to point at the
161
+ // declaration file ("declare project conventions … to adjust").
162
+ const declared = Boolean(codePaths || codeExtensions.length || docExtensions.length || docMap || standardsDoc)
163
+ return Object.freeze({
164
+ declared,
165
+ codePaths: Object.freeze(codePaths ?? [...DEFAULT_CODE_PATHS]),
166
+ index: Object.freeze({
167
+ codeExtensions: Object.freeze(codeExtensions),
168
+ docExtensions: Object.freeze(docExtensions),
169
+ }),
170
+ advisor: Object.freeze({ docMap, standardsDoc }),
171
+ })
172
+ }
173
+
174
+ /** Full-default conventions (no declaration) — the fallback every consumer gets. */
175
+ export const DEFAULT_CONVENTIONS = buildConventions(null)
176
+
177
+ const _cache = new Map()
178
+
179
+ /** Drop the per-root cache (test seam — declaration files are read once per root). */
180
+ export function clearConventionsCache() {
181
+ _cache.clear()
182
+ }
183
+
184
+ /**
185
+ * Load (and cache) the normalized conventions for a project root.
186
+ * @param {string} cwd — project root (declaration lives at .thincoder/conventions.json)
187
+ * @returns {Readonly<{declared: boolean, codePaths: readonly string[],
188
+ * index: {codeExtensions: string[], docExtensions: string[]},
189
+ * advisor: {docMap: string, standardsDoc: string}}>}
190
+ */
191
+ export function loadConventions(cwd) {
192
+ const root = resolve(cwd ?? process.cwd())
193
+ const hit = _cache.get(root)
194
+ if (hit) return hit
195
+ let conv = DEFAULT_CONVENTIONS
196
+ try {
197
+ const text = readFileSync(join(root, CONVENTIONS_REL_PATH), "utf8")
198
+ try {
199
+ const raw = JSON.parse(text)
200
+ if (!raw || typeof raw !== "object" || Array.isArray(raw)) throw new Error("top level must be a JSON object")
201
+ conv = buildConventions(raw)
202
+ const typeErrs = typeErrorsOf(raw)
203
+ if (typeErrs.length > 0) {
204
+ // Wrong-typed keys fall back per-key — but the user must SEE that their
205
+ // declaration did not take effect (never silently swallowed).
206
+ console.warn(`[conventions] ${CONVENTIONS_REL_PATH} has invalid value types (${typeErrs.join("; ")}) — those keys fall back to defaults`)
207
+ logEvent("conventions:error", { cwd: root, err: `type errors: ${typeErrs.join("; ").slice(0, 160)}` })
208
+ }
209
+ } catch (e) {
210
+ // Corrupt file / wrong shape → defaults, visible: warn + event (never silent).
211
+ console.warn(`[conventions] ${CONVENTIONS_REL_PATH} unreadable (${e?.message ?? e}) — falling back to defaults`)
212
+ logEvent("conventions:error", { cwd: root, err: String(e?.message ?? e).slice(0, 200) })
213
+ }
214
+ } catch (e) {
215
+ if (e?.code !== "ENOENT") {
216
+ // File exists but cannot be read (EACCES etc.) — same visible degradation.
217
+ console.warn(`[conventions] ${CONVENTIONS_REL_PATH} not readable (${e?.message ?? e}) — falling back to defaults`)
218
+ logEvent("conventions:error", { cwd: root, err: String(e?.message ?? e).slice(0, 200) })
219
+ }
220
+ }
221
+ _cache.set(root, conv)
222
+ return conv
223
+ }
@@ -1,12 +1,14 @@
1
1
  /**
2
- * crash-reports.mjs — R25(docs/design/ARCHITECTURE.md §R25——F-R25a/b/c)CLI 异常终止
3
- * 捕获与留痕机制。
2
+ * crash-reports.mjs — 崩溃捕获与取证(docs/design/CRASH-REPORTS.md——F1/F2 为 R25 与
3
+ * TUI-STDERR-CAPTURE 存量迁移;F3 近堆上限堆快照 = TUI-OOM-FORENSICS 批新增)。
4
4
  *
5
- * 三类能力(与设计逐项对应):
6
- * - prepareCrashReporting()(F-R25b):入口最前调用——mkdir 预建 ~/.thincoder/crash-reports/
5
+ * 能力(与设计逐项对应):
6
+ * - prepareCrashReporting()(F-R25b + F3①):入口最前调用——mkdir 预建 ~/.thincoder/crash-reports/
7
7
  * + process.report 代码内启用(reportOnFatalError + directory)——V8 OOM/原生 fatal
8
8
  * 自动写 report.*.json。实现批实测(2026-09-07):目录缺失时 Node 对 fatal 静默不写
9
9
  * 报告——预建是必要动作而非"零成本保险"。
10
+ * F3①:同点武装近堆上限堆快照(v8.setHeapSnapshotNearHeapLimit——对象级证据,回答
11
+ * "谁在持内存";全路径单源,默认开、THINCODER_HEAP_SNAPSHOT 可关——F3②)。
10
12
  * - writeCrashRecord()(F-R25a):JS 异常钩子同步落盘 crash-{ts}-{pid}.json(ts = epoch ms
11
13
  * UTC + pid——跨进程同 ms 防覆盖——复审 #3)——权限 0600(config.json 先例)。
12
14
  * - recentCrashHint()(F-R25c):启动扫描 24h 窗内记录(两类文件模式定死——评审 #8:
@@ -23,18 +25,30 @@
23
25
  */
24
26
  import { chmodSync, existsSync, mkdirSync, readdirSync, statSync, unlinkSync, writeFileSync } from "node:fs"
25
27
  import { join } from "node:path"
28
+ // F3① 命名空间 import:API 缺失(旧 Node)降级为调用期异常并被武装 try 吞掉——不做 import 期硬失败
29
+ import * as v8 from "node:v8"
26
30
  import { configDir } from "./config.mjs"
27
31
 
32
+ /** F3② 关值集合(trim + 大小写不敏感);其余取值(未设 / 空串 / 未知串)默认开——fail-open 向取证。 */
33
+ const HEAP_SNAPSHOT_OFF_VALUES = new Set(["0", "false", "off", "no"])
34
+
28
35
  /** crash-reports 运行时目录(~/.thincoder/crash-reports——非仓内——写时自清理)。 */
29
36
  export function crashReportsDir() {
30
37
  return join(configDir, "crash-reports")
31
38
  }
32
39
 
33
40
  /** 记录文件模式(评审 #8 定死):crash-*.json = 自写(F-R25a);report.*.json = Node
34
- * fatal(默认命名 report.YYYYMMDD.HHMMSS.<pid>.<seq>.json——2026-09-07 实现批实测)。 */
41
+ * fatal(默认命名 report.YYYYMMDD.HHMMSS.<pid>.<seq>.json——2026-09-07 实现批实测)。
42
+ * purge 另含 tui-stderr-*.log(TUI-STDERR-CAPTURE F-2——30 天同族淘汰)与
43
+ * Heap.*.heapsnapshot(CRASH-REPORTS F3③——快照 GB 级、TUI 落点须自动兜底)——但
44
+ * recentCrashHint 两类均不计(N3):tui-stderr 每次 TUI 启动都生成(正常退出也留档)、
45
+ * 快照非「异常终止」证据类——计入即正常会话误报。 */
35
46
  function isCrashRecordName(name) {
36
47
  return /^crash-.+\.json$/.test(name) || /^report\..+\.json$/.test(name)
37
48
  }
49
+ function isPurgeRecordName(name) {
50
+ return isCrashRecordName(name) || /^tui-stderr-.+\.log$/.test(name) || /^Heap\..+\.heapsnapshot$/.test(name)
51
+ }
38
52
 
39
53
  /** >30 天淘汰(评审 #6)——写时自清理;搭车点 = 写 / 入口 mkdir / 启动扫描(复审 #4)。 */
40
54
  function purgeOldCrashReports(dir) {
@@ -42,25 +56,38 @@ function purgeOldCrashReports(dir) {
42
56
  let names
43
57
  try { names = readdirSync(dir) } catch { return } // 目录不存在/不可读 → 无事可做
44
58
  for (const name of names) {
45
- if (!isCrashRecordName(name)) continue
59
+ if (!isPurgeRecordName(name)) continue
46
60
  try {
47
61
  if (statSync(join(dir, name)).mtimeMs < cutoff) unlinkSync(join(dir, name))
48
62
  } catch { /* 单个文件失败不影响其余 */ }
49
63
  }
50
64
  }
51
65
 
66
+ /** F3② 武装判定(单点——包装器不判 env,防两处判定漂移):关值集合命中 → 不武装;其余 → 武装。 */
67
+ function heapSnapshotEnabled(env) {
68
+ return !HEAP_SNAPSHOT_OFF_VALUES.has(String(env?.THINCODER_HEAP_SNAPSHOT ?? "").trim().toLowerCase())
69
+ }
70
+
52
71
  /**
53
72
  * F-R25b:入口最前调用(一切重活前——缩编程期窗口)——预建目录 + process.report 启用。
54
73
  * 任何失败不阻断启动(尽力面——record 路径自带降级)。返回目录路径。
74
+ * F3①:同点武装近堆上限堆快照(默认开——全路径单源;环境开关见 heapSnapshotEnabled)。
75
+ * @param {object} [opts] 注入缝(默认参数保 bin 入口调用点零改)
76
+ * @param {string} [opts.dir] 目录注入(测试用——同时服务 mkdir / report / purge / 返回)
77
+ * @param {object} [opts.env] env 注入(测试用)——仅服务 F3② 判定
78
+ * @param {(n: number) => void} [opts.armHeapSnapshot] 武装实现注入(测试用)——默认真实 node:v8 API
55
79
  */
56
- export function prepareCrashReporting() {
57
- const dir = crashReportsDir()
80
+ export function prepareCrashReporting({ dir = crashReportsDir(), env = process.env, armHeapSnapshot = v8.setHeapSnapshotNearHeapLimit } = {}) {
58
81
  try {
59
82
  mkdirSync(dir, { recursive: true })
60
83
  // 代码内启用:shebang 入口无法携带启动参数(env 单参数限制 + execArgv 仅子进程——评审 #1)
61
84
  process.report.directory = dir
62
85
  process.report.reportOnFatalError = true
63
86
  } catch { /* mkdir/启用失败不阻断启动——尽力面 */ }
87
+ // F3① 武装(独立 try——失败不阻断启动、不影响上方 F1 既有步骤;尽力面静默)
88
+ if (heapSnapshotEnabled(env)) {
89
+ try { armHeapSnapshot(1) } catch { /* 武装失败不阻断——N1 */ }
90
+ }
64
91
  purgeOldCrashReports(dir) // F-R25b 入口 mkdir 搭车清理(复审 #4——纯 fatal 序列也触发)
65
92
  return dir
66
93
  }