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/acp.mjs CHANGED
@@ -19,7 +19,7 @@ import { assembleAgent } from "./cli/make-agent.mjs"
19
19
  import { createAcpServer, ACP_ERRORS } from "./acp/transport.mjs"
20
20
  import { createAcpSession } from "./acp/session.mjs"
21
21
  import { replayHistory } from "./acp/bridge.mjs"
22
- import { listSlots, applySession, deleteSlot, normalizeCwd, loadSlotFile, slotOccupancy, loadManifest, saveManifest, getSessionId, newSession } from "./session.mjs"
22
+ import { listSlots, applySession, deleteSlot, normalizeCwd, loadSlotFile, slotOccupancy, loadManifest, saveManifest, getSessionId, newSession, slotPath, bindRecordStore } from "./session.mjs"
23
23
  import { createCheckpoint, listCheckpoints, rewind, isGitRepo } from "./git/checkpoint.mjs"
24
24
  import { createMemory, list as memList, remove as memRemove } from "./memory.mjs"
25
25
 
@@ -71,12 +71,17 @@ export function defaultIsConfigured() {
71
71
  }
72
72
  }
73
73
 
74
+ /** 第 27 批 §12.3⑤(R-A1.1):ACP 会话装配期剔除的工具。ACP 是 headless 通道——
75
+ * question 工具需要交互 UI(`src/tools/question.mjs` 无 `ctx.onQuestion` 即 throw),
76
+ * 模型可见即会调用、调用必错 → 装配期剔除(schema 直接少一条)。 */
77
+ export const ACP_EXCLUDED_TOOLS = ["question"]
78
+
74
79
  /** M1/M2 session factory: one agent per session, built from the process cwd (single-cwd model).
75
80
  * `id` is the ACP session id — it is baked into the callbacks at construction time
76
81
  * (buildAcpCallbacks closure), so it must be known BEFORE createAcpSession runs.
77
82
  * `request` is the transport's reverse-RPC channel (permissions + fs routing). */
78
83
  export async function defaultCreateSession({ id, notify, request, log }) {
79
- const agent = await assembleAgent()
84
+ const agent = await assembleAgent({ excludeTools: ACP_EXCLUDED_TOOLS })
80
85
  return createAcpSession({ id, agent, notify, request, log })
81
86
  }
82
87
 
@@ -152,6 +157,9 @@ export function buildAcpHandlers({
152
157
  // mySessionId 同进程恒真)→ 第二个会话拿到与第一个相同的槽号 → 双写同槽
153
158
  // F2 互旋。getSessionId() 是进程级,_slot 是 agent 级——粒度错配必须在此切断。
154
159
  session.agent._slot = newSession(getCwd())
160
+ // TUI-OOM-ROOTCAUSE(SESSION.md §14.3.4):新建槽绑定记录存储(baseHistory 空——
161
+ // identity 待固化)——与 /new 同语义
162
+ bindRecordStore(session.agent, { slotFile: slotPath(getCwd(), session.agent._slot), identity: session.agent._sessionStart ?? null, baseHistory: [] })
155
163
  sessions.set(id, session)
156
164
  return { id, configOptions: [{ configId: "model" }, { configId: "thinking" }, { configId: "mode" }] }
157
165
  } catch (e) {
@@ -226,7 +234,6 @@ export function buildAcpHandlers({
226
234
  try {
227
235
  const id = String(nextId++)
228
236
  const session = await createSession({ id, notify: notifyRef.current, request: requestRef.current, log })
229
- applySession(session.agent, data)
230
237
  // 2026-08-31 advisor round2 🟡:钉 _slot 前查活主——目标槽被另一活进程(CLI/另一
231
238
  // IDE)占用时不得钉回(双方 sessionStart 一致 → F2 永不轮转 → 同槽 last-write-wins
232
239
  // 静默互覆盖)。空闲则认领后钉回;占用则不钉 → 下次保存经 activeSlot 自然 fork
@@ -236,7 +243,11 @@ export function buildAcpHandlers({
236
243
  // 同进程防护完全由 sameProcessPinned 承担:本进程另一 session 已钉该槽即视为占用
237
244
  // (进程级属主无法区分 agent,双方 sessionStart 相同 → F2 永不触发 → 静默互覆盖)。
238
245
  const sameProcessPinned = [...sessions.values()].some((s) => s.agent?._slot === slot)
239
- if (!occ.occupied && !sameProcessPinned) {
246
+ const pinned = !occ.occupied && !sameProcessPinned
247
+ // TUI-OOM-ROOTCAUSE(SESSION.md §14.3.4):钉槽(非占用)才绑定记录存储——
248
+ // 占用/fork 分支模式 F(首保存 fork 新槽后补绑)
249
+ applySession(session.agent, data, pinned ? { slot } : {})
250
+ if (pinned) {
240
251
  const m = loadManifest(getCwd())
241
252
  m.slotSessions ??= {}
242
253
  m.slotSessions[slot] = getSessionId()
@@ -273,13 +284,15 @@ export function buildAcpHandlers({
273
284
  try {
274
285
  const id = String(nextId++)
275
286
  const session = await createSession({ id, notify: notifyRef.current, request: requestRef.current, log })
276
- applySession(session.agent, data)
277
287
  // 2026-08-31 advisor round2 🟡:同 session/load——活主占用的槽不钉回(防同槽双写,
278
288
  // 下次保存 fork 新槽);空闲则认领后钉回。
279
289
  const occ = slotOccupancy(getCwd(), slot)
280
290
  // 2026-09-01 会诊 kimi/glm 🔴:同 session/load——同进程其他 session 已钉同槽视为占用 → fork
281
291
  const sameProcessPinned = [...sessions.values()].some((s) => s.agent?._slot === slot)
282
- if (!occ.occupied && !sameProcessPinned) {
292
+ const pinned = !occ.occupied && !sameProcessPinned
293
+ // TUI-OOM-ROOTCAUSE(SESSION.md §14.3.4):同 load——钉槽才绑定(占用/fork 模式 F)
294
+ applySession(session.agent, data, pinned ? { slot } : {})
295
+ if (pinned) {
283
296
  const m = loadManifest(getCwd())
284
297
  m.slotSessions ??= {}
285
298
  m.slotSessions[slot] = getSessionId()
@@ -4,9 +4,14 @@
4
4
  * a host fact: every `file:line: content` reference in a review is mechanically
5
5
  * checked against the CURRENT disk state; mismatches mark the finding
6
6
  * unverified and cannot support a push-back.
7
+ *
8
+ * 第 11 批(C / F14 / §14.5):解析候选 = cwd + **评审对象声明范围派生根**(声明仓根 /
9
+ * 声明文件目录 / 声明目录——纯路径派生,零扫描、零 git);命中判据三条件全中(围栏内 ∧
10
+ * 可读 ∧ 目标行含引文内容)⇒ 零新增假命中。失败原因三分(file unreadable /
11
+ * content mismatch @ path / path traversal)——父侧不再人肉复核。
7
12
  */
8
13
  import { readFileSync, realpathSync } from "node:fs"
9
- import { resolve, sep } from "node:path"
14
+ import { resolve, relative, dirname, extname, sep, isAbsolute, join } from "node:path"
10
15
 
11
16
  // `file:line: content` citations — the file group is narrowed to source/config
12
17
  // extensions so URLs (`example.com:8080: …`) don't become false-positive
@@ -25,48 +30,105 @@ export function extractCitations(text) {
25
30
  return out
26
31
  }
27
32
 
33
+ /** 候选解析根(§14.5——纯路径派生,候选顺序 = cwd → 各声明路径的派生根):
34
+ * 声明仓根(`cwd/<segs[0]>`)与 声明文件目录 / 声明目录本身。声明在 cwd 之外 → 不派生
35
+ * (relative 越出 cwd 或跨盘符——候选与声明脱节即假命中面)。 */
36
+ function citationRoots(cwd, scope) {
37
+ const base = resolve(cwd)
38
+ const roots = [base]
39
+ for (const s of Array.isArray(scope) ? scope : []) {
40
+ if (typeof s !== "string" || !s.trim()) continue
41
+ const abs = resolve(base, s)
42
+ const rel = relative(base, abs)
43
+ if (!rel || rel.startsWith("..") || isAbsolute(rel)) continue
44
+ const segs = rel.split(/[\\/]/).filter(Boolean)
45
+ if (segs.length === 0) continue
46
+ const repoRoot = join(base, segs[0])
47
+ const declaredDir = extname(abs) ? dirname(abs) : abs
48
+ for (const r of [repoRoot, declaredDir]) if (!roots.includes(r)) roots.push(r)
49
+ }
50
+ return roots
51
+ }
52
+
53
+ /** 单引文解析(§14.5):按候选顺序试 `resolve(root, file)`;三条件全中才算命中。
54
+ * @returns {{matched: true, root: string, resolved: string}|{matched: false, reason: string}} */
55
+ function resolveCitation(citation, roots, base) {
56
+ const fence = base + sep
57
+ let mismatch = null
58
+ let traversal = false
59
+ for (const root of roots) {
60
+ let real
61
+ try {
62
+ // Path confinement: citation paths are LLM-generated — never trust them.
63
+ // A hallucinated "../config.json" would otherwise read (and leak via the
64
+ // report) files outside the project, including API-key configs.
65
+ // realpathSync resolves symlinks too — a link inside the project that
66
+ // points outside must not pass the prefix check.
67
+ real = realpathSync(resolve(root, citation.file))
68
+ } catch {
69
+ continue // 该候选无此文件(或不可解析)——试下一候选
70
+ }
71
+ if (!real.startsWith(fence)) { traversal = true; continue }
72
+ let line = ""
73
+ try {
74
+ line = readFileSync(real, "utf8").split("\n")[citation.line - 1] ?? ""
75
+ } catch {
76
+ continue // 存在但读不了(目录等)——按未命中处理,试下一候选
77
+ }
78
+ if (line.includes(citation.content)) {
79
+ return { matched: true, root, resolved: relative(base, real).split(sep).join("/") }
80
+ }
81
+ mismatch ??= real
82
+ }
83
+ if (mismatch) return { matched: false, reason: `content mismatch @ ${relative(base, mismatch).split(sep).join("/")}` }
84
+ if (traversal) return { matched: false, reason: "path traversal" }
85
+ return { matched: false, reason: "file unreadable" }
86
+ }
87
+
28
88
  /**
29
89
  * Mechanically verify citations against the CURRENT file state: read the file,
30
90
  * take the exact line, check it CONTAINS the quoted content. Reports
31
91
  * N/M matched + the mismatches. Unverified citations cannot support a
32
92
  * push-back — the evidence rule becomes a host fact, not a prompt wish.
93
+ * @param {string} text — the review text
94
+ * @param {string} cwd — the agent's working directory (workspace root)
95
+ * @param {{scope?: string[]}} [opts] — scope = 评审对象声明路径(documents + paths);
96
+ * 省略 ⇒ 旧行为(仅 cwd 候选——签名向后兼容)。
33
97
  */
34
- export function verifyCitations(text, cwd) {
98
+ export function verifyCitations(text, cwd, opts = {}) {
35
99
  const citations = extractCitations(text)
36
100
  const matched = []
37
101
  const failed = []
38
- const root = resolve(cwd) + sep
102
+ const base = resolve(cwd)
103
+ const roots = citationRoots(cwd, opts?.scope)
39
104
  for (const c of citations) {
40
- try {
41
- // Path confinement: citation paths are LLM-generated — never trust them.
42
- // A hallucinated "../config.json" would otherwise read (and leak via the
43
- // report) files outside the project, including API-key configs.
44
- // realpathSync resolves symlinks too — a link inside the project that
45
- // points outside must not pass the prefix check.
46
- const resolved = realpathSync(resolve(cwd, c.file))
47
- if (!resolved.startsWith(root)) {
48
- failed.push({ ...c, reason: "path traversal" })
49
- continue
50
- }
51
- const line = readFileSync(resolved, "utf8").split("\n")[c.line - 1] ?? ""
52
- if (line.includes(c.content)) matched.push(c)
53
- else failed.push(c)
54
- } catch {
55
- failed.push({ ...c, reason: "file unreadable" })
105
+ const r = resolveCitation(c, roots, base)
106
+ if (r.matched) {
107
+ // 命中根记录(§14.5):经派生根解析(非 cwd 直解)的命中在报告中注明解析路径。
108
+ matched.push(r.root === base ? c : { ...c, root: r.root, resolved: r.resolved })
109
+ } else {
110
+ failed.push({ ...c, reason: r.reason })
56
111
  }
57
112
  }
58
113
  return { total: citations.length, matched, failed }
59
114
  }
60
115
 
61
116
  /** Append the verification report to the review text (visible to the parent agent). */
62
- export function appendCitationReport(text, cwd) {
63
- const { total, matched, failed } = verifyCitations(text, cwd)
117
+ export function appendCitationReport(text, cwd, opts = {}) {
118
+ const { total, matched, failed } = verifyCitations(text, cwd, opts)
64
119
  if (total === 0) return text // no citations — nothing to verify
65
120
  const lines = [
66
121
  "",
67
122
  "---",
68
123
  `[host-verified] ${matched.length}/${total} citations match current file state.`,
69
124
  ]
125
+ // 命中根透明(F14/§14.5):经声明范围派生根解析的命中逐条注明解析路径(cwd 直解的不列
126
+ // ——零噪音;列的正是修复前会被误报为 unreadable 的裸路径引用)。
127
+ const derived = matched.filter((c) => c.resolved)
128
+ if (derived.length > 0) {
129
+ lines.push("Citations resolved via the declared review scope (bare path — resolved root noted):")
130
+ for (const c of derived.slice(0, 10)) lines.push(`- ${c.file}:${c.line} → ${c.resolved}`)
131
+ }
70
132
  if (failed.length > 0) {
71
133
  lines.push("Citations that do NOT match the current file state (treat their claims as unverified):")
72
134
  for (const f of failed.slice(0, 10)) {
@@ -0,0 +1,174 @@
1
+ /**
2
+ * advisor/compaction.mjs — advisor review support (split out of advisor/run.mjs,
3
+ * 第 11 批 — run.mjs was 498/500 硬帽): context trimming + the review's resource
4
+ * limits + the terminal-state guards + the review-text assembler.
5
+ *
6
+ * estimateTokens / compactMessages moved VERBATIM (the only edit is the `pinned`
7
+ * re-attach — F13/§14.4 #3); renderTimeline moved verbatim too, so the tail
8
+ * GENERATOR and the tail CLASSIFIER stay in one file with the assembler they
9
+ * feed (§14.3 谓词 ↔ §14.6 结构化尾——同族单源)。拆分线 = 行数硬帽实测(见批次档 §5)。
10
+ */
11
+
12
+ import { providerSpec } from "../config.mjs" // 第 25 批:预算派生(与 loop.mjs:11 同源导入)
13
+ // B4(群 B 批,CLI §18.3——F32):CJK 加权单源(provider/rate.mjs 叶子向无环)
14
+ import { estimateText } from "../provider/rate.mjs"
15
+
16
+ export const MAX_ADVISOR_TURNS = 100
17
+ // NOTE: prompts/advisor-round{1,2,3}.md encourage the model to finish within
18
+ // ~30 tool turns — a prompt-level efficiency target, DISTINCT from the
19
+ // 100-turn mechanical hard cap (MAX_ADVISOR_TURNS above; pure runaway-loop
20
+ // guard). They serve different purposes; do NOT synchronize them.
21
+
22
+ // Context window limits
23
+ // 上下文预算(第 25 批——120K 硬编码退场):预算跟随评审模型窗口(providerSpec:
24
+ // 模型规格表 × provider 级 context 覆盖)。头寸用途 = chars/4 估算误差 + 响应/协议开销
25
+ // (内存不构成约束——设计 §16.4);判死线仍是宿主机自限线,服务端窗口约束不变。
26
+ export const CONTEXT_LIMIT_RATIO = 0.8 // 判死线 = 窗口 × 0.8
27
+ const COMPACT_TRIGGER_RATIO = 0.8 // 压缩触发 = 判死线 × 0.8(既有关系零改)
28
+
29
+ /** 评审上下文预算(纯函数——两档阈值可机测;provider 为 null 时退化默认规格)。 */
30
+ export function advisorContextBudget(provider) {
31
+ const limit = Math.floor(providerSpec(provider).context * CONTEXT_LIMIT_RATIO)
32
+ return { limit, compactAt: Math.floor(limit * COMPACT_TRIGGER_RATIO) }
33
+ }
34
+
35
+ export const TOOL_TIMEOUT_MS = 30_000 // single tool timeout
36
+ export const REVIEW_TIMEOUT_MS = 600_000 // whole review timeout (10 minutes)
37
+ export const MAX_RESULT_CHARS = 64 * 1024 // tool result truncation (line-aware; 64K, aligned with main offload limit)
38
+ const MAX_KEY_FILES_IN_COMPACTION = 5 // files named in the compaction summary
39
+
40
+ /** Estimate token count from messages(B4——群 B 批 CLI §18.3:扁平 chars/4 改 `estimateText`
41
+ * 加权式——ASCII/4 + 非 ASCII/1;纯 ASCII 与旧式逐值相等;CJK 低估 ~3-4× 修正;
42
+ * walker(content / tool_calls 两源)与计数口径零改)。 */
43
+ export function estimateTokens(messages) {
44
+ return messages.reduce((sum, msg) => {
45
+ const content = typeof msg.content === "string" ? msg.content : JSON.stringify(msg.content || "")
46
+ const toolCalls = msg.tool_calls ? JSON.stringify(msg.tool_calls) : ""
47
+ return sum + estimateText(content + toolCalls)
48
+ }, 0)
49
+ }
50
+
51
+ /** Compact early messages when context grows too large — LOCAL trimming only
52
+ * (no LLM summarization). MUTATES in place (splice) so the caller's array
53
+ * reference stays valid — a reassignment would leave the caller's logging
54
+ * (tool-call count, token estimate) reading a stale array. */
55
+ export function compactMessages(messages, pinned = null) {
56
+ // Keep: system prompt, last 20 messages (≈ 10 assistant+tool exchanges),
57
+ // user message — the rest is summarized.
58
+ if (messages.length <= 20) return
59
+
60
+ const system = messages[0]
61
+ const recent = messages.slice(-20)
62
+ const old = messages.slice(1, -20)
63
+
64
+ // Count actual tool messages (old.length counts user/assistant rows too)
65
+ const toolCount = old.filter((m) => m.role === "tool").length
66
+ const keyFiles = old
67
+ .filter((m) => m.role === "tool")
68
+ .map((m) => m.content?.split("\n")[0]?.slice(0, 50)) // first line of tool results typically names the file that was read/grepped
69
+ .filter(Boolean)
70
+ .slice(0, MAX_KEY_FILES_IN_COMPACTION)
71
+ const filesPart = keyFiles.length > 0 ? ` Key files examined: ${keyFiles.join(", ")}` : ""
72
+ const summary = `Earlier exploration: ${toolCount} tool calls completed.${filesPart}`
73
+
74
+ // F13(第 11 批 §14.4 #3):压缩丢掉的正是**首条 user 消息**(评审简报,含 token)——
75
+ // pinned 由评审参数构建(非模型输出),在本次压缩动作内作为一条 user 消息重挂(幂等可读:
76
+ // 重复压缩允许重复挂回,不做存在性判定)。
77
+ const pin = pinned ? [{ role: "user", content: pinned }] : []
78
+ messages.splice(0, messages.length,
79
+ system,
80
+ { role: "user", content: `[Context compacted] ${summary}` },
81
+ ...pin,
82
+ ...recent)
83
+ }
84
+
85
+ // ─────────────────────────────────────────────────────────────────────────────
86
+ // 不完整判定族(A / F16 共用单谓词——§14.3;六 kind = 宿主尾族)
87
+ // ─────────────────────────────────────────────────────────────────────────────
88
+
89
+ /** 宿主尾族六 kind 的块首行逐字前缀(§14.3 表)。变量段(token 数 / 秒数 / 工具轮数)
90
+ * 不入前缀——取各尾的固定字面部分;`review_failed` = run.mjs catch 的字符串 resolve
91
+ * 形态(不 throw),其余五条 = renderTimeline 尾(loop.mjs)。 */
92
+ const ADVISOR_INCOMPLETE_PREFIXES = [
93
+ ["context_limit", "Advisor: context window limit"],
94
+ ["turn_cap", "Advisor: stopped after"],
95
+ ["timeout", "Advisor: review timeout"],
96
+ ["empty", "Advisor: empty response"],
97
+ ["interrupted", "Advisor: interrupted."],
98
+ ["review_failed", "Advisor: review failed"],
99
+ ]
100
+
101
+ /** 单谓词(三消费点同源:design 结算 / code 完成守卫 / 报告提示)——**块首行扫描**(按空行
102
+ * 分块,逐块取首行 trim 后测前缀;时间线与尾以空行相接,六条尾均以块首行形态落地)。
103
+ * 负向精度(§14.3 修正轮):引文中同串的**非块首形态**(围栏内行 / 表格行 / 引用行)不判
104
+ * incomplete;块首裸行引用同串的残余误报方向安全(fail-closed——多付一轮重跑,如实登记)。
105
+ * @returns {string|null} kind 或 null */
106
+ export function advisorIncompleteMarker(text) {
107
+ for (const block of String(text ?? "").split(/\n\s*\n/)) {
108
+ const first = block.split("\n").find((l) => l.trim() !== "")
109
+ if (!first) continue
110
+ const line = first.trim()
111
+ for (const [kind, prefix] of ADVISOR_INCOMPLETE_PREFIXES) {
112
+ if (line.startsWith(prefix)) return kind
113
+ }
114
+ }
115
+ return null
116
+ }
117
+
118
+ // ─────────────────────────────────────────────────────────────────────────────
119
+ // 预算提示 + 结构化超时尾(D / F15——§14.6 #2/#3)
120
+ // ─────────────────────────────────────────────────────────────────────────────
121
+
122
+ /** 0.75 一次性预算提示判定(纯函数——阈值两侧可机测;每场评审至多一次)。 */
123
+ export function shouldBudgetNudge(elapsedMs, budgetMs, nudged) {
124
+ return !nudged && Number.isFinite(budgetMs) && budgetMs > 0 && elapsedMs >= budgetMs * 0.75
125
+ }
126
+
127
+ /** 预算提示文案(逐字——§14.6 #2;由循环注入一条 user 消息)。 */
128
+ export function budgetNudgeText(elapsedMs, budgetMs) {
129
+ const secs = (ms) => Math.round(ms / 100) / 10
130
+ const pct = Math.round((elapsedMs / budgetMs) * 100)
131
+ return `⏳ review budget: ~${pct}% consumed (${secs(elapsedMs)}s of ${secs(budgetMs)}s). Converge now: emit your findings table for the evidence you have verified, mark anything you could not verify explicitly as \`unverified\` (unverified evidence must not support a pass), and emit your verdict line.`
132
+ }
133
+
134
+ /** 结构化超时尾(§14.6 #3):族前缀 `Advisor: review timeout after {S}s.` 逐字保持
135
+ * (判定族字面依赖);其后 = 机读统计(rounds / tool calls / review text produced)
136
+ * + 可执行恢复指引(narrower scope / 调预算)。 */
137
+ export function timeoutTail(timeoutMs, rounds, toolCalls, producedText) {
138
+ const s = Math.round(timeoutMs / 1000)
139
+ return [
140
+ `Advisor: review timeout after ${s}s. Review incomplete — the wall-clock budget was exhausted; partial findings (if any) are above.`,
141
+ `- rounds: ${rounds} · tool calls: ${toolCalls} · review text produced: ${producedText ? "yes" : "no"}`,
142
+ `- budget: ${s}s (agent.advisor.timeoutMs) — re-run with a narrower scope (split the review across fewer documents) or raise the budget.`,
143
+ ].join("\n")
144
+ }
145
+
146
+ // ─────────────────────────────────────────────────────────────────────────────
147
+ // 评审文本装配(loop 的尾经此与时间线合流——与尾族同文件:生成 / 判定 / 装配单源)
148
+ // ─────────────────────────────────────────────────────────────────────────────
149
+
150
+ // The live "[thinking…]" wait indicator shares its exact text with the TUI
151
+ // cleanup regex (agent-turn.mjs strips it before flushing to history) — keep
152
+ // them in lockstep.
153
+ export const ADVISOR_THINKING_PLACEHOLDER = "\n[thinking…]\n"
154
+
155
+ /**
156
+ * Tool-call progress line summary delegates to the single source describeToolArgs
157
+ * (../tui/tool-args.mjs) — the same function main-agent tool blocks and subagent
158
+ * blocks use. 2026-08-31: replaced the local picker (action/path/pattern/command-only)
159
+ * so advisor progress lines show the quoted-path forms everywhere else.
160
+ */
161
+ /**
162
+ * Render the ordered review timeline — thinking / tool progress / final text
163
+ * interleaved EXACTLY as emitted, so the persisted record shows the review
164
+ * process at its real positions. A summary appended at the end would lose the
165
+ * order (the user-visible "no tool calls in the advisor record" gap). The
166
+ * live "[thinking…]" placeholder is stripped (wait indicator, not content).
167
+ */
168
+ export function renderTimeline(timeline, tail = "") {
169
+ const body = timeline
170
+ .map((b) => b.text.replaceAll(ADVISOR_THINKING_PLACEHOLDER, "").trim())
171
+ .filter(Boolean)
172
+ .join("\n\n")
173
+ return [body, tail].filter(Boolean).join("\n\n")
174
+ }