thincoder 0.12.59 → 0.12.61

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 (192) hide show
  1. package/CHANGELOG.md +62 -4
  2. package/README.md +10 -8
  3. package/bin/thincoder.mjs +99 -133
  4. package/package.json +6 -4
  5. package/src/abort-provenance.mjs +116 -0
  6. package/src/acp/bridge.mjs +45 -21
  7. package/src/acp.mjs +6 -1
  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 +59 -137
  12. package/src/advisor/project-context.mjs +194 -0
  13. package/src/advisor/repos.mjs +17 -40
  14. package/src/advisor/run.mjs +156 -359
  15. package/src/advisor/truncate.mjs +57 -0
  16. package/src/advisor.mjs +27 -7
  17. package/src/agent/completion.mjs +17 -11
  18. package/src/agent/dispatch.mjs +145 -27
  19. package/src/agent/helpers.mjs +107 -13
  20. package/src/agent/record-results.mjs +55 -11
  21. package/src/agent/relay-prefix.mjs +39 -0
  22. package/src/agent/run-stages.mjs +242 -0
  23. package/src/agent/setup-reminders.mjs +69 -0
  24. package/src/agent/setup.mjs +107 -127
  25. package/src/agent/spawn-child.mjs +55 -13
  26. package/src/agent-tools/advisor-async.mjs +346 -0
  27. package/src/agent-tools/advisor-settle.mjs +231 -0
  28. package/src/agent-tools/advisor.mjs +167 -116
  29. package/src/agent-tools/async-settle.mjs +191 -0
  30. package/src/agent-tools/batch-segment.mjs +195 -0
  31. package/src/agent-tools/consult.mjs +139 -107
  32. package/src/agent-tools/design-token.mjs +117 -0
  33. package/src/agent-tools/digest-budget.mjs +76 -0
  34. package/src/agent-tools/eng.mjs +24 -29
  35. package/src/agent-tools/escalate-async.mjs +289 -0
  36. package/src/agent-tools/read-history.mjs +166 -32
  37. package/src/agent-tools/recent-changes.mjs +2 -1
  38. package/src/agent-tools/review-streak.mjs +93 -0
  39. package/src/agent-tools/settings.mjs +137 -34
  40. package/src/agent-tools/subagent-actions.mjs +180 -133
  41. package/src/agent-tools/subagent-async.mjs +184 -177
  42. package/src/agent-tools/subagent-panel.mjs +160 -0
  43. package/src/agent-tools/subagent-run.mjs +205 -0
  44. package/src/agent-tools/subagent-scheduler.mjs +100 -27
  45. package/src/agent-tools/subagent-spawn.mjs +453 -0
  46. package/src/agent-tools/subagent.mjs +256 -578
  47. package/src/agent-tools/verify.mjs +119 -292
  48. package/src/agent-tools.mjs +1 -0
  49. package/src/agent.mjs +89 -205
  50. package/src/cli/distill-command.mjs +12 -6
  51. package/src/cli/make-agent.mjs +26 -8
  52. package/src/cli/memory-command.mjs +4 -3
  53. package/src/cli/permission.mjs +2 -2
  54. package/src/cli/setup-wizard.mjs +42 -17
  55. package/src/completions.mjs +114 -0
  56. package/src/config-migrate.mjs +70 -0
  57. package/src/config.mjs +180 -63
  58. package/src/context.mjs +5 -147
  59. package/src/conventions.mjs +223 -0
  60. package/src/crash-reports.mjs +128 -0
  61. package/src/distill.mjs +11 -11
  62. package/src/expand-home.mjs +16 -0
  63. package/src/explore-distill.mjs +155 -0
  64. package/src/generate-title.mjs +1 -1
  65. package/src/hooks.mjs +7 -3
  66. package/src/memory/code-index.mjs +9 -3
  67. package/src/memory/code-sync.mjs +72 -32
  68. package/src/memory/core.mjs +6 -193
  69. package/src/memory/delete.mjs +236 -0
  70. package/src/memory/docs.mjs +68 -54
  71. package/src/memory/file-walk.mjs +109 -0
  72. package/src/memory/schema.mjs +15 -3
  73. package/src/memory.mjs +3 -1
  74. package/src/model-ref.mjs +66 -0
  75. package/src/model-specs.mjs +42 -8
  76. package/src/peer-domains.mjs +265 -0
  77. package/src/peer-instances.mjs +231 -0
  78. package/src/prompt-overlays.mjs +82 -0
  79. package/src/prompts/advisor-design.md +18 -75
  80. package/src/prompts/advisor-round1.md +14 -67
  81. package/src/prompts/advisor-round2.md +15 -51
  82. package/src/prompts/advisor-round3.md +15 -51
  83. package/src/prompts/common.md +115 -0
  84. package/src/prompts/consult-base.md +5 -23
  85. package/src/prompts/discipline-engineering.md +217 -0
  86. package/src/prompts/discipline-normal.md +179 -0
  87. package/src/prompts/persona-coder.md +21 -0
  88. package/src/prompts/persona-eng-coder.md +37 -0
  89. package/src/prompts/persona-eng-designer.md +55 -0
  90. package/src/prompts/persona-engineering.md +54 -0
  91. package/src/prompts/persona-explore.md +15 -0
  92. package/src/prompts/persona-normal.md +27 -0
  93. package/src/prompts/persona-plan.md +26 -0
  94. package/src/provider/anthropic.mjs +4 -4
  95. package/src/provider/core.mjs +18 -98
  96. package/src/provider/errors.mjs +101 -0
  97. package/src/provider/google.mjs +5 -6
  98. package/src/provider/index.mjs +2 -1
  99. package/src/provider/list-models.mjs +93 -0
  100. package/src/provider/rate.mjs +2 -1
  101. package/src/provider/responses.mjs +5 -3
  102. package/src/provider/retry.mjs +8 -45
  103. package/src/provider/sse.mjs +3 -4
  104. package/src/proxy.mjs +9 -14
  105. package/src/session-gc.mjs +214 -0
  106. package/src/session-guard.mjs +47 -0
  107. package/src/session-rename.mjs +38 -0
  108. package/src/session-slots.mjs +188 -60
  109. package/src/session.mjs +104 -124
  110. package/src/token-ttl.mjs +274 -0
  111. package/src/tools/{system.mjs → bash.mjs} +19 -221
  112. package/src/tools/checklist-sync.mjs +181 -0
  113. package/src/tools/checklist.mjs +52 -39
  114. package/src/tools/edit-batch.mjs +109 -10
  115. package/src/tools/edit-diff.mjs +110 -27
  116. package/src/tools/edit.md +17 -12
  117. package/src/tools/execute.mjs +31 -4
  118. package/src/tools/file.mjs +41 -16
  119. package/src/tools/git.md +1 -1
  120. package/src/tools/git.mjs +23 -34
  121. package/src/tools/glob-dialect.mjs +130 -0
  122. package/src/tools/glob.md +3 -3
  123. package/src/tools/grep.md +1 -1
  124. package/src/tools/index.mjs +9 -8
  125. package/src/tools/ops.mjs +188 -3
  126. package/src/tools/patch.mjs +3 -3
  127. package/src/tools/question.md +4 -0
  128. package/src/tools/question.mjs +26 -0
  129. package/src/tools/read.md +1 -2
  130. package/src/tools/read_image.md +1 -1
  131. package/src/tools/search.mjs +236 -0
  132. package/src/tools/shared.mjs +14 -13
  133. package/src/tools/wait_for.md +22 -0
  134. package/src/tui/agent-turn.mjs +36 -228
  135. package/src/tui/ansi.mjs +2 -0
  136. package/src/tui/clipboard.mjs +7 -1
  137. package/src/tui/cmd-advisor.mjs +3 -2
  138. package/src/tui/cmd-config.mjs +142 -30
  139. package/src/tui/cmd-eng.mjs +28 -40
  140. package/src/tui/cmd-exit.mjs +6 -8
  141. package/src/tui/cmd-mcp.mjs +8 -2
  142. package/src/tui/cmd-model.mjs +14 -12
  143. package/src/tui/cmd-new.mjs +3 -2
  144. package/src/tui/cmd-reindex.mjs +7 -0
  145. package/src/tui/cmd-session.mjs +19 -4
  146. package/src/tui/cmd-submodel.mjs +8 -5
  147. package/src/tui/cmd-think.mjs +10 -10
  148. package/src/tui/cmd-undo.mjs +4 -3
  149. package/src/tui/cmd-upgrade.mjs +19 -4
  150. package/src/tui/config-helpers.mjs +28 -16
  151. package/src/tui/distill-cmd.mjs +1 -1
  152. package/src/tui/index.mjs +40 -38
  153. package/src/tui/interaction.mjs +3 -3
  154. package/src/tui/key-handler.mjs +61 -17
  155. package/src/tui/key-modes.mjs +86 -8
  156. package/src/tui/layout.mjs +18 -10
  157. package/src/tui/model-catalog.mjs +89 -0
  158. package/src/tui/model-picker.mjs +498 -0
  159. package/src/tui/mouse.mjs +52 -9
  160. package/src/tui/pickers.mjs +28 -392
  161. package/src/tui/render-frame.mjs +32 -16
  162. package/src/tui/render-loop.mjs +2 -0
  163. package/src/tui/render-segments.mjs +12 -9
  164. package/src/tui/render.mjs +37 -5
  165. package/src/tui/slash-commands.mjs +2 -2
  166. package/src/tui/startup.mjs +4 -0
  167. package/src/tui/subagent-blocks.mjs +106 -295
  168. package/src/tui/subagent-children.mjs +162 -0
  169. package/src/tui/subagent-freeze.mjs +169 -0
  170. package/src/tui/subagent-panel.mjs +24 -31
  171. package/src/tui/suspension-drive.mjs +297 -0
  172. package/src/tui/tool-args.mjs +7 -5
  173. package/src/tui/tool-display.mjs +143 -0
  174. package/src/tui/tool-events.mjs +56 -185
  175. package/src/tui/tui-lifecycle.mjs +46 -4
  176. package/src/tui/update-notice.mjs +4 -0
  177. package/src/tui/wizard.mjs +61 -21
  178. package/src/tui/wrapped-spawn.mjs +38 -0
  179. package/src/prompts/coder.md +0 -56
  180. package/src/prompts/discipline.md +0 -102
  181. package/src/prompts/eng-coder.md +0 -44
  182. package/src/prompts/engineering-sub.md +0 -34
  183. package/src/prompts/engineering.md +0 -340
  184. package/src/prompts/explore.md +0 -21
  185. package/src/prompts/main.md +0 -56
  186. package/src/prompts/methodology-template.md +0 -58
  187. package/src/prompts/plan.md +0 -16
  188. package/src/prompts/system.md +0 -63
  189. package/src/tools/pdf-parse-text.mjs +0 -497
  190. package/src/tools/pdf-parse-xref.mjs +0 -499
  191. package/src/tools/pdf.mjs +0 -155
  192. package/src/tools/read_pdf.md +0 -21
@@ -1,352 +1,95 @@
1
1
  /**
2
- * advisor/run.mjs — advisor execution: tool loop, provider resolution, and the review entry point.
3
- * Message building lives in advisor.mjs.
2
+ * advisor/run.mjs — advisor execution: provider resolution and the review entry
3
+ * point. Message building lives in advisor.mjs; the tool loop lives in loop.mjs
4
+ * and the context/limit/tail guards in compaction.mjs(第 11 批硬帽拆分——
5
+ * run.mjs 原 498/500;既有 import 面经 re-export 保持不变)。
4
6
  */
5
- import { chat } from "../provider/core.mjs"
6
- import { findProvider, providerSpec } from "../config.mjs"
7
- import { toOpenAISchema } from "../tools/index.mjs"
7
+ import { findProvider } from "../config.mjs"
8
8
  import { prepareAdvisorMessages } from "../advisor.mjs"
9
+ import { buildObjectDeclarationBlock, buildDesignApprovalBlock } from "./messages.mjs"
9
10
  import { appendCitationReport } from "./citations.mjs"
10
- import { describeToolArgs } from "../tui/tool-args.mjs"
11
-
12
- const MAX_ADVISOR_TURNS = 100
13
- // Mechanical convergence cap: the protocol assumes up to 5 rounds suffice
14
- // (full review, verify+fix cycles, strict verification). A 6th call means the
15
- // model is looping — refuse it instead of burning tokens on a review that cannot
16
- // converge. Code AND design reviews share the 5-round budget (each advances
17
- // _advisorRound in agent.mjs; the cap no longer exempts design).
11
+ import { runAdvisorToolLoop } from "./loop.mjs"
12
+ import { advisorIncompleteMarker, estimateTokens } from "./compaction.mjs"
13
+ import { batchDocForReview } from "../agent-tools/batch-segment.mjs"
14
+ // 33 批(§17.5 模块图):护栏常量 / doc-set / 记录读取——单向导入(review-streak 无回指)。
15
+ import {
16
+ MAX_DESIGN_REVIEW_STREAK, docSetKey, designReviewStreakRecord, designReviewStreakStopped,
17
+ } from "../agent-tools/review-streak.mjs"
18
+
19
+ // 拆分后 import 面(既有导出名逐一保面——re-export;谓词为本批新增)。
20
+ export { ADVISOR_THINKING_PLACEHOLDER, MAX_RESULT_CHARS, renderTimeline as _renderTimeline } from "./compaction.mjs"
21
+ export { advisorToolsFor, advisorToolsFor as _advisorToolsFor } from "./loop.mjs"
22
+ export { runAdvisorToolLoop as _runAdvisorToolLoop } from "./loop.mjs"
23
+ export { advisorIncompleteMarker } from "./compaction.mjs"
24
+
25
+ // Mechanical convergence cap: up to 5 rounds suffice; a 6th call means the model
26
+ // is looping — refuse it instead of burning tokens. §11.2 D-24b (2026-09-06): PER
27
+ // REVIEW INSTANCE (agent._advisorRuns); CODE REVIEWS ONLY (2026-09-07 §8 ruling)
28
+ // — design reviews are EXEMPT: their rounds keep advancing, the cap never refuses.
18
29
  export const MAX_ADVISOR_ROUNDS = 5
19
30
 
20
- // NOTE: prompts/advisor-round{1,2,3}.md encourage the model to finish within
21
- // ~30 tool turns — a prompt-level efficiency target, DISTINCT from the
22
- // 100-turn mechanical hard cap (MAX_ADVISOR_TURNS above; pure runaway-loop
23
- // guard). They serve different purposes; do NOT synchronize them.
24
-
25
- // The live "[thinking…]" wait indicator shares its exact text with the TUI
26
- // cleanup regex (agent-turn.mjs strips it before flushing to history) — keep
27
- // them in lockstep.
28
- export const ADVISOR_THINKING_PLACEHOLDER = "\n[thinking…]\n"
29
-
30
- // Context window limits
31
- const MAX_CONTEXT_TOKENS = 120_000 // Reserve headroom to avoid OOM
32
- const TOOL_TIMEOUT_MS = 30_000 // single tool timeout
33
- const REVIEW_TIMEOUT_MS = 600_000 // whole review timeout (10 minutes)
34
- export const MAX_RESULT_CHARS = 64 * 1024 // tool result truncation (line-aware; 64K, aligned with main offload limit)
35
- const MAX_UNFIXED_DISPLAY = 10 // unfixed issues shown in the cap message
36
- const MAX_KEY_FILES_IN_COMPACTION = 5 // files named in the compaction summary
37
-
38
- /** Estimate token count from messages (rough: 1 token 4 chars) */
39
- function estimateTokens(messages) {
40
- return messages.reduce((sum, msg) => {
41
- const content = typeof msg.content === "string" ? msg.content : JSON.stringify(msg.content || "")
42
- const toolCalls = msg.tool_calls ? JSON.stringify(msg.tool_calls) : ""
43
- return sum + Math.ceil((content.length + toolCalls.length) / 4)
44
- }, 0)
31
+ /** B 启动拒绝前缀(§14.4 #2)——稳定契约单源(三消费点同串):run.mjs 生成;同步工具面
32
+ * 据此登记 `_advisorRefusals`;异步结算面据此不置 `_calledAdvisorThisRun`。 */
33
+ export const ADVISOR_LAUNCH_REFUSAL_PREFIX = "Advisor: design review launch refused"
34
+
35
+ /** 评审失败护栏稳定前缀(第 33 批 §17.4——与 `ADVISOR_LAUNCH_REFUSAL_PREFIX` 同族;
36
+ * 实现 grep / 用例断言锚)。凭证卫生:串内零 token / designId 值。 */
37
+ export const ADVISOR_DESIGN_STREAK_STOP_PREFIX = "Advisor: design review stopped"
38
+
39
+ /** kind 人读说明(第 33 批 §17.4 逐字——结论表第三列;八类 = 五 kind + stale +
40
+ * no_credential + no_report,与 review-streak.mjs 分类输出同集)。 */
41
+ const DESIGN_REVIEW_OUTCOME_MEANINGS = {
42
+ timeout: "review exceeded the wall-clock budget (agent.advisor.timeoutMs)",
43
+ context_limit: "review exceeded the model context budget",
44
+ turn_cap: "review exceeded the tool-round limit",
45
+ empty: "the provider returned an empty response",
46
+ review_failed: "provider / transport error",
47
+ stale: "the reviewed documents changed while the review was in flight",
48
+ no_credential: "the token could not be written to the session ledger",
49
+ no_report: "the review settled without a report",
45
50
  }
46
51
 
47
- /** Compact early messages when context grows too large — LOCAL trimming only
48
- * (no LLM summarization). MUTATES in place (splice) so the caller's array
49
- * reference stays valid — a reassignment would leave the caller's logging
50
- * (tool-call count, token estimate) reading a stale array. */
51
- function compactMessages(messages) {
52
- // Keep: system prompt, last 20 messages (≈ 10 assistant+tool exchanges),
53
- // user message — the rest is summarized.
54
- if (messages.length <= 20) return
55
-
56
- const system = messages[0]
57
- const recent = messages.slice(-20)
58
- const old = messages.slice(1, -20)
59
-
60
- // Count actual tool messages (old.length counts user/assistant rows too)
61
- const toolCount = old.filter((m) => m.role === "tool").length
62
- const keyFiles = old
63
- .filter((m) => m.role === "tool")
64
- .map((m) => m.content?.split("\n")[0]?.slice(0, 50)) // first line of tool results typically names the file that was read/grepped
65
- .filter(Boolean)
66
- .slice(0, MAX_KEY_FILES_IN_COMPACTION)
67
- const filesPart = keyFiles.length > 0 ? ` Key files examined: ${keyFiles.join(", ")}` : ""
68
- const summary = `Earlier exploration: ${toolCount} tool calls completed.${filesPart}`
69
-
70
- messages.splice(0, messages.length,
71
- system,
72
- { role: "user", content: `[Context compacted] ${summary}` },
73
- ...recent)
74
- }
75
-
76
- const { readTool, globTool, grepTool, lsTool } = await import("../tools/index.mjs")
77
- const { lspTool } = await import("../tools/lsp.mjs")
78
- const { codeSearchTool } = await import("../memory/code-sync.mjs")
79
-
80
- /**
81
- * Advisor tool set — ZERO git, read-only ONLY, every round. The change surface
82
- * comes from the review scope (paths / _touchedFiles injected by the caller),
83
- * never from git: git output misled reviews (committed fixes never show in
84
- * `git diff HEAD`, so "no changes" was read as "not fixed") and the user
85
- * mandate is full decoupling (7d49a52 + d3be613). The reviewer reads files
86
- * and searches code; it never touches git and never writes.
87
- * No round parameter — the set is constant across all rounds.
88
- * @param {Object} agent — only used for the code index (agent.memory); the
89
- * semantic code_search tool needs it. Without a memory, the set is 5 tools.
90
- */
91
- function advisorToolsFor(agent) {
92
- const search = agent?.memory ? codeSearchTool(agent.memory) : null
93
- const tools = search
94
- ? [readTool, globTool, grepTool, lsTool, lspTool, search]
95
- : [readTool, globTool, grepTool, lsTool, lspTool]
96
- return { schemas: tools.map(toOpenAISchema), byName: new Map(tools.map((t) => [t.name, t])) }
97
- }
98
- // Test seam: the tool set is pure (agent.memory → code_search inclusion).
99
- export { advisorToolsFor as _advisorToolsFor }
100
-
101
- /**
102
- * Tool-call progress line summary delegates to the single source describeToolArgs
103
- * (../tui/tool-args.mjs) — the same function main-agent tool blocks and subagent
104
- * blocks use. 2026-08-31: replaced the local picker (action/path/pattern/command-only)
105
- * so advisor progress lines show the quoted-path forms everywhere else.
106
- */
107
52
  /**
108
- * Render the ordered review timeline thinking / tool progress / final text
109
- * interleaved EXACTLY as emitted, so the persisted record shows the review
110
- * process at its real positions. A summary appended at the end would lose the
111
- * order (the user-visible "no tool calls in the advisor record" gap). The
112
- * live "[thinking…]" placeholder is stripped (wait indicator, not content).
53
+ * 停止结论串(第 33 §17.4 逐字;F29):稳定前缀 + 停止的 doc-set 清单 + 失败尝试表
54
+ * (记录逐条——kind + 人读说明)+ 三选项(接受现状 / 改变或缩小范围后重跑 / /new 重置)。
55
+ * 全文零凭证值;不自动执行任何恢复动作(发起权在父代理 / 用户)。
56
+ * @param {{count: number, log: string[]}|null} record 护栏记录(`designReviewStreakRecord`)
57
+ * @param {string[]|null} documents 被停的文档集(结论清单数据源)
113
58
  */
114
- function renderTimeline(timeline, tail = "") {
115
- const body = timeline
116
- .map((b) => b.text.replaceAll(ADVISOR_THINKING_PLACEHOLDER, "").trim())
117
- .filter(Boolean)
118
- .join("\n\n")
119
- return [body, tail].filter(Boolean).join("\n\n")
59
+ export function buildDesignReviewGuardMessage(record, documents) {
60
+ const log = Array.isArray(record?.log) ? record.log : []
61
+ const docList = Array.isArray(documents) ? documents.filter((d) => typeof d === "string" && d.trim()) : []
62
+ return [
63
+ `${ADVISOR_DESIGN_STREAK_STOP_PREFIX} — ${MAX_DESIGN_REVIEW_STREAK} consecutive attempts on this document set produced no design token (repeated failed settlements; no further reviews will start for this set in this session).`,
64
+ "Document set (1 design instance — no token issued):",
65
+ ...docList.map((d) => `- ${d}`),
66
+ "Attempts (most recent last):",
67
+ "| # | outcome | meaning |",
68
+ "|---|---|---|",
69
+ ...log.map((kind, i) => `| ${i + 1} | ${kind} | ${DESIGN_REVIEW_OUTCOME_MEANINGS[kind] ?? kind} |`),
70
+ "Options:",
71
+ "1. Accept the current state and proceed — implementation for this document set stays gated (no design token).",
72
+ "2. Narrow or change the scope: a different document set starts a fresh budget — fix the cause first (agent.advisor.timeoutMs / advisor model / provider).",
73
+ "3. Start a new session (/new) to reset the guard.",
74
+ ].join("\n")
120
75
  }
121
- // Test seam (mirrors _advisorToolsFor).
122
- export { renderTimeline as _renderTimeline }
123
- // Test seam (T-TS8/9): the tool loop itself — toolsOverride injects a mock tool
124
- // set with controllable timing/errors (the real set comes from advisorToolsFor).
125
- export { runAdvisorToolLoop as _runAdvisorToolLoop }
126
-
127
- /**
128
- * Run the advisor's tool loop: chat → execute tools → repeat.
129
- * Stops when the model produces text without tool calls.
130
- *
131
- * Progress lines (→ tool args) are emitted via onOutput between model bursts so
132
- * the panel keeps moving while the advisor explores — otherwise the panel sits
133
- * frozen through every tool-call phase and the review appears to have stalled.
134
- */
135
- async function runAdvisorToolLoop(provider, messages, onOutput, signal, agent, cwd, toolsOverride = null) {
136
- // Kind-tagged wrappers: the TUI panel colors reasoning / answer / tool progress differently.
137
- // Every chunk is ALSO recorded into an ordered timeline — the persisted record
138
- // must show the review process (thinking ↔ tool progress ↔ final text) at its
139
- // real positions, not a summary appended at the end. Same-kind consecutive
140
- // chunks merge (token streams); kind flips start a new entry.
141
- const timeline = []
142
- const record = (kind, text) => {
143
- const last = timeline.at(-1)
144
- if (last && last.kind === kind) last.text += text
145
- else timeline.push({ kind, text })
146
- }
147
- const emit = (kind) => (text) => { record(kind, text); onOutput?.({ kind, text }) }
148
- const onThink = emit("think")
149
- const onText = emit("text")
150
- const onTool = emit("tool")
151
- // toolsOverride = test seam (T-TS8/9): the real advisor tool set, or a mock
152
- // set with controllable timing/errors.
153
- const { schemas: toolSchemas, byName: toolByName } = toolsOverride ?? advisorToolsFor(agent)
154
- let turns = 0
155
- const startTime = Date.now()
156
-
157
- while (true) {
158
- // Interrupted (Ctrl+I) — stop immediately instead of spinning a fresh uncancellable signal
159
- if (signal?.aborted) return renderTimeline(timeline, "Advisor: interrupted.")
160
-
161
- // Check review timeout (10 minutes by default; agent.advisor.timeoutMs overrides)
162
- // 运行时校验(设计评审 #1,2026-08-24):手写 config.json 的非法值(0/负数/字符串)
163
- // 不得静默禁用或立即触发超时——非法一律回退默认。
164
- const cfg = agent.config?.advisor?.timeoutMs
165
- const timeoutMs = (Number.isFinite(cfg) && cfg > 0) ? cfg : REVIEW_TIMEOUT_MS
166
- if (Date.now() - startTime > timeoutMs) {
167
- return renderTimeline(timeline, `Advisor: review timeout after ${Math.round(timeoutMs / 1000)}s. Partial results may be available. Try again with a narrower scope.`)
168
- }
169
-
170
- if (++turns > MAX_ADVISOR_TURNS) {
171
- return renderTimeline(timeline, "Advisor: stopped after " + MAX_ADVISOR_TURNS + " tool rounds — the review appears to be looping. You may retry with a narrower scope.")
172
- }
173
-
174
- // Check context window and compact if needed
175
- const currentTokens = estimateTokens(messages)
176
- if (currentTokens > MAX_CONTEXT_TOKENS * 0.8) {
177
- onText(`\n[Context compacted: ${currentTokens} tokens → reducing to fit window]\n`)
178
- compactMessages(messages)
179
- if (estimateTokens(messages) > MAX_CONTEXT_TOKENS) {
180
- // Report the POST-compaction count — the pre-compaction currentTokens
181
- // is stale by the time compaction has run.
182
- return renderTimeline(timeline, `Advisor: context window limit reached (${estimateTokens(messages)} tokens). Review incomplete — too many tool calls. Try a narrower scope.`)
183
- }
184
- }
185
-
186
- // LLM generation silence: the reasoning phase produces no SSE bytes for
187
- // seconds to tens of seconds (server-side prefill on large contexts, per
188
- // tool-round LLM return). A placeholder keeps the panel visibly working.
189
- // kind "think" (NOT "text"): the placeholder must land in the SAME buffer
190
- // and position as the upcoming reasoning — a "text"-kind placeholder
191
- // rendered BELOW the think block, and the reasoning stream appeared ABOVE
192
- // it ("the stream runs back to the front"). Same buffer = same spot; the
193
- // reasoning continues right where the placeholder sits.
194
- onOutput?.({ kind: "think", text: ADVISOR_THINKING_PLACEHOLDER })
195
-
196
- const response = await chat(provider, {
197
- messages,
198
- tools: toolSchemas,
199
- // Pass the signal UNCONDITIONALLY: a signal aborted between the check
200
- // above and here must still cancel the fetch. core.mjs composes
201
- // AbortSignal.any([signal, timeout]) — an already-aborted signal makes
202
- // the request fail immediately instead of ignoring the interrupt.
203
- signal: signal ?? null,
204
- onToken: onText,
205
- onReasoning: onThink,
206
- // LOGGING(vscode advisor/run.mjs parity——按 stage 可 grep)
207
- // §18.6 D-TR4:轨迹元数据增补——kind=advisor(评审独立于子代理——T-TR2);role
208
- // 透出调用方角色(eng-coder 内嵌评审时为 "eng-coder");session/cwd 供轨迹对回;
209
- // traces 开关沿 agent.config(D-TR6)。
210
- logCtx: {
211
- stage: "advisor",
212
- role: agent?._role ?? null,
213
- kind: "advisor",
214
- session: agent?._sessionStart ?? null,
215
- cwd,
216
- traces: agent?.config?.traces?.enabled !== false,
217
- },
218
- })
219
76
 
220
- // No tool calls this is the final review text. The final answer was
221
- // already streamed into the timeline via onText; fall back to
222
- // response.content only if nothing was recorded.
223
- if (!response.toolCalls?.length) {
224
- if (!response.content?.trim()) return renderTimeline(timeline) || "Advisor: (empty response — review was inconclusive)"
225
- return renderTimeline(timeline) || response.content.trim()
226
- }
227
-
228
- // Push assistant message with tool calls. reasoning_content ECHO is
229
- // mandatory for reasoningEcho:"required" providers (deepseek/kimi): the
230
- // server stops returning reasoning_content on later rounds when the
231
- // tool-call assistant history lacks it — the observed "reasoning stops
232
- // after the first tool call, returns only at the final answer" symptom.
233
- // Mirrors the main agent's push (agent.mjs).
234
- messages.push({
235
- role: "assistant",
236
- content: response.content || null,
237
- tool_calls: response.toolCalls.map((tc) => ({
238
- id: tc.id, type: "function",
239
- function: { name: tc.name, arguments: tc.arguments },
240
- })),
241
- ...(response.reasoning && providerSpec(provider).reasoningEcho === "required"
242
- ? { reasoning_content: response.reasoning }
243
- : {}),
244
- })
245
-
246
- // B1 (AGENT-LOOP.md §18.7 D-TS7): the SAME LLM reply's multiple read-only
247
- // tool calls run in PARALLEL (Promise.all) — results are backfilled in
248
- // toolCalls order (Promise.all preserves the input order → tool_call_id
249
- // never mismatches); each tool's timeout/error is captured independently
250
- // (the existing TOOL_TIMEOUT stays — one failing tool does not block the
251
- // others); progress lines are emitted in toolCalls order. The read-only
252
- // tool set has no side effects — no sequencing/serialization needed.
253
- // Scope note (round1 review #10): B1 is ONLY in-loop tool parallelism — it
254
- // does NOT solve the TODO "platform execution: advisor parallel calls are
255
- // actually serial" mystery (docs/TODO.md — LOGGING evidence item), which
256
- // concerns multiple advisor CALLS observed as serial, not one reply's
257
- // tool calls.
258
- const parsed = response.toolCalls.map((tc) => {
259
- const tool = toolByName.get(tc.name)
260
- let args = {}
261
- let parseError = null
262
- try {
263
- args = JSON.parse(tc.arguments || "{}")
264
- } catch (e) {
265
- parseError = `Error: invalid JSON in tool arguments: ${e.message}\nRaw arguments: ${(tc.arguments || "").slice(0, 200)}`
266
- }
267
- return { tc, tool, args, parseError }
268
- })
269
- // Progress lines first, in toolCalls order (emitted before the parallel
270
- // run — display order is independent of completion order).
271
- for (const p of parsed) {
272
- if (p.parseError) continue // parse-error tools get no progress line (legacy behavior)
273
- const argsLine = describeToolArgs(p.tc.name, p.args)
274
- onTool(`\n→ ${p.tc.name}${argsLine ? " " + argsLine : ""}\n`)
275
- }
276
- // Every tool runs CONCURRENTLY; each result/error lands in its own slot —
277
- // Promise.all preserves input order, so index i always matches parsed[i].
278
- const executed = await Promise.all(parsed.map(async (p) => {
279
- // Parse failure → error to model immediately (no execution)
280
- if (p.parseError) return p.parseError
281
- if (!p.tool) return `Error: unknown tool "${p.tc.name}". Available: ${[...toolByName.keys()].join(", ")}`
282
- // Execute with timeout (clear the timer when the tool wins the race —
283
- // otherwise up to MAX_ADVISOR_TURNS dangling timers accumulate)
284
- try {
285
- let timeoutId
286
- const timeoutPromise = new Promise((_, reject) => {
287
- timeoutId = setTimeout(() => reject(new Error(`tool timeout after ${TOOL_TIMEOUT_MS}ms`)), TOOL_TIMEOUT_MS)
288
- })
289
- let toolPromise
290
- try {
291
- toolPromise = p.tool.execute(p.args, { cwd, agent, onOutput, signal })
292
- return await Promise.race([toolPromise, timeoutPromise])
293
- } finally {
294
- clearTimeout(timeoutId)
295
- // Timeout won → toolPromise is still pending; a later rejection
296
- // would surface as an unhandled rejection. The race already
297
- // consumed the result/error in the normal path, so this no-op
298
- // catch only fires for the abandoned-tool case.
299
- toolPromise?.catch(() => {})
300
- }
301
- } catch (e) {
302
- const errorType = e.message.includes("timeout") ? "timeout"
303
- : e.message.includes("ENOENT") ? "file_not_found"
304
- : e.message.includes("permission") ? "permission_denied"
305
- : "execution_error"
306
- return `Error (${errorType}): ${e.message}`
307
- }
308
- }))
309
-
310
- // Backfill in toolCalls order (executed[i] ↔ parsed[i]); per-result
311
- // non-string serialization + line-aware truncation stay per-tool.
312
- for (let i = 0; i < parsed.length; i++) {
313
- let result = executed[i]
314
- if (typeof result !== "string") result = JSON.stringify(result)
315
-
316
- if (result.length > MAX_RESULT_CHARS) {
317
- const lines = result.split("\n")
318
- let truncated = ""
319
- let charCount = 0
320
- let keptLines = 0
321
-
322
- for (let j = 0; j < lines.length; j++) {
323
- const line = lines[j]
324
- if (charCount + line.length + 1 > MAX_RESULT_CHARS) break
325
- truncated += line + "\n"
326
- charCount += line.length + 1
327
- keptLines++
328
- }
329
-
330
- const remainingLines = lines.length - keptLines
331
- result = (
332
- truncated +
333
- `\n… (truncated: ${remainingLines} more lines, ${result.length} chars total)\n` +
334
- `To see more content, use: read(path, offset=${keptLines + 1}, limit=200)`
335
- )
336
- }
337
-
338
- messages.push({ role: "tool", tool_call_id: parsed[i].tc.id, content: result })
339
- }
340
- }
341
- }
77
+ const MAX_UNFIXED_DISPLAY = 10 // unfixed issues shown in the cap message
342
78
 
343
79
  /** Resolve the advisor's provider: cfg.provider/model when set, otherwise the main agent's provider */
344
80
  export function resolveAdvisorProvider(agent) {
345
81
  const cfg = agent.config?.advisor
346
82
  if (cfg?.provider) {
347
83
  try {
348
- const provider = findProvider(agent.providers ?? [agent.provider], cfg.provider)
349
- const result = cfg.model ? { ...provider, model: cfg.model } : { ...provider }
84
+ // F-1 (ISSUE-FIX-BATCH): children carry no agent.providers (spawn childConfig copies the
85
+ // parent config) fall back to config.providersList (.length: [] must not skip the list).
86
+ const provider = findProvider(agent.providers?.length ? agent.providers : agent.config?.providersList ?? [agent.provider], cfg.provider)
87
+ // F-2a (MODEL-400-FIX):无 cfg.model 时渠道克隆须重派生 model——渠道裸克隆会丢 model 键
88
+ // → 无 model 请求 → serde 400。
89
+ // MODEL-SELECTION v2(M3④):无 cfg.model → 命中渠道自己的默认模型(`provider.model`
90
+ // 单值);渠道无默认模型 → 父 provider 兜底(与 subagent F-2c 同构)——绝不产出静默
91
+ // undefined-model 请求(最极端两者皆无 → chat 前 assertProviderModel fail-fast)。
92
+ const result = cfg.model ? { ...provider, model: cfg.model } : { ...provider, model: provider.model ?? agent.provider?.model }
350
93
  if (cfg.thinking === null || cfg.thinking === false) result.thinking = undefined // explicitly off
351
94
  else if (cfg.thinking !== undefined) result.thinking = cfg.thinking
352
95
  if (cfg.reasoningEffort !== undefined) result.reasoningEffort = cfg.reasoningEffort
@@ -388,17 +131,53 @@ function extractUnfixedIssues(priorText) {
388
131
  .filter(Boolean)
389
132
  .slice(0, MAX_UNFIXED_DISPLAY)
390
133
  }
134
+ /** Review-looking guard (async settle parity): a markdown table row or ≥200 chars of prose counts as a prior. */
135
+ export function looksLikeReviewOutput(text) {
136
+ const trimmed = String(text ?? "").trim()
137
+ return /\|.*\|.*\|/.test(trimmed) || trimmed.length >= 200
138
+ }
139
+ /** Cap message (shared by runAdvisorReview and the async pre-check — per-review refusal). */
140
+ export function buildCapMessage(agent) {
141
+ const prior = agent._lastAdvisorOutput
142
+ const unfixed = prior ? extractUnfixedIssues(prior) : []
143
+ let message = `Advisor: convergence cap reached after ${MAX_ADVISOR_ROUNDS} rounds.\n`
144
+ if (unfixed.length > 0) {
145
+ message += `\nUnresolved issues from prior rounds:\n${unfixed.map((i) => `- ${i}`).join("\n")}\n`
146
+ } else {
147
+ message += "\nAll prior issues appear resolved.\n"
148
+ }
149
+ message += "\nOptions:\n1. Accept current state and proceed\n2. Manually review specific concerns with read/grep\n3. Start a new session (/new) to reset the advisor"
150
+ return message
151
+ }
152
+
153
+ /**
154
+ * 压缩定锚简报(F13/§14.4 #3)——**由评审参数构建**(非模型输出):对象声明 / 文档清单 /
155
+ * Approval Signal 三锚;重内容(项目指南 / 方法论 / 文档地图)不入 pin(压缩的意义所在)。
156
+ * 形态逐字见设计 §14.4 #3(首行为机械化重挂说明)。
157
+ */
158
+ function buildPinnedBrief(reviewType, documents, object, designToken, designId) {
159
+ const docList = Array.isArray(documents) ? documents.filter((d) => typeof d === "string" && d.trim()) : []
160
+ const parts = ["[review brief — re-attached after context compaction; the original review request is no longer in the context]"]
161
+ const declaration = buildObjectDeclarationBlock(object)
162
+ if (declaration) parts.push(declaration.trimEnd())
163
+ if (docList.length > 0) {
164
+ parts.push("## Documents to Review")
165
+ parts.push(docList.map((d) => `- ${d} — Read this file in full`).join("\n"))
166
+ }
167
+ if (reviewType === "design" && designToken) parts.push(buildDesignApprovalBlock(designToken, designId))
168
+ return parts.join("\n\n")
169
+ }
391
170
 
392
171
  /**
393
172
  * Run an advisor review. reviewType: "code" (default) or "design". Returns review text or null when skipped.
394
173
  * @param {string|null} [designToken] — injected into the design-review prompt; the advisor echoes it only on approval.
395
174
  * @param {string[]|null} [documents] — design review only: explicit list of doc paths to review; passed through to the message builder.
396
175
  * @param {string[]|null} [paths] — code review only: explicit list of file/dir paths to review.
397
- * @param {Object|null} [object] — review-object declaration (§18.8 D-OA1/D-OA3):
398
- * { type, target, status, reason, exclude }; mechanically injected at the
399
- * start of every review round's user message. Absent → legacy behavior (no injection).
176
+ * @param {Object|null} [object] — review-object declaration (§18.8 D-OA1/D-OA3): { type, target, status, reason, exclude }; absent → legacy behavior (no injection).
177
+ * @param {string|null} [designId] §29.1 F2a: injected next to the design token in
178
+ * the Approval Signal (passed through to the message builders).
400
179
  */
401
- export async function runAdvisorReview(agent, reviewType, callbacks, designToken = null, documents = null, paths = null, object = null) {
180
+ export async function runAdvisorReview(agent, reviewType, callbacks, designToken = null, documents = null, paths = null, object = null, designId = null) {
402
181
  const onOutput = callbacks?.onOutput
403
182
  const signal = callbacks?.signal
404
183
  const startTime = Date.now()
@@ -407,36 +186,54 @@ export async function runAdvisorReview(agent, reviewType, callbacks, designToken
407
186
  // former advisor.enabled gate is removed — review capability has no off
408
187
  // switch; only the guard (completion pushback) is opt-in via advisor.guard.
409
188
 
410
- // Mechanical convergence cap — refuse further reviews once the protocol has run
411
- // its rounds. _advisorRound counts completed advisor calls (incremented by the
412
- // agent after each one code AND design reviews alike), so >= MAX_ADVISOR_ROUNDS
413
- // blocks the next call. 5 rounds max; after that the review is never pushed back
414
- // (the caller decides: accept, manual re-check, or /new to reset).
415
- if ((agent._advisorRound || 0) >= MAX_ADVISOR_ROUNDS) {
416
- // Summarize unresolved items from the last review output for guidance
417
- // (line-level status-word scan — no table-header parsing, decision 2026-08-08).
418
- const prior = agent._lastAdvisorOutput
419
- const unfixed = prior ? extractUnfixedIssues(prior) : []
420
-
421
- let message = `Advisor: convergence cap reached after ${MAX_ADVISOR_ROUNDS} rounds.\n`
422
- if (unfixed.length > 0) {
423
- message += `\nUnresolved issues from prior rounds:\n${unfixed.map((i) => `- ${i}`).join("\n")}\n`
424
- } else {
425
- message += "\nAll prior issues appear resolved.\n"
189
+ // Mechanical convergence cap — CODE REVIEWS ONLY (2026-09-07 §8 ruling: design
190
+ // reviews are exempt). _advisorRound is scoped to the current review instance
191
+ // (§11.2 D-24b ③), so >= MAX_ADVISOR_ROUNDS blocks the next call of THIS instance.
192
+ // 5 rounds max; after that the review is never pushed back.
193
+ if (reviewType !== "design" && (agent._advisorRound || 0) >= MAX_ADVISOR_ROUNDS) {
194
+ return buildCapMessage(agent)
195
+ }
196
+
197
+ // 33 批(§17.5 检查点 2——内防线):同一 doc-set 连续未产出可用结算达阈值 ⇒ 直接拒绝
198
+ // (不建消息、不发起、零 LLM)——防直接调用方绕过工具层预检(正常工具链在预检已拒)。
199
+ if (reviewType === "design" && Array.isArray(documents) && documents.length > 0) {
200
+ const streakKey = docSetKey(documents, agent.cwd)
201
+ if (designReviewStreakStopped(agent, streakKey)) {
202
+ return buildDesignReviewGuardMessage(designReviewStreakRecord(agent, streakKey), documents)
426
203
  }
427
- message += "\nOptions:\n1. Accept current state and proceed\n2. Manually review specific concerns with read/grep\n3. Start a new session (/new) to reset the advisor"
428
-
429
- return message
430
204
  }
431
205
 
432
206
  const provider = resolveAdvisorProvider(agent)
433
207
  // Advisor always works in the agent's cwd — scope is defined by paths/documents.
434
208
  const advisorCwd = agent.cwd
435
209
 
436
- const messages = prepareAdvisorMessages(agent, reviewType, designToken, documents, paths, null, object)
210
+ const messages = prepareAdvisorMessages(agent, reviewType, designToken, documents, paths, null, object, designId)
211
+
212
+ // B 启动断言(fail-closed——§14.4 #2):设计评审请求内**必须**携带与本次签发 token 精确
213
+ // 对应的 Approval Signal——构建面补不上就拒绝启动(不发"请回显一个不存在的 token"的请求)。
214
+ // 拒绝报告前缀 `Advisor: design review launch refused` = 稳定契约(同步工具面据此登记
215
+ // _advisorRefusals;异步结算面据此不置 _calledAdvisorThisRun)。工具路径恒签发 token ⇒
216
+ // 该拒绝为直接调用方兜底(防御纵深——正常链不可达,如实注)。
217
+ if (reviewType === "design") {
218
+ const missing = !designToken
219
+ ? "no design token was minted"
220
+ : (messages.some((m) => m.role === "user" && String(m.content ?? "").includes(`[DESIGN-TOKEN:${designToken}`))
221
+ ? null
222
+ : "the request does not carry the approval signal")
223
+ if (missing) {
224
+ return `${ADVISOR_LAUNCH_REFUSAL_PREFIX} — ${missing}. Nothing was sent: a request that asks the reviewer to echo a token it cannot see would break the credential chain. Re-run advisor(type='design') to mint a fresh token.`
225
+ }
226
+ }
227
+
228
+ // §2.20.3 批次档写通道的绑定(仅设计评审):同步路径 = 调用方(advisor 工具)传入的
229
+ // callbacks.batchDoc(即 resolved.run 的实例绑定);异步路径 = 本实例在跑池条目的
230
+ // run.batchDoc(同文档集实例键——各评审各取各档,不用单值会话态)。
231
+ const boundBatchDoc = reviewType === "design" ? batchDocForReview(agent, documents, callbacks) : null
232
+ // F13/§14.4 #3:压缩定锚简报(评审参数构建——压缩触发时由 compaction 重挂)。
233
+ const pinned = buildPinnedBrief(reviewType, documents, object, designToken, designId)
437
234
 
438
235
  try {
439
- const result = await runAdvisorToolLoop(provider, messages, onOutput, signal, agent, advisorCwd)
236
+ const result = await runAdvisorToolLoop(provider, messages, onOutput, signal, agent, advisorCwd, null, reviewType, boundBatchDoc, pinned)
440
237
 
441
238
  // Host-verified citations (decision d698434): mechanically check every
442
239
  // `file:line: content` reference in the review against the CURRENT file
@@ -444,9 +241,10 @@ export async function runAdvisorReview(agent, reviewType, callbacks, designToken
444
241
  // the prior table instead of re-reading (three consecutive false reports
445
242
  // cited pre-fix line content). Unverified citations must not support a
446
243
  // push-back; the parent agent sees the verification report.
244
+ // F14/§14.5:解析候选 = cwd + 评审对象声明范围派生根(scope = documents + paths)。
447
245
  let final = result
448
246
  if (!result.trimStart().startsWith("Advisor:")) {
449
- final = appendCitationReport(result, advisorCwd)
247
+ final = appendCitationReport(result, advisorCwd, { scope: [...(documents ?? []), ...(paths ?? [])] })
450
248
  // Success path: keep the FULL review output for convergence rounds —
451
249
  // round 2+ injects this verbatim and the model understands it (decision
452
250
  // 2026-08-08: prior-table hard parsing removed; no phrase/header matching).
@@ -454,8 +252,7 @@ export async function runAdvisorReview(agent, reviewType, callbacks, designToken
454
252
  // table row (`| a | b | c |`) or substantial prose (>200 chars). An
455
253
  // empty or tool-progress-only reply must not become the "prior review"
456
254
  // of round 2+.
457
- const trimmed = final.trim()
458
- const looksLikeReview = /\|.*\|.*\|/.test(trimmed) || trimmed.length >= 200
255
+ const looksLikeReview = looksLikeReviewOutput(final)
459
256
  if (looksLikeReview) {
460
257
  agent._lastAdvisorOutput = final
461
258
  }
@@ -0,0 +1,57 @@
1
+ /**
2
+ * truncate.mjs — advisor 工具结果的行感知双端截断(DUAL-END-TRUNCATION F-2,
3
+ * DUAL-END-TRUNCATION.md,2026-09-09——CLI/VSC 双端逐字同构镜像)。
4
+ *
5
+ * 原 run.mjs 内联截断为纯头向(line-aware 从首行累加至 64K break)——评审尾部
6
+ * 结论/裁决被切。现改头尾双保:头行累加至预算 ~60% → 中段省略注 → 尾行累加至
7
+ * 剩余 ~40%(保尾结论——与 read 工具 C 方案 / offload 双端预览同构的
8
+ * "头+尾保留、中段截断"策略——共享设计语言、分别实现)。
9
+ *
10
+ * 纯函数无导入:maxChars 由调用方传入(run.mjs 传 MAX_RESULT_CHARS)。
11
+ */
12
+
13
+ /** advisor 截断头占比——头行累加至此份额后切中段,尾自动取剩余预算(设计 §4 ≈0.6)。 */
14
+ export const ADVISOR_HEAD_RATIO = 0.6
15
+
16
+ /** 尾部预算预留(字符):中段省略注 + offset 续读提示的保守预留——注的位数只减不增
17
+ * (middle ≤ totalChars——超大结果位数可达 ~10 位——200 字符仍宽松覆盖注+提示 ≤ ~140;
18
+ * 差额留给尾行)。 */
19
+ const NOTE_RESERVE_CHARS = 200
20
+
21
+ /**
22
+ * 行感知双端截断:result > maxChars → 头 + `… (truncated: K more lines, TOTAL chars
23
+ * total)` + 尾 + offset 续读提示;≤ maxChars 原样返回(无假截断注)。K = 头尾之间
24
+ * 的中段行数——头(≤60%) + 尾(≤余预算) 均行级累加、绝不半行切开;中段存在时 K ≥ 1
25
+ * (防御:K ≤ 0 时静默返回头尾拼合——不谎报截断)。
26
+ */
27
+ export function truncateAdvisorResult(result, maxChars) {
28
+ if (result.length <= maxChars) return result
29
+ const totalChars = result.length
30
+ const lines = result.split("\n")
31
+ const headBudget = Math.floor(maxChars * ADVISOR_HEAD_RATIO)
32
+ let head = ""
33
+ let headLines = 0
34
+ let chars = 0
35
+ while (headLines < lines.length && chars + lines[headLines].length + 1 <= headBudget) {
36
+ chars += lines[headLines].length + 1
37
+ head += lines[headLines] + "\n"
38
+ headLines++
39
+ }
40
+ const tailBudget = Math.max(0, maxChars - chars - NOTE_RESERVE_CHARS)
41
+ let tail = ""
42
+ let tailChars = 0
43
+ let tailStart = lines.length
44
+ while (tailStart > headLines && tailChars + lines[tailStart - 1].length + 1 <= tailBudget) {
45
+ tailChars += lines[tailStart - 1].length + 1
46
+ tailStart--
47
+ tail = lines[tailStart] + "\n" + tail
48
+ }
49
+ const middle = tailStart - headLines
50
+ if (middle <= 0) return head + tail // 头尾已覆盖全部行——绝不产生假截断注
51
+ return (
52
+ head +
53
+ `\n… (truncated: ${middle} more lines, ${totalChars} chars total)\n` +
54
+ tail +
55
+ `To see more content, use: read(path, offset=${headLines + 1}, limit=200)`
56
+ )
57
+ }