@thincoder/core 0.9.1

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 (203) hide show
  1. package/abort-provenance.mjs +116 -0
  2. package/advisor/citations.mjs +139 -0
  3. package/advisor/compaction.mjs +174 -0
  4. package/advisor/convergence.mjs +80 -0
  5. package/advisor/history.mjs +77 -0
  6. package/advisor/loop.mjs +288 -0
  7. package/advisor/messages.mjs +299 -0
  8. package/advisor/notice.mjs +141 -0
  9. package/advisor/project-context.mjs +197 -0
  10. package/advisor/repos.mjs +150 -0
  11. package/advisor/run.mjs +190 -0
  12. package/advisor/truncate.mjs +57 -0
  13. package/advisor.mjs +281 -0
  14. package/agent/child-marks.mjs +24 -0
  15. package/agent/completion.mjs +145 -0
  16. package/agent/dispatch.mjs +493 -0
  17. package/agent/family-tools.mjs +174 -0
  18. package/agent/helpers.mjs +412 -0
  19. package/agent/post-turn.mjs +70 -0
  20. package/agent/record-results.mjs +174 -0
  21. package/agent/relay-prefix.mjs +39 -0
  22. package/agent/run-stages.mjs +244 -0
  23. package/agent/setup-reminders.mjs +199 -0
  24. package/agent/setup.mjs +234 -0
  25. package/agent/spawn-child.mjs +258 -0
  26. package/agent/suspension.mjs +240 -0
  27. package/agent/write-gate.mjs +87 -0
  28. package/agent-tools/advisor-async.mjs +481 -0
  29. package/agent-tools/advisor-settle.mjs +240 -0
  30. package/agent-tools/advisor.mjs +280 -0
  31. package/agent-tools/async-discard.mjs +143 -0
  32. package/agent-tools/async-settle.mjs +299 -0
  33. package/agent-tools/batch-segment.mjs +265 -0
  34. package/agent-tools/child-permission.mjs +45 -0
  35. package/agent-tools/consult.mjs +471 -0
  36. package/agent-tools/design-token.mjs +117 -0
  37. package/agent-tools/digest-budget.mjs +76 -0
  38. package/agent-tools/eng.mjs +102 -0
  39. package/agent-tools/escalate-async.mjs +302 -0
  40. package/agent-tools/goal.mjs +119 -0
  41. package/agent-tools/panel-blocks.mjs +24 -0
  42. package/agent-tools/parent-channel.mjs +231 -0
  43. package/agent-tools/plan.mjs +86 -0
  44. package/agent-tools/read-history.mjs +309 -0
  45. package/agent-tools/recent-changes.mjs +24 -0
  46. package/agent-tools/review-facts.mjs +31 -0
  47. package/agent-tools/settings.mjs +268 -0
  48. package/agent-tools/skill.mjs +63 -0
  49. package/agent-tools/spawn-gates.mjs +109 -0
  50. package/agent-tools/subagent-actions.mjs +496 -0
  51. package/agent-tools/subagent-async.mjs +456 -0
  52. package/agent-tools/subagent-panel.mjs +160 -0
  53. package/agent-tools/subagent-run.mjs +208 -0
  54. package/agent-tools/subagent-scheduler.mjs +446 -0
  55. package/agent-tools/subagent-spawn.mjs +478 -0
  56. package/agent-tools/subagent.mjs +419 -0
  57. package/agent-tools/task.mjs +87 -0
  58. package/agent-tools/timer.mjs +46 -0
  59. package/agent-tools/verify.mjs +295 -0
  60. package/agent-tools.mjs +23 -0
  61. package/agent.mjs +430 -0
  62. package/auto-think.mjs +115 -0
  63. package/compress-form.mjs +24 -0
  64. package/config-io.mjs +277 -0
  65. package/config-migrate.mjs +178 -0
  66. package/config-presets.mjs +49 -0
  67. package/config.mjs +419 -0
  68. package/context.mjs +495 -0
  69. package/conventions.mjs +223 -0
  70. package/embedding.mjs +120 -0
  71. package/escape.mjs +152 -0
  72. package/expand-home.mjs +16 -0
  73. package/explore-distill.mjs +152 -0
  74. package/generate-title.mjs +123 -0
  75. package/git/checkpoint.mjs +448 -0
  76. package/git/gitmem.mjs +100 -0
  77. package/history-window.mjs +179 -0
  78. package/hooks.mjs +108 -0
  79. package/i18n.mjs +106 -0
  80. package/index-bin.mjs +48 -0
  81. package/index-discover.mjs +176 -0
  82. package/ledger-cmd.mjs +209 -0
  83. package/ledger-db.mjs +86 -0
  84. package/ledger-surface.mjs +76 -0
  85. package/ledger.mjs +202 -0
  86. package/log.mjs +195 -0
  87. package/manifest.mjs +338 -0
  88. package/markdown.mjs +106 -0
  89. package/mcp/helpers.mjs +51 -0
  90. package/mcp/transport-http.mjs +248 -0
  91. package/mcp/transport-stdio.mjs +140 -0
  92. package/mcp/transport-ws.mjs +122 -0
  93. package/mcp.mjs +295 -0
  94. package/memory/code-index.mjs +219 -0
  95. package/memory/code-sync.mjs +427 -0
  96. package/memory/core.mjs +318 -0
  97. package/memory/delete.mjs +242 -0
  98. package/memory/docs.mjs +431 -0
  99. package/memory/file-walk.mjs +109 -0
  100. package/memory/origin.mjs +24 -0
  101. package/memory/scan.mjs +177 -0
  102. package/memory/schema.mjs +460 -0
  103. package/memory.mjs +21 -0
  104. package/model-ref.mjs +66 -0
  105. package/model-specs.mjs +277 -0
  106. package/package.json +32 -0
  107. package/peer-domains.mjs +265 -0
  108. package/peer-instances.mjs +178 -0
  109. package/permission.mjs +79 -0
  110. package/process-probe.mjs +315 -0
  111. package/prompt-files.mjs +113 -0
  112. package/prompt-overlays.mjs +78 -0
  113. package/prompts/advisor-design.md +43 -0
  114. package/prompts/advisor-round1.md +41 -0
  115. package/prompts/advisor-round2.md +46 -0
  116. package/prompts/advisor-round3.md +42 -0
  117. package/prompts/common.md +158 -0
  118. package/prompts/consult-base.md +19 -0
  119. package/prompts/discipline-engineering.md +123 -0
  120. package/prompts/discipline-normal.md +206 -0
  121. package/prompts/persona-coder.md +21 -0
  122. package/prompts/persona-eng-coder.md +41 -0
  123. package/prompts/persona-eng-designer.md +80 -0
  124. package/prompts/persona-engineering.md +160 -0
  125. package/prompts/persona-explore.md +15 -0
  126. package/prompts/persona-normal.md +35 -0
  127. package/prompts/persona-plan.md +27 -0
  128. package/provider/anthropic.mjs +225 -0
  129. package/provider/core.mjs +491 -0
  130. package/provider/errors.mjs +101 -0
  131. package/provider/google.mjs +257 -0
  132. package/provider/index.mjs +7 -0
  133. package/provider/list-models.mjs +163 -0
  134. package/provider/normalize.mjs +81 -0
  135. package/provider/rate.mjs +168 -0
  136. package/provider/responses.mjs +495 -0
  137. package/provider/retry.mjs +88 -0
  138. package/provider/sse.mjs +264 -0
  139. package/provider/wait-status.mjs +59 -0
  140. package/proxy.mjs +274 -0
  141. package/rules.mjs +53 -0
  142. package/session-gc.mjs +248 -0
  143. package/session-guard.mjs +59 -0
  144. package/session-lifecycle.mjs +305 -0
  145. package/session-migrate.mjs +48 -0
  146. package/session-rename.mjs +38 -0
  147. package/session-segments.mjs +99 -0
  148. package/session-slot-write.mjs +168 -0
  149. package/session-slots-manifest.mjs +264 -0
  150. package/session-slots.mjs +298 -0
  151. package/session-store.mjs +441 -0
  152. package/session.mjs +244 -0
  153. package/skills.mjs +234 -0
  154. package/text-budget.mjs +79 -0
  155. package/token-ttl.mjs +285 -0
  156. package/tool-docs/apply_patch.md +15 -0
  157. package/tool-docs/bash.md +38 -0
  158. package/tool-docs/delete.md +13 -0
  159. package/tool-docs/edit.md +30 -0
  160. package/tool-docs/execute.md +21 -0
  161. package/tool-docs/fetch.md +12 -0
  162. package/tool-docs/file_ops.md +17 -0
  163. package/tool-docs/get_current_time.md +8 -0
  164. package/tool-docs/git.md +54 -0
  165. package/tool-docs/glob.md +11 -0
  166. package/tool-docs/grep.md +19 -0
  167. package/tool-docs/hashline_edit.md +14 -0
  168. package/tool-docs/insert_after.md +15 -0
  169. package/tool-docs/lint.md +10 -0
  170. package/tool-docs/ls.md +12 -0
  171. package/tool-docs/lsp.md +10 -0
  172. package/tool-docs/process.md +10 -0
  173. package/tool-docs/question.md +16 -0
  174. package/tool-docs/read.md +20 -0
  175. package/tool-docs/read_image.md +8 -0
  176. package/tool-docs/tree.md +14 -0
  177. package/tool-docs/wait_for.md +22 -0
  178. package/tool-docs/websearch.md +16 -0
  179. package/tool-docs/write.md +11 -0
  180. package/tools/bash.mjs +276 -0
  181. package/tools/edit-batch.mjs +204 -0
  182. package/tools/edit-diff.mjs +388 -0
  183. package/tools/exec-run.mjs +43 -0
  184. package/tools/execute.mjs +243 -0
  185. package/tools/file.mjs +464 -0
  186. package/tools/git-checkpoint.mjs +143 -0
  187. package/tools/git-ext.mjs +173 -0
  188. package/tools/git.mjs +415 -0
  189. package/tools/glob-dialect.mjs +130 -0
  190. package/tools/index.mjs +76 -0
  191. package/tools/linter.mjs +120 -0
  192. package/tools/lsp.mjs +335 -0
  193. package/tools/ops.mjs +293 -0
  194. package/tools/patch.mjs +290 -0
  195. package/tools/question.mjs +26 -0
  196. package/tools/repomap.mjs +314 -0
  197. package/tools/search.mjs +248 -0
  198. package/tools/shared.mjs +467 -0
  199. package/tools/tree.mjs +81 -0
  200. package/tools/web.mjs +224 -0
  201. package/tools/write-path.mjs +191 -0
  202. package/traces/trace-store.mjs +303 -0
  203. package/undo-stack.mjs +47 -0
package/config.mjs ADDED
@@ -0,0 +1,419 @@
1
+ /**
2
+ * config.mjs — configuration loading and saving
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).
10
+ * Config file: ~/.thincoder/config.json
11
+ * API key comes from providers[].apiKey only — environment variables are not a key source.
12
+ */
13
+
14
+ import { chmodSync, copyFileSync, existsSync, mkdirSync, readFileSync, statSync, writeFileSync } from "node:fs"
15
+ import { dirname, join } from "node:path"
16
+ import { parseModelRef, resolveRuntimeProvider, defaultModelReason } from "./model-ref.mjs"
17
+ // 老形态迁移核(M7 v2——纯函数零依赖——本文件超 500 行硬限拆分,VSC 同构文件)
18
+ import { migrateLegacyModelFields } from "./config-migrate.mjs"
19
+ import { expandHome } from "./expand-home.mjs"
20
+ // 核内单一读写面 + 预设表(§2.5 #129 / #131——拆档以容纳 500 行硬限)
21
+ import {
22
+ PROVIDER_PRESETS, presetToEntry, configDir, configPath, _setConfigPathForTest, _resetConfigPathForTest,
23
+ _configPath, writeConfigAtomic,
24
+ } from "./config-io.mjs"
25
+
26
+ export { parseModelRef, resolveRuntimeProvider, defaultModelReason, migrateLegacyModelFields }
27
+ export { PROVIDER_PRESETS, presetToEntry, configDir, configPath, _setConfigPathForTest, _resetConfigPathForTest }
28
+ // 核内单一写盘执行体(已迁 `config-io.mjs`)——本档 re-export 保持既有导入面(§2.5 #131)。
29
+ export { writeConfigAtomic }
30
+
31
+ function cfgPath() { return _configPath() }
32
+
33
+ export const DEFAULTS = {
34
+ defaultModel: null, // top-level "provider:model" composite — new-session starting point (F-1)
35
+ agent: {
36
+ maxTurns: 200,
37
+ subagentTurns: 100,
38
+ subagentModel: null, // default subagent model: "provider:model" | provider name | model name (parent provider); null = inherit parent provider
39
+ subagentModels: {}, // per-type override: { explore, plan, coder, "eng-coder" } — priority: subagent tool model arg > this[role] > subagentModel > parent provider
40
+ goalTurns: 200,
41
+ compactThreshold: 100000,
42
+ verifyGuard: false, // push model back to verify when files were mutated but verify not run (opt-in)
43
+ // Multi-model consultation ("会诊") + escalate ("飞刀") — CLI parity with the VS Code plugin.
44
+ // consultModels: candidate pool for BOTH consult and escalate ({ provider, model, effort? }, up to 5).
45
+ consultModels: [],
46
+ consultTurns: 40, // per-consultant tool-turn budget (diagnosis tasks)
47
+ consultTimeoutMs: 600000, // wall-clock ceiling per consultant (10min)
48
+ streamRules: [], // time-traveling stream rules: [{ pattern: "regex", message: "reminder", action: "abort"|"warn", repeat: "always"|"once" }]
49
+ 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)
50
+ autoThink: false, // auto-classify task difficulty and set reasoning effort per-turn
51
+ engineering: false, // strict methodology enforcement — design-before-code (design review + user approval before code)
52
+ // Async pool limits (AGENT-LOOP-SUBAGENT.md §6.10 D-24a/R14 + R13 — POOL-CONFIG-
53
+ // UNIFIED 2026-09-09): { engCoder, other, advisor } — eng-coder pool / other-role
54
+ // pool / advisor-review pool, defaults 4/4/4 (user ruling "eng-coder 四路,其他
55
+ // 4 路" + advisor 评审池并入同一可配体系——三池统一默认 4)。engCoder/other
56
+ // runtime-validated at every subagent pool admission (subagent-async
57
+ // resolvePoolLimits); advisor runtime-validated at every advisor launch
58
+ // (advisor-async advisorPoolLimitFor——F-2——两域读取器独立不共享)——非法/缺省
59
+ // 回退 4——settings tool 与 /config 并发池菜单写此键——变更下个 spawn/launch 生效。
60
+ // ⚠ 与 subagent-async.mjs ASYNC_POOL_LIMITS(两键)/advisor-async.mjs
61
+ // ADVISOR_POOL_LIMIT 逐键同值(运行时回退常量)——耦合锚 T-24a4 断言锁住——
62
+ // 勿单侧改默认。
63
+ poolLimits: { engCoder: 4, other: 4, advisor: 4 },
64
+ },
65
+ memory: {
66
+ dbPath: join(configDir, "memory.db"),
67
+ projectDir: ".thincoder/memory",
68
+ team: null,
69
+ },
70
+ shell: null, // bash tool shell executable (e.g. "C:\\Program Files\\Git\\bin\\bash.exe" or "pwsh"); null = system default (cmd on Windows, /bin/sh elsewhere)
71
+ embedding: {
72
+ baseURL: "https://api.siliconflow.cn/v1",
73
+ model: "BAAI/bge-m3",
74
+ },
75
+ mcp: {
76
+ servers: [],
77
+ },
78
+ websearch: {
79
+ // Structured search via Tavily when a key is set — empty apiKey → Bing RSS/HTML fallback (zero-config).
80
+ apiKey: "", // Tavily key (tvly-...) — optional
81
+ },
82
+ traces: {
83
+ enabled: false, // §18.6 D-TR6 修订(2026-09-05 用户裁定——发布隐私:"不希望用户那边也采集"):轨迹存档默认 OFF——新用户零采集;本地调试分析可显式开(~/.thincoder/config.json traces.enabled:true)
84
+ retentionHours: 24, // D-TR10:轨迹文件保留小时数——CLI 启动时删除超过该时长的文件(默认 24h)
85
+ },
86
+ }
87
+
88
+ // Model capability table + spec lookup live in model-specs.mjs (2026-08-31
89
+ // extract — config.mjs had grown past the 300-line advisory). Re-exported here
90
+ // so the 23 existing importers keep their import paths.
91
+ import { specForModel, providerSpec, specMatch, assistantToolCallMessage } from "./model-specs.mjs"
92
+ export { specForModel, providerSpec, specMatch, assistantToolCallMessage }
93
+
94
+
95
+ // Window utilization threshold: compacts at 60% context, reserving 40% headroom
96
+ // for injected context (directory tree, git context, outline, project instructions,
97
+ // memory/doc search results) which can consume 30-50K tokens each turn.
98
+ const COMPACT_RATIO = 0.6
99
+
100
+ /** Derive compaction threshold; explicit is the value explicitly set in config file (takes priority), otherwise auto-computed from model.
101
+ * Second param accepts EITHER a model name string (pure spec lookup — legacy caller:
102
+ * first-run wizard) OR a provider object (providerSpec — the providers[].context
103
+ * override in K units is honored, PROVIDER.md §15 T-C2). */
104
+ export function resolveCompactThreshold(explicit, modelOrProvider) {
105
+ if (explicit != null) return { value: explicit, auto: false }
106
+ const provider = typeof modelOrProvider === "string" ? { model: modelOrProvider } : (modelOrProvider ?? {})
107
+ const spec = providerSpec(provider)
108
+ const value = Math.floor(spec.context * COMPACT_RATIO)
109
+ return { value, auto: true }
110
+ }
111
+
112
+ /**
113
+ * Bailian (阿里云百炼) host check — enable_thinking is a Bailian-only extension parameter;
114
+ * sending it to other endpoints (kimi/glm/custom proxies) would pollute the request.
115
+ */
116
+ export function isBailianHost(baseURL) {
117
+ return typeof baseURL === "string"
118
+ && (baseURL.includes("dashscope.aliyuncs.com") || baseURL.includes(".maas.aliyuncs.com"))
119
+ }
120
+
121
+ /**
122
+ * Resolve the Bailian `enable_thinking` switch for qwen hybrid-thinking models (PROVIDER.md §12).
123
+ * qwen3.x on Bailian defaults to thinking ON, so an explicit off must send enable_thinking:false
124
+ * or the server silently keeps thinking. Whitelist: model name starts with "qwen" (excluding the
125
+ * non-thinking qwen3-coder line) AND the provider points at a Bailian host.
126
+ * provider.thinking === null → false (explicit off: /think off, panel off — NF1 convention)
127
+ * provider.reasoningEffort → true (effort tier implies thinking on; rides with reasoning_effort)
128
+ * otherwise → undefined (field omitted — server default stays, no behavior change)
129
+ * NOTE: spec carries no model field today — the name comes from provider.model (spec?.model is
130
+ * a forward-compatible fallback). Keep the body byte-aligned with thincoder-vscode config.mjs
131
+ * (cross-repo parity test compares them).
132
+ */
133
+ export function resolveEnableThinking(provider, spec) {
134
+ const model = (provider?.model ?? spec?.model ?? "").toLowerCase()
135
+ if (!model.startsWith("qwen") || model.startsWith("qwen3-coder")) return undefined
136
+ if (!isBailianHost(provider?.baseURL)) return undefined
137
+ if (provider.thinking === null) return false
138
+ if (provider.reasoningEffort) return true
139
+ return undefined
140
+ }
141
+
142
+ /** Module-level one-time warn dedupe for invalid providers[].context (PROVIDER.md §15 D-C1). */
143
+ const warnedContextProviders = new Set()
144
+
145
+ /** F-4 (IKCDMR) 软失败清洗(D-S1 范式——loadConfig 是 CLI 启动砖点):consultModels 非法
146
+ * 条目过滤不 throw——非数组 → []、形状非法/未知渠道丢弃、超 5 截断(keep 前 5)。
147
+ * 返回 { keep, dropped }——dropped 供一次性警告;merged.agent.consultModelsFiltered 挂载
148
+ * 供后续首帧引导消费(数据层——本批文件面无消费方——同 providerInvalidReason 载体先例)。 */
149
+ function sanitizeConsultModels(cm, providerNames) {
150
+ if (cm === undefined || cm === null) return { keep: [], dropped: [] }
151
+ if (!Array.isArray(cm)) {
152
+ return { keep: [], dropped: [`agent.consultModels must be an array of { provider, model } entries (got ${typeof cm})`] }
153
+ }
154
+ const names = new Set(providerNames)
155
+ const keep = []
156
+ const dropped = []
157
+ for (const entry of cm) {
158
+ if (keep.length >= 5) { dropped.push(`over the 5-entry cap — dropped ${JSON.stringify(entry)}`); continue }
159
+ if (!entry || typeof entry !== "object" || typeof entry.provider !== "string" || !entry.provider.trim()
160
+ || typeof entry.model !== "string" || !entry.model.trim()) {
161
+ dropped.push(`invalid entry (expected { provider: string, model: string }) — got ${JSON.stringify(entry)}`)
162
+ continue
163
+ }
164
+ if (!names.has(entry.provider)) {
165
+ dropped.push(`entry "${entry.provider}:${entry.model}" references unknown provider "${entry.provider}" (available: ${[...names].join(", ") || "none"})`)
166
+ continue
167
+ }
168
+ keep.push(entry)
169
+ }
170
+ return { keep, dropped }
171
+ }
172
+
173
+ /** F-4:过滤警告——进程级一次性(loadConfig 同进程可多次调用:TUI 启动 + reloadConfig)。 */
174
+ let warnedConsultModels = false
175
+ function warnConsultModelsFiltered(dropped, path) {
176
+ if (warnedConsultModels || dropped.length === 0) return
177
+ warnedConsultModels = true
178
+ console.warn(`[config] agent.consultModels: ${dropped.length} invalid entr${dropped.length === 1 ? "y ignored" : "ies ignored"} (filtered — startup continues; no crash):\n` +
179
+ dropped.map((d) => ` - ${d}`).join("\n") +
180
+ `\n Fix: clean agent.consultModels in ${path} or use /config → consult/escalate pool menu.`)
181
+ }
182
+
183
+ /**
184
+ * Find provider by name in providers[].
185
+ * 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.
186
+ * Returns the first provider when name is empty.
187
+ */
188
+ export function findProvider(providers, name) {
189
+ if (name) {
190
+ const found = providers.find((p) => p.name === name)
191
+ if (found) return found
192
+ const available = providers.map((p) => p.name).join(", ") || "(empty)"
193
+ throw new Error(`activeProvider "${name}" not in providers list (available: ${available}); check for a typo in: ${configPath}`)
194
+ }
195
+ return providers[0] ?? { name: "default", baseURL: "", model: "" }
196
+ }
197
+
198
+ /** Normalize proxy config to { uri, web, model } or undefined (uri/url both accepted; invalid types dropped) */
199
+ export function normalizeProxy(proxy) {
200
+ if (typeof proxy === "string") return proxy ? { uri: proxy, web: true, model: false } : undefined
201
+ if (!proxy || typeof proxy !== "object" || Array.isArray(proxy)) return undefined
202
+ const uri = proxy.uri || proxy.url || ""
203
+ if (typeof uri !== "string" || !uri) return undefined
204
+ return { uri, web: proxy.web !== false, model: proxy.model === true }
205
+ }
206
+
207
+ /**
208
+ * Load configuration.
209
+ * No env-var overrides — config.json is the single source of truth
210
+ * (API keys, baseURL, model, activeProvider all come from the file).
211
+ */
212
+ /** Keep only { header: "string value" } pairs from a provider's headers field — anything
213
+ * else (null, arrays, nested objects) is dropped so it can never reach a fetch call.
214
+ * Authorization is built-in and cannot be overridden from headers (core.mjs spreads first). */
215
+ function sanitizeProviderHeaders(p) {
216
+ if (p.headers == null || typeof p.headers !== "object" || Array.isArray(p.headers)) { delete p.headers; return p }
217
+ const clean = {}
218
+ for (const [k, v] of Object.entries(p.headers)) {
219
+ if (typeof v === "string" && k.toLowerCase() !== "authorization") clean[k] = v
220
+ }
221
+ if (Object.keys(clean).length > 0) p.headers = clean
222
+ else delete p.headers
223
+ return p
224
+ }
225
+
226
+ /** loadConfig 内联迁移核已迁 config-migrate.mjs(500 行硬限拆分——VSC 同构)——
227
+ * migrateLegacyModelFields 纯函数 + 本文件 import/写回编排(折中 C 见其头注释)。 */
228
+ export function loadConfig() {
229
+ let config = {}
230
+ const path = cfgPath()
231
+ if (existsSync(path)) {
232
+ try {
233
+ config = JSON.parse(readFileSync(path, "utf8"))
234
+ } catch (error) {
235
+ throw new Error(`Config file is not valid JSON, check or delete it: ${path}\n ${error.message}`, { cause: error })
236
+ }
237
+ }
238
+
239
+ // ── 迁移(M7 v2):检测老字段(models[] / active* / 垃圾值)→ 内存迁移态先行;写回失败绝不阻断(下次 load 重试——幂等)──
240
+ if (migrateLegacyModelFields(config)) {
241
+ try {
242
+ const r = writeConfigAtomic(path, migrateLegacyModelFields) // 磁盘 fresh raw 同变换
243
+ if (r.ok === false) console.warn(`[config] migration write-back skipped (${r.reason}) — memory state continues, retried on next load`)
244
+ } catch (e) {
245
+ console.warn(`[config] migration write-back failed — memory state continues, retried on next load: ${e.message}`)
246
+ }
247
+ }
248
+
249
+ const merged = {
250
+ ...DEFAULTS,
251
+ ...config,
252
+ defaultModel: typeof config.defaultModel === "string" && config.defaultModel.trim() ? config.defaultModel : null,
253
+ providers: Array.isArray(config.providers)
254
+ ? config.providers.map((p) => sanitizeProviderHeaders({ ...p }))
255
+ : [],
256
+ agent: { ...DEFAULTS.agent, ...config.agent },
257
+ memory: { ...DEFAULTS.memory, ...config.memory },
258
+ embedding: { ...DEFAULTS.embedding, ...config.embedding },
259
+ traces: { ...DEFAULTS.traces, ...config.traces },
260
+ }
261
+
262
+ // 家目录展开(第 29 批):config 路径字段单一规范化点——只读归一(磁盘原文保留)
263
+ merged.memory.dbPath = expandHome(merged.memory.dbPath)
264
+ merged.memory.projectDir = expandHome(merged.memory.projectDir)
265
+ const team = merged.memory.team
266
+ if (team && typeof team === "object" && !Array.isArray(team) && team.dir !== undefined) {
267
+ merged.memory.team = { ...team, dir: expandHome(team.dir) } // 无 dir 键不注入(零键面变化)
268
+ }
269
+ merged.shell = expandHome(merged.shell)
270
+
271
+ // providers[].model 内存归一(v2 M3:非空字符串保留;非字符串/空串归一删除)——渠道默认模型
272
+ // 单值;无默认模型合法(模型选择经 /models 拉取候选——准入判据见 M8/M9)。
273
+ for (const p of merged.providers) {
274
+ if (typeof p.model === "string" && p.model.trim()) continue
275
+ if (p.model !== undefined) delete p.model
276
+ }
277
+
278
+ // providers[].context (K units, PROVIDER.md §15 D-C1): positive integer only — invalid
279
+ // values (0/negative/non-numeric) are IGNORED (spec value applies) with a ONE-TIME warn
280
+ // per provider name (module-level dedupe, same precedent as warnedModels in model-specs.mjs).
281
+ for (const p of merged.providers) {
282
+ if (p.context === undefined) continue
283
+ if (Number.isInteger(Number(p.context)) && Number(p.context) > 0) { p.context = Number(p.context); continue } // 数字字符串("128")归一为数字——两端语义统一(code review #1)
284
+ if (!warnedContextProviders.has(p.name ?? "(unnamed)")) {
285
+ warnedContextProviders.add(p.name ?? "(unnamed)")
286
+ console.warn(`[config] provider "${p.name}" context must be a positive integer in K units (e.g. 128 = 128K) — got ${JSON.stringify(p.context)} — ignored, using the model spec value`)
287
+ }
288
+ delete p.context
289
+ }
290
+
291
+ // F-4 (IKCDMR) consultModels 软失败化——D-S1 范式(defaultModel 无效同族):非法条目
292
+ // 过滤不 throw(去 startup brick——无修复入口的硬崩消);一次性启动警告内嵌修复指引
293
+ // (引导清条目载体——保留 discoverability);过滤记录挂 merged.agent.consultModelsFiltered
294
+ // (数据层——consumer-ready——首帧 UI 消费点同 D-S1 promptProviderIfInvalid 属后续批)。
295
+ // VSC loadAgentSettings 同规则(共享 config——双端锁步)。
296
+ const cmClean = sanitizeConsultModels(merged.agent.consultModels, merged.providers.map((p) => p.name))
297
+ warnConsultModelsFiltered(cmClean.dropped, path)
298
+ merged.agent.consultModels = cmClean.keep
299
+ if (cmClean.dropped.length) merged.agent.consultModelsFiltered = cmClean.dropped
300
+
301
+ // Backward compatibility: promote root-level config fields to agent sub-object
302
+ if (config.verifyGuard !== undefined) {
303
+ merged.agent.verifyGuard = config.verifyGuard
304
+ }
305
+
306
+ // Normalize baseURL trailing slash (prevents //chat/completions)
307
+ for (const p of merged.providers) {
308
+ if (p.baseURL) p.baseURL = p.baseURL.replace(/\/+$/, "")
309
+ }
310
+
311
+ // Normalize proxy: string → { uri, web:true, model:false }; object 补默认值;非法类型丢弃。
312
+ // 保证 agent.config.proxy 永远是规范形态或 undefined
313
+ merged.proxy = normalizeProxy(merged.proxy)
314
+
315
+ // Runtime provider = config.defaultModel 复合解析(F-2——resolveRuntimeProvider)。
316
+ // 无效/未设 → {} + providerInvalidReason(D-S1 处置不 throw——make-agent 打 _providerInvalid
317
+ // 标记 → TUI 首帧弹选择 / headless 报可读错误;同 2026-09-02 Q1 语义——不复用 findProvider
318
+ // throw 契约——findProvider 保留给 advisor/run.mjs 等直接调用方)。
319
+ merged.provider = resolveRuntimeProvider(merged.providers, merged.defaultModel)
320
+ merged.providerInvalidReason = merged.provider.name
321
+ ? null
322
+ : defaultModelReason(merged.providers, merged.defaultModel)
323
+
324
+ // Compaction threshold follows the model (provider-level context override honored — providerSpec)
325
+ const explicitThreshold = config.agent?.compactThreshold
326
+ const { value, auto } = resolveCompactThreshold(explicitThreshold, merged.provider)
327
+ merged.agent.compactThreshold = value
328
+ merged.agent.compactThresholdAuto = auto
329
+
330
+ // Write back to merged for convenient access by upper layers
331
+ // fetch 超时可配置(2026-09-01:agent.fetchTimeoutMs——provider/core.mjs effectiveFetchTimeoutMs 消费)
332
+ merged.provider.fetchTimeoutMs = Number.isFinite(merged.agent?.fetchTimeoutMs) && merged.agent.fetchTimeoutMs > 0
333
+ ? merged.agent.fetchTimeoutMs : undefined
334
+ merged.providersList = merged.providers
335
+ merged.advisor = { ...merged.agent.advisor } // promote for consistent access (decoupled copy)
336
+
337
+ return merged
338
+ }
339
+
340
+ /**
341
+ * MCP.md §5 D-3 (2026-09-01): re-read config.json and replace ONLY the agent's mcp section
342
+ * — the agent 代配 closed loop (agent edits config.json with its edit tool, /mcp picks it
343
+ * up). Never touches other config sections (providers/activeProvider stay as loaded).
344
+ *
345
+ * Malformed disk config → memory state kept, { ok:false, error } returned (the /mcp menu
346
+ * shows "⚠ disk config unreadable"). Never throws.
347
+ *
348
+ * 对账 (reconciliation, MCP.md §5 D-3 / T23): returns which disk servers CHANGED
349
+ * (fingerprint differs) or are DELETED from disk while still connected — fingerprint =
350
+ * endpoint + token + headers/env key order. Existing connections are NOT torn down (an
351
+ * in-use server must not be dropped): a deleted-but-connected server KEEPS its memory
352
+ * entry (appended after the disk list) so the /mcp list can still show the row with the
353
+ * "⚠ disk changed" mark. A server that is merely NEW on disk is not drift. persistRaw
354
+ * write + reload is idempotent (fingerprints equal → no drift mark).
355
+ *
356
+ * @param path optional config path override (tests inject a tmp file; default configPath)
357
+ */
358
+ export function reloadMcpFromDisk(agent, path) {
359
+ const memoryServers = Array.isArray(agent.config?.mcp?.servers) ? agent.config.mcp.servers : []
360
+ const fileExists = existsSync(path ?? configPath)
361
+ const diskMcp = readMcpSection(path)
362
+ if (!diskMcp.ok) return { ok: false, error: diskMcp.error, changedNames: [] }
363
+ // Missing/deleted config file → keep whichever mcp servers the session had (never
364
+ // silently drop user servers because the file vanished — same memory-keeps policy
365
+ // as the malformed-disk fallback).
366
+ let diskServers = diskMcp.servers
367
+ if (diskServers.length === 0 && !fileExists) diskServers = memoryServers
368
+ // Drift vs the RAW disk list: fingerprint-changed or deleted-from-disk (T23 ⚠ 标记依据)
369
+ const diskNames = new Set(diskServers.filter((s) => s?.name).map((s) => s.name))
370
+ const changedNames = diffMcpServers(memoryServers, diskServers)
371
+ // Connected servers deleted from disk stay in the list (memory copy) — T23: the row
372
+ // must remain visible (marked ⚠) and its live connection untouched. They are already
373
+ // in changedNames (absent from disk), and stay flagged on every reload until the user
374
+ // reconnects (re-persists them) or removes them — real drift, honestly reported.
375
+ const connectedNames = new Set((agent.tools ?? []).filter((t) => t?._mcpName).map((t) => t._mcpName))
376
+ const keptConnected = memoryServers.filter((s) => s?.name && connectedNames.has(s.name) && !diskNames.has(s.name))
377
+ const finalServers = [...diskServers, ...keptConnected]
378
+ agent.config ??= {}
379
+ agent.config.mcp = { ...agent.config.mcp, servers: finalServers }
380
+ return { ok: true, servers: finalServers, changedNames }
381
+ }
382
+
383
+ /** Disk read behind reloadMcpFromDisk — bounded, never throws. */
384
+ function readMcpSection(path = configPath) {
385
+ try {
386
+ if (!existsSync(path)) return { ok: true, servers: [] }
387
+ const raw = JSON.parse(readFileSync(path, "utf8"))
388
+ const servers = raw?.mcp?.servers
389
+ // 非数组 = 畸形磁盘配置(2.3 代码正确性批)——ok:false 走调用方既有畸形回退(reloadMcpFromDisk 早退)
390
+ if (servers !== undefined && !Array.isArray(servers)) return { ok: false, error: "mcp.servers must be an array" }
391
+ return { ok: true, servers: Array.isArray(servers) ? servers : [] }
392
+ } catch (error) {
393
+ return { ok: false, error: error?.message ?? String(error) }
394
+ }
395
+ }
396
+
397
+ /** Fingerprint = endpoint + token + headers/env entries in key order (JSON.stringify
398
+ * of a normalized subset — key order included, matching connectMcpServer's
399
+ * configFingerprint semantics: any change the connect layer would see counts).
400
+ * Drift = CHANGED (fingerprint differs) or DELETED (missing from disk) — a server
401
+ * that is new on disk is not drift (no live connection to protect). */
402
+ function diffMcpServers(memoryServers, diskServers) {
403
+ const memFp = new Map(memoryServers.filter((s) => s?.name).map((s) => [s.name, mcpFingerprint(s)]))
404
+ const diskFp = new Map(diskServers.filter((s) => s?.name).map((s) => [s.name, mcpFingerprint(s)]))
405
+ const changed = []
406
+ for (const [name, fp] of diskFp) if (memFp.has(name) && memFp.get(name) !== fp) changed.push(name)
407
+ for (const name of memFp.keys()) if (!diskFp.has(name)) changed.push(name)
408
+ return changed
409
+ }
410
+
411
+ function mcpFingerprint(s) {
412
+ return JSON.stringify([
413
+ s.wsUrl ?? s.url ?? s.command ?? null,
414
+ s.args ?? null,
415
+ s.token ?? null,
416
+ s.headers ?? null,
417
+ s.env ?? null,
418
+ ])
419
+ }