@thincoder/core 0.10.8 → 0.10.10

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 (85) hide show
  1. package/CHANGELOG.md +6 -0
  2. package/advisor/messages.mjs +4 -5
  3. package/advisor/project-context.mjs +69 -44
  4. package/agent/family-tools.mjs +11 -9
  5. package/agent/helpers.mjs +12 -6
  6. package/agent/post-turn.mjs +34 -0
  7. package/agent/run-stages.mjs +43 -9
  8. package/agent/run-start.mjs +1 -0
  9. package/agent/setup.mjs +10 -2
  10. package/agent/turn-loop.mjs +23 -13
  11. package/agent-tools/audit-block.mjs +4 -2
  12. package/agent-tools/batch-gate.mjs +197 -0
  13. package/agent-tools/batch-lifecycle.mjs +216 -36
  14. package/agent-tools/batch-skeleton.mjs +87 -0
  15. package/agent-tools/batch.mjs +11 -3
  16. package/agent-tools/checkpoint.mjs +11 -6
  17. package/agent-tools/context.mjs +28 -5
  18. package/agent-tools/design-slots.mjs +7 -33
  19. package/agent-tools/doc-check.mjs +50 -7
  20. package/agent-tools/manifest.mjs +44 -12
  21. package/agent-tools/spawn-gates.mjs +100 -41
  22. package/agent-tools/ssh-session.mjs +108 -61
  23. package/agent-tools/ssh-transport.mjs +17 -4
  24. package/agent-tools/ssh-vault.mjs +291 -0
  25. package/agent-tools/subagent-spawn.mjs +52 -30
  26. package/agent-tools/subagent.mjs +14 -8
  27. package/agent-tools/timer.mjs +2 -2
  28. package/agent-tools.mjs +1 -1
  29. package/config-defaults.mjs +92 -0
  30. package/config-normalize.mjs +109 -0
  31. package/config.mjs +88 -197
  32. package/declaration.mjs +5 -12
  33. package/doc-check/anchors.mjs +68 -34
  34. package/doc-check/index.mjs +143 -32
  35. package/doc-check/version.mjs +84 -0
  36. package/doc-check/width.mjs +10 -54
  37. package/i18n.mjs +7 -0
  38. package/ledger-cmd.mjs +314 -61
  39. package/ledger-db.mjs +22 -10
  40. package/ledger-migrate.mjs +15 -9
  41. package/ledger-tools.mjs +115 -69
  42. package/ledger.mjs +13 -10
  43. package/log.mjs +2 -2
  44. package/manifest-schema.mjs +7 -21
  45. package/manifest.mjs +4 -4
  46. package/memory/code-search.mjs +5 -14
  47. package/memory/docs.mjs +5 -14
  48. package/memory/read-gate.mjs +26 -0
  49. package/package.json +1 -1
  50. package/permission.mjs +7 -1
  51. package/prompts/advisor-design.md +95 -44
  52. package/prompts/advisor-round1.md +75 -27
  53. package/prompts/advisor-round2.md +68 -31
  54. package/prompts/advisor-round3.md +77 -33
  55. package/prompts/common.md +283 -113
  56. package/prompts/consult-base.md +16 -9
  57. package/prompts/discipline-engineering.md +213 -83
  58. package/prompts/discipline-normal.md +137 -85
  59. package/prompts/persona-coder.md +8 -6
  60. package/prompts/persona-eng-coder.md +42 -21
  61. package/prompts/persona-eng-designer.md +101 -43
  62. package/prompts/persona-engineering.md +376 -127
  63. package/prompts/persona-explore.md +3 -3
  64. package/prompts/persona-normal.md +29 -21
  65. package/prompts/persona-plan.md +7 -7
  66. package/prompts/subagent-base.md +4 -4
  67. package/provider/anthropic.mjs +4 -2
  68. package/provider/core.mjs +4 -2
  69. package/provider/google.mjs +5 -1
  70. package/provider/responses-request.mjs +3 -2
  71. package/session-lifecycle.mjs +6 -6
  72. package/team.mjs +2 -1
  73. package/token-window.mjs +1 -1
  74. package/tool-docs/batch.md +1 -1
  75. package/tool-docs/doc_check.md +47 -12
  76. package/tool-docs/fetch.md +3 -2
  77. package/tool-docs/ledger.md +7 -5
  78. package/tool-docs/manifest-read.md +8 -0
  79. package/tool-docs/manifest.md +9 -8
  80. package/tool-docs/ssh.md +23 -6
  81. package/tool-docs/subagent.md +7 -12
  82. package/tool-docs/websearch.md +3 -1
  83. package/tools/ssh.mjs +24 -11
  84. package/tools/web-deepseek.mjs +172 -0
  85. package/tools/web.mjs +29 -4
package/config.mjs CHANGED
@@ -11,8 +11,7 @@
11
11
  * API key comes from providers[].apiKey only — environment variables are not a key source.
12
12
  */
13
13
 
14
- import { chmodSync, copyFileSync, existsSync, mkdirSync, readFileSync, statSync, writeFileSync } from "node:fs"
15
- import { dirname, join } from "node:path"
14
+ import { existsSync, readFileSync } from "node:fs"
16
15
  import { parseModelRef, resolveChannelModel, resolveProviderPlan, resolveRuntimeProvider, defaultModelReason } from "./model-ref.mjs"
17
16
  // 老形态迁移核(M7 v2——纯函数零依赖——本文件超 500 行硬限拆分,VSC 同构文件)
18
17
  import { migrateLegacyModelFields } from "./config-migrate.mjs"
@@ -30,79 +29,10 @@ export { writeConfigAtomic }
30
29
 
31
30
  function cfgPath() { return _configPath() }
32
31
 
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" | 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, // initial default only — the runtime value is the session authority (MANIFEST.md §2.2 / ENGINEERING-MODE-V2.md §2.3 E5.1). Strict methodology enforcement — design-before-code (design review + user approval before code)
52
- // Async pool limits (AGENT-LOOP-ASYNC-POOL.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
- // §6.30.3 门三件③(D-TW5):timer 空闲自唤醒开关——默认开(工具描述面已承诺到点提醒);
65
- // false ⇒ 端侧不武装闩(回既有回合边界投递——零调度器面)。键面随 DEFAULTS 派生进 settings 类型表。
66
- timerWake: true,
67
- // 注入开关(PROMPT-SYSTEM.md §6.24 · 台账 #1420):文档类 ∥ 记忆类自动注入——默认开;
68
- // 判据 `!== false`(缺省 = 现行为零变)。消费点 = 核 `agent/setup.mjs` 判点(逐 run 现读)。
69
- // 2026-10-15(§6.27 · 台账 #1465):① 项目指令块独立成键——`injectDocs` 收窄为只管 ② 相关文档块;
70
- // 两键完全正交(关任一方另一方照常)。`injectProjectRules` 判点 = 核 `agent/setup.mjs`(`prepareRun` 项目指令段)。
71
- injectDocs: true,
72
- injectProjectRules: true,
73
- injectMemories: true,
74
- },
75
- memory: {
76
- dbPath: join(configDir, "memory.db"),
77
- projectDir: ".thincoder/memory",
78
- team: null,
79
- // 索引自维护(§6.14 面⑤ · 台账 #1096):维护型备份保留枚数(轮转保最新 N——引擎钳 ≥ 1)
80
- maintain: { backupKeep: 2 },
81
- },
82
- 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)
83
- embedding: {
84
- baseURL: "https://api.siliconflow.cn/v1",
85
- model: "BAAI/bge-m3",
86
- },
87
- mcp: {
88
- servers: [],
89
- },
90
- websearch: {
91
- // Structured search via Tavily when a key is set — empty apiKey → Bing RSS/HTML fallback (zero-config).
92
- apiKey: "", // Tavily key (tvly-...) — optional
93
- },
94
- // 浏览器工具(BROWSER-TOOL.md §3.2):非空 ⇒ navigate 目标 host ∥ click/evaluate/type 的当前页 host 须命中
95
- //(全等(大小写不敏感)∥ `*.` 前缀 = 子域通配)——收束面非沙箱;缺省空 = 不设限。
96
- browser: { allowDomains: [] },
97
- traces: {
98
- enabled: false, // §18.6 D-TR6 修订(2026-09-05 用户裁定——发布隐私:"不希望用户那边也采集"):轨迹存档默认 OFF——新用户零采集;本地调试分析可显式开(~/.thincoder/config.json traces.enabled:true)
99
- retentionHours: 24, // D-TR10:轨迹文件保留小时数——CLI 启动时删除超过该时长的文件(默认 24h)
100
- },
101
- // 诊断开关(用户面 ① 配置键——CONFIG.md §6.2 名录):`heapWatch` 默认开(设 false 关闭)——自愈分支
102
- // 遥测;`heapSnapshot` 默认关——采集 opt-in(堆转储敏感度 ≥ traces ⇒ 对齐发布隐私先例 D-TR6;
103
- // 2026-09-30 采集收网批;桌面端运行期热读 ∥ CLI 本进程下次启动)
104
- diagnostics: { heapWatch: true, heapSnapshot: false },
105
- }
32
+ // 配置默认值表本体已迁 `config-defaults.mjs`(CONTEXT-COMPACTION.md §6.16.9 —— C1:默认值数据 /
33
+ // 加载期归一逻辑 / 请求预算算式三件分家);本档 re-export 保持既有导入面(34 档零改)。
34
+ import { DEFAULTS } from "./config-defaults.mjs"
35
+ export { DEFAULTS }
106
36
 
107
37
  // Model capability table + spec lookup live in model-specs.mjs (2026-08-31
108
38
  // extract — config.mjs had grown past the 300-line advisory). Re-exported here
@@ -111,20 +41,24 @@ import { specForModel, providerSpec, specMatch, assistantToolCallMessage } from
111
41
  export { specForModel, providerSpec, specMatch, assistantToolCallMessage }
112
42
 
113
43
 
114
- // Window utilization threshold: compacts at 60% of the USABLE window (window − output
115
- // reservation — providers require input + max_tokens ≤ window; 2026-10-06 user ruling:
116
- // the reservation folds into the budget so compaction fires first, never a provider
117
- // rejection), the remaining 40% stays as headroom for injected context (directory tree,
118
- // git context, outline, project instructions, memory/doc search results — 30-50K tokens
119
- // per turn).
120
- const COMPACT_RATIO = 0.6
121
-
122
- /** Output reservation the request counts against the window: explicit `provider.maxTokens`
123
- * (the value actually sent — `provider/core.mjs`) when set, else the spec `maxOutput`, else 0. */
44
+ // Window utilization ratio (CONTEXT-COMPACTION.md §6.2 / D-CC30 / §6.17 / D-CC32): auto threshold =
45
+ // min(usableWindow(window, reserve), ⌊window × COMPACT_RATIO⌋) — reserve is the output budget
46
+ // ACTUALLY sent as max_tokens (uncapped; §6.17 requestBudget is the single source). Rows with
47
+ // reserve ≤ 20% of the window land on the ratio term; larger reservations pull the line down to
48
+ // window − reserve — overflow-free outranks the 75–80% band (mathematically exclusive there, §6.2).
49
+ // Headroom above the line absorbs injected context (directory tree, git context, outline, project
50
+ // instructions, memory/doc search results — 30–50K tokens per turn).
51
+ const COMPACT_RATIO = 0.8
52
+
53
+ /** Output reservation counted against the window: the value ACTUALLY sent as max_tokens —
54
+ * explicit `provider.maxTokens` when set (the transport faces send it verbatim), else the spec
55
+ * `maxOutput`, else 0. NOT capped: the old 25% cap reopened a residual band between the counted
56
+ * reservation and the real output budget — messages could cross the true window while still below
57
+ * the compaction line (2026-10-18 overflow 400: budget counted 250_000, 393_216 actually sent).
58
+ * Single source = §6.17 requestBudget; this is its reserve face (advisor budget reuses it). */
124
59
  export function outputReserve(provider, spec) {
125
60
  const explicit = provider?.maxTokens
126
- if (Number.isFinite(explicit) && explicit > 0) return explicit
127
- return spec?.maxOutput ?? 0
61
+ return Number.isFinite(explicit) && explicit > 0 ? explicit : spec?.maxOutput ?? 0
128
62
  }
129
63
 
130
64
  /** Usable input window = window − output reservation. Degenerate guard: a reservation
@@ -139,17 +73,50 @@ export function usableWindow(context, reserve) {
139
73
  return usable > 0 ? usable : Math.floor(ctx / 2)
140
74
  }
141
75
 
142
- /** Derive compaction threshold; explicit is the value explicitly set in config file (takes priority), otherwise auto-computed from model.
143
- * Second param accepts EITHER a model name string (pure spec lookup — legacy caller:
144
- * first-run wizard) OR a provider object (providerSpec — the providers[].context
76
+ /** Request budget — single source for window ∥ actually-sent max_tokens ∥ compaction threshold ∥
77
+ * overflow guard (CONTEXT-COMPACTION.md §6.17 / D-CC32). One formula, four consumer faces:
78
+ * threshold (resolveCompactThreshold / per-run setup), the four transport faces (read ONLY
79
+ * maxTokens), the stats budget line (agent-tools/context.mjs), the pre-send guard
80
+ * (agent/run-stages.mjs). transport derives from provider.format ("anthropic" | "google" |
81
+ * "responses" | default = OpenAI-compatible). Fields:
82
+ * - window / windowSource: spec.context (providers[].context K override already merged by
83
+ * providerSpec); source = "provider-override" when the override is present, else "spec".
84
+ * - maxTokens / maxTokensSource: the value THIS transport writes into the request body —
85
+ * anthropic always sends it (explicit ?? spec.maxOutput); the other three faces send only an
86
+ * explicit provider.maxTokens, absent ⇒ undefined / "absent". Never a guard criterion
87
+ * (undefined ⇒ NaN — §6.17 guard); the guard reads limit instead.
88
+ * - reserve: maxTokens ?? spec.maxOutput — uncapped, always a number.
89
+ * - usable: usableWindow(window, reserve) (degenerate half-window guard).
90
+ * - threshold: min(usable, ⌊window × COMPACT_RATIO⌋) — the compaction trigger line.
91
+ * - limit: window − reserve — hard prompt ceiling; the guard's ONLY numeric criterion field
92
+ * (always a number; negative = any prompt overflows). */
93
+ export function requestBudget(provider, spec = providerSpec(provider)) {
94
+ const window = spec?.context ?? 0
95
+ const k = Number(provider?.context)
96
+ const windowSource = Number.isInteger(k) && k > 0 ? "provider-override" : "spec"
97
+ const explicitRaw = Number(provider?.maxTokens)
98
+ const explicit = Number.isFinite(explicitRaw) && explicitRaw > 0 ? explicitRaw : undefined
99
+ const anthropicAlwaysSends = provider?.format === "anthropic"
100
+ const maxTokens = explicit ?? (anthropicAlwaysSends ? spec?.maxOutput ?? 0 : undefined)
101
+ const maxTokensSource = explicit != null ? "explicit" : anthropicAlwaysSends ? "spec" : "absent"
102
+ const reserve = maxTokens ?? spec?.maxOutput ?? 0
103
+ const usable = usableWindow(window, reserve)
104
+ return {
105
+ window, windowSource, maxTokens, maxTokensSource, reserve, usable,
106
+ threshold: Math.min(usable, Math.floor(window * COMPACT_RATIO)),
107
+ limit: window - reserve,
108
+ }
109
+ }
110
+
111
+ /** Derive compaction threshold; explicit is the value explicitly set in config file (takes priority),
112
+ * otherwise auto = requestBudget().threshold (§6.17 single source — min(usable, ⌊window × 0.8⌋),
113
+ * reserve uncapped). Second param accepts EITHER a model name string (pure spec lookup — compat shape,
114
+ * no caller passes a string today) OR a provider object (providerSpec — the providers[].context
145
115
  * override in K units is honored, PROVIDER.md §15 T-C2). */
146
116
  export function resolveCompactThreshold(explicit, modelOrProvider) {
147
117
  if (explicit != null) return { value: explicit, auto: false }
148
118
  const provider = typeof modelOrProvider === "string" ? { model: modelOrProvider } : (modelOrProvider ?? {})
149
- const spec = providerSpec(provider)
150
- const usable = usableWindow(spec.context, outputReserve(provider, spec))
151
- const value = Math.floor(usable * COMPACT_RATIO)
152
- return { value, auto: true }
119
+ return { value: requestBudget(provider).threshold, auto: true }
153
120
  }
154
121
 
155
122
  /**
@@ -178,77 +145,28 @@ export function resolveEnableThinking(provider, spec) {
178
145
  const model = (typeof raw === "string" ? raw : "").toLowerCase()
179
146
  if (!model.startsWith("qwen") || model.startsWith("qwen3-coder")) return undefined
180
147
  if (!isBailianHost(provider?.baseURL)) return undefined
148
+ // 一致性分支(#1659):effort 档位有值且非 "none" ⇒ effort 赢(thinking on)。
149
+ // 百炼约束:enable_thinking:false 时 reasoning_effort 必须为 "none",互斥即 400。
150
+ // 压缩下行(context.mjs:203)显式 thinking:null 但继承 effort(v3 同源裁定)——
151
+ // 无此分支则发出 {enable_thinking:false, reasoning_effort:"medium"} ⇒ 400 ⇒ 压缩三连败。
152
+ if (provider.reasoningEffort && provider.reasoningEffort !== "none") return true
181
153
  if (provider.thinking === null) return false
182
154
  if (provider.reasoningEffort) return true
183
155
  return undefined
184
156
  }
185
157
 
158
+ // 加载期归一 / 清洗纯函数族已迁 `config-normalize.mjs`(§6.16.9 —— C1)——四个公共函数经本档
159
+ // re-export 保持既有导入面;其余四个(sanitizeProviderHeaders ∥ sanitizeConsultModels ∥ 两枚
160
+ // 一次性警告旗)只由 loadConfig 消费。
161
+ import {
162
+ sanitizeConsultModels, warnConsultModelsFiltered, sanitizeSubagentModel, sanitizeSubagentModels,
163
+ warnSubagentModelsFiltered, sanitizeProviderHeaders, normalizeProxy, normalizeTeamSection,
164
+ } from "./config-normalize.mjs"
165
+ export { sanitizeSubagentModel, sanitizeSubagentModels, normalizeProxy, normalizeTeamSection }
166
+
186
167
  /** Module-level one-time warn dedupe for invalid providers[].context (PROVIDER.md §15 D-C1). */
187
168
  const warnedContextProviders = new Set()
188
169
 
189
- /** F-4 (IKCDMR) 软失败清洗(D-S1 范式——loadConfig 是 CLI 启动砖点):consultModels 非法
190
- * 条目过滤不 throw——非数组 → []、形状非法/未知渠道丢弃、超 5 截断(keep 前 5)。
191
- * 返回 { keep, dropped }——dropped 供一次性警告;merged.agent.consultModelsFiltered 挂载
192
- * 供后续首帧引导消费(数据层——本批文件面无消费方——同 providerInvalidReason 载体先例)。 */
193
- function sanitizeConsultModels(cm, providerNames) {
194
- if (cm === undefined || cm === null) return { keep: [], dropped: [] }
195
- if (!Array.isArray(cm)) {
196
- return { keep: [], dropped: [`agent.consultModels must be an array of { provider, model } entries (got ${typeof cm})`] }
197
- }
198
- const names = new Set(providerNames)
199
- const keep = []
200
- const dropped = []
201
- for (const entry of cm) {
202
- if (keep.length >= 5) { dropped.push(`over the 5-entry cap — dropped ${JSON.stringify(entry)}`); continue }
203
- if (!entry || typeof entry !== "object" || typeof entry.provider !== "string" || !entry.provider.trim()
204
- || typeof entry.model !== "string" || !entry.model.trim()) {
205
- dropped.push(`invalid entry (expected { provider: string, model: string }) — got ${JSON.stringify(entry)}`)
206
- continue
207
- }
208
- if (!names.has(entry.provider)) {
209
- dropped.push(`entry "${entry.provider}:${entry.model}" references unknown provider "${entry.provider}" (available: ${[...names].join(", ") || "none"})`)
210
- continue
211
- }
212
- keep.push(entry)
213
- }
214
- return { keep, dropped }
215
- }
216
-
217
- /** F-4:过滤警告——进程级一次性(loadConfig 同进程可多次调用:TUI 启动 + reloadConfig)。 */
218
- let warnedConsultModels = false
219
- function warnConsultModelsFiltered(dropped, path) {
220
- if (warnedConsultModels || dropped.length === 0) return
221
- warnedConsultModels = true
222
- console.warn(`[config] agent.consultModels: ${dropped.length} invalid entr${dropped.length === 1 ? "y ignored" : "ies ignored"} (filtered — startup continues; no crash):\n` +
223
- dropped.map((d) => ` - ${d}`).join("\n") +
224
- `\n Fix: clean agent.consultModels in ${path} or use /config → consult/escalate pool menu.`)
225
- }
226
-
227
- /** #861(AGENT-LOOP-SUBAGENT.md §6.7.1):subagent 模型覆盖加载期清洗——单源纯函数(核 loadConfig ∥ VSC 端壳 raw 读点同消费);非法形态不 throw(`subagentModel` 非法 ⇒ null、`subagentModels` 非对象 ⇒ {}、值非法 ⇒ 剔除该键)。 */
228
- export function sanitizeSubagentModel(v) {
229
- const ok = typeof v === "string" && v.trim()
230
- const dropped = ok || v == null ? [] : [`agent.subagentModel must be a non-empty string ("provider:model" | model) — got ${Array.isArray(v) ? "array" : typeof v} — ignored (inherits the parent provider)`]
231
- return { value: ok ? v : null, dropped }
232
- }
233
- export function sanitizeSubagentModels(v) {
234
- if (v == null) return { value: {}, dropped: [] }
235
- if (typeof v !== "object" || Array.isArray(v)) return { value: {}, dropped: [`agent.subagentModels must be an object mapping role → model override — got ${Array.isArray(v) ? "array" : typeof v} — ignored`] }
236
- const value = {}, dropped = []
237
- for (const [role, val] of Object.entries(v)) {
238
- if (typeof val === "string" && val.trim()) { value[role] = val; continue }
239
- dropped.push(`agent.subagentModels.${role} must be a non-empty string ("provider:model" | model) — got ${Array.isArray(val) ? "array" : typeof val} — dropped`)
240
- }
241
- return { value, dropped }
242
- }
243
-
244
- let warnedSubagentModels = false // #861:清洗警告——进程级一次性(同 consultModels 先例)
245
- function warnSubagentModelsFiltered(dropped, path) {
246
- if (warnedSubagentModels || dropped.length === 0) return
247
- warnedSubagentModels = true
248
- console.warn(`[config] agent.subagentModel/subagentModels: ${dropped.length} invalid entr${dropped.length === 1 ? "y ignored" : "ies ignored"} (filtered — startup continues; no crash):\n` +
249
- `${dropped.map((d) => ` - ${d}`).join("\n")}\n Fix: clean it in ${path} — "provider:model" | model.`)
250
- }
251
-
252
170
  /**
253
171
  * Find provider by name in providers[].
254
172
  * Throws if name is non-empty but not found — a typo in the caller-supplied name (e.g. `defaultModel`) silently falling to the first provider would use the wrong key on the wrong endpoint.
@@ -264,47 +182,13 @@ export function findProvider(providers, name) {
264
182
  return providers[0] ?? { name: "default", baseURL: "" }
265
183
  }
266
184
 
267
- /** Normalize proxy config to { uri, web } or undefined (uri/url both accepted; invalid types dropped) */
268
- export function normalizeProxy(proxy) {
269
- if (typeof proxy === "string") return proxy ? { uri: proxy, web: true } : undefined
270
- if (!proxy || typeof proxy !== "object" || Array.isArray(proxy)) return undefined
271
- const uri = proxy.uri || proxy.url || ""
272
- if (typeof uri !== "string" || !uri) return undefined
273
- return { uri, web: proxy.web !== false }
274
- }
275
-
276
- /** 团队登录段加载归一(B1 批——`docs/core/design/TEAM.md` §2.1 ∥ CONFIG.md §6.4):非对象 ⇒ null(软失败
277
- * 不阻启动——沿 proxy 段先例);键级 = trim 非空字符串(非串 ∥ 空 ⇒ null);`member` 浅形 `{ username, name }`。
278
- * **不入 `DEFAULTS`**(settings 工具面零涉——agent 不可写;`team.token` 经读面到达 = 敏感段词表遮罩)。 */
279
- export function normalizeTeamSection(value) {
280
- if (value === null || typeof value !== "object" || Array.isArray(value)) return null
281
- const str = (v) => (typeof v === "string" && v.trim() !== "" ? v.trim() : null)
282
- const raw = value.member
283
- const member = raw !== null && typeof raw === "object" && !Array.isArray(raw) ? { username: str(raw.username), name: str(raw.name) } : null
284
- return { server: str(value.server), member, label: str(value.label), token: str(value.token) }
285
- }
286
-
287
185
  /**
288
186
  * Load configuration.
289
187
  * No env-var overrides — config.json is the single source of truth
290
188
  * (API keys, baseURL, defaultModel all come from the file).
189
+ * loadConfig 内联迁移核已迁 config-migrate.mjs(500 行硬限拆分——VSC 同构)——
190
+ * migrateLegacyModelFields 纯函数 + 本文件 import/写回编排(折中 C 见其头注释)。
291
191
  */
292
- /** Keep only { header: "string value" } pairs from a provider's headers field — anything
293
- * else (null, arrays, nested objects) is dropped so it can never reach a fetch call.
294
- * Authorization is built-in and cannot be overridden from headers (core.mjs spreads first). */
295
- function sanitizeProviderHeaders(p) {
296
- if (p.headers == null || typeof p.headers !== "object" || Array.isArray(p.headers)) { delete p.headers; return p }
297
- const clean = {}
298
- for (const [k, v] of Object.entries(p.headers)) {
299
- if (typeof v === "string" && k.toLowerCase() !== "authorization") clean[k] = v
300
- }
301
- if (Object.keys(clean).length > 0) p.headers = clean
302
- else delete p.headers
303
- return p
304
- }
305
-
306
- /** loadConfig 内联迁移核已迁 config-migrate.mjs(500 行硬限拆分——VSC 同构)——
307
- * migrateLegacyModelFields 纯函数 + 本文件 import/写回编排(折中 C 见其头注释)。 */
308
192
  export function loadConfig() {
309
193
  let config = {}
310
194
  const path = cfgPath()
@@ -418,11 +302,18 @@ export function loadConfig() {
418
302
  ? null
419
303
  : plan.reason ?? defaultModelReason(merged.providers, merged.defaultModel)
420
304
 
421
- // Compaction threshold follows the model (provider-level context override honored — providerSpec)
305
+ // Compaction threshold 三档落值(CONTEXT-COMPACTION.md §6.17):数值在场 = 显式(原样,不猜来源);
306
+ // 不在场 = auto——派生值只进 compactThresholdResolved,永不写 agent.compactThreshold、不落盘。
422
307
  const explicitThreshold = config.agent?.compactThreshold
423
- const { value, auto } = resolveCompactThreshold(explicitThreshold, merged.provider)
424
- merged.agent.compactThreshold = value
425
- merged.agent.compactThresholdAuto = auto
308
+ if (explicitThreshold != null) {
309
+ merged.agent.compactThreshold = explicitThreshold
310
+ merged.agent.compactThresholdResolved = explicitThreshold
311
+ merged.agent.compactThresholdAuto = false
312
+ } else {
313
+ merged.agent.compactThreshold = undefined
314
+ merged.agent.compactThresholdResolved = resolveCompactThreshold(null, merged.provider).value
315
+ merged.agent.compactThresholdAuto = true
316
+ }
426
317
 
427
318
  // Write back to merged for convenient access by upper layers
428
319
  // fetch 超时可配置(2026-09-01:agent.fetchTimeoutMs——provider/core.mjs effectiveFetchTimeoutMs 消费)
package/declaration.mjs CHANGED
@@ -1,5 +1,5 @@
1
1
  /**
2
- * declaration.mjs — 声明装载面(PROJECT-MANIFEST.json 三族 `codePaths` / `index.*` / `advisor.*`)
2
+ * declaration.mjs — 声明装载面(PROJECT-MANIFEST.json 两族 `codePaths` / `index.*`)
3
3
  * · 自 `conventions.mjs` 迁出 · 2026-10-01 core 拆分批 #755 ∥ #786。
4
4
  * 投影单源 = `manifest.mjs`(ONE reader / ONE root resolution——`readManifest` + `manifestFilePath`;
5
5
  * name / shape / defaults = schema 面 `docs/core/design/MANIFEST.md` §2.2)。迁出块逐字;
@@ -62,11 +62,7 @@ function normalizePathList(v) {
62
62
  return out
63
63
  }
64
64
 
65
- function normalizeString(v) {
66
- return typeof v === "string" && v.trim() ? v.trim() : ""
67
- }
68
-
69
- /** Project the manifest three families into the frozen declaration object.
65
+ /** Project the manifest two families into the frozen declaration object.
70
66
  * `declared` = VALUE comparison against the defaults — at least one family deviates
71
67
  * (a key written with its default value still counts as NOT declared; the design-gate
72
68
  * hint reads it to decide whether to point at the manifest, PORTABILITY §3.1).
@@ -80,11 +76,9 @@ function buildDeclaration(m, root = null) {
80
76
  const excludePaths = normalizePathList(m?.index?.excludePaths)
81
77
  const publicRepos = normalizePathList(m?.index?.publicRepos)
82
78
  const batchRecords = batchSegments(m?.docRoot?.batches) // §6.14 面⑥ 声明值投影(`declared` 旗不涉——docRoot 族现状零改)
83
- const docMap = normalizeString(m?.advisor?.docMap)
84
- const standardsDoc = normalizeString(m?.advisor?.standardsDoc)
85
79
  const defaults = normalizeCodePaths(DEFAULT_MANIFEST.codePaths) ?? []
86
80
  const pathsDiffer = codePaths.length !== defaults.length || codePaths.some((p, i) => p !== defaults[i])
87
- const declared = pathsDiffer || codeExtensions.length > 0 || docExtensions.length > 0 || excludePaths.length > 0 || publicRepos.length > 0 || Boolean(docMap) || Boolean(standardsDoc)
81
+ const declared = pathsDiffer || codeExtensions.length > 0 || docExtensions.length > 0 || excludePaths.length > 0 || publicRepos.length > 0
88
82
  return Object.freeze({
89
83
  declared,
90
84
  codePaths: Object.freeze(codePaths),
@@ -95,7 +89,6 @@ function buildDeclaration(m, root = null) {
95
89
  excludePaths: Object.freeze(excludePaths),
96
90
  publicRepos: Object.freeze(publicRepos),
97
91
  }),
98
- advisor: Object.freeze({ docMap, standardsDoc }),
99
92
  root,
100
93
  })
101
94
  }
@@ -317,7 +310,7 @@ onManifestInit(clearDeclarationCache)
317
310
 
318
311
  /** Retired-carrier warning (existence check only — zero content parsing, zero fallback). */
319
312
  function warnRetiredCarrier(root) {
320
- console.warn(`[declaration] ${RETIRED_REL_PATH} is retired and no longer read — move codePaths / index.*Extensions / advisor.{docMap,standardsDoc} into PROJECT-MANIFEST.json`)
313
+ console.warn(`[declaration] ${RETIRED_REL_PATH} is retired and no longer read — move codePaths / index.*Extensions into PROJECT-MANIFEST.json`)
321
314
  logEvent("declaration:retired-file", { cwd: root })
322
315
  }
323
316
 
@@ -334,7 +327,7 @@ function warnRetiredCarrier(root) {
334
327
  * @param {string} cwd — project dir / anchor (root resolved by manifest.mjs)
335
328
  * @returns {Readonly<{declared: boolean, codePaths: readonly string[], batchRecords: readonly (readonly string[])[],
336
329
  * index: {codeExtensions: string[], docExtensions: string[], excludePaths: string[], publicRepos: string[]},
337
- * advisor: {docMap: string, standardsDoc: string}, root: string|null}>}
330
+ * root: string|null}>}
338
331
  */
339
332
  export function loadProjectDeclaration(cwd) {
340
333
  const file = manifestFilePath(cwd) // data-file path (single source — manifest.mjs KD-M1-18)
@@ -6,12 +6,14 @@
6
6
  * **行号坐标(`:N`)不作节指示**——行号不作引用;
7
7
  * · 档不在盘 ⇒ `档缺`;带节指示 ∧ 节不在盘 ⇒ `节缺`。
8
8
  *
9
- * 抽取面:逐行扫;fenced 块整块跳过;可执行行(`isExecutableLine`)整行跳过(命令保字面)。
10
- * 解析序:① 仓根相对 → ② 域根相对(`scanDirs` 逐根)→ ③ 引用方档所在目录相对 →
11
- * ④ 域内唯一 basename 索引;同名 ≥2 ⇒ **不判**(列报告面)。
12
- * 节在盘:目标档内存在标题行,其标题编号(标题文本去 `^#{1,6}\s` 后的首个 `\d+(?:\.\d+)*`)= 指针节号。
13
- * 残余登记(fail-open 方向——归评审核验面):裸档名在域内多义 ⇒ 不判;未闭合围栏 ⇒ 至档尾免;
14
- * 指针行的坐标尾不入判、同行的档 token 照判。
9
+ * 抽取面:逐行扫;fenced 块整块跳过;可执行行(`isExecutableLine`)整行跳过(命令保字面);
10
+ * 提示词面另剥 CANON 操作数(面判据 = 本档 `OPERAND_FACE`——六名整串删除后再抽,见 `CANON_OPERANDS`)。
11
+ * 解析序**两级**:① 仓根相对全路径 → ② 引用方档所在目录相对。零命中 ⇒ `档缺`。
12
+ * **裸名不跨目录解析**:跨目录解析须先建全域目录索引——为认出一个裸名而重建索引,等于把全仓扫描请回来;
13
+ * 写全路径后解析零索引,逐档判就是全部(与 D4 实现坐标口径一致)。
14
+ * 节在盘:**只读解析命中的那一档**(点查,不遍历)——目标档内存在标题行,其标题编号
15
+ * (标题文本去 `^#{1,6}\s` 后的首个 `\d+(?:\.\d+)*`)= 指针节号。
16
+ * 残余登记(fail-open 方向——归评审核验面):未闭合围栏 ⇒ 至档尾免;指针行的坐标尾不入判、同行的档 token 照判。
15
17
  */
16
18
  import { readFileSync, statSync } from "node:fs"
17
19
  import { basename, dirname, resolve } from "node:path"
@@ -19,7 +21,44 @@ import { fenceMark, isExecutableLine } from "./width.mjs"
19
21
 
20
22
  /** 文档指针 token:`(目录段/)*名.md` + 可选行号坐标尾 + 可选节指示(`§` + 编号)。
21
23
  * 左界守卫排除更长 token 的尾段;右界排除后续字母数字(`.md` 之后紧邻 `[\w]` ⇒ 非完整 token)。 */
22
- const ANCHOR_RE = /(?<![A-Za-z0-9_.\-\\/@])((?:[A-Za-z0-9_.\-]+\/)*[A-Za-z0-9_.\-]+\.md)(?::\d+(?:-\d+)?)?(?:\s*§\s*(\d+(?:\.\d+)*))?(?![\w])/g
24
+ const ANCHOR_RE = /(?<![A-Za-z0-9_.\-\\/@<>])((?:[A-Za-z0-9_.\-]+\/)*[A-Za-z0-9_.\-]+\.md)(?::\d+(?:-\d+)?)?(?:\s*§\s*(\d+(?:\.\d+)*))?(?![\w])/g
25
+
26
+ /** 约定文件名(**类名**——文档在描述「会读哪些文件」、黑名单名字、`files` 字段值、测试数据):不抽。
27
+ * 依据 = 这些名字在仓根 / 兄弟目录不一定存在,它们指的不是「某个仓内档」。 */
28
+ const FILENAME_CLASSES = new Set([
29
+ "agents.md", "claude.md", "skill.md", "project_rules.md",
30
+ "changelog.md", "agent.md", "advisor.md", "todo.md", "todo-archive.md",
31
+ ])
32
+
33
+ /** 占位符词干(示例写法 / 反例样本——`X.md` / `file.md` / `docs/batches/x.md` 类):不抽。 */
34
+ const PLACEHOLDER_STEMS = new Set([
35
+ "x", "a", "b", "file", "out", "foo", "bar", "example", "sample", "name", "path",
36
+ ])
37
+
38
+ /** CANON 操作数族(**规范豁免串** · 判据单源 = `docs/core/design/PROMPT-SYSTEM.md` §6.6「判保留面」):
39
+ * 六个名字在模型面是**模型要写出来 / 读写的文件名**(行为操作数 · 路径操作数),不是「去读这个档」的指针——
40
+ * 换个目录解析必然 `档缺`。**只对提示词面剥**(面判据 = 下方 `OPERAND_FACE`):`docs/` 面不会因本条多放行一个名——
41
+ * `README.md` / `MANIFEST.md` 在那里仍按指针判(其余四名早已走 `FILENAME_CLASSES` 类名免,非本条引入)。
42
+ * **一处定义、两处消费**:`scripts/prompt-refs-check.mjs` 的 J3 豁免从本档 import 本表与剥除函数(不复制);
43
+ * 反方向零 import——判据链自持 = `docs/core/design/DOC-DISCIPLINE.md` A-DD29 ⑤(`thincoder-core/**` 对
44
+ * `scripts/**` 的 import 点 = 0)。闭集外的同形 token(非操作数)照报。 */
45
+ export const CANON_OPERANDS = [
46
+ "AGENTS.md", "SKILL.md", "README.md", "MANIFEST.md",
47
+ ".thincoder/advisor.md", "project_rules.md",
48
+ ]
49
+
50
+ /** 操作数剥除:逐行先整串删除六名 CANON 操作数再抽(与 `scripts/prompt-refs-check.mjs` 的 J3 豁免同式)。 */
51
+ export function stripCanonOperands(line) {
52
+ let out = line
53
+ for (const s of CANON_OPERANDS) out = out.split(s).join("")
54
+ return out
55
+ }
56
+
57
+ /** 操作数豁免的**作用面** = 提示词面(`thincoder-core/prompts/**`)——**只此一面**:`docs/` 面同名 token 仍按指针判
58
+ * (`README.md` / `MANIFEST.md` 在那里照报 `档缺`;其余四名走既有的 `FILENAME_CLASSES` 类名免,非本条引入)。
59
+ * 这是锚判自己的面判据——与行宽判据零共享开关:行宽按传入档全量判(提示词面照判,无面级豁免)。 */
60
+ const OPERAND_FACE = /(?:^|\/)thincoder-core\/prompts\//i
61
+ const isOperandFace = (abs) => OPERAND_FACE.test(String(abs).replace(/\\/g, "/"))
23
62
 
24
63
  /** 文件存在性(目录 / 读错 ⇒ false)。 */
25
64
  function isFile(p) { try { return statSync(p).isFile() } catch { return false } }
@@ -40,41 +79,32 @@ function sectionOnDisk(path, num) {
40
79
  return false
41
80
  }
42
81
 
43
- /** 档解析(判据 ① 的解析序 ①–④):命中 ⇒ `{ ok, path }`;同名 ≥2 ⇒ `{ ambiguous, candidates }`;零命中 ⇒ `{ ok:false }`。
44
- * 行号坐标尾(`:N`)不参与档解析——token 只取 `.md` 段(抽取面已剥离坐标尾与节指示)。 */
45
- function resolveDoc(token, refFile, base, scanDirs, index) {
82
+ /** 档解析(判据 ① 的解析序两级):命中 ⇒ `{ ok, path }`;零命中 ⇒ `{ ok:false }`。
83
+ * 行号坐标尾(`:N`)不参与档解析——token 只取 `.md` 段(抽取面已剥离坐标尾与节指示)。
84
+ * `num` = 指针节号(`§` 后编号;无节指示 ⇒ null)——仅用于判定节在盘,不参与档解析。 */
85
+ function resolveDoc(token, refFile, base) {
46
86
  const p1 = resolve(base, token)
47
- if (isFile(p1)) return { ok: true, path: p1 } // ① 仓根相对
48
- for (const d of scanDirs) { const p = resolve(base, d, token); if (isFile(p)) return { ok: true, path: p } } // ② 域根相对
49
- const p3 = resolve(dirname(refFile), token)
50
- if (isFile(p3)) return { ok: true, path: p3 } // ③ 引用方档所在目录相对
51
- const cands = index.get(basename(token)) ?? [] // ④ 域内唯一 basename 索引
52
- if (cands.length === 1) return { ok: true, path: cands[0] }
53
- if (cands.length > 1) return { ambiguous: true, candidates: cands }
87
+ if (isFile(p1)) return { ok: true, path: p1 } // ① 仓根相对全路径
88
+ const p2 = resolve(dirname(refFile), token)
89
+ if (isFile(p2)) return { ok: true, path: p2 } // ② 引用方档所在目录相对
54
90
  return { ok: false }
55
91
  }
56
92
 
57
93
  /**
58
- * 锚判据主行程:域内逐档逐行抽取 + 解析 + 节在盘判定。
59
- * @param {string} root 项目根(判据参数已由调用方自 `checkConfig` 读出)
60
- * @param {{scanDirs?:string[], files?:string[]}} o `files` = 域内 `.md` 全集(单源 = `scanDomain`)
61
- * @returns {{hits:Array, reports:Array}} hits = 命中(`档缺` / `节缺`);reports = 报告面(多义——不作判)
94
+ * 锚判据主行程:传入档逐档逐行抽取 + 解析 + 节在盘判定。
95
+ * @param {string} root 项目根(解析序 ① 的基准)
96
+ * @param {{files?:string[]}} o `files` = **判定集兼解析集**(调用方传入的档清单——判定范围 = 解析范围,
97
+ * 无目录遍历、无全域索引:清单外的档既不判也不参与解析)
98
+ * @returns {{hits:Array}} hits = 命中(`档缺` / `节缺`)
62
99
  */
63
- export function checkAnchors(root, { scanDirs = [], files = [] } = {}) {
100
+ export function checkAnchors(root, { files = [] } = {}) {
64
101
  const base = resolve(root)
65
- const dirs = Array.isArray(scanDirs) ? scanDirs : []
66
- const index = new Map()
67
- for (const f of files) {
68
- const k = basename(f)
69
- if (!index.has(k)) index.set(k, [])
70
- index.get(k).push(f)
71
- }
72
102
  const hits = []
73
- const reports = []
74
103
  for (const f of files) {
75
104
  let text = ""
76
105
  try { text = readFileSync(f, "utf8") } catch { continue }
77
106
  const re = new RegExp(ANCHOR_RE.source, "g") // 每档一份实例(零共享 lastIndex 状态)
107
+ const stripOperands = isOperandFace(f) // 只有提示词面剥(见 `OPERAND_FACE`)
78
108
  let fence = null // 围栏标记(` ∥ ~);null = 不在块内
79
109
  text.split("\n").forEach((l, i) => {
80
110
  const mark = fenceMark(l)
@@ -85,16 +115,20 @@ export function checkAnchors(root, { scanDirs = [], files = [] } = {}) {
85
115
  }
86
116
  if (fence !== null) return
87
117
  if (isExecutableLine(l)) return // 可执行行整行跳过
118
+ const scan = stripOperands ? stripCanonOperands(l) : l // 提示词面:操作数先剥再抽;文档面 = 原行
88
119
  let m
89
- while ((m = re.exec(l)) !== null) {
120
+ while ((m = re.exec(scan)) !== null) {
90
121
  const token = m[1]
91
122
  const num = m[2] ?? null
92
- const r = resolveDoc(token, f, base, dirs, index)
93
- if (r.ambiguous) { reports.push({ file: f, line: i + 1, token, candidates: r.candidates }); continue }
123
+ const bn = basename(token).toLowerCase()
124
+ const stem = bn.replace(/\.md$/, "")
125
+ // 类名 ∥ 占位符两免:抽取面不把它们当指针(判据 = DOC-DISCIPLINE §3.20 ① 的「档 token」定义面)
126
+ if (FILENAME_CLASSES.has(bn) || stem.length <= 1 || PLACEHOLDER_STEMS.has(stem)) continue
127
+ const r = resolveDoc(token, f, base)
94
128
  if (!r.ok) { hits.push({ file: f, line: i + 1, token, clazz: "档缺" }); continue }
95
129
  if (num !== null && !sectionOnDisk(r.path, num)) hits.push({ file: f, line: i + 1, token, clazz: "节缺", section: num, target: r.path })
96
130
  }
97
131
  })
98
132
  }
99
- return { hits, reports }
133
+ return { hits }
100
134
  }