thincoder 0.12.58 → 0.12.60

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 (158) hide show
  1. package/CHANGELOG.md +78 -2
  2. package/README.md +3 -3
  3. package/bin/thincoder.mjs +88 -19
  4. package/package.json +4 -3
  5. package/src/acp/bridge.mjs +135 -26
  6. package/src/advisor/messages.mjs +57 -4
  7. package/src/advisor/run.mjs +119 -79
  8. package/src/advisor.mjs +34 -7
  9. package/src/agent/completion.mjs +17 -11
  10. package/src/agent/dispatch.mjs +182 -22
  11. package/src/agent/helpers.mjs +71 -4
  12. package/src/agent/record-results.mjs +46 -10
  13. package/src/agent/run-stages.mjs +227 -0
  14. package/src/agent/setup-reminders.mjs +62 -0
  15. package/src/agent/setup.mjs +107 -20
  16. package/src/agent/spawn-child.mjs +54 -4
  17. package/src/agent-tools/advisor-async.mjs +456 -0
  18. package/src/agent-tools/advisor.mjs +133 -109
  19. package/src/agent-tools/async-settle.mjs +191 -0
  20. package/src/agent-tools/consult.mjs +154 -104
  21. package/src/agent-tools/design-token.mjs +104 -0
  22. package/src/agent-tools/eng.mjs +26 -30
  23. package/src/agent-tools/escalate-async.mjs +286 -0
  24. package/src/agent-tools/goal.mjs +11 -1
  25. package/src/agent-tools/read-history.mjs +284 -0
  26. package/src/agent-tools/recent-changes.mjs +2 -1
  27. package/src/agent-tools/settings.mjs +152 -0
  28. package/src/agent-tools/skill.mjs +2 -1
  29. package/src/agent-tools/subagent-actions.mjs +470 -0
  30. package/src/agent-tools/subagent-async.mjs +382 -0
  31. package/src/agent-tools/subagent-panel.mjs +153 -0
  32. package/src/agent-tools/subagent-run.mjs +202 -0
  33. package/src/agent-tools/subagent-scheduler.mjs +343 -0
  34. package/src/agent-tools/subagent-spawn.mjs +406 -0
  35. package/src/agent-tools/subagent.mjs +203 -377
  36. package/src/agent-tools/task.mjs +4 -3
  37. package/src/agent-tools/timer.mjs +9 -4
  38. package/src/agent-tools/verify.mjs +198 -238
  39. package/src/agent-tools.mjs +1 -0
  40. package/src/agent.mjs +145 -242
  41. package/src/auto-think.mjs +14 -0
  42. package/src/cli/distill-command.mjs +10 -4
  43. package/src/cli/make-agent.mjs +4 -1
  44. package/src/cli/memory-command.mjs +2 -1
  45. package/src/cli/permission.mjs +8 -1
  46. package/src/cli/setup-wizard.mjs +17 -12
  47. package/src/config.mjs +61 -8
  48. package/src/context.mjs +81 -163
  49. package/src/crash-reports.mjs +123 -0
  50. package/src/distill.mjs +30 -12
  51. package/src/escape.mjs +6 -4
  52. package/src/explore-distill.mjs +155 -0
  53. package/src/log.mjs +195 -0
  54. package/src/memory/code-sync.mjs +2 -1
  55. package/src/memory/core.mjs +11 -72
  56. package/src/memory/delete.mjs +234 -0
  57. package/src/memory/docs.mjs +206 -87
  58. package/src/memory.mjs +3 -1
  59. package/src/model-specs.mjs +15 -1
  60. package/src/peer-domains.mjs +265 -0
  61. package/src/peer-instances.mjs +231 -0
  62. package/src/prompt-overlays.mjs +25 -0
  63. package/src/prompts/advisor-design.md +18 -39
  64. package/src/prompts/advisor-round1.md +20 -32
  65. package/src/prompts/advisor-round2.md +16 -16
  66. package/src/prompts/advisor-round3.md +16 -16
  67. package/src/prompts/coder.md +7 -28
  68. package/src/prompts/consult-base.md +4 -11
  69. package/src/prompts/discipline.md +31 -44
  70. package/src/prompts/eng-coder.md +9 -34
  71. package/src/prompts/engineering-sub.md +10 -8
  72. package/src/prompts/engineering.md +61 -264
  73. package/src/prompts/explore.md +4 -14
  74. package/src/prompts/main.md +18 -35
  75. package/src/prompts/methodology-template.md +32 -38
  76. package/src/prompts/plan.md +2 -9
  77. package/src/prompts/system.md +18 -35
  78. package/src/provider/core.mjs +62 -69
  79. package/src/provider/errors.mjs +76 -0
  80. package/src/provider/retry.mjs +8 -45
  81. package/src/session-gc.mjs +214 -0
  82. package/src/session-guard.mjs +47 -0
  83. package/src/session-rename.mjs +38 -0
  84. package/src/session-slots.mjs +181 -58
  85. package/src/session.mjs +48 -89
  86. package/src/token-ttl.mjs +273 -0
  87. package/src/tools/apply_patch.md +3 -1
  88. package/src/tools/bash.md +1 -1
  89. package/src/tools/checklist-sync.mjs +181 -0
  90. package/src/tools/checklist.mjs +52 -39
  91. package/src/tools/delete.md +1 -0
  92. package/src/tools/edit-batch.mjs +131 -44
  93. package/src/tools/edit-diff.mjs +348 -0
  94. package/src/tools/edit.md +20 -13
  95. package/src/tools/execute.md +7 -7
  96. package/src/tools/execute.mjs +55 -24
  97. package/src/tools/file.mjs +25 -70
  98. package/src/tools/file_ops.md +2 -1
  99. package/src/tools/get_current_time.md +3 -1
  100. package/src/tools/git.mjs +14 -6
  101. package/src/tools/glob-dialect.mjs +130 -0
  102. package/src/tools/glob.md +3 -3
  103. package/src/tools/grep.md +1 -1
  104. package/src/tools/hashline_edit.md +2 -0
  105. package/src/tools/index.mjs +3 -3
  106. package/src/tools/insert_after.md +2 -1
  107. package/src/tools/lint.md +2 -0
  108. package/src/tools/lsp.md +4 -1
  109. package/src/tools/ops.mjs +175 -3
  110. package/src/tools/patch.mjs +84 -13
  111. package/src/tools/question.md +5 -1
  112. package/src/tools/repomap.mjs +1 -1
  113. package/src/tools/shared.mjs +18 -25
  114. package/src/tools/system.mjs +50 -30
  115. package/src/tools/tree.md +2 -1
  116. package/src/tools/wait_for.md +22 -0
  117. package/src/tools/web.mjs +5 -3
  118. package/src/tools/websearch.md +2 -1
  119. package/src/tools/write.md +2 -0
  120. package/src/traces/trace-store.mjs +224 -0
  121. package/src/tui/agent-turn.mjs +179 -27
  122. package/src/tui/clipboard.mjs +15 -4
  123. package/src/tui/cmd-config.mjs +77 -16
  124. package/src/tui/cmd-eng.mjs +20 -16
  125. package/src/tui/cmd-extract.mjs +1 -1
  126. package/src/tui/cmd-mcp.mjs +17 -2
  127. package/src/tui/cmd-new.mjs +3 -2
  128. package/src/tui/cmd-session.mjs +19 -4
  129. package/src/tui/cmd-think.mjs +11 -11
  130. package/src/tui/cmd-upgrade.mjs +19 -4
  131. package/src/tui/config-helpers.mjs +28 -16
  132. package/src/tui/distill-cmd.mjs +1 -1
  133. package/src/tui/index.mjs +31 -96
  134. package/src/tui/interaction.mjs +13 -2
  135. package/src/tui/key-handler.mjs +105 -155
  136. package/src/tui/key-modes.mjs +215 -0
  137. package/src/tui/layout.mjs +22 -1
  138. package/src/tui/mouse.mjs +46 -0
  139. package/src/tui/pickers.mjs +51 -25
  140. package/src/tui/render-conversation.mjs +13 -161
  141. package/src/tui/render-frame.mjs +27 -10
  142. package/src/tui/render-loop.mjs +4 -1
  143. package/src/tui/render-segments.mjs +182 -0
  144. package/src/tui/startup.mjs +40 -0
  145. package/src/tui/subagent-blocks.mjs +272 -262
  146. package/src/tui/subagent-children.mjs +176 -0
  147. package/src/tui/subagent-freeze.mjs +172 -0
  148. package/src/tui/subagent-panel.mjs +125 -12
  149. package/src/tui/suspension-drive.mjs +351 -0
  150. package/src/tui/tool-args.mjs +10 -2
  151. package/src/tui/tool-display.mjs +142 -0
  152. package/src/tui/tool-events.mjs +127 -231
  153. package/src/tui/tui-lifecycle.mjs +29 -0
  154. package/src/tui/update-notice.mjs +76 -0
  155. package/src/tui/wizard.mjs +48 -12
  156. package/src/agent-tools/escalate.mjs +0 -179
  157. package/src/agent-tools/subagent-check.mjs +0 -107
  158. package/src/tools/exec-prelude.mjs +0 -84
@@ -3,9 +3,12 @@ import { join } from "node:path"
3
3
  import { createAgent } from "../agent.mjs"
4
4
  import { loadConfig, configDir } from "../config.mjs"
5
5
  import { createMemory, memoryTools, syncDir, codeSearchTool, docSearchTool } from "../memory.mjs"
6
+ import { settingsTool } from "../agent-tools/settings.mjs"
6
7
  import { repoOutlineTool } from "../tools/repomap.mjs"
7
8
  import { builtinTools } from "../tools/index.mjs"
8
9
  import { discoverRules } from "../rules.mjs"
10
+ // R10 L2(MULTI-INSTANCE-COLLAB §2a.4 D-L2b):peer_instances 只读工具——挂感知模块导出
11
+ import { peerInstancesTool } from "../peer-instances.mjs"
9
12
 
10
13
  /** Assemble an agent with memory, MCP tools, and code/doc indices attached (sync all layers, then return) */
11
14
  export async function assembleAgent() {
@@ -48,7 +51,7 @@ export async function assembleAgent() {
48
51
  await ensureClone(team)
49
52
  await syncDir(memory, { layer: "team", dir: team.dir })
50
53
  }
51
- const baseTools = [...builtinTools, ...memoryTools(memory, { cwd, projectDir: config.memory.projectDir, author: gitAuthor(), team }), codeSearchTool(memory), docSearchTool(memory), repoOutlineTool(memory.db, cwd)]
54
+ const baseTools = [...builtinTools, ...memoryTools(memory, { cwd, projectDir: config.memory.projectDir, author: gitAuthor(), team }), codeSearchTool(memory), docSearchTool(memory), repoOutlineTool(memory.db, cwd), settingsTool(), peerInstancesTool]
52
55
 
53
56
  // MCP servers: connect in parallel (a dead server won't block startup), collect failures as warnings (stderr invisible in TUI, passed via agent object)
54
57
  const mcpServers = config.mcp?.servers ?? []
@@ -1,7 +1,8 @@
1
1
  import { join } from "node:path"
2
2
  import { loadConfig } from "../config.mjs"
3
3
  import { teamConfig } from "./make-agent.mjs"
4
- import { put, search, list, deleteByUid } from "../memory/core.mjs"
4
+ import { put, search, list } from "../memory/core.mjs"
5
+ import { deleteByUid } from "../memory/delete.mjs"
5
6
 
6
7
  /** thincoder memory <list|search|put|remove> subcommands.
7
8
  * opts.dirs: { project, team } layer directories for project/team file deletion (tests inject their own);
@@ -19,7 +19,14 @@ export function formatPermission(name, args) {
19
19
  }
20
20
  if (base === "delete") return `${args.path}${args.force ? "(force:跟踪文件也删)" : ""}`
21
21
  if (base === "subagent") return cap(args.task ?? "", 500)
22
- if (base === "memory_put") return `[${args.type ?? ""}] ${args.title ?? ""}\n${cap(args.content ?? "", 500)}`
22
+ if (base === "memory") {
23
+ // §6 action-routed preview: put shows content, batch delete/clear show the gate args
24
+ const action = String(args.action ?? "")
25
+ if (action === "put") return `[${args.type ?? ""}] ${args.title ?? ""}\n${cap(args.content ?? "", 500)}`
26
+ if (action === "delete") return args.id ? `id=${args.id}${args.layer ? ` layer=${args.layer}` : ""}` : `batch delete layer=${args.layer ?? ""} type=${args.type ?? ""} keyword=${args.keyword ?? ""} confirm=${args.confirm}`
27
+ if (action === "clear") return `clear layer=${args.layer ?? ""} confirm=${args.confirm}`
28
+ return cap(summarize(args), 300)
29
+ }
23
30
  return cap(summarize(args), 300)
24
31
  }
25
32
 
@@ -1,6 +1,5 @@
1
- import { existsSync, readFileSync } from "node:fs"
2
1
  import { createInterface } from "node:readline"
3
- import { configPath, saveConfig, PROVIDER_PRESETS } from "../config.mjs"
2
+ import { configPath, writeConfigAtomic, PROVIDER_PRESETS } from "../config.mjs"
4
3
 
5
4
  /** First-time setup (TTY chat / distill): ask a few questions to configure a provider, save to disk, return runtime provider. Cancel returns null. */
6
5
  export async function setupWizard() {
@@ -52,16 +51,22 @@ export async function setupWizard() {
52
51
  return null
53
52
  }
54
53
  const embedKey = (await ask("Optional: embedding API key (SiliconFlow, for vector search; press Enter to skip): ")).trim()
55
- const raw = existsSync(configPath) ? JSON.parse(readFileSync(configPath, "utf8")) : {}
56
- const providers = raw.providers?.length ? raw.providers : []
57
- const existing = providers.find((p) => p.name === name)
58
- if (existing) Object.assign(existing, { baseURL, model, apiKey })
59
- else providers.push({ name, baseURL, model, apiKey })
60
- raw.providers = providers
61
- raw.activeProvider = name
62
- delete raw.activeModel // reset to default model
63
- if (embedKey) raw.embedding = { ...(raw.embedding ?? {}), apiKey: embedKey }
64
- saveConfig(raw)
54
+ // D-F5b:磁盘新鲜读 mutate mtime 门控写(writeConfigAtomic 收口);冲突 = 放弃
55
+ // + 提示重试(首配场景另有实例同时写盘——极低概率;不自动合并——决策点① A)
56
+ const r = writeConfigAtomic(configPath, (raw) => {
57
+ const providers = raw.providers?.length ? raw.providers : []
58
+ const existing = providers.find((p) => p.name === name)
59
+ if (existing) Object.assign(existing, { baseURL, model, apiKey })
60
+ else providers.push({ name, baseURL, model, apiKey })
61
+ raw.providers = providers
62
+ raw.activeProvider = name
63
+ delete raw.activeModel // reset to default model
64
+ if (embedKey) raw.embedding = { ...(raw.embedding ?? {}), apiKey: embedKey }
65
+ })
66
+ if (!r.ok) {
67
+ console.error("config changed on disk concurrently — retry")
68
+ return null
69
+ }
65
70
  console.error(`Configured: ${name} / ${model} (saved to ${configPath})`)
66
71
  console.error(embedKey ? "Vector search enabled\n" : "(No embedding key configured: memory search will use text-only FTS. Add embedding.apiKey to config.json to enable vector search later.)\n")
67
72
  return { name, baseURL, model, apiKey }
package/src/config.mjs CHANGED
@@ -5,9 +5,9 @@
5
5
  * API key can fall back to environment variables (when not configured in providers).
6
6
  */
7
7
 
8
- import { chmodSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"
8
+ import { chmodSync, copyFileSync, existsSync, mkdirSync, readFileSync, statSync, writeFileSync } from "node:fs"
9
9
  import { homedir } from "node:os"
10
- import { join } from "node:path"
10
+ import { dirname, join } from "node:path"
11
11
 
12
12
  export const configDir = join(homedir(), ".thincoder")
13
13
  export const configPath = join(configDir, "config.json")
@@ -55,6 +55,14 @@ export const DEFAULTS = {
55
55
  advisor: { guard: false }, // code review is always available; guard: true pushes completion back until reviewed (opt-in). Also accepts provider/model/thinking/reasoningEffort/timeoutMs overrides. Deprecated: enabled (2026-08-21)
56
56
  autoThink: false, // auto-classify task difficulty and set reasoning effort per-turn
57
57
  engineering: false, // strict methodology enforcement — read METHODOLOGY.md, design-before-code
58
+ // Async subagent pool limits per role domain (AGENT-LOOP.md §24 D-24a/R14):
59
+ // { engCoder, other } — eng-coder pool / other-role pool, defaults 4/4 (user
60
+ // ruling "eng-coder 四路,其他 4 路"). Runtime-validated at every pool admission
61
+ // (positive integer ≥1, invalid/absent keys fall back to 4 — settings tool and
62
+ // the /config 并发池 menu write this key; change applies to the next spawn).
63
+ // ⚠ 与 subagent-async.mjs ASYNC_POOL_LIMITS 逐键同值(运行时回退常量)——耦合锚
64
+ // T-24a4 断言锁住——勿单侧改默认。
65
+ poolLimits: { engCoder: 4, other: 4 },
58
66
  },
59
67
  memory: {
60
68
  dbPath: join(configDir, "memory.db"),
@@ -73,6 +81,10 @@ export const DEFAULTS = {
73
81
  provider: "tavily", // structured search API; empty apiKey → fall back to Bing HTML scraping
74
82
  apiKey: "", // Tavily key (tvly-...) — optional
75
83
  },
84
+ traces: {
85
+ enabled: false, // §18.6 D-TR6 修订(2026-09-05 用户裁定——发布隐私:"不希望用户那边也采集"):轨迹存档默认 OFF——新用户零采集;本地调试分析可显式开(~/.thincoder/config.json traces.enabled:true)
86
+ retentionHours: 24, // D-TR10:轨迹文件保留小时数——CLI 启动时删除超过该时长的文件(默认 24h)
87
+ },
76
88
  }
77
89
 
78
90
  // Model capability table + spec lookup live in model-specs.mjs (2026-08-31
@@ -195,6 +207,7 @@ export function loadConfig() {
195
207
  agent: { ...DEFAULTS.agent, ...config.agent },
196
208
  memory: { ...DEFAULTS.memory, ...config.memory },
197
209
  embedding: { ...DEFAULTS.embedding, ...config.embedding },
210
+ traces: { ...DEFAULTS.traces, ...config.traces },
198
211
  }
199
212
 
200
213
  // providers[].context (K units, PROVIDER.md §15 D-C1): positive integer only — invalid
@@ -363,12 +376,52 @@ function mcpFingerprint(s) {
363
376
  }
364
377
 
365
378
  /**
366
- * Save configuration. Preserves providers list structure and activeProvider pointer.
367
- * providers[i].apiKey is only written when explicitly passed in (does not overwrite env-var-fallback keys).
379
+ * R10 F5(D-F5b,2026-09-06)——config.json 写前 mtime 门控收口函数(session-rename
380
+ * mtime-conflict 先例同型——MULTI-INSTANCE-COLLAB.md §2a.2)。所有 config.json 写点
381
+ * (config-helpers persistRaw / cmd-config saveProxy / cli setup-wizard / settings
382
+ * writeDisk)都经它落盘。
383
+ *
384
+ * 本函数持有整条「新鲜读 → mutate 单操作 → 写前重 stat → 写」链:
385
+ * - t0 = 写前重 stat 的比对基线,取在**新鲜读之前**(stat→read 序):若对端在本端
386
+ * stat 与 read 之间的微窗口写入,只会造成假冲突(放弃重试),绝不会带着旧内容覆盖
387
+ * 对端新值——read→stat 序存在漏检窗口(stat 已反映对端新 mtime → 门控放行旧内容)。
388
+ * - 写前重 stat ≠ t0 → **放弃**本次写(D-F5a 后各流已是 fresh 单操作语义,磁盘上对端
389
+ * 的新值保持在线不抹);先 copy `.bak-{ts}` 留现场(仅冲突时——config 低频写不膨胀;
390
+ * copy 而非 rename:冲突即放弃、本体不动,"保现场"是额外副本,非轮转腾位)。
391
+ * - 返回 { ok:false, reason:"mtime-conflict" },调用方提示 "config changed on disk
392
+ * concurrently — retry"——不自动合并(config 是用户显式操作——重试比猜测合并安全,
393
+ * 决策点① A)。
394
+ * - 文件缺失(首写)→ t0 = null;对端在本端读后创建 → null ≠ 新 mtime → 冲突放弃。
395
+ * - 畸形文件拒写(throw,绝不静默覆盖);写后 chmod 0600 尽力而为(saveConfig 旧语义)。
396
+ *
397
+ * @param path config.json 路径(生产默认 configPath;测试注入 tmp 路径)
398
+ * @param mutate 在磁盘新鲜 raw 上执行单操作的同步回调(如 push/splice/单字段补丁)
399
+ * @returns { ok: true } | { ok: false, reason: "mtime-conflict" }
368
400
  */
369
- export function saveConfig(config) {
370
- mkdirSync(configDir, { recursive: true })
401
+ export function writeConfigAtomic(path, mutate) {
402
+ const mtimeOf = (p) => {
403
+ try { return statSync(p).mtimeMs } catch { return null } // 缺失 → null(t0 比对基线)
404
+ }
405
+ const t0 = mtimeOf(path) // stat 先于 read(安全方向——见头注释)
406
+ const text = existsSync(path) ? readFileSync(path, "utf8") : null
407
+ let raw = {}
408
+ if (text !== null) {
409
+ try {
410
+ raw = JSON.parse(text)
411
+ } catch (error) {
412
+ throw new Error(`config file not parseable — refusing to overwrite: ${path} — ${error.message}`, { cause: error })
413
+ }
414
+ }
415
+ mutate(raw)
416
+ const t1 = mtimeOf(path)
417
+ if (t0 !== t1) {
418
+ // 对端在我们新鲜读后改过磁盘 → 放弃本次写(对端内容保持在线);.bak 副本留现场
419
+ try { if (existsSync(path)) copyFileSync(path, `${path}.bak-${Date.now()}`) } catch { /* 现场保留失败不阻断冲突报告 */ }
420
+ return { ok: false, reason: "mtime-conflict" }
421
+ }
422
+ mkdirSync(dirname(path), { recursive: true })
371
423
  // 0600: config.json contains API keys, must not be world-readable (POSIX; chmod is best-effort on Windows)
372
- writeFileSync(configPath, JSON.stringify(config, null, 2) + "\n", { encoding: "utf8", mode: 0o600 })
373
- try { chmodSync(configPath, 0o600) } catch { /* may fail on Windows, ignore */ }
424
+ writeFileSync(path, JSON.stringify(raw, null, 2) + "\n", { encoding: "utf8", mode: 0o600 })
425
+ try { chmodSync(path, 0o600) } catch { /* may fail on Windows, ignore */ }
426
+ return { ok: true }
374
427
  }
package/src/context.mjs CHANGED
@@ -37,9 +37,13 @@ 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
48
  // provider is guaranteed at every call site (runAgent always builds one); providerSpec
45
49
  // degrades to DEFAULT_SPEC (128K) only if provider is somehow absent — acceptable
@@ -48,6 +52,10 @@ function keepTailSize(provider, historyLen) {
48
52
  const ctxWindow = providerSpec(provider).context
49
53
  return Math.min(Math.max(10, Math.floor((ctxWindow / 100_000) * 30)), Math.floor(historyLen * 0.4))
50
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
+ }
51
59
 
52
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.
53
61
  Requirements:
@@ -59,7 +67,7 @@ Requirements:
59
67
  - Explicitly list UNRESOLVED ISSUES / TODOs: anything still open plus the next steps — so post-compaction recovery knows where to resume
60
68
  - Drop: pleasantries, repetition, fine-grained tool output details
61
69
  - Honestly mark uncertain items: anything not actually verified must say "unverified"; do not present guesses as facts
62
- - 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.
63
71
 
64
72
  Work log:
65
73
  `
@@ -68,7 +76,7 @@ Work log:
68
76
  const COMPACTION_PREFIX =
69
77
  "[Context was automatically compacted. Below is a summary of earlier work. " +
70
78
  "Treat it as notes, not proof — trust its conclusions (don't redo what it reports as done) " +
71
- "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"
72
80
 
73
81
  /** After this many consecutive compaction summary failures, degrade to deterministic truncation (losing info is better than task-killing 400 errors) */
74
82
  export const COMPRESS_FAILURE_LIMIT = 3
@@ -84,12 +92,13 @@ const FALLBACK_NOTE =
84
92
 
85
93
  /**
86
94
  * Split history into head / middle (to be summarized) / tail; return null if no middle to compress.
87
- * head is normally empty (KEEP_HEAD = 0 — earliest messages go into the summary); the
88
- * tool_calls-extension logic below is defensive for future KEEP_HEAD > 0.
89
- * The tail boundary must include any assistant whose tool results are in the tail — if the assistant is in the middle,
90
- * 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).
91
100
  */
92
- function splitHistory(history, keepTail) {
101
+ function splitHistory(history, keepTail, budgetTokens = null) {
93
102
  if (history.length <= KEEP_HEAD + keepTail + 1) return null
94
103
  let headEnd = KEEP_HEAD
95
104
  // head must not end with dangling tool_calls: when assistant declares tool_calls, all its tool results must stay in head.
@@ -97,10 +106,24 @@ function splitHistory(history, keepTail) {
97
106
  if (history[headEnd - 1]?.role === "assistant" && history[headEnd - 1].tool_calls?.length) {
98
107
  while (headEnd < history.length && history[headEnd].role === "tool") headEnd++
99
108
  }
100
- 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
+ }
101
119
 
102
- // Tool messages in the tail region whose assistant tool_calls are in the middle: the summary would swallow the assistant,
103
- // 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) {
104
127
  const tailToolIds = new Set()
105
128
  for (let i = tailStart; i < history.length; i++) {
106
129
  if (history[i].role === "tool") tailToolIds.add(history[i].tool_call_id)
@@ -112,16 +135,28 @@ function splitHistory(history, keepTail) {
112
135
  break
113
136
  }
114
137
  }
115
-
116
- // skip orphan tool messages at the new tail boundary (tool whose assistant was pulled in above)
117
- // NOTE: single-assistant assumption — the backwards scan pulls the nearest owner only; in
118
- // practice a tail spans at most one assistant→tools cycle (parallel calls share one assistant).
119
- // Bounds-guarded so an all-tool tail cannot push tailStart past history.length.
120
138
  while (tailStart < history.length && tailStart > headEnd && history[tailStart].role === "tool") {
121
139
  tailStart++
122
140
  }
123
- if (tailStart <= headEnd) return null
124
- 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
125
160
  }
126
161
 
127
162
  /**
@@ -132,9 +167,14 @@ function splitHistory(history, keepTail) {
132
167
  * Machine-only messages ([System reminder:...], compaction notes, task/plan/checkpoint re-injections)
133
168
  * are pushed directly to agent.history WITHOUT going through here, so they never enter _fullHistory.
134
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).
135
174
  */
136
175
  export function pushReal(agent, msg) {
137
176
  if (!Array.isArray(agent._fullHistory)) agent._fullHistory = []
177
+ if (msg && msg.ts === undefined) msg.ts = Date.now()
138
178
  agent._fullHistory.push(msg)
139
179
  agent.history.push(msg)
140
180
  }
@@ -148,10 +188,14 @@ function applyCompression(agent, headEnd, tailStart, note) {
148
188
  // possibly-completed earlier requests.
149
189
  const head = agent.history.slice(0, headEnd)
150
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()
151
195
  agent.history = [
152
196
  ...head,
153
- { role: "user", content: note },
154
- { 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 },
155
199
  ...tail,
156
200
  ]
157
201
  // Compaction REBUILDS the machine line (head + note + "Understood" + tail), so the pre-compaction
@@ -217,7 +261,7 @@ export async function compressIfNeeded(agent, threshold, callbacks, extras = {},
217
261
  if (tokens <= threshold) return false
218
262
 
219
263
  const keepTail = keepTailSize(agent.provider, history.length)
220
- const split = splitHistory(history, keepTail)
264
+ const split = splitHistory(history, keepTail, tailBudgetTokens(agent.provider))
221
265
  if (!split) {
222
266
  // History is too short (≤KEEP_HEAD+keepTail+1 messages) to find a middle section, but tokens exceed threshold — typically a single giant message
223
267
  // (large paste / huge injection). When summarization has no room, degrade to deterministic shrinking to ensure context always reduces
@@ -243,13 +287,21 @@ export async function compressIfNeeded(agent, threshold, callbacks, extras = {},
243
287
  // Silent by design (D11): no onToken/onReasoning — the compaction process must not stream to the frontend.
244
288
  // signal propagates user cancellation (Ctrl+C) to the in-flight summary call.
245
289
  // Compression visibility (CONTEXT-COMPACTION.md §7 D-C1/D-C2): the frontend learns the compression
246
- // STARTED right before the LLM call ("Compressing context… / summarizing N messages" panel) — only
290
+ // STARTED right before the summary LLM call ("Compressing context… / summarizing N messages" panel) — only
247
291
  // the lifecycle is surfaced, never the summary body. N = the number of history messages being summarized.
248
292
  callbacks?.onCompressStart?.({ messages: middle.length })
249
293
  const startedAt = performance.now()
250
294
  const summary = await chat({ ...agent.provider, thinking: null, reasoningEffort: null }, {
251
295
  messages: [{ role: "user", content: SUMMARIZE_PROMPT + serialized }],
252
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
+ },
253
305
  })
254
306
 
255
307
  applyCompression(agent, split.headEnd, split.tailStart, COMPACTION_PREFIX + summary.content)
@@ -272,7 +324,7 @@ export async function compressIfNeeded(agent, threshold, callbacks, extras = {},
272
324
  */
273
325
  export function compressFallback(agent) {
274
326
  const keepTail = keepTailSize(agent.provider, agent.history.length)
275
- const split = splitHistory(agent.history, keepTail)
327
+ const split = splitHistory(agent.history, keepTail, tailBudgetTokens(agent.provider))
276
328
  if (!split) return false
277
329
  const tailMessages = agent.history.length - split.tailStart
278
330
  applyCompression(agent, split.headEnd, split.tailStart, FALLBACK_NOTE)
@@ -322,142 +374,8 @@ function shrinkOversized(agent, limit = OVERSIZE_CONTENT_LIMIT) {
322
374
  return shrunk
323
375
  }
324
376
 
325
- // ─── End-of-run exploration distillation (AGENT-LOOP §13 + CONTEXT-COMPACTION §5, 2026-08-23) ───
326
- // The main agent's machine line is flooded by inline step-by-step exploration (read/grep/...).
327
- // At run end we distill THIS run's exploration tool-results into one semantic summary note that
328
- // replaces them in the machine line, while agent._fullHistory (the human line) stays untouched.
329
-
330
- /** Read-only knowledge tools counted as "exploration" (execute writes files → never exploration). */
331
- export const EXPLORE_TOOLS = new Set([
332
- "read", "grep", "glob", "ls", "code_search", "doc_search", "repo_outline",
333
- ])
334
-
335
- /** Summary prompt for turning a burst of exploration results into a semantic summary. */
336
- export const EXPLORE_SUMMARY_PROMPT = `You are distilling exploration tool results. Summarize the following read-only codebase exploration into a compact semantic summary for the main agent's own context.
337
-
338
- Requirements:
339
- - Capture WHAT was discovered, WHERE (which files / directories / symbols), and the KEY CONCLUSIONS — do not list tool calls mechanically
340
- - Keep actionable facts the main agent needs to continue: code locations, function names, file paths, structure, and open questions the exploration raised
341
- - Drop raw tool-output noise, repeated lines, and verbatim file dumps — keep only what must be remembered
342
- - Be honest: mark anything not actually verified as "unverified"; do not present guesses as facts
343
- - Use bullet points; aim for information completeness, not a hard word limit
344
-
345
- Exploration log:
346
- `
347
-
348
- /** tool_calls name across both stored shapes ({function:{name}} and flat {name}). */
349
- function toolCallName(tc) {
350
- return tc?.function?.name ?? tc?.name ?? ""
351
- }
352
-
353
- /** Tool that produced a tool-result message (falls back to its owner assistant's tool_call). */
354
- function toolResultName(msg, ownerToolCalls) {
355
- if (typeof msg?.name === "string" && msg.name) return msg.name
356
- const owner = (ownerToolCalls ?? []).find((tc) => tc.id === msg?.tool_call_id)
357
- return owner ? toolCallName(owner) : ""
358
- }
359
-
360
- /**
361
- * Find the pure-exploration "assistant(tool_calls)→tool…" pair blocks added since `start`.
362
- * A block is explorable only when EVERY tool call AND every tool result in it is an exploration
363
- * tool — mixed blocks (read + edit in one turn) stay untouched, or we'd orphan the edit pairing.
364
- */
365
- function findExplorationBlocks(history, start) {
366
- const blocks = []
367
- let i = start
368
- while (i < history.length) {
369
- const m = history[i]
370
- if (m?.role === "assistant" && Array.isArray(m.tool_calls) && m.tool_calls.length > 0) {
371
- let j = i + 1
372
- while (j < history.length && history[j]?.role === "tool") j++
373
- const toolMsgs = history.slice(i + 1, j)
374
- const allCallsExplore = m.tool_calls.every((tc) => EXPLORE_TOOLS.has(toolCallName(tc)))
375
- const allResultsExplore = toolMsgs.length > 0 && toolMsgs.every((t) => EXPLORE_TOOLS.has(toolResultName(t, m.tool_calls)))
376
- if (allCallsExplore && allResultsExplore) {
377
- blocks.push({ start: i, end: j, messages: history.slice(i, j), toolCount: toolMsgs.length })
378
- }
379
- i = j
380
- } else {
381
- i++
382
- }
383
- }
384
- return blocks
385
- }
386
-
387
- /** Serialize a batch of exploration messages for the summary LLM (same shape as compaction serialization). */
388
- function serializeExplorationMessages(messages) {
389
- const cap = 8000 // exploration results ARE the signal to distill — generous cap (quality-first, N1)
390
- return messages
391
- .map((m) => {
392
- const toolNote = m.tool_calls ? ` [called tools: ${m.tool_calls.map(toolCallName).join(", ")}]` : ""
393
- let text = ""
394
- if (typeof m.content === "string") text = m.content
395
- else if (Array.isArray(m.content)) text = m.content.filter((p) => p?.type === "text").map((p) => p.text ?? "").join(" ")
396
- return `[${m.role}]${toolNote} ${text.slice(0, cap)}`
397
- })
398
- .join("\n")
399
- }
400
-
401
- /**
402
- * Core (shared) distillation: replace this run's pure-exploration pair blocks with a single
403
- * "[Exploration summary]" note placed where the first block was. Returns a NEW history array,
404
- * or null when there is nothing to shrink (<3 exploration results / LLM failure). Pairing-safe:
405
- * whole assistant→tool blocks are removed, so no orphan tool_calls/tool can survive.
406
- */
407
- async function distillExplorations(history, start, provider, signal) {
408
- if (!Array.isArray(history) || history.length - start < 2) return null
409
- const blocks = findExplorationBlocks(history, start)
410
- const resultCount = blocks.reduce((n, b) => n + b.toolCount, 0)
411
- if (resultCount < 3) return null
412
-
413
- const serialized = blocks.map((b) => serializeExplorationMessages(b.messages)).join("\n")
414
-
415
- let summary
416
- try {
417
- // Silent by design (D11): thinking:null and no onToken/onReasoning — this internal
418
- // distillation must not stream to the frontend. signal propagates user cancellation.
419
- const resp = await chat({ ...provider, thinking: null, reasoningEffort: null }, {
420
- messages: [{ role: "user", content: EXPLORE_SUMMARY_PROMPT + serialized }],
421
- signal,
422
- })
423
- summary = resp?.content
424
- } catch {
425
- return null // N3: never block the run's return or lose history — original results stay
426
- }
427
- if (!summary) return null
428
-
429
- const drop = new Set()
430
- for (const b of blocks) for (let k = b.start; k < b.end; k++) drop.add(k)
431
- const note = { role: "user", content: "[Exploration summary]\n" + summary }
432
- const next = []
433
- let inserted = false
434
- for (let k = 0; k < history.length; k++) {
435
- if (drop.has(k)) {
436
- if (!inserted) { next.push(note); inserted = true }
437
- continue
438
- }
439
- next.push(history[k])
440
- }
441
- return next
442
- }
377
+ // ─── End-of-run exploration distillation(2026-09-05 module-split:524 > 500 硬限——verbatim
378
+ // 迁至 explore-distill.mjs,语义零变——VS Code compact.mjs 同款联动;cross-repo parity 锚改指
379
+ // explore-distill.mjs——消费方 import 面不变(re-export))───────────────────────
443
380
 
444
- /**
445
- * End-of-run exploration distillation (runAgent's final return). Shrinks the MACHINE line
446
- * (agent.history) only; agent._fullHistory is never touched. Triggers when this run added ≥3
447
- * exploration tool results; on LLM failure it silently keeps the original history (N3).
448
- * The distillation itself is silent and never streams (D11); `callbacks.onDistilled` fires
449
- * ONLY after the replacement actually lands (never on no-op/failure) — callers persist the
450
- * compressed session (SEND-STALL-DISTILL §2.3).
451
- */
452
- export async function summarizeRunExplorations(agent, callbacks, signal) {
453
- const next = await distillExplorations(agent.history, agent._runStartHistoryLen ?? 0, agent.provider, signal)
454
- if (!next) return
455
- agent.history = next
456
- // The machine line changed shape — the measured token baseline was for the pre-shrink context.
457
- // Invalidate so the next compaction check re-estimates instead of over-counting stale history.
458
- agent._lastPromptTokens = null
459
- agent._usageAtLen = null
460
- // The compressed machine line must reach the disk: the run's own save already happened,
461
- // so without this hook the async distill would leave the session un-compressed on exit.
462
- callbacks.onDistilled?.()
463
- }
381
+ export { summarizeRunExplorations, EXPLORE_TOOLS, EXPLORE_SUMMARY_PROMPT } from "./explore-distill.mjs"