thincoder 0.12.54 → 0.12.59

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 (129) hide show
  1. package/CHANGELOG.md +98 -0
  2. package/README.md +1 -1
  3. package/bin/thincoder.mjs +25 -3
  4. package/package.json +3 -7
  5. package/src/acp/bridge.mjs +132 -26
  6. package/src/advisor/messages.mjs +38 -3
  7. package/src/advisor/run.mjs +91 -53
  8. package/src/advisor.mjs +15 -7
  9. package/src/agent/dispatch.mjs +156 -39
  10. package/src/agent/helpers.mjs +46 -4
  11. package/src/agent/setup.mjs +102 -19
  12. package/src/agent/spawn-child.mjs +28 -1
  13. package/src/agent-tools/advisor.mjs +43 -11
  14. package/src/agent-tools/consult.mjs +37 -6
  15. package/src/agent-tools/eng.mjs +4 -1
  16. package/src/agent-tools/goal.mjs +11 -1
  17. package/src/agent-tools/read-history.mjs +160 -0
  18. package/src/agent-tools/settings.mjs +162 -0
  19. package/src/agent-tools/skill.mjs +2 -1
  20. package/src/agent-tools/subagent-actions.mjs +432 -0
  21. package/src/agent-tools/subagent-async.mjs +427 -0
  22. package/src/agent-tools/subagent-scheduler.mjs +319 -0
  23. package/src/agent-tools/subagent.mjs +565 -128
  24. package/src/agent-tools/task.mjs +4 -3
  25. package/src/agent-tools/timer.mjs +9 -4
  26. package/src/agent-tools/verify.mjs +161 -49
  27. package/src/agent-tools.mjs +1 -0
  28. package/src/agent.mjs +182 -81
  29. package/src/auto-think.mjs +14 -0
  30. package/src/cli/make-agent.mjs +27 -1
  31. package/src/cli/memory-command.mjs +28 -7
  32. package/src/cli/permission.mjs +8 -1
  33. package/src/config.mjs +125 -8
  34. package/src/context.mjs +115 -34
  35. package/src/distill.mjs +19 -1
  36. package/src/escape.mjs +82 -27
  37. package/src/log.mjs +195 -0
  38. package/src/mcp/transport-http.mjs +13 -1
  39. package/src/mcp.mjs +52 -7
  40. package/src/memory/code-sync.mjs +1 -1
  41. package/src/memory/core.mjs +204 -10
  42. package/src/memory/docs.mjs +197 -62
  43. package/src/memory.mjs +1 -1
  44. package/src/model-specs.mjs +38 -1
  45. package/src/prompts/advisor-design.md +46 -0
  46. package/src/prompts/advisor-round1.md +49 -2
  47. package/src/prompts/advisor-round2.md +47 -0
  48. package/src/prompts/advisor-round3.md +47 -0
  49. package/src/prompts/coder.md +22 -0
  50. package/src/prompts/consult-base.md +13 -0
  51. package/src/prompts/discipline.md +25 -6
  52. package/src/prompts/eng-coder.md +2 -2
  53. package/src/prompts/engineering-sub.md +23 -1
  54. package/src/prompts/engineering.md +157 -50
  55. package/src/prompts/explore.md +1 -2
  56. package/src/prompts/main.md +11 -5
  57. package/src/prompts/methodology-template.md +14 -0
  58. package/src/prompts/system.md +5 -2
  59. package/src/provider/anthropic.mjs +7 -5
  60. package/src/provider/core.mjs +104 -28
  61. package/src/provider/google.mjs +57 -24
  62. package/src/provider/normalize.mjs +1 -1
  63. package/src/provider/rate.mjs +0 -2
  64. package/src/provider/responses.mjs +8 -13
  65. package/src/provider/sse.mjs +20 -0
  66. package/src/session.mjs +15 -0
  67. package/src/tools/apply_patch.md +5 -1
  68. package/src/tools/bash.md +3 -3
  69. package/src/tools/delete.md +1 -0
  70. package/src/tools/edit-batch.mjs +92 -0
  71. package/src/tools/edit-diff.mjs +265 -0
  72. package/src/tools/edit.md +11 -6
  73. package/src/tools/execute.md +8 -8
  74. package/src/tools/execute.mjs +31 -35
  75. package/src/tools/file.mjs +26 -114
  76. package/src/tools/file_ops.md +3 -2
  77. package/src/tools/get_current_time.md +3 -1
  78. package/src/tools/git.md +1 -1
  79. package/src/tools/git.mjs +8 -16
  80. package/src/tools/hashline_edit.md +2 -0
  81. package/src/tools/index.mjs +3 -2
  82. package/src/tools/insert_after.md +2 -1
  83. package/src/tools/lint.md +3 -1
  84. package/src/tools/linter.mjs +9 -37
  85. package/src/tools/lsp.md +4 -1
  86. package/src/tools/patch.mjs +84 -13
  87. package/src/tools/pdf-parse-text.mjs +497 -0
  88. package/src/tools/pdf-parse-xref.mjs +499 -0
  89. package/src/tools/pdf.mjs +155 -0
  90. package/src/tools/question.md +2 -1
  91. package/src/tools/read.md +1 -0
  92. package/src/tools/read_pdf.md +21 -0
  93. package/src/tools/repomap.mjs +1 -1
  94. package/src/tools/shared.mjs +11 -32
  95. package/src/tools/system.mjs +6 -21
  96. package/src/tools/tree.md +2 -1
  97. package/src/tools/web.mjs +5 -3
  98. package/src/tools/websearch.md +2 -1
  99. package/src/tools/write.md +2 -0
  100. package/src/traces/trace-store.mjs +224 -0
  101. package/src/tui/agent-turn.mjs +387 -24
  102. package/src/tui/clipboard.mjs +17 -6
  103. package/src/tui/cmd-config.mjs +29 -9
  104. package/src/tui/cmd-eng.mjs +1 -0
  105. package/src/tui/cmd-extract.mjs +1 -1
  106. package/src/tui/cmd-mcp-form.mjs +197 -0
  107. package/src/tui/cmd-mcp.mjs +264 -114
  108. package/src/tui/cmd-think.mjs +1 -1
  109. package/src/tui/index.mjs +49 -95
  110. package/src/tui/interaction.mjs +41 -3
  111. package/src/tui/key-handler.mjs +105 -143
  112. package/src/tui/key-modes.mjs +215 -0
  113. package/src/tui/layout.mjs +22 -1
  114. package/src/tui/mouse.mjs +41 -1
  115. package/src/tui/pickers.mjs +73 -7
  116. package/src/tui/render-conversation.mjs +13 -161
  117. package/src/tui/render-frame.mjs +45 -20
  118. package/src/tui/render-loop.mjs +4 -1
  119. package/src/tui/render-segments.mjs +165 -0
  120. package/src/tui/render.mjs +4 -4
  121. package/src/tui/startup.mjs +40 -2
  122. package/src/tui/subagent-blocks.mjs +404 -111
  123. package/src/tui/subagent-panel.mjs +88 -13
  124. package/src/tui/tool-args.mjs +10 -2
  125. package/src/tui/tool-events.mjs +172 -95
  126. package/src/tui/update-notice.mjs +72 -0
  127. package/src/tui/wizard.mjs +36 -6
  128. package/src/agent-tools/escalate.mjs +0 -179
  129. package/src/tools/exec-prelude.mjs +0 -84
package/src/config.mjs CHANGED
@@ -73,13 +73,17 @@ export const DEFAULTS = {
73
73
  provider: "tavily", // structured search API; empty apiKey → fall back to Bing HTML scraping
74
74
  apiKey: "", // Tavily key (tvly-...) — optional
75
75
  },
76
+ traces: {
77
+ enabled: false, // §18.6 D-TR6 修订(2026-09-05 用户裁定——发布隐私:"不希望用户那边也采集"):轨迹存档默认 OFF——新用户零采集;本地调试分析可显式开(~/.thincoder/config.json traces.enabled:true)
78
+ retentionHours: 24, // D-TR10:轨迹文件保留小时数——CLI 启动时删除超过该时长的文件(默认 24h)
79
+ },
76
80
  }
77
81
 
78
82
  // Model capability table + spec lookup live in model-specs.mjs (2026-08-31
79
83
  // extract — config.mjs had grown past the 300-line advisory). Re-exported here
80
84
  // so the 23 existing importers keep their import paths.
81
- import { specForModel } from "./model-specs.mjs"
82
- export { specForModel }
85
+ import { specForModel, providerSpec } from "./model-specs.mjs"
86
+ export { specForModel, providerSpec }
83
87
 
84
88
 
85
89
  // Window utilization threshold: compacts at 60% context, reserving 40% headroom
@@ -87,10 +91,14 @@ export { specForModel }
87
91
  // memory/doc search results) which can consume 30-50K tokens each turn.
88
92
  const COMPACT_RATIO = 0.6
89
93
 
90
- /** Derive compaction threshold; explicit is the value explicitly set in config file (takes priority), otherwise auto-computed from model */
91
- export function resolveCompactThreshold(explicit, model) {
94
+ /** Derive compaction threshold; explicit is the value explicitly set in config file (takes priority), otherwise auto-computed from model.
95
+ * Second param accepts EITHER a model name string (pure spec lookup — legacy caller:
96
+ * first-run wizard) OR a provider object (providerSpec — the providers[].context
97
+ * override in K units is honored, PROVIDER.md §15 T-C2). */
98
+ export function resolveCompactThreshold(explicit, modelOrProvider) {
92
99
  if (explicit != null) return { value: explicit, auto: false }
93
- const spec = specForModel(model)
100
+ const provider = typeof modelOrProvider === "string" ? { model: modelOrProvider } : (modelOrProvider ?? {})
101
+ const spec = providerSpec(provider)
94
102
  const value = Math.floor(spec.context * COMPACT_RATIO)
95
103
  return { value, auto: true }
96
104
  }
@@ -125,6 +133,9 @@ export function resolveEnableThinking(provider, spec) {
125
133
  return undefined
126
134
  }
127
135
 
136
+ /** Module-level one-time warn dedupe for invalid providers[].context (PROVIDER.md §15 D-C1). */
137
+ const warnedContextProviders = new Set()
138
+
128
139
  /**
129
140
  * Find provider by name in providers[].
130
141
  * Throws if name is non-empty but not found — a typo in activeProvider silently falling to the first provider would use the wrong key on the wrong endpoint.
@@ -188,6 +199,20 @@ export function loadConfig() {
188
199
  agent: { ...DEFAULTS.agent, ...config.agent },
189
200
  memory: { ...DEFAULTS.memory, ...config.memory },
190
201
  embedding: { ...DEFAULTS.embedding, ...config.embedding },
202
+ traces: { ...DEFAULTS.traces, ...config.traces },
203
+ }
204
+
205
+ // providers[].context (K units, PROVIDER.md §15 D-C1): positive integer only — invalid
206
+ // values (0/negative/non-numeric) are IGNORED (spec value applies) with a ONE-TIME warn
207
+ // per provider name (module-level dedupe, same precedent as warnedModels in model-specs.mjs).
208
+ for (const p of merged.providers) {
209
+ if (p.context === undefined) continue
210
+ if (Number.isInteger(Number(p.context)) && Number(p.context) > 0) { p.context = Number(p.context); continue } // 数字字符串("128")归一为数字——两端语义统一(code review #1)
211
+ if (!warnedContextProviders.has(p.name ?? "(unnamed)")) {
212
+ warnedContextProviders.add(p.name ?? "(unnamed)")
213
+ console.warn(`[config] provider "${p.name}" context must be a positive integer in K units (e.g. 128 = 128K) — got ${JSON.stringify(p.context)} — ignored, using the model spec value`)
214
+ }
215
+ delete p.context
191
216
  }
192
217
 
193
218
  // Consult/escalate pool validation (CLI parity with the plugin): up to 5 candidates.
@@ -227,7 +252,16 @@ export function loadConfig() {
227
252
  merged.proxy = normalizeProxy(merged.proxy)
228
253
 
229
254
  // Get the currently active provider
230
- const active = findProvider(merged.providers, merged.activeProvider)
255
+ // 2026-09-02 Q1(SESSION.md §8):activeProvider 指向不存在的 provider 不再抛错——runtimeProvider
256
+ // 置空对象,由 make-agent.mjs assembleAgent 后的校验打 `_providerInvalid` 标记 → TUI 引导重选 /
257
+ // headless 报可读错误(原 findProvider throw 直接击穿 loadConfig → uncaughtException 退出)。
258
+ // findProvider 的 throw 契约保留(advisor/run.mjs 等直接调用方仍依赖)。
259
+ let active
260
+ try {
261
+ active = findProvider(merged.providers, merged.activeProvider)
262
+ } catch {
263
+ active = {}
264
+ }
231
265
 
232
266
  // Build runtime provider object (for agent.provider usage)
233
267
  const runtimeProvider = { ...active }
@@ -236,20 +270,103 @@ export function loadConfig() {
236
270
  if (merged.activeModel) runtimeProvider.model = merged.activeModel
237
271
  merged.activeModel = merged.activeModel || null // normalize for agent.activeModel
238
272
 
239
- // Compaction threshold follows the model
273
+ // Compaction threshold follows the model (provider-level context override honored — providerSpec)
240
274
  const explicitThreshold = config.agent?.compactThreshold
241
- const { value, auto } = resolveCompactThreshold(explicitThreshold, runtimeProvider.model)
275
+ const { value, auto } = resolveCompactThreshold(explicitThreshold, runtimeProvider)
242
276
  merged.agent.compactThreshold = value
243
277
  merged.agent.compactThresholdAuto = auto
244
278
 
245
279
  // Write back to merged for convenient access by upper layers
246
280
  merged.provider = runtimeProvider
281
+ // fetch 超时可配置(2026-09-01:agent.fetchTimeoutMs——provider/core.mjs effectiveFetchTimeoutMs 消费)
282
+ runtimeProvider.fetchTimeoutMs = Number.isFinite(merged.agent?.fetchTimeoutMs) && merged.agent.fetchTimeoutMs > 0
283
+ ? merged.agent.fetchTimeoutMs : undefined
247
284
  merged.providersList = merged.providers
248
285
  merged.advisor = { ...merged.agent.advisor } // promote for consistent access (decoupled copy)
249
286
 
250
287
  return merged
251
288
  }
252
289
 
290
+ /**
291
+ * MCP.md §5 D-3 (2026-09-01): re-read config.json and replace ONLY the agent's mcp section
292
+ * — the agent 代配 closed loop (agent edits config.json with its edit tool, /mcp picks it
293
+ * up). Never touches other config sections (providers/activeProvider stay as loaded).
294
+ *
295
+ * Malformed disk config → memory state kept, { ok:false, error } returned (the /mcp menu
296
+ * shows "⚠ disk config unreadable"). Never throws.
297
+ *
298
+ * 对账 (reconciliation, MCP.md §5 D-3 / T23): returns which disk servers CHANGED
299
+ * (fingerprint differs) or are DELETED from disk while still connected — fingerprint =
300
+ * endpoint + token + headers/env key order. Existing connections are NOT torn down (an
301
+ * in-use server must not be dropped): a deleted-but-connected server KEEPS its memory
302
+ * entry (appended after the disk list) so the /mcp list can still show the row with the
303
+ * "⚠ disk changed" mark. A server that is merely NEW on disk is not drift. persistRaw
304
+ * write + reload is idempotent (fingerprints equal → no drift mark).
305
+ *
306
+ * @param path optional config path override (tests inject a tmp file; default configPath)
307
+ */
308
+ export function reloadMcpFromDisk(agent, path) {
309
+ const memoryServers = Array.isArray(agent.config?.mcp?.servers) ? agent.config.mcp.servers : []
310
+ const fileExists = existsSync(path ?? configPath)
311
+ const diskMcp = readMcpSection(path)
312
+ if (!diskMcp.ok) return { ok: false, error: diskMcp.error, changedNames: [] }
313
+ // Missing/deleted config file → keep whichever mcp servers the session had (never
314
+ // silently drop user servers because the file vanished — same memory-keeps policy
315
+ // as the malformed-disk fallback).
316
+ let diskServers = diskMcp.servers
317
+ if (diskServers.length === 0 && !fileExists) diskServers = memoryServers
318
+ // Drift vs the RAW disk list: fingerprint-changed or deleted-from-disk (T23 ⚠ 标记依据)
319
+ const diskNames = new Set(diskServers.filter((s) => s?.name).map((s) => s.name))
320
+ const changedNames = diffMcpServers(memoryServers, diskServers)
321
+ // Connected servers deleted from disk stay in the list (memory copy) — T23: the row
322
+ // must remain visible (marked ⚠) and its live connection untouched. They are already
323
+ // in changedNames (absent from disk), and stay flagged on every reload until the user
324
+ // reconnects (re-persists them) or removes them — real drift, honestly reported.
325
+ const connectedNames = new Set((agent.tools ?? []).filter((t) => t?._mcpName).map((t) => t._mcpName))
326
+ const keptConnected = memoryServers.filter((s) => s?.name && connectedNames.has(s.name) && !diskNames.has(s.name))
327
+ const finalServers = [...diskServers, ...keptConnected]
328
+ agent.config ??= {}
329
+ agent.config.mcp = { ...agent.config.mcp, servers: finalServers }
330
+ return { ok: true, servers: finalServers, changedNames }
331
+ }
332
+
333
+ /** Disk read behind reloadMcpFromDisk — bounded, never throws. */
334
+ function readMcpSection(path = configPath) {
335
+ try {
336
+ if (!existsSync(path)) return { ok: true, servers: [] }
337
+ const raw = JSON.parse(readFileSync(path, "utf8"))
338
+ const servers = raw?.mcp?.servers
339
+ if (servers !== undefined && !Array.isArray(servers)) return { ok: true, servers: [] }
340
+ return { ok: true, servers: Array.isArray(servers) ? servers : [] }
341
+ } catch (error) {
342
+ return { ok: false, error: error?.message ?? String(error) }
343
+ }
344
+ }
345
+
346
+ /** Fingerprint = endpoint + token + headers/env entries in key order (JSON.stringify
347
+ * of a normalized subset — key order included, matching connectMcpServer's
348
+ * configFingerprint semantics: any change the connect layer would see counts).
349
+ * Drift = CHANGED (fingerprint differs) or DELETED (missing from disk) — a server
350
+ * that is new on disk is not drift (no live connection to protect). */
351
+ function diffMcpServers(memoryServers, diskServers) {
352
+ const memFp = new Map(memoryServers.filter((s) => s?.name).map((s) => [s.name, mcpFingerprint(s)]))
353
+ const diskFp = new Map(diskServers.filter((s) => s?.name).map((s) => [s.name, mcpFingerprint(s)]))
354
+ const changed = []
355
+ for (const [name, fp] of diskFp) if (memFp.has(name) && memFp.get(name) !== fp) changed.push(name)
356
+ for (const name of memFp.keys()) if (!diskFp.has(name)) changed.push(name)
357
+ return changed
358
+ }
359
+
360
+ function mcpFingerprint(s) {
361
+ return JSON.stringify([
362
+ s.wsUrl ?? s.url ?? s.command ?? null,
363
+ s.args ?? null,
364
+ s.token ?? null,
365
+ s.headers ?? null,
366
+ s.env ?? null,
367
+ ])
368
+ }
369
+
253
370
  /**
254
371
  * Save configuration. Preserves providers list structure and activeProvider pointer.
255
372
  * providers[i].apiKey is only written when explicitly passed in (does not overwrite env-var-fallback keys).
package/src/context.mjs CHANGED
@@ -11,7 +11,7 @@
11
11
 
12
12
  import { chat } from "./provider/index.mjs"
13
13
  import { estimateText } from "./provider/rate.mjs"
14
- import { specForModel } from "./config.mjs"
14
+ import { providerSpec } from "./config.mjs"
15
15
 
16
16
  const IMAGE_TOKEN_ESTIMATE = 2000 // rough estimate for image content tokens (CLI legacy 256 underestimated real image costs, delaying compaction)
17
17
 
@@ -37,16 +37,25 @@ export function estimateTokens(messages) {
37
37
  const KEEP_HEAD = 0 // No dedicated head: earliest messages may be a COMPLETED earlier task in multi-task
38
38
  // sessions — keeping them verbatim anchored attention on stale work. Everything before the tail is
39
39
  // summarized (the summary itself distinguishes completed vs in-progress work; see SUMMARIZE_PROMPT).
40
- // Tail size scales with the model context window (~30 messages per 100K tokens),
41
- // capped at 40% of history so small histories don't over-reserve. Window-adaptive
42
- // replaces the old fixed 10: on a 1M window, 10 messages is too thin for recent work.
40
+ // Tail count formula (D4): window-adaptive (~30 msgs per 100K — old fixed 10 too thin on 1M), capped
41
+ // at 40% of history; §9 D-T1/D-T2 make the count only a CANDIDATE — a token budget (TAIL_BUDGET_FRACTION
42
+ // × window SUMMARY_TOKEN_ESTIMATE ≈1K, §8) tightens it over pair-safe boundaries when compaction runs,
43
+ // never below TAIL_FLOOR_MESSAGES; ordinary sessions never reach it (D-T4: trigger 0.6 untouched).
44
+ const TAIL_BUDGET_FRACTION = 0.15
45
+ const SUMMARY_TOKEN_ESTIMATE = 1000 // §8: summary output target ~1K tokens — reserved from the 15%
46
+ const TAIL_FLOOR_MESSAGES = 10 // §9 D-T2: the tail keeps ≥10 verbatim messages — floor beats budget
43
47
  function keepTailSize(provider, historyLen) {
44
- // provider is guaranteed at every call site (runAgent always builds one); specForModel
45
- // degrades to DEFAULT_SPEC (128K) only if provider/model is somehow absent — acceptable
46
- // because the 40% history cap still bounds the tail.
47
- const ctxWindow = specForModel(provider?.model ?? "").context
48
+ // provider is guaranteed at every call site (runAgent always builds one); providerSpec
49
+ // degrades to DEFAULT_SPEC (128K) only if provider is somehow absent — acceptable
50
+ // because the 40% history cap still bounds the tail. providers[].context override
51
+ // (K units) is honored here (PROVIDER.md §15 T-C2: tail formula follows the window).
52
+ const ctxWindow = providerSpec(provider).context
48
53
  return Math.min(Math.max(10, Math.floor((ctxWindow / 100_000) * 30)), Math.floor(historyLen * 0.4))
49
54
  }
55
+ // §9 D-T1 tail token budget: window×15% − summary ~1K — the compressed history segment (summary + placeholder + tail) lands ≈ 15% (B 口径 §9.5).
56
+ function tailBudgetTokens(provider) {
57
+ return Math.max(0, Math.floor(providerSpec(provider).context * TAIL_BUDGET_FRACTION) - SUMMARY_TOKEN_ESTIMATE)
58
+ }
50
59
 
51
60
  export const SUMMARIZE_PROMPT = `You are a conversation compressor. Summarize the following agent work log into a compact summary for use as context in the ongoing conversation.
52
61
  Requirements:
@@ -58,7 +67,7 @@ Requirements:
58
67
  - Explicitly list UNRESOLVED ISSUES / TODOs: anything still open plus the next steps — so post-compaction recovery knows where to resume
59
68
  - Drop: pleasantries, repetition, fine-grained tool output details
60
69
  - Honestly mark uncertain items: anything not actually verified must say "unverified"; do not present guesses as facts
61
- - Use bullet-point output; aim for information completeness, not a hard word limit (old 500-char cap is deprecated; in a 1M-context era, err on the long side)
70
+ - 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.
62
71
 
63
72
  Work log:
64
73
  `
@@ -67,7 +76,7 @@ Work log:
67
76
  const COMPACTION_PREFIX =
68
77
  "[Context was automatically compacted. Below is a summary of earlier work. " +
69
78
  "Treat it as notes, not proof — trust its conclusions (don't redo what it reports as done) " +
70
- "but re-verify transient state with tools. Check memory_search for any missing decisions.]\n\n"
79
+ "but re-verify transient state with tools. Check memory search for any missing decisions.]\n\n"
71
80
 
72
81
  /** After this many consecutive compaction summary failures, degrade to deterministic truncation (losing info is better than task-killing 400 errors) */
73
82
  export const COMPRESS_FAILURE_LIMIT = 3
@@ -83,12 +92,13 @@ const FALLBACK_NOTE =
83
92
 
84
93
  /**
85
94
  * Split history into head / middle (to be summarized) / tail; return null if no middle to compress.
86
- * head is normally empty (KEEP_HEAD = 0 — earliest messages go into the summary); the
87
- * tool_calls-extension logic below is defensive for future KEEP_HEAD > 0.
88
- * The tail boundary must include any assistant whose tool results are in the tail — if the assistant is in the middle,
89
- * the summary swallows it, leaving orphan tool results protocol 400.
95
+ * 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.
96
+ * 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.
97
+ * `budgetTokens` (optional, §9 D-T1): when the candidate's estimate exceeds it, the boundary moves
98
+ * forward until the tail fits never below the D-T2 floor (10 msgs, or the candidate itself when
99
+ * the 40% cap made it < 10 — short history).
90
100
  */
91
- function splitHistory(history, keepTail) {
101
+ function splitHistory(history, keepTail, budgetTokens = null) {
92
102
  if (history.length <= KEEP_HEAD + keepTail + 1) return null
93
103
  let headEnd = KEEP_HEAD
94
104
  // head must not end with dangling tool_calls: when assistant declares tool_calls, all its tool results must stay in head.
@@ -96,10 +106,24 @@ function splitHistory(history, keepTail) {
96
106
  if (history[headEnd - 1]?.role === "assistant" && history[headEnd - 1].tool_calls?.length) {
97
107
  while (headEnd < history.length && history[headEnd].role === "tool") headEnd++
98
108
  }
99
- let tailStart = history.length - keepTail
109
+ const candidate = repairedTailStart(history, headEnd, history.length - keepTail)
110
+ if (candidate <= headEnd) return null
111
+ let tailStart = candidate
112
+ // §9 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.
113
+ if (budgetTokens > 0 && keepTail > TAIL_FLOOR_MESSAGES) {
114
+ const floor = repairedTailStart(history, headEnd, history.length - TAIL_FLOOR_MESSAGES)
115
+ if (floor > candidate) tailStart = tightenTailByBudget(history, candidate, floor, budgetTokens)
116
+ }
117
+ return { headEnd, tailStart }
118
+ }
100
119
 
101
- // Tool messages in the tail region whose assistant tool_calls are in the middle: the summary would swallow the assistant,
102
- // leaving orphan tool results protocol 400. Collect tool_call_ids from the tail, find their owner assistants and pull them into tail
120
+ /**
121
+ * D5 tail-side pairing repair for a raw cut at history.length tailCount: pull into the tail any
122
+ * assistant whose tool results are in the tail (the summary swallowing the owner leaves orphan tool
123
+ * results → protocol 400), then skip orphan tool messages at the new boundary. Single-assistant
124
+ * assumption (nearest owner only — a tail spans at most one assistant→tools cycle); bounds-guarded.
125
+ */
126
+ function repairedTailStart(history, headEnd, tailStart) {
103
127
  const tailToolIds = new Set()
104
128
  for (let i = tailStart; i < history.length; i++) {
105
129
  if (history[i].role === "tool") tailToolIds.add(history[i].tool_call_id)
@@ -111,16 +135,28 @@ function splitHistory(history, keepTail) {
111
135
  break
112
136
  }
113
137
  }
114
-
115
- // skip orphan tool messages at the new tail boundary (tool whose assistant was pulled in above)
116
- // NOTE: single-assistant assumption — the backwards scan pulls the nearest owner only; in
117
- // practice a tail spans at most one assistant→tools cycle (parallel calls share one assistant).
118
- // Bounds-guarded so an all-tool tail cannot push tailStart past history.length.
119
138
  while (tailStart < history.length && tailStart > headEnd && history[tailStart].role === "tool") {
120
139
  tailStart++
121
140
  }
122
- if (tailStart <= headEnd) return null
123
- return { headEnd, tailStart }
141
+ return tailStart
142
+ }
143
+
144
+ /**
145
+ * §9 D-T1 budget tightening (pair-safe, review #2): walk the boundary FORWARD (fewer tail messages —
146
+ * the rest joins the summary) while the tail's estimated tokens exceed the budget. Only pair-safe
147
+ * positions may stop the walk: a boundary ON a tool message would orphan its owner assistant into the
148
+ * middle (D5); pairing is contiguous in the machine line (§6 note) — every non-tool boundary is safe.
149
+ * No fit before the floor → keep the floor, accept the overrun.
150
+ */
151
+ function tightenTailByBudget(history, start, floorStart, budgetTokens) {
152
+ const suffixTokens = new Array(history.length + 1)
153
+ suffixTokens[history.length] = 0
154
+ for (let i = history.length - 1; i >= 0; i--) suffixTokens[i] = suffixTokens[i + 1] + estimateTokens([history[i]])
155
+ if (suffixTokens[start] <= budgetTokens) return start // already fits — ordinary sessions stay untouched (D-T2)
156
+ for (let p = start + 1; p <= floorStart; p++) { // first fit keeps the most recent verbatim context
157
+ if (history[p].role !== "tool" && suffixTokens[p] <= budgetTokens) return p
158
+ }
159
+ return floorStart
124
160
  }
125
161
 
126
162
  /**
@@ -131,9 +167,14 @@ function splitHistory(history, keepTail) {
131
167
  * Machine-only messages ([System reminder:...], compaction notes, task/plan/checkpoint re-injections)
132
168
  * are pushed directly to agent.history WITHOUT going through here, so they never enter _fullHistory.
133
169
  * The two lines are written independently at the source — no after-the-fact delta sync.
170
+ * Message timestamps (SESSION.md §9 D-S1): stamped HERE once at push time (epoch ms) — a single
171
+ * point covers every real message. Pre-existing ts (e.g. from another end writing the shared slot)
172
+ * is preserved; restored old messages keep no ts rather than getting a misleading backdate (D-S3).
173
+ * ts is a LOCAL-ONLY field — the send layer strips it before any provider request (T-S3).
134
174
  */
135
175
  export function pushReal(agent, msg) {
136
176
  if (!Array.isArray(agent._fullHistory)) agent._fullHistory = []
177
+ if (msg && msg.ts === undefined) msg.ts = Date.now()
137
178
  agent._fullHistory.push(msg)
138
179
  agent.history.push(msg)
139
180
  }
@@ -147,10 +188,14 @@ function applyCompression(agent, headEnd, tailStart, note) {
147
188
  // possibly-completed earlier requests.
148
189
  const head = agent.history.slice(0, headEnd)
149
190
  const tail = agent.history.slice(tailStart)
191
+ // SESSION.md §9 D-S1: compaction-injected messages (note + "Understood") carry a ts —
192
+ // Date.now() at the compaction moment. They are machine-only (never in _fullHistory),
193
+ // but the machine-line timeline stays consistent for any audit use.
194
+ const now = Date.now()
150
195
  agent.history = [
151
196
  ...head,
152
- { role: "user", content: note },
153
- { role: "assistant", content: "Understood. I'll continue from these notes, re-verifying anything transient." },
197
+ { role: "user", content: note, ts: now },
198
+ { role: "assistant", content: "Understood. I'll continue from these notes, re-verifying anything transient.", ts: now },
154
199
  ...tail,
155
200
  ]
156
201
  // Compaction REBUILDS the machine line (head + note + "Understood" + tail), so the pre-compaction
@@ -195,8 +240,10 @@ function applyCompression(agent, headEnd, tailStart, note) {
195
240
  * Automatically re-injects task list state after compaction.
196
241
  * @param {object} agent
197
242
  * @param {number} threshold - compaction threshold in tokens
198
- * @param {object} callbacks - { onToken, onReasoning, onCompress } — summary generation is SILENT
199
- * (never forwards onToken/onReasoning: the compaction process is an internal mechanism, not a model reply)
243
+ * @param {object} callbacks - { onToken, onReasoning, onCompress, onCompressStart } — summary
244
+ * generation is SILENT (never forwards onToken/onReasoning: the compaction process is an
245
+ * internal mechanism, not a model reply); onCompressStart fires right before the summary call
246
+ * (§7 D-C1, compression lifecycle visibility — panel start state)
200
247
  * @param {object} extras - { systemPrompt?, tools? } — estimated overhead for the pure-estimation
201
248
  * path (no measured baseline); the measured path already includes system+tools in prompt_tokens.
202
249
  */
@@ -214,7 +261,7 @@ export async function compressIfNeeded(agent, threshold, callbacks, extras = {},
214
261
  if (tokens <= threshold) return false
215
262
 
216
263
  const keepTail = keepTailSize(agent.provider, history.length)
217
- const split = splitHistory(history, keepTail)
264
+ const split = splitHistory(history, keepTail, tailBudgetTokens(agent.provider))
218
265
  if (!split) {
219
266
  // History is too short (≤KEEP_HEAD+keepTail+1 messages) to find a middle section, but tokens exceed threshold — typically a single giant message
220
267
  // (large paste / huge injection). When summarization has no room, degrade to deterministic shrinking to ensure context always reduces
@@ -239,13 +286,35 @@ export async function compressIfNeeded(agent, threshold, callbacks, extras = {},
239
286
  // The summary is a plain-text task, no reasoning needed — passing thinking to the compaction provider wastes tokens.
240
287
  // Silent by design (D11): no onToken/onReasoning — the compaction process must not stream to the frontend.
241
288
  // signal propagates user cancellation (Ctrl+C) to the in-flight summary call.
289
+ // Compression visibility (CONTEXT-COMPACTION.md §7 D-C1/D-C2): the frontend learns the compression
290
+ // STARTED right before the LLM call ("Compressing context… / summarizing N messages" panel) — only
291
+ // the lifecycle is surfaced, never the summary body. N = the number of history messages being summarized.
292
+ callbacks?.onCompressStart?.({ messages: middle.length })
293
+ const startedAt = performance.now()
242
294
  const summary = await chat({ ...agent.provider, thinking: null, reasoningEffort: null }, {
243
295
  messages: [{ role: "user", content: SUMMARIZE_PROMPT + serialized }],
244
296
  signal,
297
+ // §18.6 D-TR4:轨迹元数据增补——kind=compress(上下文构建面——agent 元数据透出;
298
+ // depth 经 extras.traceDepth——agent.mjs 主作用域传入——compress 调用点补齐)
299
+ logCtx: {
300
+ stage: "compress", child: agent._logId, kind: "compress",
301
+ role: agent._role ?? null, depth: extras?.traceDepth ?? null,
302
+ session: agent._sessionStart ?? null, cwd: agent.cwd,
303
+ traces: agent.config?.traces?.enabled !== false,
304
+ },
245
305
  })
246
306
 
247
307
  applyCompression(agent, split.headEnd, split.tailStart, COMPACTION_PREFIX + summary.content)
248
308
 
309
+ // Completion info for the compression panel (D-C2): tokens freed = the pre-compression prompt
310
+ // estimate (`tokens` — the value that tripped the threshold, incl. system/tools overhead on the
311
+ // pure-estimation path) minus the post-compression estimate on the same basis. Elapsed = the
312
+ // summary call + splice duration. agent.mjs forwards this to onCompress unchanged.
313
+ agent._lastCompressInfo = {
314
+ mode: "summary",
315
+ tokensFreed: Math.max(0, Math.round(tokens - (estimateTokens(agent.history) + overhead))),
316
+ elapsedMs: performance.now() - startedAt,
317
+ }
249
318
  return true
250
319
  }
251
320
 
@@ -255,9 +324,13 @@ export async function compressIfNeeded(agent, threshold, callbacks, extras = {},
255
324
  */
256
325
  export function compressFallback(agent) {
257
326
  const keepTail = keepTailSize(agent.provider, agent.history.length)
258
- const split = splitHistory(agent.history, keepTail)
327
+ const split = splitHistory(agent.history, keepTail, tailBudgetTokens(agent.provider))
259
328
  if (!split) return false
329
+ const tailMessages = agent.history.length - split.tailStart
260
330
  applyCompression(agent, split.headEnd, split.tailStart, FALLBACK_NOTE)
331
+ // Fallback completion info (D-C2): mode marks the deterministic-truncation path — the panel
332
+ // shows the degradation note ("truncated to N messages") ONLY after 3 consecutive failures.
333
+ agent._lastCompressInfo = { mode: "fallback", tailMessages }
261
334
  return true
262
335
  }
263
336
 
@@ -383,7 +456,7 @@ function serializeExplorationMessages(messages) {
383
456
  * or null when there is nothing to shrink (<3 exploration results / LLM failure). Pairing-safe:
384
457
  * whole assistant→tool blocks are removed, so no orphan tool_calls/tool can survive.
385
458
  */
386
- async function distillExplorations(history, start, provider, signal) {
459
+ async function distillExplorations(history, start, provider, signal, agent, depth) {
387
460
  if (!Array.isArray(history) || history.length - start < 2) return null
388
461
  const blocks = findExplorationBlocks(history, start)
389
462
  const resultCount = blocks.reduce((n, b) => n + b.toolCount, 0)
@@ -398,6 +471,14 @@ async function distillExplorations(history, start, provider, signal) {
398
471
  const resp = await chat({ ...provider, thinking: null, reasoningEffort: null }, {
399
472
  messages: [{ role: "user", content: EXPLORE_SUMMARY_PROMPT + serialized }],
400
473
  signal,
474
+ // §18.6 D-TR4:轨迹元数据增补——kind=distill(探索蒸馏面——agent 元数据透出;
475
+ // depth 经 summarizeRunExplorations 参数透传——agent.mjs 主作用域传入)
476
+ logCtx: {
477
+ stage: "distill", child: agent?._logId ?? null, kind: "distill",
478
+ role: agent?._role ?? null, depth: depth ?? null,
479
+ session: agent?._sessionStart ?? null, cwd: agent?.cwd ?? process.cwd(),
480
+ traces: agent?.config?.traces?.enabled !== false,
481
+ },
401
482
  })
402
483
  summary = resp?.content
403
484
  } catch {
@@ -428,8 +509,8 @@ async function distillExplorations(history, start, provider, signal) {
428
509
  * ONLY after the replacement actually lands (never on no-op/failure) — callers persist the
429
510
  * compressed session (SEND-STALL-DISTILL §2.3).
430
511
  */
431
- export async function summarizeRunExplorations(agent, callbacks, signal) {
432
- const next = await distillExplorations(agent.history, agent._runStartHistoryLen ?? 0, agent.provider, signal)
512
+ export async function summarizeRunExplorations(agent, callbacks, signal, depth = 0) {
513
+ const next = await distillExplorations(agent.history, agent._runStartHistoryLen ?? 0, agent.provider, signal, agent, depth)
433
514
  if (!next) return
434
515
  agent.history = next
435
516
  // The machine line changed shape — the measured token baseline was for the pre-shrink context.
package/src/distill.mjs CHANGED
@@ -5,6 +5,7 @@
5
5
  */
6
6
 
7
7
  import { chat } from "./provider/index.mjs"
8
+ import { loadConfig } from "./config.mjs"
8
9
  import { put, putMarkdown } from "./memory.mjs"
9
10
  import { commitAndPush } from "./git/gitmem.mjs"
10
11
 
@@ -39,13 +40,30 @@ If the session is long, prioritize conclusions that appeared last and are still
39
40
  Session log:
40
41
  `
41
42
 
43
+ /** §18.6 D-TR6(2026-09-04 fix round1):distill 调用点无 agent 作用域——traces 开关
44
+ * 缺省回退磁盘配置(loadConfig——与 agent.config 同源:traces.enabled 缺省 on);
45
+ * 配置不可读时按缺省 on(注:CLI 启动早已 loadConfig——此处仅是兜底防御)。 */
46
+ function tracesEnabledFromConfig() {
47
+ try {
48
+ return loadConfig().traces?.enabled !== false
49
+ } catch {
50
+ return true
51
+ }
52
+ }
53
+
42
54
  /**
43
55
  * Extract candidates from a session transcript. transcript: plain-text session record.
44
56
  * Returns [{ type, title, content, tags, scope }], or [] on parse failure.
57
+ * opts.traces(可选):§18.6 D-TR6 开关显式透传(测试隔离/未来调用方)——缺省回退
58
+ * 磁盘配置(tracesEnabledFromConfig)——关 = chat() 出口不落盘。
45
59
  */
46
- export async function extractCandidates(provider, transcript) {
60
+ export async function extractCandidates(provider, transcript, opts = {}) {
61
+ const traces = opts.traces ?? tracesEnabledFromConfig()
47
62
  const res = await chat(provider, {
48
63
  messages: [{ role: "user", content: DISTILL_PROMPT + transcript }],
64
+ // §18.6 D-TR4/D-TR6(fix round1):distill 调用经 chat() 唯一采集点——补轨迹
65
+ // 元数据 + traces 开关透传(关=不落盘必须全覆盖——不再静默越过开关)
66
+ logCtx: { stage: "distill", kind: "distill", traces },
49
67
  })
50
68
  // Balanced-bracket extraction: find the first '[' and track depth through nested
51
69
  // brackets (tags arrays, nested objects, etc.) until the matching ']'.