@thincoder/core 0.9.4 → 0.10.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 (221) hide show
  1. package/CHANGELOG.md +63 -0
  2. package/README.md +1 -1
  3. package/abort-provenance.mjs +10 -10
  4. package/advisor/citations.mjs +5 -5
  5. package/advisor/compaction.mjs +16 -15
  6. package/advisor/loop.mjs +14 -14
  7. package/advisor/messages.mjs +6 -6
  8. package/advisor/project-context.mjs +5 -5
  9. package/advisor/repos.mjs +3 -3
  10. package/advisor/run.mjs +23 -13
  11. package/advisor.mjs +4 -4
  12. package/agent/assemble.mjs +111 -0
  13. package/agent/chat-call.mjs +68 -0
  14. package/agent/child-marks.mjs +2 -2
  15. package/agent/completion.mjs +9 -2
  16. package/agent/continue-decision.mjs +25 -0
  17. package/agent/dispatch-gates.mjs +140 -0
  18. package/agent/dispatch-run.mjs +167 -0
  19. package/agent/dispatch.mjs +55 -294
  20. package/agent/family-tools.mjs +11 -11
  21. package/agent/helpers.mjs +63 -3
  22. package/agent/live-beat.mjs +49 -0
  23. package/agent/post-turn.mjs +4 -8
  24. package/agent/record-results.mjs +23 -20
  25. package/agent/relay-prefix.mjs +2 -2
  26. package/agent/run-stages.mjs +19 -15
  27. package/agent/run-start.mjs +111 -0
  28. package/agent/setup-reminders.mjs +9 -9
  29. package/agent/setup.mjs +32 -4
  30. package/agent/spawn-child.mjs +9 -9
  31. package/agent/suspension.mjs +90 -30
  32. package/agent/timers.mjs +113 -0
  33. package/agent/turn-loop.mjs +244 -0
  34. package/agent/write-gate.mjs +103 -6
  35. package/agent-tools/advisor-async.mjs +11 -11
  36. package/agent-tools/advisor-settle.mjs +17 -17
  37. package/agent-tools/advisor.mjs +38 -43
  38. package/agent-tools/async-discard.mjs +44 -1
  39. package/agent-tools/async-settle.mjs +23 -20
  40. package/agent-tools/audit-block.mjs +7 -7
  41. package/agent-tools/bash-async.mjs +281 -0
  42. package/agent-tools/batch-lifecycle.mjs +65 -34
  43. package/agent-tools/batch-paths.mjs +186 -0
  44. package/agent-tools/batch-skeleton.mjs +19 -1
  45. package/agent-tools/batch.mjs +22 -119
  46. package/agent-tools/checkpoint.mjs +121 -0
  47. package/agent-tools/child-permission.mjs +5 -3
  48. package/agent-tools/consult.mjs +58 -58
  49. package/agent-tools/context.mjs +4 -20
  50. package/agent-tools/design-token.mjs +3 -3
  51. package/agent-tools/digest-budget.mjs +1 -1
  52. package/agent-tools/eng.mjs +5 -5
  53. package/agent-tools/escalate-async.mjs +18 -13
  54. package/agent-tools/goal.mjs +3 -10
  55. package/agent-tools/panel-blocks.mjs +3 -3
  56. package/agent-tools/parent-channel.mjs +3 -13
  57. package/agent-tools/plan.mjs +3 -4
  58. package/agent-tools/read-history.mjs +12 -28
  59. package/agent-tools/recent-changes.mjs +4 -5
  60. package/agent-tools/review-facts.mjs +1 -1
  61. package/agent-tools/settings.mjs +47 -20
  62. package/agent-tools/skill.mjs +3 -5
  63. package/agent-tools/spawn-gates.mjs +3 -3
  64. package/agent-tools/subagent-actions-query.mjs +270 -0
  65. package/agent-tools/subagent-actions.mjs +29 -273
  66. package/agent-tools/subagent-async.mjs +36 -31
  67. package/agent-tools/subagent-panel.mjs +38 -9
  68. package/agent-tools/subagent-run.mjs +44 -29
  69. package/agent-tools/subagent-scheduler.mjs +29 -28
  70. package/agent-tools/subagent-spawn.mjs +57 -25
  71. package/agent-tools/subagent.mjs +46 -66
  72. package/agent-tools/task.mjs +3 -9
  73. package/agent-tools/timer.mjs +12 -12
  74. package/agent-tools/verify.mjs +14 -13
  75. package/agent-tools.mjs +0 -1
  76. package/agent.mjs +20 -351
  77. package/attachments.mjs +125 -0
  78. package/auto-think.mjs +1 -1
  79. package/config-presets.mjs +12 -7
  80. package/config-watch.mjs +83 -0
  81. package/config.mjs +17 -4
  82. package/context-degrade.mjs +81 -0
  83. package/context-echo.mjs +88 -0
  84. package/context-push.mjs +51 -0
  85. package/context.mjs +8 -187
  86. package/conventions.mjs +110 -161
  87. package/declaration.mjs +194 -0
  88. package/embedding.mjs +3 -1
  89. package/env-ref.mjs +81 -0
  90. package/explore-distill.mjs +2 -3
  91. package/file-links.mjs +53 -0
  92. package/file-refs.mjs +103 -0
  93. package/generate-title.mjs +28 -4
  94. package/git/checkpoint.mjs +4 -8
  95. package/history-window.mjs +17 -2
  96. package/i18n.mjs +2 -0
  97. package/index-discover.mjs +7 -7
  98. package/ledger-cmd.mjs +34 -126
  99. package/ledger-db.mjs +35 -7
  100. package/ledger-migrate.mjs +299 -0
  101. package/ledger-tools.mjs +188 -0
  102. package/ledger.mjs +11 -5
  103. package/log.mjs +12 -7
  104. package/manifest-discovery.mjs +131 -0
  105. package/manifest-schema.mjs +166 -0
  106. package/manifest.mjs +18 -217
  107. package/mcp.mjs +4 -1
  108. package/memory/code-search.mjs +149 -0
  109. package/memory/code-sync.mjs +89 -230
  110. package/memory/core.mjs +47 -32
  111. package/memory/docs.mjs +63 -262
  112. package/memory/file-list.mjs +89 -0
  113. package/memory/file-walk.mjs +7 -4
  114. package/memory/memory-tool.mjs +229 -0
  115. package/memory/schema.mjs +18 -3
  116. package/memory/sweep.mjs +289 -0
  117. package/memory/sync-tail.mjs +81 -0
  118. package/memory-status.mjs +69 -0
  119. package/memory.mjs +3 -0
  120. package/model-specs.mjs +131 -66
  121. package/notify-policy.mjs +51 -0
  122. package/package.json +1 -1
  123. package/peer-claims.mjs +263 -0
  124. package/peer-domains.mjs +100 -71
  125. package/peer-instances.mjs +9 -10
  126. package/process-probe-exec.mjs +187 -0
  127. package/process-probe.mjs +27 -175
  128. package/prompt-overlays.mjs +11 -10
  129. package/prompts/advisor-design.md +2 -2
  130. package/prompts/advisor-round2.md +1 -1
  131. package/prompts/advisor-round3.md +1 -1
  132. package/prompts/common.md +19 -7
  133. package/prompts/discipline-engineering.md +57 -16
  134. package/prompts/discipline-normal.md +6 -3
  135. package/prompts/persona-coder.md +0 -1
  136. package/prompts/persona-eng-coder.md +1 -5
  137. package/prompts/persona-eng-designer.md +1 -2
  138. package/prompts/persona-engineering.md +41 -11
  139. package/prompts/persona-explore.md +0 -1
  140. package/prompts/persona-normal.md +10 -1
  141. package/prompts/persona-plan.md +3 -4
  142. package/prompts/subagent-base.md +14 -0
  143. package/provider/core.mjs +11 -48
  144. package/provider/list-models.mjs +3 -0
  145. package/provider/responses-request.mjs +237 -0
  146. package/provider/responses.mjs +6 -228
  147. package/provider-flows.mjs +248 -0
  148. package/queued.mjs +90 -0
  149. package/rules.mjs +209 -2
  150. package/session-gc.mjs +67 -6
  151. package/session-guard.mjs +27 -7
  152. package/session-lifecycle.mjs +90 -18
  153. package/session-rename.mjs +5 -1
  154. package/session-slot-claims.mjs +230 -0
  155. package/session-slot-scan.mjs +299 -0
  156. package/session-slot-verify.mjs +304 -0
  157. package/session-slot-write.mjs +74 -8
  158. package/session-slots-manifest.mjs +163 -236
  159. package/session-slots.mjs +99 -71
  160. package/session.mjs +30 -10
  161. package/shell-candidates.mjs +78 -0
  162. package/text-budget.mjs +2 -2
  163. package/think-off.mjs +49 -0
  164. package/token-ttl.mjs +15 -4
  165. package/tool-args.mjs +73 -0
  166. package/tool-docs/advisor.md +9 -0
  167. package/tool-docs/batch.md +1 -0
  168. package/tool-docs/code_search.md +1 -0
  169. package/tool-docs/consult_start.md +5 -0
  170. package/tool-docs/consult_stop.md +3 -0
  171. package/tool-docs/context.md +7 -0
  172. package/tool-docs/doc_search.md +1 -0
  173. package/tool-docs/edit.md +2 -13
  174. package/tool-docs/eng.md +1 -0
  175. package/tool-docs/execute.md +1 -9
  176. package/tool-docs/git.md +25 -51
  177. package/tool-docs/goal.md +6 -0
  178. package/tool-docs/ledger_add.md +1 -0
  179. package/tool-docs/ledger_close.md +1 -0
  180. package/tool-docs/ledger_count.md +1 -0
  181. package/tool-docs/ledger_query.md +1 -0
  182. package/tool-docs/ledger_update.md +1 -0
  183. package/tool-docs/main_history.md +1 -0
  184. package/tool-docs/memory.md +10 -0
  185. package/tool-docs/notify_parent.md +9 -0
  186. package/tool-docs/peer_instances.md +1 -0
  187. package/tool-docs/plan.md +5 -0
  188. package/tool-docs/process.md +7 -3
  189. package/tool-docs/question.md +1 -0
  190. package/tool-docs/read_history.md +7 -0
  191. package/tool-docs/recent_changes.md +1 -0
  192. package/tool-docs/repo_outline.md +1 -0
  193. package/tool-docs/settings.md +6 -0
  194. package/tool-docs/skill.md +5 -0
  195. package/tool-docs/subagent.md +31 -0
  196. package/tool-docs/task.md +7 -0
  197. package/tool-docs/timer.md +6 -0
  198. package/tool-docs/verify.md +1 -0
  199. package/tool-docs/wait_for.md +2 -1
  200. package/tools/bash.mjs +28 -4
  201. package/tools/edit-batch.mjs +10 -11
  202. package/tools/edit-diff.mjs +33 -25
  203. package/tools/exec-run.mjs +102 -4
  204. package/tools/execute.mjs +5 -2
  205. package/tools/file.mjs +11 -12
  206. package/tools/git-run.mjs +1 -1
  207. package/tools/git.mjs +0 -24
  208. package/tools/glob-dialect.mjs +3 -3
  209. package/tools/index.mjs +2 -1
  210. package/tools/linter.mjs +2 -1
  211. package/tools/node-child.mjs +21 -0
  212. package/tools/ops.mjs +50 -13
  213. package/tools/patch.mjs +4 -2
  214. package/tools/repomap.mjs +20 -9
  215. package/tools/search.mjs +2 -2
  216. package/tools/shared.mjs +9 -7
  217. package/tools/web.mjs +5 -2
  218. package/tools/write-path.mjs +1 -1
  219. package/traces/trace-store.mjs +17 -14
  220. package/vision-reader.mjs +76 -0
  221. package/agent-tools/batch-segment.mjs +0 -18
@@ -11,11 +11,13 @@
11
11
  * path (doc-only changes return early) — the task list and self-review checklist.
12
12
  */
13
13
 
14
- import { isCodePath, isDocPath, loadConventions } from "../conventions.mjs"
14
+ import { isCodePath, isDocPath, loadProjectDeclaration } from "../conventions.mjs"
15
15
  // #96(信息段与执行方式按端注入——CORE-UNIFICATION §2.13.4):git / node --check 执行
16
16
  // 全部经核内执行面单点(默认径 = execFileSync 等价——CLI 语义零行为变;端侧 injected ⇒
17
17
  // 可中断执行器);编辑器诊断段经 `configureVerifyDiagnostics` 注入(缺省不注入)。
18
18
  import { runCommand } from "../tools/exec-run.mjs"
19
+ // #15 描述外置:描述文本单点 = tool-docs/verify.md(DESC 单解析面,缺档抛错语义不变)
20
+ import { DESC } from "../tools/shared.mjs"
19
21
  import { existsSync } from "node:fs"
20
22
  import { resolve } from "node:path"
21
23
 
@@ -34,7 +36,7 @@ export function configureVerifyDiagnostics(impl) {
34
36
  export function resetVerifyDiagnostics() { injectedDiagnostics = null }
35
37
 
36
38
  /**
37
- * §18.12 D-VR1 path normalization — mirrors the §20.5 file-domain handling:
39
+ * D-VR1 path normalization — mirrors the file-domain handling (AGENT-LOOP-SUBAGENT.md §6.9):
38
40
  * resolved to an absolute path with forward slashes; dedup uses the win32
39
41
  * lowercase comparison key (changedFileKey) so "D:/x/src/a.mjs" and
40
42
  * "d:\\x\SRC\a.mjs" count as the same file.
@@ -74,28 +76,27 @@ function appendBlockGuidance(lines, codeFiles) {
74
76
  */
75
77
  export const verifyTool = {
76
78
  name: "verify",
77
- description:
78
- "Generic pre-completion verification gate — language/framework/project agnostic. verify does NOT run any test/verification command for you and does NOT require a test per change. You DECLARE your verification status via the verification argument: { status: 'passed' | 'failed' | 'skipped', command?, summary? }. 'passed' → allowed; 'failed' → blocked (cannot claim done); 'skipped' → allowed but requires a summary reason. Whether/how to verify is described in natural language in the project's AGENTS.md and is decided + executed by you — verify only mechanically gates on your declaration. Also reports changed files (git diff), an advisory node --check hint on changed .js/.mjs (not gating), the task list, and a self-review checklist. Call this BEFORE declaring a coding task complete — do not say 'done' until verify passes.",
79
+ description: DESC("verify"),
79
80
  parameters: {
80
81
  type: "object",
81
82
  properties: {
82
83
  verification: {
83
84
  type: "object",
84
- description: "The model's declaration of whether it already ran the project's verification and its outcome. verify does not execute the command — it is self-reported evidence.",
85
+ description: "Your declaration of whether you already ran the project's verification and its outcome — self-reported evidence (verify does not execute the command).",
85
86
  properties: {
86
87
  status: { type: "string", enum: ["passed", "failed", "skipped"], description: "The verification outcome you declare: 'passed' → allowed; 'failed' → blocked; 'skipped' → allowed but requires a summary reason." },
87
- command: { type: "string", description: "Optional: the verification command you ran (self-reported — verify does not execute it)." },
88
- summary: { type: "string", description: "Optional: summary/result of the verification. Required when status='skipped' — a concrete reason (e.g. \"project has no automated tests — verified by manual review\")." },
88
+ command: { type: "string", description: "Optional: the verification command you ran (self-reported)." },
89
+ summary: { type: "string", description: "Optional: the result. Required when status='skipped' — a concrete reason (e.g. \"project has no automated tests — verified by manual review\")." },
89
90
  },
90
91
  required: ["status"],
91
92
  },
92
- workdir: { type: "string", description: "Optional: run verify in this subdirectory (relative to cwd or absolute) — for monorepos" },
93
+ workdir: { type: "string", description: "Optional: run verify in this subdirectory (relative to cwd or absolute) — monorepos" },
93
94
  },
94
95
  },
95
96
  readonly: true,
96
97
  async execute(args, ctx) {
97
98
  const cwd = ctx.agent.cwd
98
- // Changed-file resolution (§18.12 D-VR3): _touchedFiles (per-run bookkeeping,
99
+ // Changed-file resolution (D-VR3): _touchedFiles (per-run bookkeeping,
99
100
  // absolute paths) ∪ git diff fallback — git is tried at testCwd
100
101
  // (workdir-resolved) first, then at ctx.agent.cwd; first success wins.
101
102
  // git-diff paths are repo-root-relative — resolved against the discovered
@@ -105,7 +106,7 @@ export const verifyTool = {
105
106
  lines.push("=== VERIFICATION REPORT ===")
106
107
  lines.push("")
107
108
 
108
- // 1. Changed files — _touchedFiles ∪ git diff (§18.12 D-VR1)
109
+ // 1. Changed files — _touchedFiles ∪ git diff (D-VR1)
109
110
  let gitOk = false
110
111
  let gitStat = ""
111
112
  let gitFiles = []
@@ -131,7 +132,7 @@ export const verifyTool = {
131
132
  lines.push("Changed files: (not a git repo or git unavailable)")
132
133
  }
133
134
  // _touchedFiles: files written by the tools this run — captured regardless
134
- // of git state (§18.12 F-VR1: subagent cwd ≠ git root must still locate).
135
+ // of git state (F-VR1: subagent cwd ≠ git root must still locate).
135
136
  const touchedFiles = (ctx.agent._touchedFiles ?? []).map((p) => normalizeChangedPath(p, cwd))
136
137
  const gitKeys = new Set(gitFiles.map(changedFileKey))
137
138
  const touchedOnly = touchedFiles.filter((p) => !gitKeys.has(changedFileKey(p)))
@@ -173,10 +174,10 @@ export const verifyTool = {
173
174
  // for doc changes, and the task list/self-review checklist add nothing either.
174
175
  // Paths inside a declared code segment (default: src — incl. prompts/*.md) are
175
176
  // product code — excluded from the fast path, consistent with the design gate.
176
- // The project's own layout is declarable (.thincoder/conventions.json).
177
+ // The project's own layout is declarable (the manifest's codePaths).
177
178
  // Empty list (no changes / git unavailable)
178
179
  // intentionally falls through to the normal path below.
179
- const conv = loadConventions(cwd)
180
+ const conv = loadProjectDeclaration(cwd)
180
181
  if (changedFiles.length > 0 && changedFiles.every((f) => isDocPath(f, conv))) {
181
182
  lines.push("")
182
183
  lines.push("Documentation-only changes — skipping syntax checks and tests.")
package/agent-tools.mjs CHANGED
@@ -21,7 +21,6 @@ export { batchTool } from "./agent-tools/batch.mjs"
21
21
  // 批次档生命周期工具(BATCH-RECORD §4.3 挂载表——主名 `batch` 单工具四 action:depth-0 主 agent
22
22
  // create/close + append §1/§4/§6 + status §1(轮 2 裁定②:§4/§6 状态面走普通文档写)(D-BR18);
23
23
  // eng 子代理 eng 分支绑定段写入;评审 §3。
24
- // 过渡别名 batchSegmentTool 不入登记册(§4.14——生产挂载面全切主名;别名仅供 shim 导出面)。
25
24
  // CORE-UNIFICATION TOOLS #83(统一登记册——VSC `agent-tools/index.mjs:15` 含 consult 家族;
26
25
  // CLI 原把 consult 另挂 `agent/setup.mjs:173` ⇒ 归位:登记册即单一来源,装配方只读本档)。
27
26
  export { consultStartTool, consultStopTool } from "./agent-tools/consult.mjs"
package/agent.mjs CHANGED
@@ -2,33 +2,22 @@
2
2
  * agent.mjs — Agent main loop
3
3
  * LLM ↔ tool-call loop, until the task is done.
4
4
  */
5
- import { chat } from "./provider/index.mjs"
6
- import { abortError, annotateAbort } from "./abort-provenance.mjs"
7
- import { pushReal, summarizeRunExplorations } from "./context.mjs"
8
- import { specForModel, assistantToolCallMessage } from "./config.mjs"
9
- import { resolve } from "node:path"
10
- import { executeToolCalls } from "./agent/dispatch.mjs"
11
- import { recordToolResults } from "./agent/record-results.mjs"
12
- import { FILE_MUTATORS } from "./agent/helpers.mjs"
13
- import { prepareRun } from "./agent/setup.mjs"
14
- import { injectPostTurn } from "./agent/post-turn.mjs"
15
- import { handleCompletion } from "./agent/completion.mjs"
16
5
  // PROMPT-SYSTEM 施工② G1(2026-09-10):六件槽位常量装载收口 prompt-overlays.mjs
17
6
  // (与子代理角色常量同源——单一权威锚 D1);旧三件文件读取随本批退役。
18
7
  // 槽位常量通过下方 re-export 面(mod: rel — re-export 保 import 面);本文件自身零直接消费。
19
8
  // 主循环阶段函数(压缩检查/注入组/回合收尾)2026-09-05 实践轮迁 agent/run-stages.mjs
20
- import { runCompactionCheck, injectTurnReminders, finalizeAgentTurn, injectResponseReminders } from "./agent/run-stages.mjs"
9
+ // 2026-09-29 P2 三拆(#579):起跑前段 ∕ 回合环 ∕ 模型调用逐字迁 agent/run-start.mjs ∕
10
+ // agent/turn-loop.mjs ∕ agent/chat-call.mjs——本档残留 = 编排 + 状态工厂 + 面(re-export 保面)。
11
+ import { beginRun } from "./agent/run-start.mjs"
12
+ import { runTurnLoop } from "./agent/turn-loop.mjs"
13
+ import { finalizeAgentTurn } from "./agent/run-stages.mjs"
21
14
  import {
22
- escapeXml, repairHistory, listWorkDir,
15
+ escapeXml, listWorkDir,
23
16
  readonlyToolNames, collectGitContext, loadProjectInstructions,
24
17
  ContinueError,
25
- turnFrame, // 第 19 批(TURN-ACROSS-SEGMENTS):跨段累计编号帧(设计 §19.3)
26
18
  DEFAULT_MAX_TURNS, DEFAULT_SUBAGENT_TURNS,
27
19
  MIN_REPORT_CHARS, REPORT_CONTINUATION,
28
- AUTO_TURN_DIGEST_DOMAIN,
29
- AUTO_TURN_DIGEST_DOMAIN_ENG, // §6.15.3(F10 第三面):工程模式 digest 基座变体(task 指针改批次档 + 台账)
30
- UPSTREAM_TURN_DOMAIN, // §6.27.12.8:上行唤醒轮域文本(手动档——ask 轮不沿用 digest 域文本)
31
- restoreGuard, // digest D-S6 读侧单点(AGENT-LOOP-ASYNC-POOL.md §6.8;P2 机制层端差批 §2.18——键清单归核)
20
+ SUBAGENT_TOOL_EXCLUSIONS, excludeSubagentTools, // TOOLS.md §6.16:子代面按面排除(helpers 单源)
32
21
  } from "./agent/helpers.mjs"
33
22
  // ENG 提醒族 + auto-turn domain 2026-09-05 迁 agent/helpers.mjs(agent.mjs 530 > 500 硬限)
34
23
  // PROMPT-SYSTEM 施工② G1(2026-09-10):六件槽位常量装载收口 prompt-overlays.mjs
@@ -50,11 +39,14 @@ export {
50
39
  listWorkDir, loadProjectInstructions,
51
40
  readonlyToolNames, collectGitContext, escapeXml,
52
41
  MIN_REPORT_CHARS, REPORT_CONTINUATION, DEFAULT_SUBAGENT_TURNS,
42
+ SUBAGENT_TOOL_EXCLUSIONS, excludeSubagentTools, // TOOLS.md §6.16(子代装配点从本档取用)
53
43
  }
54
44
 
55
45
 
56
46
  // Re-exported for API compatibility (single source of truth: advisor/repos.mjs)
57
47
  export { hasCodeMutations } from "./advisor/repos.mjs"
48
+ // 流式输出门(§2.5 #78)2026-09-29 P2 三拆随 agent/chat-call.mjs 迁出——re-export 保 import 面
49
+ export { streamOutputAllowed } from "./agent/chat-call.mjs"
58
50
 
59
51
  /** Create a new agent state object with all fields initialized to defaults */
60
52
  export function createAgent({
@@ -73,8 +65,8 @@ export function createAgent({
73
65
  // (AC3 零写) — no field initializer; the multi-slot Map `_engDesignTokens` is the
74
66
  // authoritative ledger (hydrated by restoreEngTokens / written by settle).
75
67
  _touchedFiles: [], _verifyRetries: 0, _advisorRound: 0, _advisorSession: null,
76
- _advisorRuns: new Map(), // §11.2 D-24b: per-review convergence instances (rounds/prior/designId)
77
- _mutationSeq: 0, _mutLog: [], // §11.2 D-24b: mutation log (in-flight review staleness scan)
68
+ _advisorRuns: new Map(), // AGENT-LOOP-ASYNC-POOL.md §6.10 D-24b: per-review convergence instances (rounds/prior/designId)
69
+ _mutationSeq: 0, _mutLog: [], // AGENT-LOOP-ASYNC-POOL.md §6.10 D-24b: mutation log (in-flight review staleness scan)
78
70
  _lastAdvisorOutput: null, // full review output from the most recent advisor call (convergence rounds inject it verbatim)
79
71
  _lastEngState: false,
80
72
  _pendingReminders: [],
@@ -89,341 +81,18 @@ export function createAgent({
89
81
  }
90
82
  }
91
83
 
92
- /** 流式输出门(§2.5 #78 并入——VSC 三态门,纯函数可直驱测试):depth 0 恒通;
93
- * consult 子代理豁免(其输出进会诊面板);其余经 `streamOutput` 显式选择进入。 */
94
- export function streamOutputAllowed(depth, role, streamOutput = false) {
95
- return depth === 0 || role === "consult" || streamOutput === true
96
- }
97
-
98
84
  /** Run the agent loop: LLM ↔ tool-call cycle until task completion or turn limit. Returns final text content. */
99
- export async function runAgent(agent, input, callbacks = {}, { depth = 0, signal, maxTurns: overrideTurns, resume = false, autoTurn = false, upstreamTurn = false, suspDriven = false, consumeInjected = null, streamOutput = false, extraTools = null } = {}) {
100
- // Previous run's async exploration distillation must settle before this run pushes
101
- // input (SEND-STALL-DISTILL §2.2 N1) — await first, or its history replace wipes it.
102
- if (agent._pendingDistill) {
103
- const p = agent._pendingDistill
104
- agent._pendingDistill = null
105
- await p
106
- }
107
- // §17 D-S3: suspension-settled async results inject before EVERY run's prepareRun
108
- // (user + auto-turn); spliced = consumed. collectSettledAsync owns a different
109
- // container, so no double-inject across the two consumption points.
110
- // ASYNC-RESULT-CONTAINER.md D2 (2026-09-08):pending 单容器 `_pendingAsyncResults`
111
- // +role——四族(subagent/advisor/escalate/consult)统一停靠;注入器按 role 分发
112
- // (consult → injectConsultResult;其余 → injectAsyncResult——族分支同 §25 D-R17a/b)
113
- // ——单容器一处清,不再逐族三段。
114
- const pendingAsync = agent._pendingAsyncResults
115
- if (pendingAsync?.length) {
116
- const { injectAsyncResult } = await import("./agent-tools/subagent.mjs")
117
- const { injectConsultResult } = await import("./agent-tools/consult.mjs")
118
- // TUI-OOM-ROOTCAUSE(AGENT-LOOP.md §23.3.1 消费点②——run 起始 pending 注入):
119
- // 注入完成后释放条目对子代理对象的持有(childAgent/report 置空——幂等 helper)。
120
- const { releaseSettledEntry } = await import("./agent-tools/async-settle.mjs")
121
- for (const e of pendingAsync.splice(0)) {
122
- if (e.role === "consult") await injectConsultResult(agent, e)
123
- else await injectAsyncResult(agent, e)
124
- releaseSettledEntry(e)
125
- }
126
- }
127
- agent._inAutoTurn = autoTurn // spawn gate for manual-tier digests (§17 D-S6/N3;上行唤醒轮同持 autoTurn——§6.27.12.4 ①)
128
- const { maxTurns, threshold, tools, toolSchemas, toolByName, systemPrompt } = await prepareRun(
129
- // G1/G2(施工②):prompt 装配收口 prepareRun 内部(assemblePrompt——prompt-overlays.mjs
130
- // 槽位常量,与子代理角色常量同源——单一权威锚 D1);本调用不再携带 prompt 常量。
131
- agent, input, callbacks,
132
- { depth, signal, overrideTurns, resume: resume || autoTurn, extraTools },
133
- )
134
-
135
- // Exploration-distillation boundary (CONTEXT-COMPACTION §5): prepareRun already
136
- // pushed input + injections — appended from here counts as "this run's" work.
137
- agent._runStartHistoryLen = agent.history.length
138
-
139
- // Per-run bookkeeping reset — PRESERVED on `resume` (ContinueError continuation):
140
- // mutation/guard continuity and the convergence budget must survive a continuation.
141
- if (!resume) {
142
- // 第 19 批(TURN-ACROSS-SEGMENTS——设计 TURN-CAP-CONTINUE.md §19.3):链内累计编号
143
- // 只在链起点复位——续跑(resume:true)不重置、不回退(编号帧公式见 helpers.mjs
144
- // turnFrame)。与下方 mutation/guard 复位同条件同点(全档唯一复位点)。
145
- agent._turnSeq = 0
146
- // §17 D-S6: an auto-turn's guard marks are inherited by the next USER run (not
147
- // reset) so auto-turn changes never escape the guard silently.
148
- const g = agent._inheritedGuard
149
- if (g) {
150
- restoreGuard(agent, g)
151
- agent._inheritedGuard = null
152
- } else {
153
- agent._mutatedThisRun = false
154
- agent._verifiedThisRun = false
155
- agent._verifyPassed = undefined
156
- agent._calledAdvisorThisRun = false
157
- agent._touchedFiles = []
158
- agent._verifyRetries = 0
159
- agent._advisorRound = 0
160
- agent._advisorSession = null // advisor session is per-run: discard when the task ends, next task starts fresh
161
- agent._emptyRetries = 0 // empty-response retry budget is per-run: a fresh user turn restarts from zero
162
- agent._compressFailures = 0 // compaction summary-failure counter is per-run: a fresh user turn restarts from zero
163
- // F-CC2(§6.16.2):模型主动压缩的排队槽也是回合级——上一回合末尾未被安全点消费的请求不得跨回合生效
164
- agent._pendingCompact = null
165
- }
166
- }
167
- // digest D-S6 manual tier(AGENT-LOOP-ASYNC-POOL.md §6.8): action-domain reminder (system-driven turn — organize only).
168
- // §6.27.12.4 ②: an up-stream wake turn answers a RUNNING subagent waiting for the reply — it
169
- // must not reuse the digest text ("no one is waiting" is the opposite of the truth).
170
- if ((autoTurn || upstreamTurn) && !agent.autoApprove) {
171
- agent.history.push({ role: "user", content: upstreamTurn ? UPSTREAM_TURN_DOMAIN : (agent.config?.agent?.engineering === true ? AUTO_TURN_DIGEST_DOMAIN_ENG : AUTO_TURN_DIGEST_DOMAIN), transient: true })
172
- }
173
- // eng-coder authorization (_engDesignReviewed) is eng-coder-only: set by subagent-spawn.mjs
174
- // (spawn gate) / design-token.mjs (design review pass) BEFORE the child runAgent — the
175
- // depth-0 parent never reads or writes it (the parent gate reads anyLiveDesignSlot; the
176
- // depth-0 per-turn reset was removed 2026-09-08, ENG-SESSION-PROVIDER-CLEANUP D1.3).
177
- // Design slots (_engDesignTokens Map) survive across turns (design review → approval →
178
- // eng-coder spawn) — persisted to the session slot at settle time (DESIGN-TOKEN-
179
- // SETTLEMENT D1); lifecycle: issued on a passing review, consumed by consume-design / TTL.
180
- let guardPushbacks = 0
181
- let advisorPushbacks = 0
182
- let honestReminderInjected = false
183
- const recentCallSigs = []
184
- // "once" stream rules fire at most once per runAgent call; the set survives across
185
- // chat() calls (rule abort-retry, tool loop) within the turn.
186
- const streamRuleFired = new Set()
187
-
188
- // Compaction overhead for the pure-estimation path: system prompt + tools schema are
189
- // in every request but not in history — without them the first-turn/just-compacted
190
- // estimate under-counts and may never trigger. Measured path already includes both.
191
- const compactionOverhead = {
192
- systemPrompt,
193
- tools: toolSchemas,
194
- // §18.6 D-TR4:compress 轨迹 depth 元数据(runAgent 的 depth 在此作用域——
195
- // context.mjs compressIfNeeded 经 extras 透出到 logCtx)
196
- traceDepth: depth,
197
- }
198
- // F-CC1(§6.16.4 阈值单源接线):本回合压缩判定所用的阈值 + 固定开销面暂存——
199
- // `context` 工具的 stats 报**同一口径**(不自行重算第二口径;VSC checkAndCompact 同款暂存)。
200
- agent._ctxBasis = { threshold, overhead: compactionOverhead }
201
-
202
- // SUBAGENT-UPSTREAM-CHANNEL(AGENT-LOOP-UPSTREAM.md §6.27.4 消费点):子 → 父在飞消息的
203
- // 回合边界注入单点取用一次(模块缓存 ⇒ 每 run 一次代价);动态 import = 零新增静态边
204
- // (先例 = 上方 injectAsyncResult :113-117)。
205
- const { drainChildUpstream } = await import("./agent-tools/parent-channel.mjs")
85
+ export async function runAgent(agent, input, callbacks = {}, { depth = 0, signal, maxTurns: overrideTurns, resume = false, autoTurn = false, upstreamTurn = false, timerTurn = false, suspDriven = false, consumeInjected = null, consumeQueuedInput = null, streamOutput = false, extraTools = null, injections = null, turnDomainText = null, distillSignal = null, toolDecorate = undefined } = {}) {
86
+ const { maxTurns, threshold, toolSchemas, toolByName, systemPrompt } = await beginRun(agent, input, callbacks, {
87
+ depth, signal, overrideTurns, resume, autoTurn, upstreamTurn, timerTurn, extraTools, injections, turnDomainText, toolDecorate,
88
+ })
206
89
 
207
90
  let thrownError = null
208
91
  try {
209
- for (let turn = 0; turn < maxTurns; turn++) {
210
- // 第 19 批(TURN-ACROSS-SEGMENTS——设计 TURN-CAP-CONTINUE.md §19.3):编号帧——
211
- // `_turnSeq` 每轮 +1(跨段累计,仅 `!resume` 链起点复位);面向消费面的两字段
212
- // (状态行 + ⟦ev⟧turn / ⟦ev⟧approval 事件共用)在此同点赋值(编号唯一权威;帧
213
- // 公式 = helpers.mjs turnFrame)。段内帽判定不读帧(下行循环条件只读段内
214
- // turn / maxTurns——N6)。
215
- const frame = turnFrame(++agent._turnSeq, turn, maxTurns)
216
- agent._currentTurn = frame.turn
217
- agent._maxTurns = frame.maxTurns
218
- // D2 (AGENT-LOOP.md §7.2): depth>0 children emit a ⟦ev⟧turn progress token each turn —
219
- // single emit point covering all three spawn tools; phase=llm (tool/done progress rides
220
- // the onToolCall/onToolResult relay — no token for those). 第 19 批:载荷取上方帧值
221
- // (agent._currentTurn / _maxTurns——字段形态 / 字段数 / phase 零变化,N5)。
222
- if (depth > 0 && callbacks.onToken) {
223
- callbacks.onToken(`⟦ev⟧turn\x1e${agent._currentTurn}\x1e${agent._maxTurns}\x1ellm\x1e`)
224
- }
225
- // §2.5 #78 并入(VSC 帧回调):每轮帧另经结构化回调发出(池条目 entry.turn 的
226
- // VSC 等价通道——⟦ev⟧turn token 解析的宿主面随时可用;签名扩展向后兼容)。
227
- callbacks.onAgentTurn?.(frame.turn, frame.maxTurns)
228
-
229
- // SUBAGENT-OBSERVE-SEND D2: 子代理回合边界消费点——每轮开头把父侧经 subagent
230
- // action:'send' 注入队列(entry._injected)的消息按普通 user 回合推入子历史
231
- // (pushReal → 下一轮 chat 即含该指令)。由 executeAsyncSpawn 经 childRunOpts 贯通的
232
- // consumeInjected 回调承载(异步子代理专属——缺省 null:主会话/阻塞子代理零开销)。
233
- consumeInjected?.(agent)
234
- // 子代理在飞消息(子 → 父;§6.27):空队列 no-op;非空 ⇒ 恰一条合并 user 消息注入。
235
- drainChildUpstream(agent)
236
-
237
- const lastRole = agent.history.at(-1)?.role
238
- if (lastRole === "user" || lastRole === "tool") {
239
- // 2026-09-05 实践轮:压缩检查/降级计数提为 runCompactionCheck(agent/run-stages.mjs——
240
- // CLI 对位 VS run-stages)——循环骨架此处只剩检查调用(recentCallSigs 对象引用回流)。
241
- await runCompactionCheck(agent, { threshold, callbacks, compactionOverhead, signal, recentCallSigs })
242
- }
243
-
244
- // 2026-09-05 实践轮:plan cadence + eng 状态注入提为 injectTurnReminders(run-stages)
245
- await injectTurnReminders(agent, { depth })
246
-
247
- const messages = [{ role: "system", content: systemPrompt }, ...agent.history]
248
- let response
249
-
250
- // Auto-think: classify difficulty and set reasoning effort on turn 0; silent on failure.
251
- if (agent.config?.agent?.autoThink && turn === 0) {
252
- const { classifyAndApply } = await import("./auto-think.mjs")
253
- await classifyAndApply(agent, turn).catch(() => {})
254
- }
255
-
256
- if (process.env.ADVISOR_DEBUG) console.error("[chat-call]", JSON.stringify({ turn, histLen: agent.history.length, lastRole: agent.history.at(-1)?.role }))
257
- try { response = await chat(agent.provider, {
258
- messages, tools: toolSchemas,
259
- // §2.5 #78 并入(VSC onToken 三态门):depth 0 恒通;consult 子代理豁免(其输出进
260
- // 会诊面板);escalate 等经 opts.streamOutput 显式选择进入;其余子代理不流式。
261
- onToken: streamOutputAllowed(depth, agent._role, streamOutput) ? callbacks.onToken : null,
262
- onReasoning: callbacks.onReasoning,
263
- onWait: callbacks.onWait,
264
- signal,
265
- streamRules: agent.config.agent?.streamRules ?? [],
266
- firedPatterns: streamRuleFired,
267
- // LOGGING(LOGGING.md):llm:* 事件的语义上下文(stage=turn 主循环回合——含
268
- // digest 消化轮 auto=true;child=子代理 id(spawn 时 stamp 于 child._logId))
269
- // §18.6 D-TR4:轨迹元数据增补(role/depth/kind/session/cwd——trace-store 只读
270
- // logCtx,签名不变);kind:depth>0 = subagent(consult 孩子 = consult)——子代理
271
- // 对回靠 role+depth+child id(children 无 _sessionStart——不经 depth-0 设置——
272
- // session 字段对子代理轨迹为 null——见 trace-store/agent.mjs 注释)。
273
- logCtx: {
274
- stage: "turn", turn: turn + 1, auto: autoTurn, child: agent._logId,
275
- role: agent._role ?? null,
276
- depth,
277
- kind: depth > 0 ? (agent._role === "consult" ? "consult" : "subagent") : "turn",
278
- session: agent._sessionStart ?? null,
279
- cwd: agent.cwd,
280
- traces: agent.config?.traces?.enabled !== false,
281
- },
282
- })
283
- } catch (e) {
284
- // User interrupt (Ctrl+I): controller.abort({ interrupt: true, message }).
285
- // Inject into history; the outer loop recreates the controller and resumes.
286
- if (e.name === "AbortError" && signal?.reason?.interrupt) {
287
- const msg = `[User interrupt: ${signal.reason.message}]`
288
- // Dedup: if already handled during tool execution (interrupt branch below),
289
- // don't push a duplicate — the outer loop still recreates the controller.
290
- if (agent.history.at(-1)?.content !== msg) {
291
- agent.history.push({ role: "user", content: msg })
292
- }
293
- }
294
- throw e
295
- }
296
-
297
- // 内置工具(Responses web_search)结果本地化:服务端已执行——入历史为 tool 消息;
298
- // 服务端 item id 是 msg_xxx 非 web_search_call_ 前缀——必须合成前缀(toItems 识别锚点),
299
- // 原始 id 存入 content(真机冒烟 2026-08-31 验证)。
300
- for (const btr of response.builtinToolResults ?? []) {
301
- if (!btr?.id) continue
302
- pushReal(agent, {
303
- role: "tool",
304
- tool_call_id: `web_search_call_${btr.id}`,
305
- content: JSON.stringify({ id: btr.id, query: btr.query ?? "", sources: btr.sources ?? [], status: btr.status ?? "completed" }),
306
- })
307
- }
308
-
309
- // Stream rule triggered mid-generation (action: "abort"): halt, inject the rule's
310
- // message as a reminder, retry from the same context.
311
- if (response.ruleTriggered) {
312
- if (response.content) {
313
- pushReal(agent, { role: "assistant", content: response.content })
314
- }
315
- const label = response.ruleName ? ` — stream rule "${response.ruleName}"` : ""
316
- agent.history.push({
317
- role: "user",
318
- content: `[System reminder${label}: ${response.ruleMessage}]`,
319
- })
320
- continue
321
- }
322
-
323
- // Stream rule warnings / finish-reason 警告(2026-09-05 实践轮——提为
324
- // injectResponseReminders,agent/run-stages.mjs——verbatim,语义零变)
325
- injectResponseReminders(agent, response)
326
-
327
- // User interrupted mid-generation (Ctrl+I): commit partial output + inject the
328
- // message, then signal the outer loop to recreate the controller and resume.
329
- if (response.interrupted) {
330
- if (response.content) {
331
- pushReal(agent, { role: "assistant", content: response.content })
332
- }
333
- agent.history.push({
334
- role: "user",
335
- content: `[User interrupt: ${response.interruptMessage}]`,
336
- })
337
- // §20.3 站点 #8(第 24 批):错误对象已自带 name/message——只补来源标注(缺 abortInfo 才补)
338
- throw annotateAbort(Object.assign(new Error("User interrupted"), { name: "AbortError" }), signal, "agent", "interrupted-response")
339
- }
340
-
341
- if (response.usage) {
342
- callbacks.onUsage?.(response.usage)
343
- if (response.usage.prompt_tokens != null) {
344
- agent._lastPromptTokens = response.usage.prompt_tokens
345
- agent._usageAtLen = agent.history.length
346
- }
347
- }
348
-
349
- // Warn on abnormal finish reasons — the response may be incomplete/truncated.
350
- // 2026-09-05 实践轮:finish-reason 注入随流规则警告提为 injectResponseReminders。
351
-
352
- if (response.toolCalls.length === 0) {
353
- const cr = handleCompletion(agent, response, depth, turn, guardPushbacks, honestReminderInjected, advisorPushbacks, callbacks)
354
- guardPushbacks = cr.guardPushbacks
355
- honestReminderInjected = cr.honestReminderInjected
356
- advisorPushbacks = cr.advisorPushbacks
357
- if (cr.action === "continue") continue
358
- if (depth === 0) {
359
- // End-of-run exploration distillation (CONTEXT-COMPACTION §5 + SEND-STALL-DISTILL
360
- // §2.1): async — the promise hangs on _pendingDistill, settling at the next run's
361
- // start or the TUI exit flush. Silent (N3): failure never blocks return/history.
362
- // §18.6 D-TR4:depth 透传(distill 轨迹元数据——与 compress 同通道);extras = 会话续写前缀面(§6.15)
363
- const distill = summarizeRunExplorations(agent, callbacks, signal, depth,
364
- { systemPrompt, tools: toolSchemas }).catch(() => {}) // 与回合请求同源(无第二构造点)
365
- agent._pendingDistill = distill
366
- }
367
- return cr.content
368
- }
369
-
370
- // abort after chat completes, before committing history: don't commit a half-finished turn
371
- if (signal?.aborted) throw abortError(signal, "agent", "post-chat")
372
-
373
- pushReal(agent, assistantToolCallMessage(response, specForModel(agent.provider.model)))
374
-
375
- const results = await executeToolCalls(agent, toolByName, response.toolCalls, callbacks, depth, signal)
376
-
377
- // Ctrl+I interrupt during tool execution: skip committing partial results — inject
378
- // the interrupt and retry (placeholder results keep strict providers pairable).
379
- if (signal?.reason?.interrupt) {
380
- // 中断变更记账(2026-08-31 评审 #4):此分支的工具已全部执行完成(磁盘已变,execute 已完成),
381
- // 真实结果按语义不进历史(placeholder 替代)——但变更必须记账:否则 guard 看到
382
- // "本轮未改代码" 放行,评审/verify 门禁被绕过(文件改了却没评审)。
383
- // §29 fix A(2026-09-07):mutation-seq 记账已收敛到 dispatch runOne 执行成功即刻
384
- // (唯一记账点——本分支不再 noteMutations——不双计——中断+同批 launch seq 单计
385
- // 回归断言见 §29 T-A1i);此处仅剩 guard 标志 + touchedFiles 记账。
386
- for (const { toolCall, ok } of results) {
387
- const tool = toolByName.get(toolCall.name)
388
- if (!ok || !tool || !FILE_MUTATORS.has(toolCall.name)) continue
389
- agent._mutatedThisRun = true
390
- agent._calledAdvisorThisRun = false
391
- agent._verifiedThisRun = false
392
- agent._verifyPassed = undefined
393
- try {
394
- const args = JSON.parse(toolCall.arguments)
395
- const paths = tool.touchedPaths ? tool.touchedPaths(args) : [args.path]
396
- for (const p of paths) {
397
- const abs = resolve(agent.cwd, p)
398
- if (!agent._touchedFiles.includes(abs)) agent._touchedFiles.push(abs)
399
- }
400
- } catch { /* 畸形 args 不影响记账(touchedFiles 尽力而为) */ }
401
- }
402
- // The assistant tool_calls were committed above — synthesize placeholder tool
403
- // results BEFORE the interrupt message (strict providers 400 on dangling
404
- // tool_calls; consult P1, 2026-08-30).
405
- for (const tc of response.toolCalls) {
406
- agent.history.push({ role: "tool", tool_call_id: tc.id, content: "[Tool execution interrupted — results discarded]" })
407
- }
408
- agent.history.push({
409
- role: "user",
410
- content: `[User interrupt: ${signal.reason.message}]`,
411
- })
412
- callbacks.onTurnEnd?.(agent, turn)
413
- continue
414
- }
415
-
416
- // Model is executing tools → real work: reset guard pushback counters
417
- guardPushbacks = 0
418
- advisorPushbacks = 0
419
-
420
- // Commit tool results (pairing, multimodal deferral, mutation accounting, reindex)
421
- await recordToolResults(agent, toolByName, results)
422
-
423
- injectPostTurn(agent, results, recentCallSigs, callbacks, turn)
424
- }
425
-
426
- throw new ContinueError(maxTurns)
92
+ return await runTurnLoop(agent, {
93
+ maxTurns, threshold, toolSchemas, toolByName, systemPrompt,
94
+ depth, signal, autoTurn, streamOutput, consumeInjected, consumeQueuedInput, callbacks, distillSignal,
95
+ })
427
96
  } catch (e) {
428
97
  thrownError = e
429
98
  throw e
@@ -0,0 +1,125 @@
1
+ /**
2
+ * attachments.mjs — 回合附件(粘贴图)族:dataURL 解析 ∕ 临时落盘管线 ∕ 非多模态降级
3
+ * (上提核件 —— 处理流批 R5 · 2026-09-28)。
4
+ *
5
+ * 上提源(迁移前坐标)= VSC `thincoder-vscode/src/extension/image-handler.mjs`(`parseDataUrl` :34 ·
6
+ * `savePastedImages` :49 · `downgradeNonVisionImages` :109);预算语义(合计 30MB ∕ 轮 · 溢出项弃后
7
+ * **继续扫**「其余照发」)与配额单位 = 桌面同档 `thincoder-desktop/src/main/attachments.mjs`
8
+ * (15MB ∕ 条 —— 与核 `read_image` 内闸同值同单位:`tools/file.mjs:26` `MAX_IMAGE_BYTES = 15_000_000`,
9
+ * 接纳面不得宽于该闸)。本档 = 单源(消费面 = 桌面 `src/main/attachments.mjs`;VSC 自持副本已随本批(parity-b4)迁移改指本档)。
10
+ * 指引行(`[Attached images: …]`)不迁 —— 单源 = `agent/setup-reminders.mjs:248` `appendImagePointer`(原位)。
11
+ *
12
+ * 与 VSC 自持副本的在册差异(双写窗口)**收正已毕**(迁移轮 parity-b4 —— 逐条处置见批档 §2.3):① 阈单位 ∕
13
+ * ② 弃项索引 ∕ ③ 合计预算闸 ∕ ④ 降级成功判据 = **两端同源单值**(收正方向——VSC 侧本地字面 ∕ 过滤后下标 ∕ 无预算 ∕
14
+ * 真值判四面随迁移删;VSC 消费本档 `IMAGE_MAX_BYTES` ∕ 源序命名 ∕ `TURN_MAX_BYTES` ∕ `out.ok !== true`);
15
+ * ⑤ 清理面 = **端侧时序面**(本档不回收):桌面 = 逐回合显式清(`cleanupTurn`——仅删本回合在册路径;
16
+ * 半失败写入的孤儿件无回收面〔此判仅桌面面成立〕)· CLI ∥ VSC = 贴图落盘写时 mtime 扫除
17
+ * (3 天窗——目录内超龄件(含孤儿件)按龄回收);机制单源 = `docs/core/design/PROVIDER.md` §6.18。
18
+ *
19
+ * 两面 + 两缝:
20
+ * ① 落盘管线(`savePastedImages`):逐项解析 ⇒ 预算判 ⇒ 写 `<cwd>/.thincoder/tmp/paste-<id>-<i>.<ext>`
21
+ * (懒建目录;建面 ∕ 写面运行时失败 ⇒ 该项弃 + 一次诊断 —— 皆不阻断发送)。**fs 写转注入缝**
22
+ * (`fs = { mkdirSync, writeFileSync }` —— `node:fs` 同名面;本档零 `node:fs`)。
23
+ * ② 非多模态降级(`downgradeNonVisionImages`):判据 = 图非空 ∧ 模型在场 ∧ 非多模态
24
+ * (`isNonVisionModel`)⇒ 读图;成功 ⇒ 描述注文(`[图片 … 描述: …]`)+ 图清空;
25
+ * 失败 ⇒ 原样兜底(不静默丢)。**视觉子代理 spawn 转注入缝**(`visionReader({ paths, signal })` ——
26
+ * 缺省实现 = 核 `vision-reader.mjs` `runVisionReader`(桌面径直取);VSC 经宿主包装注入)。
27
+ */
28
+ import { join } from "node:path"
29
+ import { specForModel } from "./model-specs.mjs"
30
+
31
+ /** 单项上限 15MB(与 `read_image` 内闸同值同单位 —— `tools/file.mjs:26` `MAX_IMAGE_BYTES = 15_000_000`:
32
+ * 超阈件交核也读不进 ⇒ 接纳面不得宽于该闸)。 */
33
+ export const IMAGE_MAX_BYTES = 15_000_000
34
+
35
+ /** 合计上限 30MB ∕ 轮(超阈的**溢出部分**弃、其余照发 —— 与单项阈同单位)。 */
36
+ export const TURN_MAX_BYTES = 30_000_000
37
+
38
+ /** 栅格四型(捕获组 = `ext` 源 —— dataURL 媒体类型即实际字节的声明;表外类型恒不成附件)。 */
39
+ const DATA_URL_RE = /^data:image\/(png|jpe?g|gif|webp);base64,(.+)$/
40
+
41
+ /** dataURL ⇒ `{ ext, buffer }` ∥ `null`:栅格四型;表外媒体类型 ∕ 空载荷 ∕ 超单项阈 ⇒ `null`
42
+ * (巨图在落盘时刻即弃 —— 不留给 `read_image` 调用时报错)。 */
43
+ export function parseDataUrl(value) {
44
+ const hit = typeof value === "string" ? value.match(DATA_URL_RE) : null
45
+ if (!hit) return null
46
+ const buffer = Buffer.from(hit[2], "base64")
47
+ if (buffer.length === 0 || buffer.length > IMAGE_MAX_BYTES) return null
48
+ return { ext: hit[1] === "jpeg" ? "jpg" : hit[1], buffer }
49
+ }
50
+
51
+ /** 非多模态判据(单源 = 规格表 `multimodal` 位):显式 `true` 之外一切(未声明 ∕ 未知模型 ∕ 模型缺省)
52
+ * ⇒ `true`。降级判决与本档外「档位处置」(端侧各留其处置面)共用本判据。 */
53
+ export function isNonVisionModel(model) {
54
+ return specForModel(model).multimodal !== true
55
+ }
56
+
57
+ /**
58
+ * 落盘管线:dataURL 串列 ⇒ `{ paths, dropped }`(`dropped` = 未落盘者(解析拒 ∕ 超单项阈 ∕ 预算溢出 ∕ 写失败)——零静默判据)。
59
+ * 逐项:解析(表外 ∕ 空载荷 ∕ 超条阈 ⇒ 弃)⇒ 合计预算(`running + size ≤ TURN_MAX_BYTES` 才收,
60
+ * 溢出项弃后**继续扫**)⇒ 写 `<cwd>/.thincoder/tmp/paste-<id>-<i>.<ext>`(`index` = 源序;目录
61
+ * **懒建** —— 零落盘项 ⇒ 零目录)。`fs` = 写面注入缝(`{ mkdirSync(p, {recursive}), writeFileSync(p, buf) }`
62
+ * —— `node:fs` 同名面;缺 ∕ 形违 ⇒ 抛,fail-loud:写面未接线不得静默扮成「全部弃」)。
63
+ * 出口零抛(缝缺除外):无 cwd ∕ 建面 ∕ 写面失败 ⇒ 相应项弃 + 一次诊断(附件面不把回合驱动带崩)。
64
+ */
65
+ export function savePastedImages(dataUrls, cwd, { fs } = {}) {
66
+ const list = Array.isArray(dataUrls) ? dataUrls : []
67
+ if (list.length === 0) return { paths: [], dropped: 0 } // 空表先于缝校验早退(零判据面 —— 缝不检)
68
+ if (typeof fs?.mkdirSync !== "function" || typeof fs?.writeFileSync !== "function") {
69
+ throw new TypeError("savePastedImages: fs seam missing — inject { mkdirSync, writeFileSync } (the write face must not silently drop every image)")
70
+ }
71
+ if (typeof cwd !== "string" || cwd === "") {
72
+ console.error(`[attachments] no project cwd — ${list.length} pasted image(s) dropped`)
73
+ return { paths: [], dropped: list.length }
74
+ }
75
+ const tmpDir = join(cwd, ".thincoder", "tmp")
76
+ const runId = Date.now().toString(36) + Math.random().toString(36).slice(2, 6) // 命名先例 = image-handler.mjs:56
77
+ const paths = []
78
+ let dropped = 0
79
+ let budget = TURN_MAX_BYTES
80
+ let dir = null // `null` = 未试 · `false` = 建失败(一次诊断后逐项弃)· string = 可用目录
81
+ const ensureDir = () => {
82
+ if (dir === null) {
83
+ try { fs.mkdirSync(tmpDir, { recursive: true }); dir = tmpDir }
84
+ catch (error) { dir = false; console.error(`[attachments] tmp dir unavailable: ${tmpDir}`, error) }
85
+ }
86
+ return dir !== false
87
+ }
88
+ for (let i = 0; i < list.length; i += 1) {
89
+ const parsed = parseDataUrl(list[i])
90
+ if (parsed === null || parsed.buffer.length > budget) { dropped += 1; continue } // 表外 ∕ 超阈 ⇒ 弃,继续扫
91
+ if (!ensureDir()) { dropped += 1; continue }
92
+ const file = join(tmpDir, `paste-${runId}-${i}.${parsed.ext}`)
93
+ try {
94
+ fs.writeFileSync(file, parsed.buffer)
95
+ paths.push(file)
96
+ budget -= parsed.buffer.length
97
+ } catch (error) {
98
+ dropped += 1
99
+ console.error(`[attachments] image write failed: ${file}`, error)
100
+ }
101
+ }
102
+ return { paths, dropped }
103
+ }
104
+
105
+ /**
106
+ * 非多模态降级(判据 + 结果成形;读图 = 注入缝):判据 = `images` 非空 ∧ `model` 在场 ∧
107
+ * `isNonVisionModel(model)`。`visionReader({ paths, signal })` ⇒ `{ ok, description }`(缺省实现 = 核
108
+ * `vision-reader.mjs` `runVisionReader`;注入实现闭包自身上下文);成功 ⇒ 描述注文(`[图片 <路径表> 描述: <描述>]`,正文空 ⇒ 描述独占)+ `images`
109
+ * 清空;无返回 ∕ `ok` 非真 ∕ 空描述 ∕ 异常 ⇒ 原样兜底(`{ text, images }` 不动 —— 不静默丢)。
110
+ * `visionReader` 缺 ∕ 形违 ⇒ 抛(fail-loud:降级路径未接线不得静默扮成「原样兜底」);判据未过
111
+ * ⇒ 早退(缝不检、零调用)。返回 `{ text, images, downgraded }`(成功径 `images` = `undefined`)。
112
+ */
113
+ export async function downgradeNonVisionImages({ text, images, model, visionReader, signal } = {}) {
114
+ const keep = { text, images, downgraded: false }
115
+ if (!Array.isArray(images) || images.length === 0) return keep // 无图 ⇒ no-op(VSC :110 同判)
116
+ if (!model || !isNonVisionModel(model)) return keep // 多模态主模型 ∕ 模型缺省 ⇒ 不降级
117
+ if (typeof visionReader !== "function") {
118
+ throw new TypeError("downgradeNonVisionImages: visionReader seam missing — inject the one-shot vision-subagent reader")
119
+ }
120
+ let out = null
121
+ try { out = await visionReader({ paths: images, signal }) } catch { out = null }
122
+ if (out?.ok !== true || typeof out.description !== "string" || out.description.trim() === "") return keep
123
+ const marker = `[图片 ${images.join("、")} 描述: ${out.description.trim()}]`
124
+ return { text: text?.trim() ? `${text}\n\n${marker}` : marker, images: undefined, downgraded: true }
125
+ }