@thincoder/core 0.9.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (203) hide show
  1. package/abort-provenance.mjs +116 -0
  2. package/advisor/citations.mjs +139 -0
  3. package/advisor/compaction.mjs +174 -0
  4. package/advisor/convergence.mjs +80 -0
  5. package/advisor/history.mjs +77 -0
  6. package/advisor/loop.mjs +288 -0
  7. package/advisor/messages.mjs +299 -0
  8. package/advisor/notice.mjs +141 -0
  9. package/advisor/project-context.mjs +197 -0
  10. package/advisor/repos.mjs +150 -0
  11. package/advisor/run.mjs +190 -0
  12. package/advisor/truncate.mjs +57 -0
  13. package/advisor.mjs +281 -0
  14. package/agent/child-marks.mjs +24 -0
  15. package/agent/completion.mjs +145 -0
  16. package/agent/dispatch.mjs +493 -0
  17. package/agent/family-tools.mjs +174 -0
  18. package/agent/helpers.mjs +412 -0
  19. package/agent/post-turn.mjs +70 -0
  20. package/agent/record-results.mjs +174 -0
  21. package/agent/relay-prefix.mjs +39 -0
  22. package/agent/run-stages.mjs +244 -0
  23. package/agent/setup-reminders.mjs +199 -0
  24. package/agent/setup.mjs +234 -0
  25. package/agent/spawn-child.mjs +258 -0
  26. package/agent/suspension.mjs +240 -0
  27. package/agent/write-gate.mjs +87 -0
  28. package/agent-tools/advisor-async.mjs +481 -0
  29. package/agent-tools/advisor-settle.mjs +240 -0
  30. package/agent-tools/advisor.mjs +280 -0
  31. package/agent-tools/async-discard.mjs +143 -0
  32. package/agent-tools/async-settle.mjs +299 -0
  33. package/agent-tools/batch-segment.mjs +265 -0
  34. package/agent-tools/child-permission.mjs +45 -0
  35. package/agent-tools/consult.mjs +471 -0
  36. package/agent-tools/design-token.mjs +117 -0
  37. package/agent-tools/digest-budget.mjs +76 -0
  38. package/agent-tools/eng.mjs +102 -0
  39. package/agent-tools/escalate-async.mjs +302 -0
  40. package/agent-tools/goal.mjs +119 -0
  41. package/agent-tools/panel-blocks.mjs +24 -0
  42. package/agent-tools/parent-channel.mjs +231 -0
  43. package/agent-tools/plan.mjs +86 -0
  44. package/agent-tools/read-history.mjs +309 -0
  45. package/agent-tools/recent-changes.mjs +24 -0
  46. package/agent-tools/review-facts.mjs +31 -0
  47. package/agent-tools/settings.mjs +268 -0
  48. package/agent-tools/skill.mjs +63 -0
  49. package/agent-tools/spawn-gates.mjs +109 -0
  50. package/agent-tools/subagent-actions.mjs +496 -0
  51. package/agent-tools/subagent-async.mjs +456 -0
  52. package/agent-tools/subagent-panel.mjs +160 -0
  53. package/agent-tools/subagent-run.mjs +208 -0
  54. package/agent-tools/subagent-scheduler.mjs +446 -0
  55. package/agent-tools/subagent-spawn.mjs +478 -0
  56. package/agent-tools/subagent.mjs +419 -0
  57. package/agent-tools/task.mjs +87 -0
  58. package/agent-tools/timer.mjs +46 -0
  59. package/agent-tools/verify.mjs +295 -0
  60. package/agent-tools.mjs +23 -0
  61. package/agent.mjs +430 -0
  62. package/auto-think.mjs +115 -0
  63. package/compress-form.mjs +24 -0
  64. package/config-io.mjs +277 -0
  65. package/config-migrate.mjs +178 -0
  66. package/config-presets.mjs +49 -0
  67. package/config.mjs +419 -0
  68. package/context.mjs +495 -0
  69. package/conventions.mjs +223 -0
  70. package/embedding.mjs +120 -0
  71. package/escape.mjs +152 -0
  72. package/expand-home.mjs +16 -0
  73. package/explore-distill.mjs +152 -0
  74. package/generate-title.mjs +123 -0
  75. package/git/checkpoint.mjs +448 -0
  76. package/git/gitmem.mjs +100 -0
  77. package/history-window.mjs +179 -0
  78. package/hooks.mjs +108 -0
  79. package/i18n.mjs +106 -0
  80. package/index-bin.mjs +48 -0
  81. package/index-discover.mjs +176 -0
  82. package/ledger-cmd.mjs +209 -0
  83. package/ledger-db.mjs +86 -0
  84. package/ledger-surface.mjs +76 -0
  85. package/ledger.mjs +202 -0
  86. package/log.mjs +195 -0
  87. package/manifest.mjs +338 -0
  88. package/markdown.mjs +106 -0
  89. package/mcp/helpers.mjs +51 -0
  90. package/mcp/transport-http.mjs +248 -0
  91. package/mcp/transport-stdio.mjs +140 -0
  92. package/mcp/transport-ws.mjs +122 -0
  93. package/mcp.mjs +295 -0
  94. package/memory/code-index.mjs +219 -0
  95. package/memory/code-sync.mjs +427 -0
  96. package/memory/core.mjs +318 -0
  97. package/memory/delete.mjs +242 -0
  98. package/memory/docs.mjs +431 -0
  99. package/memory/file-walk.mjs +109 -0
  100. package/memory/origin.mjs +24 -0
  101. package/memory/scan.mjs +177 -0
  102. package/memory/schema.mjs +460 -0
  103. package/memory.mjs +21 -0
  104. package/model-ref.mjs +66 -0
  105. package/model-specs.mjs +277 -0
  106. package/package.json +32 -0
  107. package/peer-domains.mjs +265 -0
  108. package/peer-instances.mjs +178 -0
  109. package/permission.mjs +79 -0
  110. package/process-probe.mjs +315 -0
  111. package/prompt-files.mjs +113 -0
  112. package/prompt-overlays.mjs +78 -0
  113. package/prompts/advisor-design.md +43 -0
  114. package/prompts/advisor-round1.md +41 -0
  115. package/prompts/advisor-round2.md +46 -0
  116. package/prompts/advisor-round3.md +42 -0
  117. package/prompts/common.md +158 -0
  118. package/prompts/consult-base.md +19 -0
  119. package/prompts/discipline-engineering.md +123 -0
  120. package/prompts/discipline-normal.md +206 -0
  121. package/prompts/persona-coder.md +21 -0
  122. package/prompts/persona-eng-coder.md +41 -0
  123. package/prompts/persona-eng-designer.md +80 -0
  124. package/prompts/persona-engineering.md +160 -0
  125. package/prompts/persona-explore.md +15 -0
  126. package/prompts/persona-normal.md +35 -0
  127. package/prompts/persona-plan.md +27 -0
  128. package/provider/anthropic.mjs +225 -0
  129. package/provider/core.mjs +491 -0
  130. package/provider/errors.mjs +101 -0
  131. package/provider/google.mjs +257 -0
  132. package/provider/index.mjs +7 -0
  133. package/provider/list-models.mjs +163 -0
  134. package/provider/normalize.mjs +81 -0
  135. package/provider/rate.mjs +168 -0
  136. package/provider/responses.mjs +495 -0
  137. package/provider/retry.mjs +88 -0
  138. package/provider/sse.mjs +264 -0
  139. package/provider/wait-status.mjs +59 -0
  140. package/proxy.mjs +274 -0
  141. package/rules.mjs +53 -0
  142. package/session-gc.mjs +248 -0
  143. package/session-guard.mjs +59 -0
  144. package/session-lifecycle.mjs +305 -0
  145. package/session-migrate.mjs +48 -0
  146. package/session-rename.mjs +38 -0
  147. package/session-segments.mjs +99 -0
  148. package/session-slot-write.mjs +168 -0
  149. package/session-slots-manifest.mjs +264 -0
  150. package/session-slots.mjs +298 -0
  151. package/session-store.mjs +441 -0
  152. package/session.mjs +244 -0
  153. package/skills.mjs +234 -0
  154. package/text-budget.mjs +79 -0
  155. package/token-ttl.mjs +285 -0
  156. package/tool-docs/apply_patch.md +15 -0
  157. package/tool-docs/bash.md +38 -0
  158. package/tool-docs/delete.md +13 -0
  159. package/tool-docs/edit.md +30 -0
  160. package/tool-docs/execute.md +21 -0
  161. package/tool-docs/fetch.md +12 -0
  162. package/tool-docs/file_ops.md +17 -0
  163. package/tool-docs/get_current_time.md +8 -0
  164. package/tool-docs/git.md +54 -0
  165. package/tool-docs/glob.md +11 -0
  166. package/tool-docs/grep.md +19 -0
  167. package/tool-docs/hashline_edit.md +14 -0
  168. package/tool-docs/insert_after.md +15 -0
  169. package/tool-docs/lint.md +10 -0
  170. package/tool-docs/ls.md +12 -0
  171. package/tool-docs/lsp.md +10 -0
  172. package/tool-docs/process.md +10 -0
  173. package/tool-docs/question.md +16 -0
  174. package/tool-docs/read.md +20 -0
  175. package/tool-docs/read_image.md +8 -0
  176. package/tool-docs/tree.md +14 -0
  177. package/tool-docs/wait_for.md +22 -0
  178. package/tool-docs/websearch.md +16 -0
  179. package/tool-docs/write.md +11 -0
  180. package/tools/bash.mjs +276 -0
  181. package/tools/edit-batch.mjs +204 -0
  182. package/tools/edit-diff.mjs +388 -0
  183. package/tools/exec-run.mjs +43 -0
  184. package/tools/execute.mjs +243 -0
  185. package/tools/file.mjs +464 -0
  186. package/tools/git-checkpoint.mjs +143 -0
  187. package/tools/git-ext.mjs +173 -0
  188. package/tools/git.mjs +415 -0
  189. package/tools/glob-dialect.mjs +130 -0
  190. package/tools/index.mjs +76 -0
  191. package/tools/linter.mjs +120 -0
  192. package/tools/lsp.mjs +335 -0
  193. package/tools/ops.mjs +293 -0
  194. package/tools/patch.mjs +290 -0
  195. package/tools/question.mjs +26 -0
  196. package/tools/repomap.mjs +314 -0
  197. package/tools/search.mjs +248 -0
  198. package/tools/shared.mjs +467 -0
  199. package/tools/tree.mjs +81 -0
  200. package/tools/web.mjs +224 -0
  201. package/tools/write-path.mjs +191 -0
  202. package/traces/trace-store.mjs +303 -0
  203. package/undo-stack.mjs +47 -0
package/context.mjs ADDED
@@ -0,0 +1,495 @@
1
+ /**
2
+ * context.mjs — Context management and compaction
3
+ * When no measured token count is available, use estimation as fallback (ASCII/4 + non-ASCII/1, no tokenizer dependency).
4
+ * When a measured value exists (response usage.prompt_tokens), trust it — estimation underestimates CJK by 3-4x and relying solely on it may never trigger compaction.
5
+ * Compaction strategy: summarize everything before the tail into one LLM note, keep the latest N messages verbatim.
6
+ * NOTE: no dedicated head is kept (KEEP_HEAD = 0) — in multi-task sessions the earliest messages are
7
+ * typically a COMPLETED earlier task; preserving them verbatim anchored the model's attention on stale
8
+ * work after compaction. The earliest messages now go into the summary (which distinguishes completed
9
+ * vs in-progress work), so the post-compaction context anchors on the current task (recent tail) only.
10
+ */
11
+
12
+ import { chat } from "./provider/index.mjs"
13
+ import { estimateText } from "./provider/rate.mjs"
14
+ import { providerSpec } from "./config.mjs"
15
+ import { buildCompressMessages } from "./compress-form.mjs"
16
+
17
+ const IMAGE_TOKEN_ESTIMATE = 2000 // rough estimate for image content tokens (CLI legacy 256 underestimated real image costs, delaying compaction)
18
+
19
+ /** Rough token count for a list of messages (body + reasoning + tool_calls params) */
20
+ export function estimateTokens(messages) {
21
+ let tokens = 0
22
+ for (const m of messages) {
23
+ if (typeof m.content === "string") tokens += estimateText(m.content)
24
+ else if (Array.isArray(m.content)) {
25
+ for (const part of m.content) {
26
+ if (part.type === "text") tokens += estimateText(part.text)
27
+ else if (part.type === "image_url") tokens += IMAGE_TOKEN_ESTIMATE
28
+ }
29
+ }
30
+ if (typeof m.reasoning_content === "string") tokens += estimateText(m.reasoning_content)
31
+ for (const tc of m.tool_calls ?? []) {
32
+ tokens += estimateText(tc.function?.name ?? "") + estimateText(tc.function?.arguments ?? "")
33
+ }
34
+ }
35
+ return tokens
36
+ }
37
+
38
+ const KEEP_HEAD = 0 // No dedicated head: earliest messages may be a COMPLETED earlier task in multi-task
39
+ // sessions — keeping them verbatim anchored attention on stale work. Everything before the tail is
40
+ // summarized (the summary itself distinguishes completed vs in-progress work; see SUMMARIZE_PROMPT).
41
+ // Tail count formula (D4): window-adaptive (~30 msgs per 100K — old fixed 10 too thin on 1M), capped
42
+ // at 40% of history; §6.4④ D-T1/D-T2 make the count only a CANDIDATE — a token budget (TAIL_BUDGET_FRACTION
43
+ // × window − SUMMARY_TOKEN_ESTIMATE ≈1K, §6.9) tightens it over pair-safe boundaries when compaction runs,
44
+ // never below TAIL_FLOOR_MESSAGES; ordinary sessions never reach it (D-T4: trigger 0.6 untouched).
45
+ const TAIL_BUDGET_FRACTION = 0.15
46
+ const SUMMARY_TOKEN_ESTIMATE = 1000 // §6.9: summary output target ~1K tokens — reserved from the 15%
47
+ const TAIL_FLOOR_MESSAGES = 10 // §6.4④ D-T2: the tail keeps ≥10 verbatim messages — floor beats budget
48
+ function keepTailSize(provider, historyLen) {
49
+ // provider is guaranteed at every call site (runAgent always builds one); providerSpec
50
+ // degrades to DEFAULT_SPEC (128K) only if provider is somehow absent — acceptable
51
+ // because the 40% history cap still bounds the tail. providers[].context override
52
+ // (K units) is honored here (PROVIDER.md §6.15 T-C2: tail formula follows the window).
53
+ const ctxWindow = providerSpec(provider).context
54
+ return Math.min(Math.max(10, Math.floor((ctxWindow / 100_000) * 30)), Math.floor(historyLen * 0.4))
55
+ }
56
+ // §6.4④ D-T1 tail token budget: window×15% − summary ~1K — the compressed history segment (summary + placeholder + tail) lands ≈ 15% (B 口径 §6.4④).
57
+ function tailBudgetTokens(provider) {
58
+ return Math.max(0, Math.floor(providerSpec(provider).context * TAIL_BUDGET_FRACTION) - SUMMARY_TOKEN_ESTIMATE)
59
+ }
60
+
61
+ export const SUMMARIZE_PROMPT = `The conversation above is our work log so far — summarize it into a compact summary for use as context in the ongoing conversation.
62
+ Requirements:
63
+ - Write in first person, present tense — these are "my" handover notes, continuing my own train of thought
64
+ - Most important: preserve design decisions and their reasons — architecture choices, API contracts, naming conventions, trade-off rationale. These are the anchors the subsequent code must not deviate from
65
+ - Distinguish COMPLETED vs IN-PROGRESS work: completed tasks get a ONE-LINE recap each (what was done, key outcome); spend the detail budget on unresolved issues, next steps, and the CURRENT task
66
+ - The user's most recent request defines the current task — anchor on it. Earlier requests are likely already completed and only need the one-line recap; do NOT preserve them at full fidelity
67
+ - Explicitly list FILES CHANGED: every modified file path plus a one-line "why" — so post-compaction work can re-locate what was edited and where
68
+ - Explicitly list UNRESOLVED ISSUES / TODOs: anything still open plus the next steps — so post-compaction recovery knows where to resume
69
+ - Drop: pleasantries, repetition, fine-grained tool output details
70
+ - Honestly mark uncertain items: anything not actually verified must say "unverified"; do not present guesses as facts
71
+ - Use bullet-point output. Stay under ~1K tokens (≈1000 Chinese chars / 4000 ASCII chars) — a hard target. An oversized summary wastes window and dilutes the tail; the old unbounded-length guidance is deprecated. When over budget, trim in this order: completed recaps to one line; FILES CHANGED why-notes to bare paths; in-progress prose tightened. NEVER cut design anchors or UNRESOLVED ISSUES/TODOs — recovery depends on them.
72
+ `
73
+
74
+ /** Context prefix after compaction, informing the agent what happened */
75
+ const COMPACTION_PREFIX =
76
+ "[Context was automatically compacted. Below is a summary of earlier work. " +
77
+ "Treat it as notes, not proof — trust its conclusions (don't redo what it reports as done) " +
78
+ "but re-verify transient state with tools. Check memory search for any missing decisions.]\n\n"
79
+
80
+ /** Placeholder assistant reply committed right after compaction (D9); D-CC18 merges it into an adjacent tail assistant instead of emitting it as a separate message */
81
+ const COMPACTION_PLACEHOLDER = "Understood. I'll continue from these notes, re-verifying anything transient."
82
+
83
+ /** After this many consecutive compaction summary failures, degrade to deterministic truncation (losing info is better than task-killing 400 errors) */
84
+ export const COMPRESS_FAILURE_LIMIT = 3
85
+
86
+ /** Task re-injection reminder prefix (after compaction, clear old versions from history first for a single source of truth) */
87
+ const TASK_REINJECT_PREFIX = "[System reminder: your current task list after compaction:"
88
+
89
+ /** Truncation fallback note (used when the summary LLM fails repeatedly; no LLM call) */
90
+ const FALLBACK_NOTE =
91
+ "[Context was truncated after repeated summarization failures. " +
92
+ "The middle portion of earlier work was dropped WITHOUT a summary. " +
93
+ "Re-verify any state you need with tools before relying on it.]\n\n"
94
+
95
+ /**
96
+ * Split history into head / middle (to be summarized) / tail; return null if no middle to compress.
97
+ * head is normally empty (KEEP_HEAD = 0 — earliest messages go into the summary); the tool_calls-extension logic below is defensive for future KEEP_HEAD > 0.
98
+ * The tail boundary must include any assistant whose tool results are in the tail — if the assistant is in the middle, the summary swallows it, leaving orphan tool results → protocol 400.
99
+ * `budgetTokens` (optional, §6.4④ D-T1): when the candidate's estimate exceeds it, the boundary moves
100
+ * forward until the tail fits — never below the D-T2 floor (10 msgs, or the candidate itself when
101
+ * the 40% cap made it < 10 — short history).
102
+ */
103
+ function splitHistory(history, keepTail, budgetTokens = null) {
104
+ if (history.length <= KEEP_HEAD + keepTail + 1) return null
105
+ let headEnd = KEEP_HEAD
106
+ // head must not end with dangling tool_calls: when assistant declares tool_calls, all its tool results must stay in head.
107
+ // Parallel calls: one assistant followed by multiple tool messages — accepting only one still causes 400, must collect all
108
+ if (history[headEnd - 1]?.role === "assistant" && history[headEnd - 1].tool_calls?.length) {
109
+ while (headEnd < history.length && history[headEnd].role === "tool") headEnd++
110
+ }
111
+ const candidate = repairedTailStart(history, headEnd, history.length - keepTail)
112
+ if (candidate <= headEnd) return null
113
+ let tailStart = candidate
114
+ // §6.4④ D-T1: tighten only above the floor — a candidate ≤ 10 IS the floor (short history under the 40% cap must not tighten further, review #5); the floor is D5-repaired too.
115
+ if (budgetTokens > 0 && keepTail > TAIL_FLOOR_MESSAGES) {
116
+ const floor = repairedTailStart(history, headEnd, history.length - TAIL_FLOOR_MESSAGES)
117
+ if (floor > candidate) tailStart = tightenTailByBudget(history, candidate, floor, budgetTokens)
118
+ }
119
+ return { headEnd, tailStart }
120
+ }
121
+
122
+ /**
123
+ * D5 tail-side pairing repair for a raw cut at history.length − tailCount: pull into the tail any
124
+ * assistant whose tool results are in the tail (the summary swallowing the owner leaves orphan tool
125
+ * results → protocol 400), then skip orphan tool messages at the new boundary. Single-assistant
126
+ * assumption (nearest owner only — a tail spans at most one assistant→tools cycle); bounds-guarded.
127
+ */
128
+ function repairedTailStart(history, headEnd, tailStart) {
129
+ const tailToolIds = new Set()
130
+ for (let i = tailStart; i < history.length; i++) {
131
+ if (history[i].role === "tool") tailToolIds.add(history[i].tool_call_id)
132
+ }
133
+ for (let i = tailStart - 1; i > headEnd; i--) {
134
+ const m = history[i]
135
+ if (m.role === "assistant" && m.tool_calls?.some((tc) => tailToolIds.has(tc.id))) {
136
+ tailStart = i
137
+ break
138
+ }
139
+ }
140
+ while (tailStart < history.length && tailStart > headEnd && history[tailStart].role === "tool") {
141
+ tailStart++
142
+ }
143
+ return tailStart
144
+ }
145
+
146
+ /**
147
+ * §6.4④ D-T1 budget tightening (pair-safe, review #2): walk the boundary FORWARD (fewer tail messages —
148
+ * the rest joins the summary) while the tail's estimated tokens exceed the budget. Only pair-safe
149
+ * positions may stop the walk: a boundary ON a tool message would orphan its owner assistant into the
150
+ * middle (D5); pairing is contiguous in the machine line (§6.4③) — every non-tool boundary is safe.
151
+ * No fit before the floor → keep the floor, accept the overrun.
152
+ */
153
+ function tightenTailByBudget(history, start, floorStart, budgetTokens) {
154
+ const suffixTokens = new Array(history.length + 1)
155
+ suffixTokens[history.length] = 0
156
+ for (let i = history.length - 1; i >= 0; i--) suffixTokens[i] = suffixTokens[i + 1] + estimateTokens([history[i]])
157
+ if (suffixTokens[start] <= budgetTokens) return start // already fits — ordinary sessions stay untouched (D-T2)
158
+ for (let p = start + 1; p <= floorStart; p++) { // first fit keeps the most recent verbatim context
159
+ if (history[p].role !== "tool" && suffixTokens[p] <= budgetTokens) return p
160
+ }
161
+ return floorStart
162
+ }
163
+
164
+ /**
165
+ * pushReal — the single entry point for REAL conversation messages.
166
+ * A real message (user input, assistant reply, tool result, multimodal image) is appended to BOTH:
167
+ * agent.history — the machine context (compaction shrinks this)
168
+ * agent._fullHistory — the human-readable record (persistence source)
169
+ * Machine-only messages ([System reminder:...], compaction notes, task/plan/checkpoint re-injections)
170
+ * are pushed directly to agent.history WITHOUT going through here, so they never enter _fullHistory.
171
+ * The two lines are written independently at the source — no after-the-fact delta sync.
172
+ * Message timestamps (SESSION.md §6.9): stamped HERE once at push time (epoch ms) — a single
173
+ * point covers every real message. Pre-existing ts (e.g. from another end writing the shared slot)
174
+ * is preserved; restored old messages keep no ts rather than getting a misleading backdate (D-S3).
175
+ * ts is a LOCAL-ONLY field — the send layer strips it before any provider request (T-S3).
176
+ *
177
+ * TUI-OOM-ROOTCAUSE 批(SESSION.md §6.14)——人读线内存有界 + 磁盘为准:
178
+ * ① `agent._recordStore?.append(msg)`:记录同步追加(磁盘为准——append-only sidecar);
179
+ * ② 窗口驱逐:绑定态(agent._historyWindow = 200)下 _fullHistory 只保最近窗口条——
180
+ * 更早内容仅存磁盘(翻页/检索/保存从盘按需读)。未绑定(模式 F)不驱逐(零回归)。
181
+ * 追加失败不阻断回合(独立 try/catch——尽力面 N-S6;store 内部另置 degraded 并停写)。
182
+ */
183
+ export function pushReal(agent, msg) {
184
+ if (!Array.isArray(agent._fullHistory)) agent._fullHistory = []
185
+ if (msg && msg.ts === undefined) msg.ts = Date.now()
186
+ agent._fullHistory.push(msg)
187
+ try { agent._recordStore?.append(msg) } catch { /* 尽力面:落盘失败不阻断回合(N-S6) */ }
188
+ const win = agent._historyWindow
189
+ if (win > 0 && agent._fullHistory.length > win) {
190
+ agent._fullHistory.splice(0, agent._fullHistory.length - win)
191
+ }
192
+ agent.history.push(msg)
193
+ }
194
+
195
+ /**
196
+ * Content-shape-safe prefixing (D-CC18, generalized for D-CC19 merge reuse):
197
+ * string → text + blank line + original; multimodal array → text part prepended;
198
+ * empty string / null / undefined / other → text alone.
199
+ */
200
+ function prefixContent(content, text) {
201
+ if (typeof content === "string" && content.length > 0) return `${text}\n\n${content}`
202
+ if (Array.isArray(content)) return [{ type: "text", text }, ...content]
203
+ return text
204
+ }
205
+
206
+ /**
207
+ * D-CC18 echo safety: prefix the placeholder onto an existing assistant message's content.
208
+ * Thin wrapper over prefixContent (behavioral semantics unchanged).
209
+ */
210
+ function withPlaceholderPrefix(content) {
211
+ return prefixContent(content, COMPACTION_PLACEHOLDER)
212
+ }
213
+
214
+ /**
215
+ * D-CC19 restore-path echo merge (2026-09-16 ENGINE-DEBT 批 ED-1): persisted `contextHistory`
216
+ * is loaded back VERBATIM on session restore, so the D-CC18 pathological shape (an assistant
217
+ * WITHOUT reasoning_content directly followed by another assistant — DeepSeek-family thinking
218
+ * mode rejects the first request with 400) can revive from disk. Pure in-core function: scans
219
+ * only at restore time, never prompts the user, never rewrites the session file. Merge direction
220
+ * matches D-CC18 (the reasoning-less message is absorbed INTO its follower — the follower's
221
+ * tool_calls / reasoning_content / other fields are kept verbatim; texts joined with a blank
222
+ * line). Iterates to a fixed point (chains collapse in full). Copy-on-write: messages may be
223
+ * shared with other lines, so the merged message is always a NEW object; clean input returns
224
+ * the SAME array reference (zero copy).
225
+ */
226
+
227
+ /** Pair predicate: prev = assistant with no/empty reasoning_content and no tool_calls,
228
+ * directly followed by another assistant. (Prev WITH tool_calls is never merged — pairing
229
+ * safety, F-3.) */
230
+ export function isAssistantEchoPair(prev, next) {
231
+ return prev?.role === "assistant"
232
+ && next?.role === "assistant"
233
+ && !prev.reasoning_content
234
+ && !(Array.isArray(prev.tool_calls) && prev.tool_calls.length > 0)
235
+ }
236
+
237
+ /** Text of a message content in any supported shape (string / parts array / null-ish). */
238
+ function contentTextOf(content) {
239
+ if (typeof content === "string") return content
240
+ if (Array.isArray(content)) {
241
+ return content.filter((p) => p?.type === "text").map((p) => p.text ?? "").join("\n\n")
242
+ }
243
+ return ""
244
+ }
245
+
246
+ /** Absorb prev's text into next's content (blank-line join; empty prev text → next unchanged). */
247
+ function absorbEchoContent(prevContent, nextContent) {
248
+ const text = contentTextOf(prevContent)
249
+ if (text.length === 0) return nextContent
250
+ return prefixContent(nextContent, text)
251
+ }
252
+
253
+ export function mergeAdjacentAssistantEchoes(history) {
254
+ if (!Array.isArray(history)) return history
255
+ let src = history
256
+ for (;;) {
257
+ let changed = false
258
+ const next = []
259
+ for (let i = 0; i < src.length; i++) {
260
+ if (i + 1 < src.length && isAssistantEchoPair(src[i], src[i + 1])) {
261
+ next.push({ ...src[i + 1], content: absorbEchoContent(src[i].content, src[i + 1].content) })
262
+ i += 1
263
+ changed = true
264
+ } else {
265
+ next.push(src[i])
266
+ }
267
+ }
268
+ if (!changed) return src === history ? history : src
269
+ src = next
270
+ }
271
+ }
272
+
273
+ /** Replace middle with a note, then re-inject task/plan state (shared by LLM summary and truncation fallback) */
274
+ function applyCompression(agent, headEnd, tailStart, note) {
275
+ // _fullHistory already holds every real message (written at the source via pushReal),
276
+ // so compaction only shrinks the machine line — nothing to preserve here.
277
+ // head is normally empty (KEEP_HEAD = 0) — the summary note becomes the first message,
278
+ // which is exactly the intent: post-compaction context anchors on the current task, not on
279
+ // possibly-completed earlier requests.
280
+ const head = agent.history.slice(0, headEnd)
281
+ const tail = agent.history.slice(tailStart)
282
+ // SESSION.md §6.9: compaction-injected messages carry a ts — Date.now() at the compaction
283
+ // moment (the note below; and the separate "Understood" placeholder in the non-merge branch).
284
+ // They are machine-only (never in _fullHistory), but the machine-line timeline stays consistent
285
+ // for any audit use. D-CC18 exception: in the merge branch the placeholder rides inside a REAL
286
+ // tail message, which keeps its OWN ts — merging must not rewrite that message's time semantics
287
+ // (and must not backfill ts-less restored messages either, D-S3).
288
+ const now = Date.now()
289
+ // D-CC18 echo safety: an assistant WITHOUT reasoning_content directly followed by another
290
+ // assistant is rejected 400 by DeepSeek-family thinking mode ("The `reasoning_content` in the
291
+ // thinking mode must be passed back to the API." — traced from a subagent's first post-compaction
292
+ // request, 2026-09-16). The synthetic placeholder has no reasoning to echo, so when the tail
293
+ // starts with an assistant the placeholder is merged INTO that message — copy-on-write, because
294
+ // pushReal shares message objects with _fullHistory (never mutate in place) — instead of being
295
+ // emitted as a separate message.
296
+ //
297
+ // Compaction REBUILDS the machine line (head + note + placeholder + tail), so the pre-compaction
298
+ // _runStartHistoryLen index is stale — a longer array shrank beneath it, and end-of-run exploration
299
+ // distillation would then silently skip or slice from the wrong offset. Reset the boundary to the
300
+ // first verbatim tail message: head.length + 1 in the merge branch (the placeholder rides inside
301
+ // the rewritten tail[0], which keeps its index), head.length + 2 otherwise (the note and the
302
+ // separate "Understood" placeholder sit between head and tail). Exploration before the tail was
303
+ // already covered by the compaction summary, so only the still-raw tail needs distilling. `head`
304
+ // is empty today (KEEP_HEAD = 0) — the formula stays correct if KEEP_HEAD ever grows.
305
+ // (shrinkOversized only truncates message bodies in place and leaves the array length unchanged,
306
+ // so this boundary stays valid there — no reset needed.)
307
+ if (tail[0]?.role === "assistant") {
308
+ const merged = { ...tail[0], content: withPlaceholderPrefix(tail[0].content) }
309
+ agent.history = [
310
+ ...head,
311
+ { role: "user", content: note, ts: now },
312
+ merged,
313
+ ...tail.slice(1),
314
+ ]
315
+ agent._runStartHistoryLen = head.length + 1
316
+ } else {
317
+ agent.history = [
318
+ ...head,
319
+ { role: "user", content: note, ts: now },
320
+ { role: "assistant", content: COMPACTION_PLACEHOLDER, ts: now },
321
+ ...tail,
322
+ ]
323
+ agent._runStartHistoryLen = head.length + 2
324
+ }
325
+ // Measured token baseline is invalidated along with old history (prompt_tokens were for pre-compaction context), fall back to estimation until next response
326
+ agent._lastPromptTokens = null
327
+ agent._usageAtLen = null
328
+
329
+ // After compaction, re-inject the task list (the agent needs to know what it was doing).
330
+ // Single source of truth: first remove any stale re-injections from the tail, then inject the latest version —
331
+ // no longer embedded in the summary body (would duplicate and grow stale)
332
+ agent.history = agent.history.filter(
333
+ (m) => !(m.role === "user" && typeof m.content === "string" && m.content.startsWith(TASK_REINJECT_PREFIX))
334
+ )
335
+ if (agent.tasks.length > 0) {
336
+ const taskSummary = agent.tasks.map((t) => `- [${t.status}] ${t.title}`).join("\n")
337
+ agent.history.push({
338
+ role: "user",
339
+ content: `${TASK_REINJECT_PREFIX}\n${taskSummary}\nContinue from where you left off.]`,
340
+ })
341
+ }
342
+
343
+ // Plan mode compaction: re-inject plan mode guidance
344
+ if (agent.planMode) {
345
+ agent.history.push({
346
+ role: "user",
347
+ content: "[System reminder: plan mode is active. Explore the codebase read-only, design your solution, then call plan with action='exit' to present it for user approval.]",
348
+ })
349
+ }
350
+ }
351
+
352
+ /**
353
+ * If history exceeds threshold, compact it. Returns whether compaction happened.
354
+ * Only called at safe points in the loop (history ends with user or tool message — a complete exchange boundary).
355
+ * Automatically re-injects task list state after compaction.
356
+ * @param {object} agent
357
+ * @param {number} threshold - compaction threshold in tokens
358
+ * @param {object} callbacks - { onToken, onReasoning, onCompress, onCompressStart } — summary
359
+ * generation is SILENT (never forwards onToken/onReasoning: the compaction process is an
360
+ * internal mechanism, not a model reply); onCompressStart fires right before the summary call
361
+ * (§6.8 D-C1, compression lifecycle visibility — panel start state)
362
+ * @param {object} extras - { systemPrompt?, tools? } — estimated overhead for the pure-estimation
363
+ * path (no measured baseline); the measured path already includes system+tools in prompt_tokens.
364
+ */
365
+ export async function compressIfNeeded(agent, threshold, callbacks, extras = {}, signal) {
366
+ const history = agent.history
367
+ // Prefer the real baseline: the last response's prompt_tokens is the measured value for the full context (system+tools+history).
368
+ // Subsequent appended messages use estimation as increment; when no measured value exists (first turn / after restore / right after compaction), fall back to pure estimation
369
+ const overhead =
370
+ (extras.systemPrompt ? estimateText(extras.systemPrompt) : 0) +
371
+ (extras.tools ? estimateText(JSON.stringify(extras.tools)) : 0)
372
+ const tokens =
373
+ agent._lastPromptTokens != null
374
+ ? agent._lastPromptTokens + estimateTokens(history.slice(agent._usageAtLen ?? history.length))
375
+ : estimateTokens(history) + overhead
376
+ if (tokens <= threshold) return false
377
+
378
+ const keepTail = keepTailSize(agent.provider, history.length)
379
+ const split = splitHistory(history, keepTail, tailBudgetTokens(agent.provider))
380
+ if (!split) {
381
+ // History is too short (≤KEEP_HEAD+keepTail+1 messages) to find a middle section, but tokens exceed threshold — typically a single giant message
382
+ // (large paste / huge injection). When summarization has no room, degrade to deterministic shrinking to ensure context always reduces
383
+ return shrinkOversized(agent)
384
+ }
385
+
386
+ const middle = history.slice(split.headEnd, split.tailStart)
387
+
388
+ // Compression request form v2/v3 (§6.14 / D-CC20): continuation — same system + same tools declaration (the turn's own
389
+ // array) + the middle's verbatim messages + one tail instruction. 首现即复用回合所建前缀 ⟺ 带 tools 声明(无 tool_choice)
390
+ // 且 `reasoning_effort` 与回合侧同值——v2 探针复测:8 格 84–98%(读数详表见批次档 §5);不带 tools / 带 tool_choice:"none" / effort 异值 ⇒ 首现 0%(自建项复跑例外);无 extras.tools ⇒ 不发 tools。
391
+ // ⚠️ **不带 `tool_choice`**(设计 §6.14 备选② · 预注册判定规则「S3 <0.9 且 S4 ≥0.9 ⇒ 采纳 v2 减 tool_choice」启用——实测该参数使服务端丢弃 tools 区 ⇒ 首现命中 0%)。
392
+ // ⚠️ v3(父侧 2026-09-18 裁定):**随带与回合请求同源的 `reasoning_effort`**——下行不再覆盖 `agent.provider.reasoningEffort`(回合调用 `chat(agent.provider, …)` 同字段;
393
+ // 不硬编码、未配置 ⇒ 缺省同修前。实证:deepseek 同值 95.69% / 异值首现 0%;族差/窗差:百炼 qwen 另有 `enable_thinking` 派生差、autoThink 的 turn 0 有改写窗口——登记见批次档 §5 上抛)。
394
+ // Silent by design (D11): no onToken/onReasoning — the compaction process must not stream to the frontend.
395
+ // signal propagates user cancellation (Ctrl+C) to the in-flight summary call.
396
+ // Compression visibility (CONTEXT-COMPACTION.md §6.8 D-C1/D-C2): the frontend learns the compression
397
+ // STARTED right before the summary LLM call ("Compressing context… / summarizing N messages" panel) — only
398
+ // the lifecycle is surfaced, never the summary body. N = the number of history messages being summarized.
399
+ callbacks?.onCompressStart?.({ messages: middle.length })
400
+ const startedAt = performance.now()
401
+ const summary = await chat({ ...agent.provider, thinking: null }, {
402
+ messages: buildCompressMessages(history, split.tailStart, extras?.systemPrompt, SUMMARIZE_PROMPT),
403
+ tools: extras?.tools,
404
+ signal,
405
+ // §18.6 D-TR4:轨迹元数据增补——kind=compress(上下文构建面——agent 元数据透出;
406
+ // depth 经 extras.traceDepth——agent.mjs 主作用域传入——compress 调用点补齐)
407
+ logCtx: {
408
+ stage: "compress", child: agent._logId, kind: "compress",
409
+ role: agent._role ?? null, depth: extras?.traceDepth ?? null,
410
+ session: agent._sessionStart ?? null, cwd: agent.cwd,
411
+ traces: agent.config?.traces?.enabled !== false,
412
+ },
413
+ })
414
+
415
+ // Blank-summary guard (§6.14 退化面 3): a blank summary would land in applyCompression as
416
+ // "middle dropped + empty note" and still count as success — throw into the failure chain instead.
417
+ if (!summary.content?.trim()) throw new Error("compaction summary is empty")
418
+
419
+ applyCompression(agent, split.headEnd, split.tailStart, COMPACTION_PREFIX + summary.content)
420
+
421
+ // Completion info for the compression panel (D-C2): tokens freed = the pre-compression prompt
422
+ // estimate (`tokens` — the value that tripped the threshold, incl. system/tools overhead on the
423
+ // pure-estimation path) minus the post-compression estimate on the same basis. Elapsed = the
424
+ // summary call + splice duration. agent.mjs forwards this to onCompress unchanged.
425
+ agent._lastCompressInfo = {
426
+ mode: "summary",
427
+ tokensFreed: Math.max(0, Math.round(tokens - (estimateTokens(agent.history) + overhead))),
428
+ elapsedMs: performance.now() - startedAt,
429
+ }
430
+ return true
431
+ }
432
+
433
+ /**
434
+ * Deterministic truncation fallback: called when the summary LLM fails repeatedly, no network call.
435
+ * Drops the middle so the task can continue. Returns whether truncation happened.
436
+ */
437
+ export function compressFallback(agent) {
438
+ const keepTail = keepTailSize(agent.provider, agent.history.length)
439
+ const split = splitHistory(agent.history, keepTail, tailBudgetTokens(agent.provider))
440
+ if (!split) return false
441
+ const tailMessages = agent.history.length - split.tailStart
442
+ applyCompression(agent, split.headEnd, split.tailStart, FALLBACK_NOTE)
443
+ // Fallback completion info (D-C2): mode marks the deterministic-truncation path — the panel
444
+ // shows the degradation note ("truncated to N messages") ONLY after 3 consecutive failures.
445
+ agent._lastCompressInfo = { mode: "fallback", tailMessages }
446
+ return true
447
+ }
448
+
449
+ /** Hard truncation limit for a single message body: when exceeded and the splitter can't find a middle section, truncate to a stub (prevents one giant message from blocking compaction) */
450
+ const OVERSIZE_CONTENT_LIMIT = 8_000
451
+
452
+ /**
453
+ * Deterministic shrinking: last resort when splitHistory can't find a middle section (history too short) but threshold is exceeded. No LLM call.
454
+ * Truncates user/tool message bodies exceeding OVERSIZE_CONTENT_LIMIT to a stub (keeps head + tail);
455
+ * does not touch reasoning_content (DeepSeek/Kimi echo protocol) or tool_calls pairing structure — no protocol 400
456
+ * risk from this path (this module's echo-safety face is `applyCompression`: the compaction placeholder is never
457
+ * committed as a separate reasoning-less assistant next to another assistant — D-CC18, 2026-09-16 batch).
458
+ * Only called after compressIfNeeded determines threshold is exceeded. Returns whether any message was truncated.
459
+ */
460
+ function shrinkOversized(agent, limit = OVERSIZE_CONTENT_LIMIT) {
461
+ let shrunk = false
462
+ // Copy-on-write: build a NEW array and replace only truncated entries. pushReal stores the SAME
463
+ // message object in both `agent.history` (machine line) and `agent._fullHistory` (human/persistence
464
+ // line), so in-place `m.content = ...` would ALSO truncate the never-compacted human line and lose
465
+ // the original pasted content on session persist (session.mjs persists _fullHistory). VS Code port
466
+ // already copies (`history.map(m => ({ ...m }))`); this brings CLI to parity.
467
+ const next = agent.history.map((m) => {
468
+ if ((m.role !== "user" && m.role !== "tool") || typeof m.content !== "string") return m
469
+ if (m.content.length <= limit) return m
470
+ // Truncate keeping head + tail, insert stub in between; keepHead/keepTail proportional but not exceeding 50%/25% of limit
471
+ const keepHead = Math.min(Math.floor(limit * 0.5), 4000)
472
+ const keepTail = Math.min(Math.floor(limit * 0.25), 2000)
473
+ shrunk = true
474
+ return {
475
+ ...m,
476
+ content:
477
+ m.content.slice(0, keepHead) +
478
+ `\n[... ${m.content.length - keepHead - keepTail} chars truncated — single message too large for context window ...]\n` +
479
+ m.content.slice(-keepTail),
480
+ }
481
+ })
482
+ if (shrunk) {
483
+ agent.history = next
484
+ // Same as compaction: measured token baseline is invalidated by the changed history, fall back to estimation until next response
485
+ agent._lastPromptTokens = null
486
+ agent._usageAtLen = null
487
+ }
488
+ return shrunk
489
+ }
490
+
491
+ // ─── End-of-run exploration distillation(2026-09-05 module-split:524 > 500 硬限——verbatim
492
+ // 迁至 explore-distill.mjs,语义零变——VS Code compact.mjs 同款联动;cross-repo parity 锚改指
493
+ // explore-distill.mjs——消费方 import 面不变(re-export))───────────────────────
494
+
495
+ export { summarizeRunExplorations, EXPLORE_TOOLS, EXPLORE_SUMMARY_PROMPT } from "./explore-distill.mjs"