cardbuddy 0.14.2 → 0.15.0

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 (68) hide show
  1. package/dist/cli.js +0 -0
  2. package/dist/preview-editor/assets/{index-BN79pTkQ.js → index-CIIfMlwI.js} +43 -40
  3. package/dist/preview-editor/assets/index-DQn0zQBP.css +1 -0
  4. package/dist/preview-editor/index.html +2 -2
  5. package/dist/standalone.js +3 -0
  6. package/dist/vendored/mcp/client.mjs +8 -1
  7. package/dist/vendored/server/core/ai-chat.js +1521 -124
  8. package/dist/vendored/server/core/browser.js +12 -3
  9. package/dist/vendored/server/core/cardFiles.js +3 -0
  10. package/dist/vendored/server/core/cardHistory.js +1 -0
  11. package/dist/vendored/server/core/cardImportMeta.js +7 -6
  12. package/dist/vendored/server/core/cardOpenFolder.js +1 -0
  13. package/dist/vendored/server/core/cardSave.js +1 -0
  14. package/dist/vendored/server/core/cardSync.js +3 -0
  15. package/dist/vendored/server/core/codeBridge.js +5 -1
  16. package/dist/vendored/server/core/logStream.js +1 -0
  17. package/dist/vendored/server/core/sliceStatic.js +24 -4
  18. package/dist/vendored/server/core/sliceUpload.js +1 -0
  19. package/dist/vendored/server/core/turnJournal.js +6 -0
  20. package/dist/vendored/skills/card-skill-trainer/SKILL.md +196 -195
  21. package/dist/vendored/skills/dsl-card-ai/SKILL.md +1 -1
  22. package/dist/vendored/skills/dsl-card-generator/SKILL.md +142 -380
  23. package/dist/vendored/skills/dsl-card-generator/references/generation_pitfalls.md +65 -18
  24. package/dist/vendored/skills/dsl-card-generator/references/ir-format.md +116 -2
  25. package/dist/vendored/skills/dsl-card-generator/references/iteration_protocol.md +58 -0
  26. package/dist/vendored/skills/dsl-card-generator/references/previewer_contract.md +1 -1
  27. package/dist/vendored/skills/dsl-card-generator/references/quickref.md +15 -7
  28. package/dist/vendored/skills/dsl-card-generator/references/recipe_snippets/title-underline-overlay.json +59 -11
  29. package/dist/vendored/skills/dsl-card-generator/references/recipes.md +14 -0
  30. package/dist/vendored/skills/dsl-card-generator/references/rules.md +23 -4
  31. package/dist/vendored/skills/dsl-card-generator/references/scripts_reference.md +51 -0
  32. package/dist/vendored/skills/dsl-card-generator/references/semantic_adjudication.md +23 -0
  33. package/dist/vendored/skills/dsl-card-generator/references/slices_convention.md +4 -3
  34. package/dist/vendored/skills/dsl-card-generator/references/spec_to_schema_patterns.md +32 -0
  35. package/dist/vendored/skills/dsl-card-generator/references/ui_fidelity_rules.md +13 -1
  36. package/dist/vendored/skills/dsl-card-generator/scripts/autofix_schema.py +45 -2
  37. package/dist/vendored/skills/dsl-card-generator/scripts/check_fidelity.mjs +116 -10
  38. package/dist/vendored/skills/dsl-card-generator/scripts/check_magic_values.mjs +6 -1
  39. package/dist/vendored/skills/dsl-card-generator/scripts/crop_slices.mjs +92 -6
  40. package/dist/vendored/skills/dsl-card-generator/scripts/expand_ir.mjs +227 -23
  41. package/dist/vendored/skills/dsl-card-generator/scripts/gate.mjs +144 -0
  42. package/dist/vendored/skills/dsl-card-generator/scripts/generate_cards.mjs +12 -8
  43. package/dist/vendored/skills/dsl-card-generator/scripts/ir_merge.mjs +288 -0
  44. package/dist/vendored/skills/dsl-card-generator/scripts/ir_restore.mjs +175 -0
  45. package/dist/vendored/skills/dsl-card-generator/scripts/iterate.mjs +644 -42
  46. package/dist/vendored/skills/dsl-card-generator/scripts/lib/geometry_audit.mjs +131 -0
  47. package/dist/vendored/skills/dsl-card-generator/scripts/lib/layout_rules.mjs +160 -0
  48. package/dist/vendored/skills/dsl-card-generator/scripts/lib/slicePath.mjs +17 -5
  49. package/dist/vendored/skills/dsl-card-generator/scripts/lib/slice_dedupe.mjs +102 -0
  50. package/dist/vendored/skills/dsl-card-generator/scripts/lib/v3_assemble.mjs +5443 -0
  51. package/dist/vendored/skills/dsl-card-generator/scripts/lib/v3_extract.mjs +4108 -0
  52. package/dist/vendored/skills/dsl-card-generator/scripts/lib/v3_shared.mjs +1116 -0
  53. package/dist/vendored/skills/dsl-card-generator/scripts/lint_card.mjs +214 -4
  54. package/dist/vendored/skills/dsl-card-generator/scripts/preflight.mjs +48 -2
  55. package/dist/vendored/skills/dsl-card-generator/scripts/render_card.mjs +91 -43
  56. package/dist/vendored/skills/dsl-card-generator/scripts/route_input.mjs +4 -3
  57. package/dist/vendored/skills/dsl-card-generator/scripts/spec_audit.mjs +150 -38
  58. package/dist/vendored/skills/dsl-card-generator/scripts/spec_to_schema_v3.mjs +43 -10499
  59. package/dist/vendored/skills/dsl-card-generator/scripts/v3_regression.mjs +186 -0
  60. package/dist/vendored/skills/sketch-spec-to-card/SKILL.md +18 -12
  61. package/dist/vendored/skills/sketch-spec-to-card/references/spec-schema.md +1 -1
  62. package/dist/vendored/skills/sketch-spec-to-card/scripts/build_prompt.py +47 -1
  63. package/dist/vendored/skills/sketch-spec-to-card/scripts/parse_meaxure.py +460 -49
  64. package/dist/vendored/skills/sketch-spec-to-card/scripts/png_utils.py +51 -0
  65. package/dist/vendored/skills/sketch-spec-to-card/scripts/test_text_only_artboard.py +157 -0
  66. package/dist/vendored/skills/sketch-spec-to-card/scripts/vision.py +24 -0
  67. package/package.json +1 -1
  68. package/dist/preview-editor/assets/index-cbTCHp8J.css +0 -1
@@ -66,7 +66,7 @@ import path from 'node:path'
66
66
  import fs from 'node:fs'
67
67
  import os from 'node:os'
68
68
  import { execFile, spawn, spawnSync } from 'node:child_process'
69
- import { SNAPSHOT_ROOT, skillsDirFor, resolveSkillPath, normalizeSkillRel } from './skillPaths.js'
69
+ import { SNAPSHOT_ROOT, skillsCandidates, skillsDirFor, resolveSkillPath, normalizeSkillRel } from './skillPaths.js'
70
70
  // 对话记录落盘(.cache/ai-chats/<cardId>.json):读写清 + 删卡联动都在 chatHistory.js,
71
71
  // 本文件只负责 HTTP 端点编排,路径约定不再在这里重复一份。
72
72
  import {
@@ -115,6 +115,44 @@ const USAGE_BODY = { stream_options: { include_usage: true } }
115
115
  /** 网关拒绝扩展字段时的摘除顺序:先摘观测字段(无副作用),再摘思考开关(有行为影响) */
116
116
  const EXT_PEEL_ORDER = ['stream_options', 'chat_template_kwargs']
117
117
 
118
+ /**
119
+ * 网关扩展字段能力缓存:`${baseUrl}::${model}` → 已知被拒的字段名集合。
120
+ *
121
+ * 为什么需要:严格网关会在 400 详情里点名它不认的字段,摘掉后重试能成——但**下一轮请求又会原样带上**,
122
+ * 于是每一轮都白撞一次 400,还伴随一条 warn notice(实测建卡回合每轮都撞)。能力是网关的属性、
123
+ * 不是单次请求的属性,探到一次就该记住。
124
+ *
125
+ * 键里必须带 model:同一网关下不同模型的能力可以不同(vLLM 系多半认 chat_template_kwargs,
126
+ * 严格 OpenAI 兼容实现都不认)。进程内缓存即可——换模型/换网关就是换 key,不必持久化。
127
+ */
128
+ const gatewayExtraRejects = new Map()
129
+ /** 已经就「哪些字段被这个网关拒了」提示过的 key:同一网关只提示一次,别每轮刷同一条 notice */
130
+ const gatewayExtraNotified = new Set()
131
+ function gatewayKey(baseUrl, model) {
132
+ return `${String(baseUrl || '').replace(/\/+$/, '')}::${String(model || '')}`
133
+ }
134
+ function rememberExtraReject(baseUrl, model, keys) {
135
+ const list = (keys || []).filter(Boolean)
136
+ if (!list.length) return
137
+ const k = gatewayKey(baseUrl, model)
138
+ const set = gatewayExtraRejects.get(k) || new Set()
139
+ for (const key of list) set.add(key)
140
+ gatewayExtraRejects.set(k, set)
141
+ }
142
+ /**
143
+ * 按能力缓存过滤请求体扩展字段。
144
+ * 注意只过滤「已知被拒」的:观测字段被摘掉会连带损失缓存命中统计,能留就留。
145
+ * @returns {{extra: object|undefined, skipped: string[]}} skipped = 这次因缓存而没发的字段(用于告知用户)
146
+ */
147
+ function filterExtraByCapability(baseUrl, model, extra) {
148
+ const rejected = gatewayExtraRejects.get(gatewayKey(baseUrl, model))
149
+ const merged = { ...USAGE_BODY, ...(extra || {}) }
150
+ if (!rejected || !rejected.size) return { extra: merged, skipped: [] }
151
+ const skipped = Object.keys(merged).filter((k) => rejected.has(k))
152
+ const kept = Object.fromEntries(Object.entries(merged).filter(([k]) => !rejected.has(k)))
153
+ return { extra: kept, skipped }
154
+ }
155
+
118
156
  // ---- 工具定义(延迟加载) ----
119
157
  let _toolsCache = null
120
158
  let _dispatchRef = null
@@ -180,6 +218,53 @@ const SKILL_DOC_ENTRIES = [
180
218
  ]
181
219
  let _systemPromptCache = null
182
220
 
221
+ /**
222
+ * 合法样式键全集(唯一来源 skills/dsl-card-generator/vendor/preview/meta/style-meta.json)。
223
+ *
224
+ * 为什么直接进系统提示词:实测(2026-09-21 建卡回合)模型不知道 flex 组没有 `flexGrow`,
225
+ * 写了 `s.flex.flexGrow` → 展开 itemId 空串 → 运行时静默丢弃 → 下游只报「整卡 FAIL」,
226
+ * 模型看不出是键名错,于是 8 次 shell_exec 翻 meta 文件考古(还 grep 过 1MB 压缩 bundle)、
227
+ * 6 次把组名写错(`size.width` 而非 `rect.width`),并用全量重写 IR 试错。整条链约 220 万 token。
228
+ * 这张表 13 组 / 59 键、约 600 字符,把「猜键名」变成「照着抄」。
229
+ * 缓存键带 meta mtime:改了 style-meta 提示词必须跟着刷新(否则两者长期不一致)。
230
+ */
231
+ let _styleKeyCache = null
232
+ function styleKeySection(root) {
233
+ const file = path.join(skillsDirFor(root), 'dsl-card-generator', 'vendor', 'preview', 'meta', 'style-meta.json')
234
+ let mtime = 0
235
+ let meta = null
236
+ try {
237
+ mtime = fs.statSync(file).mtimeMs
238
+ meta = JSON.parse(fs.readFileSync(file, 'utf8'))
239
+ } catch {
240
+ return { text: '', mtime: 0 }
241
+ }
242
+ if (_styleKeyCache && _styleKeyCache.key === `${file}:${mtime}`) return _styleKeyCache
243
+ const groups = []
244
+ for (const g of meta.properties || []) {
245
+ const kids = (g.itemChildren || []).map((c) => c.itemKey)
246
+ if (!kids.length || !g.itemKey) continue
247
+ groups.push(`${g.itemKey}: ${kids.join(' ')}`)
248
+ }
249
+ if (!groups.length) return { text: '', mtime }
250
+ const rows = groups.join('\n')
251
+ const has = (gk, ck) => new RegExp(`^${gk}: .*\\b${ck}\\b`, 'm').test(rows)
252
+ const traps = []
253
+ if (!has('flex', 'flexGrow') && has('flex', 'flexShrink')) {
254
+ traps.push('flex 组**没有 flexGrow**——要「撑满剩余空间」用 flexShrink:"1" + 父容器 justifyContent')
255
+ }
256
+ if (/^rect:/m.test(rows) && !/^size:/m.test(rows)) {
257
+ traps.push('**没有 size 组**——尺寸写 rect.width / rect.height,位置写 position.left / position.top')
258
+ }
259
+ const text =
260
+ `\n## 合法样式键(唯一来源 style-meta.json;写表外的名字 expand_ir 会 exit=3 中止写盘,schema 不会被改坏)\n` +
261
+ `${rows}\n` +
262
+ (traps.length ? `**两个最高频的坑**:${traps.join(';')}。\n` : '') +
263
+ `IR 里样式写在前述分组下(\`s:{flex:{gap:"8"}, rect:{width:"100%"}}\`);组名与键名一律照抄本表,不要用缩写(缩写别名只覆盖少量键,猜错即中止)。\n`
264
+ _styleKeyCache = { key: `${file}:${mtime}`, text, mtime }
265
+ return _styleKeyCache
266
+ }
267
+
183
268
  /**
184
269
  * 系统提示词只放「工作协议 + 参考索引」。
185
270
  * 此前整份注入两份 SKILL.md + rules.md(约 125KB / 数万 token),上下文臃肿会显著放大
@@ -187,8 +272,10 @@ let _systemPromptCache = null
187
272
  */
188
273
  function buildSystemPrompt({ shellAllowed = false, vision = false, autoApprove = DEFAULT_AUTO_APPROVE, root = '' } = {}) {
189
274
  const refsDir = skillRefsDir(root)
190
- // 缓存键带上实际命中的技能库:仓库内与消费者项目(init 释放的副本)文档集可能不同
191
- const cacheKey = `${shellAllowed ? 'shell' : 'base'}${vision ? '+vision' : ''}+aa:${autoApprove}+sk:${skillsDir(root)}`
275
+ const styleKeys = styleKeySection(root)
276
+ // 缓存键带上实际命中的技能库:仓库内与消费者项目(init 释放的副本)文档集可能不同;
277
+ // 再带上 style-meta 的 mtime:合法样式键表直接决定提示词内容,meta 改了必须重算。
278
+ const cacheKey = `${shellAllowed ? 'shell' : 'base'}${vision ? '+vision' : ''}+aa:${autoApprove}+sk:${skillsDir(root)}+skm:${styleKeys.mtime}`
192
279
  if (_systemPromptCache && _systemPromptCache.key === cacheKey) return _systemPromptCache.text
193
280
  // 免确认策略要如实写进协议:模型若以为「每次都要用户点确认」,会主动停下来等批准 / 把命令甩给用户
194
281
  const skillAuto = autoApproves(autoApprove, 'skill_exec')
@@ -231,29 +318,40 @@ function buildSystemPrompt({ shellAllowed = false, vision = false, autoApprove =
231
318
  const _text = `你是 CardBuddy 编辑器内置的卡片编辑助手:帮用户读取、修改、生成低代码卡片 schema.json。
232
319
  工作协议:
233
320
  1. 先用 card_read / card_node_models 了解卡片现状;修改保持最小 diff,不要重写无关节点。
234
- 2. 写之前按需 skill_read 参考文档(path = 下面索引里的文件名):常规修改只读最相关的 1-2 个文件——表达式 / 格式化问题先读 expression_engine.md;card_write 前读 rules.md;不要一次读全部。
321
+ 2. 写之前按需 skill_read 参考文档(path = 下面索引里的文件名):常规修改只读最相关的 1-2 个文件——表达式 / 格式化问题先读 expression_engine.md;card_write 前读 rules.md;不要一次读全部。**「不要一次读全部」针对的是整份大文档**:recipe_snippets/*.json 这类小片段(常 <2KB、读一次 0.3s)不确定就直接读,别为了「省着读」而在思考里猜字段形状——实测模型因为「被告知别读太多文件」而拒绝读一个 1KB 片段,转而在思考里硬推十几分钟。文档比一屏长时(如 ir-format.md)skill_read 会在尾巴给出 offset(读到「已截断」就照它再读一次):别拿前半段契约猜后半段字段。
235
322
  2b. 大卡(card_read 回喂被截断者)修改流程:card_grep 定位 JSON 路径 → card_patch 按路径最小写;严禁 card_write 整包重写大卡。小卡可直接 card_write。
236
- 2c. **从零建卡(尤其设计稿建卡)不要在工具参数里吐整份 schema**:光参数就上万 token,会被单次输出上限截断,整轮白跑。正路是——先 skill_read ir-format.md 看字段契约 → fs_write 把紧凑 IR 写到 .cache/ai-scratch/<id>.ir.json(比 schema 小 4-5 倍)→ skill_exec 跑 skills/dsl-card-generator/scripts/expand_ir.mjs,args=["--ir",".cache/ai-scratch/<id>.ir.json","--out","src/cards/<id>/"](自带 autofix + 真 validate,报错会原样回传)→ 报错就修。**IR 全文只写这一次**:之后所有调整都用 iterate.mjs 的 --set 点路径原子微调(一轮可带多条);整份重写 IR 一次要 40-110s 纯输出浪费,只有结构级推翻才允许重写。
323
+ 2c. **从零建卡(尤其设计稿建卡)不要在工具参数里吐整份 schema**:光参数就上万 token,会被单次输出上限截断,整轮白跑。正路是——先 skill_read ir-format.md 看字段契约 → fs_write 把紧凑 IR 写到 .cache/ai-scratch/<id>.ir.json(比 schema 小 4-5 倍)→ skill_exec 跑 skills/dsl-card-generator/scripts/expand_ir.mjs,args=["--ir",".cache/ai-scratch/<id>.ir.json","--out","src/cards/<id>/","--slices-from","<要素包>/slices,src/slices"](自带 autofix + 真 validate,报错会原样回传;引用的切图找不到时会列出搜索目录里名字沾边的候选)→ 报错就修。**IR 全文只写这一次**:之后所有调整都用 iterate.mjs 的 --set 点路径原子微调(一轮可带多条);整份重写 IR 一次要 40-110s 纯输出浪费,只有结构级推翻才允许重写。**一次写不进去就分片**:预计紧凑 JSON > 10KB、本轮已被单次输出上限截断过(finish_reason=length)、或上一轮写 IR 中途被打断过 ⇒ 改走「骨架 + 每区块一片 .cache/ai-scratch/<id>.pNN.json + ir_merge.mjs 合并一次」(格式与边界见 ir-format.md §2c)。分片是给「一次写不进去 / 写了会丢」兜底,每片要多付一个 LLM 步骤(实测 40-140s/步),小卡别拆。
237
324
  2e. **flex / 尺寸红线(validate 与 lint 直接拦,别试错)**:枚举值 kebab-case(flex-start / flex-end / space-between;写 flexEnd 即 validate ERROR);Column / Row 默认 gap=4,不需要间距就显式 gap:"0";justifyContent:space-between 的 Row 必须 rect.width:"100%"(lint R19);Row 父级里的 Row / AbsoluteLayout 子节点默认 width:100%,会互相把对方挤出画布——定宽子簇要显式 rect.width,且并列子节点至少一个 flexShrink:"1"(lint R35)。
238
325
  2d. **先抄范式,再动手写**:建卡 / 大改布局前按序取范式——① skill_read recipes.md 或 spec_to_schema_patterns.md 找最接近的组合配方;② 布局与自绘的字段形状(绝对布局 AbsoluteLayout、Canvas 自绘、列表展开收起、金额格式化、弹窗配对等)直接 skill_read recipe_snippets/ 下对应片段或 previewer_contract.md 照抄;③ 需要整卡范本时查 card_index.json,按组件 / 范式标签 / 名称找最相似的已有卡,card_read 或 fs_read 它的关键片段照着写。**禁止反推脚本源码(rg 翻 expand_ir.mjs 之类)或凭记忆猜字段形状**;范式里确实没有才允许自己设计,并在正文里说明为什么没有套用范式。
239
326
  3. 改变量类型时要同步处理引用它的地方(绑定表达式 / mock 数据);金额字段配格式化表达式生成展示文本。
240
327
  4. 改完用人话说明:改了什么、为什么、需要用户确认什么。
241
- 5. 信息足够后立即收敛:直接调用工具落盘或回复方案;同一内容不要重复读取。**思考里不要逐字草拟 schema / IR / 长 JSON,也不要复述卡片数据**——那会同时吃掉思考与输出额度,想清结构就立刻调工具把内容写出去。
328
+ 5. 信息足够后立即收敛:直接调用工具落盘或回复方案;同一内容不要重复读取。**思考里不要逐字草拟 schema / IR / 长 JSON,也不要复述卡片数据**——那会同时吃掉思考与输出额度,想清结构就立刻调工具把内容写出去。**同样禁止在思考里做像素算术**:逐行核对坐标、心算 gap / 对齐 / 层高(「文字 22 + gap 6 + 下划线 2 到底放不放得进 48」这种)实测让三个回合各烧掉 10 分钟思考并被熔断丢弃;几何细节照审计值先落盘,再用 render-status / check_fidelity / iterate 的数值指标迭代。
329
+ 5b. **正文只写结论**:工具轮之间的正文最多两句 / 300 字(发现了什么、改了什么、下一步调哪个工具);像素比对、逐行对齐、几何推算这类过程性推理在内部推理里做完直接调工具,不要写进正文。实测一次建卡把 35 万字过程分析写进了正文(144 轮里多轮单轮 2 万字),用户要的只是结论与门禁数值。
242
330
  6. 读文件用 fs_read(仓库内任意文本文件 / 挂载附件),列目录用 fs_list —— fs_read 只能读文件,对目录一定报错,别拿它试目录;被中止过的轮次按上下文里的中止标记从中断处继续,不要从零重做。
243
331
  7. 跑技能脚本用 skill_exec(script 传 skills/<skill>/scripts/<脚本>,args 传参数数组):设计稿前处理管线与 spec_audit / crop_slices / expand_ir / iterate / check_fidelity 等建卡脚本都靠它。${skillAuto ? '本仓库已设「技能脚本免确认」:调用即执行(审计写 .cache/ai-audit.log),不要停下来等用户批准,也不要因为怕要授权而改用 shell_exec 绕路、或把命令甩给用户自己跑。' : '每次调用会先请用户确认(用户可选「本回合不再询问」),被拒就把命令原样写给用户自行运行。'}${shellAllowed ? `\n8. 技能脚本之外的本地命令(npm / node / git / rg / cd 等)用 shell_exec:经真实 shell 执行,管道 |、链接 && / ;、重定向、引号与循环 / 条件 / 子shell 都可用(其中真正执行的每个外部命令需在白名单),不必再为元字符把命令拆开;复制文件用 fs_copy。${shellAuto ? '本仓库已设「本地命令免确认」:调用即执行。' : '每次调用都会弹给用户确认,被拒返回错误。'}cwd 已是工程根(技能脚本按 DSL_CARD_PROJECT_ROOT / cwd 认工程根,不按自身位置推算,落点由服务端注入),不要写 cd;白名单不含 sips / convert / magick / python3 —— 图像裁剪派生一律 crop_slices.mjs,Python 脚本一律 skill_exec,别去试系统图像命令。` : ''}
244
332
 
333
+ ${styleKeys.text}
334
+ ## 卡片检查 / 校验(对已有卡跑门禁:**不是**建卡协议,别调 S1-S6 那套)
335
+ - **脚本里没有 \`validate.mjs\` 这个东西**(2026-09-21 实测:card_validate 已返回 ok 之后模型又去跑它,白烧一步)。校验入口就三个,各答一个问题:
336
+ ① 契约校验(组件/属性/样式/表达式/data.json 一致性)= 工具 \`card_validate({id})\`;② 体验与布局规则 = skill_exec script=skills/dsl-card-generator/scripts/lint_card.mjs;
337
+ ③ 收敛门禁(需该卡跑过 iterate)= skill_exec script=skills/dsl-card-generator/scripts/iterate.mjs args=["--gate","--out","src/cards/<id>/"]。
338
+ - 渲染自检 / 组件统计用 \`card_render_status\`,看卡内容用 \`card_read\`(或 describe_card.mjs)。**它们不互相替代,但也不必串成一串全跑**:用户只说「检查一下」时跑 ①(卡正在改就加 ②),别把 ③ 也拉上——③ 读的是上一轮落盘报告,没跑过 iterate 的卡它只会判 FAIL。
339
+ - 脚本名一律以磁盘为准:\`skill_exec\` 只跑 skills/<skill>/scripts/ 下真实存在的文件;报错会附上同目录的真实候选,照着里面的名字重发,**别继续猜下一个名字**。
340
+
245
341
  ## 设计稿建卡协议(Stage Machine:逐阶段推进,上一阶段产物齐了才进下一阶段;用户挂载设计稿目录 / 图片并要求建卡时严格执行,不要自由发挥)
246
342
  S0 路由:输入形态已由服务端机械判定,写在「用户挂载的本地文件」段(kind / preprocess / next):照它执行,不要再调 card_route_input;该段缺失才自己调 card_route_input({ path })。
247
343
  S1 要素包:kind=meaxure-export(Sketch MeaXure 标注导出)必须先有 sketch-spec-to-card 要素包,禁止直接读 preview/ 整图建卡。
248
344
  - fs_read 判定里给出的 manifest(通常 UI/manifest.json)找目标画板条目:status=generated ⇒ 产物已在;status=pending ⇒ 先看 preprocess.alsoFound(同一导出的其它产物目录,平铺与按稿分片常并存),有就直接用;确实都没有才补跑:
249
- ① skill_exec script=skills/sketch-spec-to-card/scripts/parse_meaxure.py,args=["--src","<导出目录>","--list"] 拿画板序号与名称;
250
- ② args=["--src","<导出目录>","--out","UI","--draft","<稿名>","--indices","<n>","--build"] 只产该画板(多个逗号;按名称子串用 "--only","<子串>");
345
+ ① skill_exec script=skills/sketch-spec-to-card/scripts/parse_meaxure.py,args=["--src","<导出目录>","--list"] 拿画板清单(**每行行尾自带该画板的 --pick,板号 1 起**);
346
+ ② args=["--src","<导出目录>","--out","UI","--draft","<稿名>","--pick","<照抄清单行尾>","--build"] 只产该画板(**照抄行尾的 --pick,禁止把序号换算成 0 起的 --indices**——实测换算错一位,白跑 34s 解析 + 一步 LLM 才发现;多张逗号分隔,连板写 7~9、同板连卡写 8.1~8.2);
251
347
  ③ script=skills/sketch-spec-to-card/scripts/cluster_states.py,args=["--out","UI/<稿名>"]。
252
348
  - 要素包落点记作 <bundle>:传了 --draft 在 UI/<稿名>/<画板名>/,否则 UI/<画板名>/;以 parse_meaxure.py 打印的落盘路径为准。仓库可能两种布局并存,manifest 说的画板在 UI/ 下找不到就用 fs_list UI 找同名目录,别急着判定产物缺失。
349
+ S1.4 批量建卡(用户一次点了多张卡 / 回了「全部」):**一次** parse_meaxure --pick 出全部要素包(别每卡重跑一遍解析),然后**同一个回合内**按「S2→S5 走完一张,再走下一张」连续推进——每张卡各写各的 IR、各跑各的 expand + iterate。禁止每张卡各起一个回合:新回合的首步要把整段对话重发一遍(实测 1.3-3 万 token 一步),还要把同一个要素包重读一遍,纯浪费。
253
350
  S1.5 选卡门禁(多画板 / 多卡画板必需;SKILL.md §13 + rules.md §13.4):目标目录定下后先跑 skill_exec script=skills/dsl-card-generator/scripts/spec_tree.mjs,args=["--list-cards","<目标目录>"] 拿「画板序号.卡片序号」全量清单(一个画板含 N 张卡展开为 K.1~K.N,行下自带 "图: <card-N.png 绝对路径>");S0 挂载段已带同一份命令就直接复用,不要重复跑。
254
351
  - 只有 1 张卡、或用户已明说目标(序号如 2.3 / 画板名 / 卡片名)⇒ 直接进 S2,不要为走流程停下来问。
255
352
  - 多张卡且用户未指定 ⇒ **本轮到此为止**:把清单写进正文 + 明确提问,然后**停止输出、等用户回序号再继续**。聊天面板**没有**弹选项工具(不存在 request_user_input),不要去调用或寻找弹窗工具,也不要替用户挑。
256
353
  清单**逐行配图**:每个卡片行下紧跟它自己的聚焦图——把 "图:" 那行的绝对路径原样写成内联 Markdown 图片(让它直接显示出来,**不要只贴路径**),与序号一一对应;缺图显式标缺失,禁止把图堆到清单末尾。末尾追加一项「全部」,用户回「全部」才算显式批量授权(产了几张卡要在正文报)。
354
+ **路径必须写完整绝对路径**(以 /Users/… 或 C:\… 打头,一字不改照抄),**禁止用省略号(… 或 ...)代替前缀**(实测「…/积分助手-4-xx.png」这种写法渲染端取不到图,用户那几行就只剩一串路径=配图全丢),**禁止把路径包进反引号或写进代码块**(反引号 / 代码块里的是给人抄的文本,不会渲染成图片)。清单长不是省前缀的理由:图显示不出来才是真的白写。
257
355
  只回画板序号默认取该画板第 1 张卡。
258
356
  - **禁止**自行挑画板 / 挑卡、**禁止**「先建一张看看」、**禁止**未经同意批量产卡。
259
357
  S2 审计(一步代替反复探索):skill_exec script=skills/dsl-card-generator/scripts/spec_audit.mjs,args=["--spec","<bundle>/spec.json","--card","N","--slices-dir","<bundle>/slices","--project-slices","src/slices","--design","<bundle>/card-N.png","--save",".cache/ai-scratch/<id>.audit.json"]。
@@ -264,9 +362,9 @@ S2 审计(一步代替反复探索):skill_exec script=skills/dsl-card-gene
264
362
  S3 补切图:S2 的 missing 非空 ⇒ skill_exec script=skills/dsl-card-generator/scripts/crop_slices.mjs,args=["--design","<bundle>/card-N.png","--card-width","<卡宽>","--out","src/slices","--from-audit",".cache/ai-scratch/<id>.audit.json"],从聚焦截图派生缺失切图(审计已落盘,直接引用)。切图唯一落点 src/slices/ 扁平命名,IR 里 Image.value 用 /slices/<名>。
265
363
  只缺一两张时用 --crop <名>@x,y,w,h(可多条),x/y/w/h 直接抄审计 missing[].crop 的**卡片本地 1x 坐标**,脚本自己按 design 宽 ÷ 卡宽换算像素。**禁止用 sips / convert / magick 等系统图像命令裁图**:它们不在 shell 白名单,且要手算缩放比与裁剪偏移,实测必错。
266
364
  切图用法看 slices[].baked:label=true ⇒ 切图已含标签文字,tile 用单个 Image(96×84 之类照 rect 抄);label=false ⇒ 需要 Image + Text 组合;verdict=reused-differs ⇒ 同一张切图被标到内容不同的多个位置,除第一个外必须用审计 slices[].crops 给出的 _alt 名(exists=true 直接引用,exists=false 由 crop_slices 派生)——**禁止自造新名字**(实测自造 icon_xx_左/右 让同一设计内容在 src/slices 下出现三套名字)。
267
- S4 写 IR(全文只写这一次):fs_write .cache/ai-scratch/<id>.ir.json。结构照 bands 一条一段;gaps.rowGaps 映射成 flex.gap / padding(paddingType:"1" + 四边值);texts 逐条转 Text(fontSize / fontWeight / color / lineHeight 全抄,**lineHeight 必写**,否则渲染器按 1.5 倍字号撑高、整卡累计漂移几个像素);标题 + 底部装饰条照抄 recipe_snippets/title-underline-overlay.json(AbsoluteLayout + position.top,禁负 margin)。
268
- **类型开关铁律(渲染器静默丢弃类缺陷的唯一防线)**:margin / padding 组写单边值(marginTop 等)必须同组写 marginType:"1" / paddingType:"1"(expand_ir 会自动补;手写 schema 漏了会被 lint 报 r44-*-no-type);背景三选一:纯色 backgroundType:"01"+backgroundColor、图片 "02"+backgroundImage、渐变 "03"+backgroundGradient。审计给了 bgGradientHint 就照抄它的 ir 字段;**禁止自造 gradientColor / gradientDirection,禁止把 linear-gradient(...) 塞进 backgroundColor**——这三者都会静默失效(实测模型连试 3 轮全无效后放弃并编造理由)。
269
- 落点卡默认 = 当前正在编辑的卡片 ID(见「当前卡片上下文」):IR 的 id 与 iterate 的 --out 一律 src/cards/<当前卡片ID>/,即覆盖当前卡 / 空卡是默认行为;只有用户明说「新建一张」才自拟 ID。当前卡已有内容时,动手前一句正文说明将覆盖、原内容留 .history 快照可回滚。
365
+ S4 写 IR(全文只写这一次;**单次输出上限截断过 ⇒ 必须分片写**,见 2c 与 ir-format.md §2c:骨架 fs_write → 每区块一片 pNN.json → skill_exec ir_merge.mjs --ir <ir> --part …(可多条) 合并一次 → 再 expand_ir):fs_write .cache/ai-scratch/<id>.ir.json。结构照 bands 一条一段;gaps.rowGaps 映射成 flex.gap / padding(paddingType:"1" + 四边值);texts 逐条转 Text(fontSize / fontWeight / color / lineHeight 全抄,**lineHeight 必写**,否则渲染器按 1.5 倍字号撑高、整卡累计漂移几个像素);标题 + 底部装饰条**整段照抄 ir-format.md §2d 的片段**(AbsoluteLayout(align:"BottomLeft") > [Row(条, s.position.top/left), Text(标题, s.position.top/left)],子节点顺序 [装饰条, 标题];几何读标注差值 position.top = 标注条.y − 标注标题.y,别套「行高 + gap」的公式;条子不得排在流式里、不得用 margin 顶位(正负都不行,lint r64 硬拦))。
366
+ **类型开关铁律(渲染器静默丢弃类缺陷的唯一防线)**:margin / padding 组写单边值(marginTop 等)必须同组写 marginType:"1" / paddingType:"1"(expand_ir 会自动补;手写 schema 漏了会被 lint 报 r44-*-no-type);背景三选一:纯色 backgroundType:"01"+backgroundColor、图片 "02"+backgroundImage、渐变 "03"+backgroundGradient。审计给了 bgGradientHint 就照抄它的 ir 字段;**禁止自造 gradientColor / gradientDirection,禁止把 linear-gradient(...) 塞进 backgroundColor**——这三者都会静默失效(实测模型连试 3 轮全无效后放弃并编造理由)。 **水平内缩禁止写成「子节点 width:100% + marginLeft/Right」**:width:100% 已等于父容器内容宽,水平 margin 只会把节点推出父容器右缘、被 overflow:hidden 裁掉(父容器按列排布时水平方向是交叉轴,flexShrink 救不回来)——间距要放**父容器** paddingLeft/Right,或删掉 width 靠 stretch 补宽(lint r63-width100-with-hmargin ERROR 拦截,pitfalls §25)。
367
+ 落点卡默认 = 当前正在编辑的卡片 ID(见「当前卡片上下文」):IR 的 id 与 iterate 的 --out 一律 src/cards/<当前卡片ID>/,即覆盖当前卡 / 空卡是默认行为。**「建卡」「生成卡片」「用这张画板做一张卡」都不是「新建一张」**——那是在说「用设计稿出一张卡」,落点仍是当前卡;只有用户明确说「新建一张 / 另存为新卡 / 换个新 id / 不要覆盖当前卡」才自拟 ID,且自拟前先用一句正文写明「将新建卡片 <新id>,不覆盖当前卡 <当前卡id>」,让用户当场看得见(实测:把「建卡」读成「新建」,界面里凭空多出一张卡而对话里没有任何交代)。当前卡已有内容时,动手前一句正文说明将覆盖、原内容留 .history 快照可回滚。
270
368
  S4.5 变量抽取(真实卡片不是截图:这步漏了,产物就是 dictionary / data.json 全空的死图):先 skill_read ir-format.md 的「2b. 变量绑定」看契约,再决定哪些文本绑变量——
271
369
  下限 = S2 审计的 bindHints[](amount / percent / date / masked / count / level),再按语义补正则认不出的动态值(用户名 / 客户层级 / 卡片名称 / 状态词);静态标签(区块标题 / 按钮 / 说明文字)才写字面量。
272
370
  - 动态值:Text 主值用 v:"$key"(条件文案写表达式如 "$showAll ? '收起' : '查看更多'",金额展示照 recipe_snippets/money-format.json,别把「¥7,000.00」拆成字面量)。
@@ -275,18 +373,24 @@ S4.5 变量抽取(真实卡片不是截图:这步漏了,产物就是 dicti
275
373
  - 命名语义化(memberLevel / totalAmount / assetRows),禁 text1 / val2;写完在正文列一句绑定清单(key → 设计稿文案)。
276
374
  S5 收敛循环(唯一门禁 = iterate.mjs 报告里的 gate 字段:内部已跑 autofix + 真 validate + lint + preflight + render + 并集像素对比,别再单独跑 validate_schema.py):
277
375
  skill_exec script=skills/dsl-card-generator/scripts/iterate.mjs,args=["--ir",".cache/ai-scratch/<id>.ir.json","--out","src/cards/<落点卡>/","--design","<bundle>/card-N.png","--width","<卡宽>","--spec","<bundle>/spec.json"]。
278
- 修 IR 一律 --set 点路径原子微调(数字段=数组下标,一轮可多条;中段不存在会响亮报错,按报错改路径),例:--set children.0.s.flex={"gap":"0"}。
279
- **收敛 = 报告 gate.passed === true**(validate/lint/preflight 零 error ∧ match ≥ 95 ∧ heightDelta=0 ∧ widthDelta=0 ∧ |contentOffset1x| ≤ 2)。gate 是脚本判的,正文只能引用它的 verdict / failed,不得自行宣布达标。像素对比按并集画布算,删内容 / 高度塌缩只会降分,不可能凑分。
280
- 报告还带:contentOffset1x + contentOffsetHint(内容整体平移时先修对齐,别逐行调间距)、lint.warningDetails、preflight.details、renderImage(渲染原图路径)、diffImageNote(并排图顶部色条是工具标题栏,不是卡片内容)、stalled(连续多轮 match 与热区不变)。
281
- **stalled 出现 ⇒ 停止 --set 盲调,照 stalled.hint 换证据链**:跑 diff_bands.mjs(args=["--id","<落点卡>","--design","<bundle>/card-N.png","--render","<报告 renderImage>"])拿逐带 dy/dh 与分区主色,再对照 references/generation_pitfalls.md 的「类型开关+值 / 静默丢弃」清单。实测事故:match 卡 73% 连调 6 轮无效,根因是 6 处 margin 漏 marginType,diff_bands 一轮就能指认。
376
+ 修 IR 用 **ir_patch**(点路径 JSON 补丁,一次可多条,形如 ops=[{"path":"children.0.s.flex.gap","value":"0"}])——**它是改 IR 的首选**:结构化参数不吃引号转义、原子写、回旧值→新值摘要;也可用 iterate.mjs 的 --set(字符串字面量语法,注意引号转义,例:--set children.0.s.flex={"gap":"0"})。数字段=数组下标;中段不存在会响亮报错,按报错改路径。**两条路径都不是「整份重写」**——同轮内对同一草稿文件第 2 次全量 fs_write 会收到提醒、第 3 次起被拒(要重排结构才传 rewrite:"true")。
377
+ **收敛 = 报告 gate.passed === true**(validate/lint/preflight 零 error ∧ **elements.missing=0** ∧ match ≥ 95 ∧ heightDelta=0 ∧ widthDelta=0 ∧ |contentOffset1x| ≤ 2)。gate 是脚本判的,正文只能引用它的 verdict / failed,不得自行宣布达标。像素对比按并集画布算,删内容 / 高度塌缩只会降分,不可能凑分。
378
+ **要素是固定真值:先骨架、后样式(两段式门禁)**。S2 审计里的每条文本 / 每张切图都是这张卡的既定要素,S5 只许改**样式与布局**(尺寸 / 间距 / 颜色 / 字号 / flex / 位置),**不许丢要素、不许自造内容**。报告带 gate.stage 与 gate.checks[].name === 'elements.missing':
379
+ · stage=structure(elements.missing>0 或 validate/lint/preflight 未过)⇒ **先补要素,别调像素**:把设计稿的文案补回对应节点;绑定变量的把 defaultValue 改成设计稿真值。改样式不会把丢掉的文案变回来。
380
+ · stage=pixel ⇒ 要素齐了,才进入样式 / 布局收敛(一轮只改一个分项)。
381
+ 实测事故(2026-09-21):模型把设计稿的产品名 / 利率 / 期限整批换成自己编的占位值(「理财优选1号 / 2.45 / 每日申购」),像素照样刷到 89%、validate/lint 全绿,而卡的**内容真值整批丢失**,直到人工比对设计稿才发现。报告 elements.invented 会把这类「schema 里有、设计稿里没有」的自造文案逐条点名,elements.missing 列出漏掉的要素。
382
+ 报告还带:contentOffset1x + contentOffsetHint(内容整体平移时先修对齐,别逐行调间距)、**contributors + contributorsNote(分项归因:每个差异热区给出最可能出问题的具体节点)**、widthMismatch(--width 与设计稿卡面宽不符 ⇒ 先改 --width,否则 match/dy 都不可信)、lint.warningDetails、preflight.details、renderImage(渲染原图路径)、diffImageNote(并排图顶部色条是工具标题栏,不是卡片内容)、stalled(**连续 3 轮未刷新历史最好成绩**——震荡下滑同样触发——附 best 与回退命令)、best(历史最好成绩 {match,round})。
383
+ **contributors 是「差在哪个节点」的唯一答案,用它替代整份重写 IR**:数组每项 = 一个热区(hotspot/diffPct)+ elements[](按最内层优先排序的具体节点:cid / cls / text / rect / h / hSibling(同类中位高度)/ designH / dH(与设计稿高度差)/ flags / hotCover)。读法:flags 里 text-clipped ⇒ 该节点文本被裁剪,查 isMaxLine / maxLine / lineHeight;vs-design ±N ⇒ 该节点高度与设计稿差 N,查它自己的 paddingTop/Bottom 与 lineHeight;vs-siblings ±N ⇒ 同类元素里只有它高度不同,查它自己的 padding/margin(**不是整层 gap**);bottom-edge / right-edge ⇒ 差异贴边界外,查下边距 / 圆角 / 阴影。**一轮只改 contributors[0].elements[0] 这一个节点的一个属性**,改完立刻重跑 iterate 看是否刷新最好成绩——同时改多项无法归因。designH/dH 为 null 表示没给 --spec 或文本匹配不上,此时只能靠 flags 与同类中位判断,**不要自己编设计稿高度**。
384
+ **报告里出现 envBlocked / verdict 以 BLOCKED 开头 ⇒ 本机没有可用的 python,validate 根本没跑**:这是环境问题,改 schema、继续 --set 都修不好它——立刻停手如实告诉用户(装 Python 或设 DSL_CARD_PYTHON=<解释器路径>)后重跑,别把它当门禁缺口反复重试。
385
+ **stalled 出现 ⇒ 第一件事是回退,不是接着调**(它=连续 3 轮没刷新最好成绩,说明 --set 已到天花板):① skill_exec script=skills/dsl-card-generator/scripts/ir_restore.mjs args=["--best",".cache/ai-scratch/<落点卡>.ir.best.json","--ir",".cache/ai-scratch/<落点卡>.ir.json"] 回退到历史最好成绩那一轮(写回前自动备份当前 IR),**回退后立刻重跑一轮 iterate**——旧报告的 schemaHash 已失效,--gate 会直接判 FAIL;② 之后一轮只改 gate.failed 里的**一个**分项;③ 同一项连改两轮都不动才换证据链:跑 diff_bands.mjs(args=["--id","<落点卡>","--design","<bundle>/card-N.png","--render","<报告 renderImage>"])拿逐带 dy/dh 与分区主色,再对照 references/generation_pitfalls.md 的「类型开关+值 / 静默丢弃」清单。**禁止**在 stalled 之后继续盲调 --set、**禁止**再全量重写 IR。实测事故:match 卡 73% 连调 6 轮无效,根因是 6 处 margin 漏 marginType,diff_bands 一轮就能指认;另一条轨迹 r10 做到 92.42 后 13 轮越改越差、烧掉 707 万 token 才被人工中止——正因为旧判据非要「完全不变」才触发,震荡下滑落在盲区里。
282
386
  match 不升反降、heightDelta 非 0、或 hotspots 集中在原本已有内容的区域 ⇒ 说明在删内容凑分:回退该轮 --set 换思路。diffImage 每轮重写(报告里的路径),只给用户预览。
283
- **取证禁令**:禁止用 shell_exec 写 node -e pngjs 像素脚本(行扫描 / 取色 / 自算 diff)——diff_bands.mjs、报告 hotspots、contentOffset1x、审计 colors/bgGradientHint 已经覆盖全部取证需求,实测手写脚本 4 次全是浪费步数;禁止 fs_read 任何图片;禁止在 S5 之前调 card_screenshot(渲染还没意义);禁止把 src/cards/<卡>/<卡>.png 当渲染图(那是设计稿归档,渲染原图是报告里的 renderImage)。
387
+ **取证禁令**:禁止用 shell_exec 写 node -e pngjs 像素脚本(行扫描 / 取色 / 自算 diff)——diff_bands.mjs、报告 hotspots、contentOffset1x、审计 colors/bgGradientHint 已经覆盖全部取证需求,实测手写脚本 4 次全是浪费步数;禁止 fs_read 任何图片;禁止在 S5 之前调 card_screenshot(渲染还没意义);禁止把 src/cards/<卡>/<卡>.png 当渲染图(那是设计稿归档,渲染原图是报告里的 renderImage)。同理**禁止自造分析 / 补丁脚本**(往草稿区写 .cjs/.mjs 再跑、node -e 扫像素或改 JSON 都在此列,实测白烧 11 轮)与**用 sed -i / 重定向直接改 IR、schema**(会绕过 validate、原子写与 .history 快照):改 IR 用 ir_patch(首选:点路径 JSON 补丁、原子、不吃引号转义)或 iterate.mjs --set,改卡片内容用 card_patch / card_write。
284
388
  每轮用一两句正文汇报:gate.verdict / match% / heightDelta / 这轮修了什么,不要闷头连调。
285
389
  S6 收尾门禁:① skill_exec script=skills/dsl-card-generator/scripts/check_fidelity.mjs,args=["--spec","<bundle>/spec.json","--schema","src/cards/<落点卡>/schema.json","--card","N"],按它报的文本缺失 / 字号偏差 / 颜色偏差修到无 ERROR;② skill_exec script=skills/dsl-card-generator/scripts/iterate.mjs,args=["--gate","--out","src/cards/<落点卡>/"] 拿机器判定(读最近一轮落盘报告,schema 在报告之后被改过会直接判 FAIL)。
286
390
  正文汇报必须**逐字引用 --gate 的 verdict 与 failed 列表** + 落点卡 + 补了哪些切图 + 回滚方式。gate 未过禁止写「门禁全绿 / 已完成 / 验收通过」,必须列出未达标项、原因与下一步;禁止把未达标归因于「测量噪声 / 工具合成产物」——pixelDiff 只用渲染原图与设计原图,合成图色条不参与计算(见 diffImageNote)。
287
391
  绑定对齐:check_fidelity 会按 data.json / defaultValue 把 $ 绑定解析回文案再对齐——报 missing-text 而该文本已绑定 ⇒ 是 mock 与设计稿不一致,改 defaultValue / data.json,**不是拆回字面量**。收尾自查:bindHints 每一项要么已绑定、要么正文说明为何保持字面量。
288
392
  ${vision ? '你有视觉能力:S5 里 match 不达标可直接读注入的并排对比图定位差异(哪一行 / 哪个颜色 / 哪段间距)再 --set;数值指标仍是收敛判据,图只用于定位。' : '当前模型无视觉:不要声称看过图;S2 的审计值 + S5 的数值指标就是你的眼睛,所有尺寸颜色文案取审计值,缺时按 references/ui_fidelity_rules.md 降级规则处理,禁止凭空估数值。'}
289
- 步数不设硬顶(除非用户在设置里配了上限),但有四道闸:①连续多轮「同工具 + 同参数」会被判定原地打转并强制收尾 ②单轮墙钟到点会强制收尾 ③每 24 步会收到一次收敛提醒 ④单轮上下文超预算时**早期工具结果会被折叠成摘要**并标注「已折叠」——看到折叠标记就用工具重读,不要拿摘要当结论。所以别把额度当免费:S2 / S3 / S5 各尽量一次调用解决;同类信息只读一次;收到重复调用警告就立刻换证据链(diff_bands.mjs / generation_pitfalls.md),不要把同一条调用原样再发一次;被中止过的轮次按上下文里的中止标记从中断处继续,不要从零重做。
393
+ 步数不设硬顶(除非用户在设置里配了上限),但有四道闸:①连续多轮「同工具 + 同参数」会被判定原地打转并强制收尾(判据含 --ir 文件的指纹:**改过 IR 再原样重跑同一条命令不算打转**,按「改一版 → 重跑一次拿新指标」的节奏走是安全的;输入一字未变还反复跑同一条命令才会被拦) ②单轮墙钟到点会强制收尾 ③每 24 步会收到一次收敛提醒 ④单轮上下文超预算时**早期工具结果会被折叠成摘要**并标注「已折叠」——看到折叠标记就用工具重读,不要拿摘要当结论。所以别把额度当免费:S2 / S3 / S5 各尽量一次调用解决;同类信息只读一次;收到重复调用警告就立刻换证据链(diff_bands.mjs / generation_pitfalls.md),不要把同一条调用原样再发一次;被中止过的轮次按上下文里的中止标记从中断处继续,不要从零重做。
290
394
 
291
395
  ## 收尾协议(不要停下来等「继续」)
292
396
  - 任务没做完就不要交还控制权:接着调用工具推进到下一个门禁。**禁止**输出「下一步(请确认或直接说「继续」)」这类等待式收尾——用户已开启自动续跑,停手只会让任务白等一轮。
@@ -306,15 +410,26 @@ const SKILL_READ_TOOL = {
306
410
  type: 'function',
307
411
  function: {
308
412
  name: 'skill_read',
309
- description: '读取系统提示词「按需参考索引」中列出的建卡参考文档(文件名),返回正文(超长截断)。只在需要时读,不要全读。',
413
+ description: '读取系统提示词「按需参考索引」中列出的建卡参考文档(文件名),返回正文。文档比一屏长时只返回一段,尾巴上给出下一段 offset——需要后半段就直接带 offset 再读一次,不要凭半截内容猜。只在需要时读,不要全读。',
310
414
  parameters: {
311
415
  type: 'object',
312
- properties: { path: { type: 'string', description: '索引中的文件名,如 expression_engine.md' } },
416
+ properties: {
417
+ path: { type: 'string', description: '索引中的文件名,如 expression_engine.md' },
418
+ offset: { type: 'integer', description: '续读起点(字符偏移)。照抄上次返回尾巴里的 offset,不要自己估算。' },
419
+ },
313
420
  required: ['path'],
314
421
  },
315
422
  },
316
423
  }
317
- const SKILL_READ_MAX = 30000
424
+ // skill_read 的自有上限**必须**等于统一截断口径:以前这里写 30000 > TOOL_RESULT_MAX_CHARS,
425
+ // 文案「已截断」还没送出就被外层按 12000 又切一刀——模型拿到的是 12000 字、且**看不到任何截断提示**,
426
+ // 实测 ir-format.md(17285 字符)每次读都静默丢 31%,模型拿着前半段契约猜后半段字段。
427
+ const SKILL_READ_MAX = TOOL_RESULT_MAX_CHARS
428
+ /**
429
+ * 续读锚点预留:尾巴要写「读了哪段 / 下一段 offset / 下文小节索引」,正文得让出这点地方。
430
+ * 取 240 是「够尾巴写满 6 个小节标题」的量级;真超了 clipSkillDoc 会再让正文,不漏字。
431
+ */
432
+ const SKILL_READ_TRAILER_RESERVE = 240
318
433
 
319
434
  /** 大卡定位/最小写工具:card_read 只回喂 12KB、card_write 要整包替换,
320
435
  * 400KB 级卡片上二者都不可行(模型只能空转)。card_grep 给 JSON 路径,card_patch 按路径最小写。 */
@@ -490,7 +605,7 @@ function shellTool(auto = false) {
490
605
  type: 'function',
491
606
  function: {
492
607
  name: 'shell_exec',
493
- description: `在仓库根经真实 shell(/bin/sh -c)执行一条本地命令并返回输出(60s 超时)。cwd 已是仓库根,命令里不要写 cd(要切目录用脚本自己的 --src / --out 参数)。支持管道 |、链接 && / ;、重定向、引号与循环 / 条件 / 子shell / $( ) 等 shell 语法;其中真正执行的每个外部命令(含管道各段与替换体内部)必须在白名单(${[...SHELL_ALLOW_BIN].join(' / ')})。复制文件用 fs_copy,不要试 cp / mv。白名单**不含**图像处理命令(sips / convert / magick:裁切图用 skill_exec 跑 crop_slices.mjs)与解释器(python / python3:Python 脚本用 skill_exec)。${auto ? '本仓库已设为免确认:调用即执行,不要停下来等用户批准。' : '每次调用都会先弹给用户确认,被拒会返回错误。'}用于跑测试、构建、git 等结构化卡片工具覆盖不了的操作。`,
608
+ description: `在仓库根经真实 shell(/bin/sh -c)执行一条本地命令并返回输出(60s 超时)。cwd 已是仓库根,命令里不要写 cd(要切目录用脚本自己的 --src / --out 参数)。支持管道 |、链接 && / ;、重定向、引号与循环 / 条件 / 子shell / $( ) 等 shell 语法;其中真正执行的每个外部命令(含管道各段与替换体内部)必须在白名单(${[...SHELL_ALLOW_BIN].join(' / ')})。复制文件优先 fs_copy(二进制安全、自动建父目录),cp 亦可用;mv / rm 不在白名单(不可逆操作)。白名单**不含**图像处理命令(sips / convert / magick:裁切图用 skill_exec 跑 crop_slices.mjs)与解释器(python / python3:Python 脚本用 skill_exec)。${auto ? '本仓库已设为免确认:调用即执行,不要停下来等用户批准。' : '每次调用都会先弹给用户确认,被拒会返回错误。'}**这是只读通道**:不要用它改文件(sed -i 这类原地写会被拦)——改卡片用 card_patch / card_write、改草稿用 fs_write、改 IR 用 skill_exec iterate.mjs --set。用于跑测试、构建、git 等结构化卡片工具覆盖不了的操作。`,
494
609
  parameters: {
495
610
  type: 'object',
496
611
  properties: {
@@ -517,7 +632,7 @@ function skillExecTool(auto = false) {
517
632
  type: 'function',
518
633
  function: {
519
634
  name: 'skill_exec',
520
- description: `执行 skills/<skill>/scripts/ 下的技能脚本(.mjs/.js 用 node、.py 用 python3),cwd=仓库根,返回 exit code + stdout/stderr(默认 180s 超时)。设计稿建卡必用:sketch-spec-to-card 的 parse_meaxure.py(--list 列画板 / --indices 选画板 / --build 出要素包)与 cluster_states.py;dsl-card-generator 的 spec_audit.mjs(一步审计带出 bands/gaps/texts/slices/missing)、crop_slices.mjs(从聚焦截图派生缺失切图:--from-audit 或 --crop 名@x,y,w,h)、spec_tree.mjs(spec.json 压缩成层级树)、expand_ir.mjs(IR 展开成 schema,自带 autofix + validate)、iterate.mjs(--set 点路径微调 + 渲染 + 并集像素对比 + preflight + gate 机器门禁的唯一入口;--gate 单独判收敛)、diff_bands.mjs(渲染 vs 设计的逐带 dy/dh / 内容底边 / 分区主色,凡「看起来不一致」先跑它拿证据,支持 --render 复用渲染原图)、preflight.mjs(Pre-flight Checklist 的机械化 10 条)、render_card.mjs(离线渲染原图)、describe_card.mjs / extract_subtree.mjs(反向读存量卡 / 抽子树范式,替代手写 node -e 遍历 schema)、check_fidelity.mjs(还原度门禁)等。${auto ? '本仓库已设为免确认:调用即执行,按协议连跑到底,不要停下来等用户批准,也不要因为怕要授权而改用 shell_exec。' : '每次调用先请用户确认(用户可选「本回合不再询问」)。'}`,
635
+ description: `执行 skills/<skill>/scripts/ 下的技能脚本(.mjs/.js 用 node、.py 用 python3),cwd=仓库根,返回 exit code + stdout/stderr(默认 180s 超时)。设计稿建卡必用:sketch-spec-to-card 的 parse_meaxure.py(--list 列画板 / --indices 选画板 / --build 出要素包)与 cluster_states.py;dsl-card-generator 的 spec_audit.mjs(一步审计带出 bands/gaps/texts/slices/missing)、crop_slices.mjs(从聚焦截图派生缺失切图:--from-audit 或 --crop 名@x,y,w,h)、spec_tree.mjs(spec.json 压缩成层级树)、expand_ir.mjs(IR 展开成 schema,自带 autofix + validate)、iterate.mjs(--set 点路径微调 + 渲染 + 并集像素对比 + preflight + gate 机器门禁的唯一入口;--gate 单独判收敛)、ir_merge.mjs(分片写 IR 的合并器:骨架 + 逐区块片段 .pNN.json 一次合并进主 IR,幂等 + 原子,大卡或本轮被单次输出上限截断过时用,见 ir-format.md §2c)、diff_bands.mjs(渲染 vs 设计的逐带 dy/dh / 内容底边 / 分区主色,凡「看起来不一致」先跑它拿证据,支持 --render 复用渲染原图)、preflight.mjs(Pre-flight Checklist 的机械化 10 条)、render_card.mjs(离线渲染原图)、describe_card.mjs / extract_subtree.mjs(反向读存量卡 / 抽子树范式,替代手写 node -e 遍历 schema)、check_fidelity.mjs(还原度门禁)等。${auto ? '本仓库已设为免确认:调用即执行,按协议连跑到底,不要停下来等用户批准,也不要因为怕要授权而改用 shell_exec。' : '每次调用先请用户确认(用户可选「本回合不再询问」)。'}`,
521
636
  parameters: {
522
637
  type: 'object',
523
638
  properties: {
@@ -560,6 +675,53 @@ function pythonBin() {
560
675
  return _pythonBin
561
676
  }
562
677
 
678
+ /** 候选清单长度上限:够模型认出真名即可,别把整目录灌进上下文。 */
679
+ const SCRIPT_HINT_MAX = 12
680
+
681
+ /**
682
+ * 「脚本不存在」时的真实候选清单。
683
+ *
684
+ * 为什么必须有:模型会凭记忆编脚本名(实测 2026-09-21 信用卡账单卡:`card_validate` 已经返回
685
+ * ok 之后,又去 skill_exec 一个不存在的 `skills/dsl-card-generator/scripts/validate.mjs`)。
686
+ * 只回一句「脚本不存在」,它要么接着猜下一个名字(一路试到熔断),要么误判「这个能力不可用」而放弃。
687
+ * 带上同目录的真实文件名,一次报错就能自我纠正。
688
+ *
689
+ * 排序:同名干优先(validate.mjs → validate_schema.py),其余按字典序补齐。
690
+ * skill 目录本身不存在时,退化成列出技能库里真实的 skill 名。
691
+ */
692
+ function skillScriptHint(root, clean) {
693
+ const segs = String(clean || '').split('/')
694
+ const skill = segs[0] || ''
695
+ const stem = (segs[segs.length - 1] || '').replace(/\.[^.]+$/, '').toLowerCase()
696
+ for (const base of skillsCandidates(root)) {
697
+ const dir = path.join(base, skill, 'scripts')
698
+ let files = []
699
+ try {
700
+ if (!fs.existsSync(dir)) continue
701
+ files = fs.readdirSync(dir).filter((f) => /\.(mjs|cjs|js|py)$/.test(f))
702
+ } catch {
703
+ continue // 候选根不可读(权限 / 竞态)→ 换下一个
704
+ }
705
+ if (!files.length) continue
706
+ const stemOf = (f) => f.toLowerCase().replace(/\.[^.]+$/, '')
707
+ const near = files.filter((f) => stemOf(f).includes(stem) || stem.includes(stemOf(f)))
708
+ const names = [...near.sort(), ...files.filter((f) => !near.includes(f)).sort()].slice(0, SCRIPT_HINT_MAX)
709
+ return `\n${skill}/scripts 下真实存在的脚本(共 ${files.length} 个,只能挑这些):${names.join(' / ')}${files.length > names.length ? ' …' : ''}`
710
+ }
711
+ const found = new Set()
712
+ for (const base of skillsCandidates(root)) {
713
+ try {
714
+ for (const d of fs.readdirSync(base)) {
715
+ if (fs.statSync(path.join(base, d)).isDirectory()) found.add(d)
716
+ }
717
+ } catch {
718
+ /* 换下一个候选根 */
719
+ }
720
+ }
721
+ if (!found.size) return ''
722
+ return `\n技能库里没有 ${skill} 这个技能;现有技能:${[...found].sort().join(' / ')}`
723
+ }
724
+
563
725
  function resolveSkillScript(root, script) {
564
726
  const rel = String(script || '').trim().replace(/\\/g, '/').replace(/^\.\//, '')
565
727
  if (!rel) throw new Error('缺少 script')
@@ -571,7 +733,13 @@ function resolveSkillScript(root, script) {
571
733
  const clean = normalizeSkillRel(rel)
572
734
  if (!clean) throw new Error('非法路径:脚本必须在仓库 skills/ 目录内')
573
735
  const abs = resolveSkillPath(root, clean)
574
- if (!abs || !fs.statSync(abs).isFile()) throw new Error(`脚本不存在:${rel}`)
736
+ if (!abs || !fs.statSync(abs).isFile()) {
737
+ throw new Error(
738
+ `脚本不存在:${rel}${skillScriptHint(root, clean)}\n` +
739
+ '**别再猜下一个名字**:清单里有近似的就直接用那个名字重发(脚本名一律以磁盘为准,禁止凭记忆拼);' +
740
+ '清单里没有的能力换个入口——卡片契约校验用工具 card_validate,其余本地命令用 shell_exec。',
741
+ )
742
+ }
575
743
  return { rel, abs, bin: path.extname(abs).toLowerCase() === '.py' ? pythonBin() : 'node' }
576
744
  }
577
745
 
@@ -594,8 +762,32 @@ function normalizeSkillArgs(args) {
594
762
  const SHELL_ALLOW_BIN = new Set([
595
763
  'npm', 'node', 'npx', 'git', 'rg', 'grep', 'cat', 'ls', 'pwd', 'cd', 'find',
596
764
  'head', 'tail', 'wc', 'sort', 'uniq', 'cut', 'tr', 'sed', 'awk', 'jq', 'diff', 'echo', 'mkdir',
765
+ // 文件搬运与跨平台小工具(2026-09-17 起放行):这些拦下来只换来「换个同类命令再撞一次」——
766
+ // 实测 cp 被拒 → 提示指去 fs_write → fs_write 只写草稿区 → 又回 cp,一轮白烧两次。
767
+ // mv / rm 仍不在白名单(那才是不可逆操作,见 SHELL_BIN_HINTS)。
768
+ 'cp', 'touch', 'dirname', 'basename', 'which', 'env', 'date', 'printf', 'xargs', 'tee',
597
769
  ])
598
770
 
771
+ /**
772
+ * 透明命令:段首是它时,真正执行的外部命令在它后面(env FOO=1 node x / xargs -I{} node y)。
773
+ * 放行它们就必须接着往下看一层,否则 `env rm -rf x`、`xargs rm` 会借这两个词绕过白名单。
774
+ */
775
+ const SHELL_TRANSPARENT_WORDS = new Set(['env', 'xargs'])
776
+
777
+ /** 透明命令后第一个「真命令词」:跳过选项与 VAR=value 赋值;找不到(如纯 env / 纯 xargs)返回 null */
778
+ function innerCommandWord(seg) {
779
+ const flat = seg.includes('$(') || seg.includes('`') ? shellFlatSegments(seg)[0] || '' : seg
780
+ const toks = tokenizeCommand(flat)
781
+ for (let i = 1; i < toks.length; i++) {
782
+ const t = toks[i]
783
+ if (!t || t.startsWith('-') || t.startsWith(SUBST_PLACEHOLDER)) continue
784
+ if (/^[A-Za-z_][A-Za-z0-9_]*=/.test(t)) continue // env 的 FOO=1
785
+ if (SHELL_SYNTAX_WORDS.has(t) || SHELL_TAIL_WORDS.has(t)) continue
786
+ return t
787
+ }
788
+ return null
789
+ }
790
+
599
791
  /**
600
792
  * 白名单外的高频误用 → 指回范式工具。
601
793
  * 报错文本是模型唯一的路标:只说「不在白名单」它下一轮会换个同类命令再撞一次
@@ -611,10 +803,7 @@ const SHELL_BIN_HINTS = {
611
803
  python3: 'Python 脚本用 skill_exec(script=skills/<skill>/scripts/x.py,args 传参数数组):白名单不含解释器',
612
804
  rm: '不要删文件:覆盖写用 fs_write,卡片改动用 card_patch / card_write,历史版本由 .history 快照保管',
613
805
  mv: '移动 = fs_copy 复制到新落点;删旧文件不支持(留给用户或 .history 快照)',
614
- cp: '复制文件用 fs_copy 工具(from / to 两个路径参数,二进制安全、自动建父目录);改写文本内容才用 fs_write(仅草稿区)',
615
806
  dir: 'Windows 下用 ls 列目录(Git Bash 自带);或直接用自己的领域知识判断文件位置',
616
- mkdir: '目录由写入工具自动创建(fs_write / fs_copy / crop_slices.mjs --out),不需要手动 mkdir',
617
- touch: '写空文件用 fs_write(content 传空串,仅草稿区);仓库内落占位文件用 fs_copy',
618
807
  curl: '助手不联网:网关调用由后端代理,不要自己发 HTTP 请求',
619
808
  wget: '助手不联网:网关调用由后端代理,不要自己发 HTTP 请求',
620
809
  open: '不要打开 GUI 程序:产物路径写进正文回复,用户自己点开',
@@ -825,6 +1014,19 @@ function shellSubCommands(command) {
825
1014
  * 循环 / 条件 / 子shell / $( ) 等 shell 语法都可用,但语法之内跑的外部命令(含管道各段、
826
1015
  * 替换体内部)逐个过白名单,ls; rm -rf x 依旧在 rm 处被拦。
827
1016
  */
1017
+ /**
1018
+ * sed 的 -i / --in-place:shell 是只读通道,这类「原地写文件」必须拦下。
1019
+ * 实测(test-1 会话):模型拿 sed -i 逐行改 IR,改出过 JSON 语法错再回修,
1020
+ * 既绕开 iterate.mjs --set 的原子写与快照,又把「白名单 = 读多写少」的约定破掉。
1021
+ * 判据用 tokenizeCommand 去引号后的参数位,sed 脚本正文里的 -i 字样不会误伤。
1022
+ */
1023
+ function sedWritesInPlace(seg) {
1024
+ const flat = seg.includes('$(') || seg.includes('`') ? shellFlatSegments(seg)[0] || '' : seg
1025
+ const toks = tokenizeCommand(flat)
1026
+ if (toks[0] !== 'sed') return false
1027
+ return toks.slice(1).some((t) => /^--in-place(=.*)?$/.test(t) || /^-[A-Za-z.]*i[A-Za-z.]*$/.test(t))
1028
+ }
1029
+
828
1030
  function assertShellAllowlist(command) {
829
1031
  const top = scanShellText(command)
830
1032
  // 顶层用扁平段(替换体折叠成占位符,别把赋值体里的词当段首命令);每个替换体再走一遍同样的闸
@@ -842,12 +1044,30 @@ function assertShellAllowlist(command) {
842
1044
  if (/^[<>]/.test(head)) {
843
1045
  throw new Error(`不支持以重定向开头的片段:${seg.slice(0, 60)}(把 > / < 放到命令后面)`)
844
1046
  }
1047
+ // 白名单里的 sed 是「读 + 过滤」用的;-i 是原地写文件,单独拦(报错里给正路)
1048
+ if (head === 'sed' && sedWritesInPlace(seg)) {
1049
+ throw new Error(
1050
+ '正路:shell 是只读通道,不要用 sed -i 原地改文件——卡片改动用 card_patch / card_write;' +
1051
+ '草稿(IR / 审计 JSON)首次写用 fs_write;**改已存在的 IR 用 ir_patch(点路径最小写,原子、不需引号转义)**,或 skill_exec iterate.mjs --set;不要整份重写。',
1052
+ )
1053
+ }
1054
+ if (SHELL_TRANSPARENT_WORDS.has(head)) {
1055
+ const inner = innerCommandWord(seg)
1056
+ if (inner && !SHELL_ALLOW_BIN.has(inner)) {
1057
+ const hint = SHELL_BIN_HINTS[inner]
1058
+ throw new Error(
1059
+ (hint ? `正路:${hint}\n` : '') +
1060
+ `${head} 后面真正执行的是 ${inner},它不在白名单(允许:${[...SHELL_ALLOW_BIN].join(' / ')})——` +
1061
+ '不要让透明命令替别的命令开路,直接调用白名单里的命令。',
1062
+ )
1063
+ }
1064
+ }
845
1065
  if (!SHELL_ALLOW_BIN.has(head)) {
846
1066
  const hint = SHELL_BIN_HINTS[head]
847
1067
  throw new Error(
848
- `命令不在白名单:${head}(允许:${[...SHELL_ALLOW_BIN].join(' / ')})。循环 / 条件 / 子shell / $( ) 等 shell 语法都支持,` +
1068
+ (hint ? `正路:${hint}\n` : '') +
1069
+ `命令不在白名单:${head}(允许:${[...SHELL_ALLOW_BIN].join(' / ')})。循环 / 条件 / 子shell / $( ) 等 shell 语法都支持,` +
849
1070
  `但其中真正执行的每个外部命令(含管道各段与替换体内部)都必须在白名单内` +
850
- (hint ? `\n正路:${hint}` : '') +
851
1071
  (group.quotedSub
852
1072
  ? '\n(它写在双引号里:双引号内的 $( ) 与反引号会被 shell 真实执行,所以同样过闸;只想写字面文本请转义或改用单引号)'
853
1073
  : '') +
@@ -1018,12 +1238,17 @@ const FS_WRITE_TOOL = {
1018
1238
  function: {
1019
1239
  name: 'fs_write',
1020
1240
  description:
1021
- '把 UTF-8 文本写入仓库草稿区 .cache/ai-scratch/(不入库)。主要用途:写「紧凑 IR」JSON(字段契约见 skill_read ir-format.md),再用 skill_exec 跑 skills/dsl-card-generator/scripts/expand_ir.mjs --ir <草稿路径> --out src/cards/<id>/ 展开成完整 schema(自带 autofix+validate+lint)。只能写草稿区;改卡片内容用 card_write / card_patch。',
1241
+ '把 UTF-8 文本写入仓库草稿区 .cache/ai-scratch/(不入库)。主要用途:写「紧凑 IR」JSON(字段契约见 skill_read ir-format.md),再用 skill_exec 跑 skills/dsl-card-generator/scripts/expand_ir.mjs --ir <草稿路径> --out src/cards/<id>/ 展开成完整 schema(自带 autofix+validate+lint)。**改已存在的 IR 一律用 ir_patch(点路径最小写),不要整份重写**——同轮内对同一文件第 2 次全量写会收到提醒,第 3 次起需显式 rewrite:"true" 才放行。只能写草稿区;改卡片内容用 card_write / card_patch。**只收数据文件**(IR / 审计 JSON / mock):不要写 .js / .cjs / .mjs / .py 脚本——取证与几何比对用现成脚本(diff_bands.mjs 逐带差异、spec_audit.mjs 标注审计、iterate.mjs 报告里的 gate/hotspots/contributors/contentOffset1x、describe_card.mjs / extract_subtree.mjs 读卡),自造脚本会被拒。',
1022
1242
  parameters: {
1023
1243
  type: 'object',
1024
1244
  properties: {
1025
1245
  name: { type: 'string', description: '草稿文件名,如 riskAssessTip.ir.json' },
1026
1246
  content: { type: 'string', description: '文件内容(文本)' },
1247
+ rewrite: {
1248
+ type: 'string',
1249
+ enum: ['true'],
1250
+ description: '确实需要整体重排结构时才传 "true"(并在正文说明为何必须重排);只改几个字段请用 ir_patch',
1251
+ },
1027
1252
  },
1028
1253
  required: ['name', 'content'],
1029
1254
  },
@@ -1032,8 +1257,13 @@ const FS_WRITE_TOOL = {
1032
1257
 
1033
1258
  const SCRATCH_RELPATH = path.join('.cache', 'ai-scratch')
1034
1259
  const FS_WRITE_MAX_BYTES = 2 * 1024 * 1024
1260
+ /** 草稿区不收可执行脚本:实测模型写 .cjs 自造像素/几何取证与 JSON 补丁脚本,绕过现成工具与校验 */
1261
+ const FS_WRITE_DENY_EXT = new Set(['.js', '.cjs', '.mjs', '.py', '.sh', '.ps1', '.bat', '.cmd'])
1262
+ /** 同轮内同一草稿文件全量写的提醒 / 拒绝阈值(第 2 次提醒、第 3 次起须显式 rewrite:"true") */
1263
+ const FS_WRITE_REWRITE_WARN = 2
1264
+ const FS_WRITE_REWRITE_MAX = 3
1035
1265
 
1036
- function fsWrite(root, { name, content = '' }) {
1266
+ function fsWrite(root, { name, content = '', rewrite = '' }, ledger) {
1037
1267
  const rel = String(name || '').trim().replace(/^[/\\]+/, '')
1038
1268
  if (!rel) throw new Error('缺少 name')
1039
1269
  const scratch = path.join(root, SCRATCH_RELPATH)
@@ -1041,14 +1271,177 @@ function fsWrite(root, { name, content = '' }) {
1041
1271
  if (abs !== scratch && !abs.startsWith(scratch + path.sep)) {
1042
1272
  throw new Error('非法文件名:只能写 .cache/ai-scratch/ 草稿区内的路径')
1043
1273
  }
1274
+ const ext = path.extname(abs).toLowerCase()
1275
+ if (FS_WRITE_DENY_EXT.has(ext)) {
1276
+ throw new Error(
1277
+ `草稿区不收可执行脚本(${ext}):取证 / 几何比对用现成脚本——diff_bands.mjs(渲染 vs 设计逐带 dy/dh、内容底边、分区主色)、spec_audit.mjs(标注审计)、iterate.mjs 报告(gate / hotspots / contributors / contentOffset1x)、describe_card.mjs / extract_subtree.mjs(读存量卡);改 schema 用 card_patch / card_write,改 IR 用 ir_patch 或 iterate.mjs --set。`,
1278
+ )
1279
+ }
1044
1280
  const text = String(content ?? '')
1045
1281
  const bytes = Buffer.byteLength(text, 'utf8')
1046
1282
  if (bytes > FS_WRITE_MAX_BYTES) throw new Error('内容超过 2MB 上限')
1283
+ const relRepo = path.relative(root, abs)
1284
+
1285
+ // 无变化的重写:与盘上逐字节相同 ⇒ 不写、如实告知(实测出现过原样重发同一份 IR 的步)。
1286
+ // expand_ir 之后模型会看到 schema,很容易把「IR 原文」再倒一遍,白付一整步的 output。
1287
+ let onDisk = null
1288
+ try {
1289
+ onDisk = fs.readFileSync(abs, 'utf8')
1290
+ } catch {}
1291
+ if (onDisk !== null && onDisk === text) {
1292
+ return `未写入 ${relRepo}:内容与盘上现有文件**逐字节相同**(无变化的重写)。要改 IR 用 ir_patch(点路径 JSON 补丁,可一次多条)或 skill_exec iterate.mjs --set '<path>=<value>',不要整份重发。`
1293
+ }
1294
+
1295
+ // 全量重写闸门(实测事故:同一份 IR 被全量重写 16 次、共 124,561 字符 ≈ 全部 completion 的 45%,
1296
+ // 每次 12–25 秒纯生成;而「唯一合法增量路径」iterate --set 在 90 次调用里用了 0 次 ——
1297
+ // 根因是 fs_write 语义最简、一定「能跑」,而服务端对这个退化零感知、零成本)。
1298
+ // 这里给退化成木加两道:第 2 次附事实、第 3 次起拒绝并给出替代路径。
1299
+ const forced = String(rewrite || '').toLowerCase() === 'true'
1300
+ let rewriteNote = ''
1301
+ if (ledger) {
1302
+ const rec = ledger.get(relRepo)
1303
+ const nth = (rec?.count || 0) + 1
1304
+ const totalBytes = (rec?.totalBytes || 0) + bytes
1305
+ ledger.set(relRepo, { count: nth, totalBytes })
1306
+ if (nth >= FS_WRITE_REWRITE_MAX && !forced) {
1307
+ return `已拒绝写入 ${relRepo}(本轮第 ${nth} 次**全量重写**同一文件,累计 ${totalBytes} 字节):整份重写是最烧钱的改法,一次要重新生成整份 JSON,且无法保证不破坏已调好的部分。改用最小写:ir_patch(点路径 JSON 补丁,一次可多条)或 skill_exec skill=skills/dsl-card-generator/scripts/iterate.mjs args=["--set","<path>=<value>","--ir",".cache/ai-scratch/${rel}"]。确实需要整体重排结构时,再调 fs_write 并带 rewrite:"true"(并在正文说明为何必须重排)。`
1308
+ }
1309
+ if (nth >= FS_WRITE_REWRITE_WARN) {
1310
+ rewriteNote = ` ⚠️ 这是本轮第 ${nth} 次全量写入同一文件(累计 ${totalBytes} 字节):只改几个字段请改用 ir_patch / iterate.mjs --set,别整份重发。`
1311
+ }
1312
+ }
1313
+
1047
1314
  fs.mkdirSync(path.dirname(abs), { recursive: true })
1048
1315
  fs.writeFileSync(abs, text, 'utf8')
1049
- const relRepo = path.relative(root, abs)
1050
1316
  const slicesNote = text.includes('/slices/') ? ';IR 引用 /slices/ 切图时加 --slices-dir <要素包>/slices,免拷贝' : ''
1051
- return `已写入 ${relRepo}(${bytes} 字节)。下一步可用 skill_exec 跑 expand_ir.mjs --ir ${relRepo} --out src/cards/<id>/` + slicesNote
1317
+ const nextStep = /\.json$/i.test(rel) ? `下一步可用 skill_exec 跑 expand_ir.mjs --ir ${relRepo} --out src/cards/<id>/` : '(非 IR:按用途继续,别把它当脚本跑)'
1318
+ return `已写入 ${relRepo}(${bytes} 字节)。${nextStep}` + slicesNote + rewriteNote
1319
+ }
1320
+
1321
+ /**
1322
+ * ir_patch:对草稿区里的紧凑 IR 做**点路径最小写**(服务端原生,不 spawn、不跑渲染)。
1323
+ *
1324
+ * 补 P1-3 的根因:模型不是不想最小写,是 `iterate --set` 是**字符串字面量语法**
1325
+ * (`--set 'children.0.s.margin.marginTop="7"'`,引号/转义易错),而 fs_write 语义最简单、一定「能跑」,
1326
+ * 于是路径依赖地退化成整份重写。给一个结构化 JSON 参数的最小写工具,这条退化路径才有替代品。
1327
+ *
1328
+ * 语义与 card_patch 同构:点路径(数字段=数组下标)+ 原子写(任一条失败则整份不写)+ 返回旧值→新值摘要。
1329
+ */
1330
+ const IR_PATCH_TOOL = {
1331
+ type: 'function',
1332
+ function: {
1333
+ name: 'ir_patch',
1334
+ description:
1335
+ '对草稿区 .cache/ai-scratch/ 里的 IR JSON 做点路径最小写(**改 IR 优先用它,不要 fs_write 整份重写**)。path 用点路径、数字段是数组下标(如 children.0.s.flex.gap、children.2.t);op 默认 set,另有 merge(对象浅合并)、push(数组追加)、delete。原子:任一条失败则整份不写,并在报错里指出是哪条、中段为何不存在。返回每条 path 的旧值→新值摘要。注意:改完仍要跑 iterate.mjs 才有门禁结论(ir_patch 不渲染、不校验)。',
1336
+ parameters: {
1337
+ type: 'object',
1338
+ properties: {
1339
+ file: { type: 'string', description: '草稿区文件名,如 settlement.ir.json' },
1340
+ ops: {
1341
+ type: 'array',
1342
+ description: '补丁列表,按序应用',
1343
+ items: {
1344
+ type: 'object',
1345
+ properties: {
1346
+ path: { type: 'string', description: '点路径,数字段是数组下标' },
1347
+ op: { type: 'string', enum: ['set', 'merge', 'push', 'delete'], description: '默认 set' },
1348
+ value: { description: 'set/merge/push 的值(任意 JSON)' },
1349
+ },
1350
+ required: ['path'],
1351
+ },
1352
+ },
1353
+ },
1354
+ required: ['file', 'ops'],
1355
+ },
1356
+ },
1357
+ }
1358
+
1359
+ /** 点路径寻址:返回 { parent, key };中段缺失时抛错并指明第几段 */
1360
+ function irResolve(root, dottedPath) {
1361
+ const segs = String(dottedPath || '')
1362
+ .split('.')
1363
+ .filter((s) => s !== '')
1364
+ if (!segs.length) throw new Error('空路径')
1365
+ let node = root
1366
+ for (let i = 0; i < segs.length - 1; i++) {
1367
+ const k = segs[i]
1368
+ const next = Array.isArray(node) ? node[Number(k)] : node[k]
1369
+ if (next === undefined || next === null || typeof next !== 'object') {
1370
+ throw new Error(`路径中段不存在或不是对象:${segs.slice(0, i + 1).join('.')}`)
1371
+ }
1372
+ node = next
1373
+ }
1374
+ return { parent: node, key: segs[segs.length - 1] }
1375
+ }
1376
+
1377
+ const irBrief = (v) => {
1378
+ const s = typeof v === 'string' ? v : JSON.stringify(v)
1379
+ return s === undefined ? 'undefined' : s.length > 60 ? `${s.slice(0, 60)}…` : s
1380
+ }
1381
+
1382
+ function irPatch(root, { file, ops }) {
1383
+ const rel = String(file || '').trim().replace(/^[/\\]+/, '')
1384
+ if (!rel) throw new Error('缺少 file')
1385
+ const scratch = path.join(root, SCRATCH_RELPATH)
1386
+ const abs = path.resolve(scratch, rel)
1387
+ if (abs !== scratch && !abs.startsWith(scratch + path.sep)) {
1388
+ throw new Error('非法文件名:只能改 .cache/ai-scratch/ 草稿区内的路径')
1389
+ }
1390
+ if (!fs.existsSync(abs)) throw new Error(`文件不存在:${path.relative(root, abs)}(先 fs_write 写出 IR)`)
1391
+ const list = Array.isArray(ops) ? ops : []
1392
+ if (!list.length) throw new Error('ops 不能为空')
1393
+
1394
+ let ir
1395
+ try {
1396
+ ir = JSON.parse(fs.readFileSync(abs, 'utf8'))
1397
+ } catch (e) {
1398
+ throw new Error(`目标不是合法 JSON,拒绝改:${e.message}`)
1399
+ }
1400
+ const applied = []
1401
+ // 先在内存副本上全部应用,任一条失败就整体放弃(不写盘 = 不留半状态)
1402
+ for (const [i, raw] of list.entries()) {
1403
+ const op = String(raw?.op || 'set').toLowerCase()
1404
+ const path = String(raw?.path || '')
1405
+ try {
1406
+ const { parent, key } = irResolve(ir, path)
1407
+ const isArr = Array.isArray(parent)
1408
+ const idx = isArr ? Number(key) : null
1409
+ const oldVal = isArr ? parent[idx] : parent[key]
1410
+ let newVal
1411
+ if (op === 'set') {
1412
+ // 末段允许新建(与 iterate --set 一致):IR 里补一个样式键是常见动作,
1413
+ // 强行要求先建父节点只会逼模型改用整份重写
1414
+ if (isArr) parent[idx] = raw.value
1415
+ else parent[key] = raw.value
1416
+ newVal = raw.value
1417
+ } else if (op === 'merge') {
1418
+ const cur = isArr ? parent[idx] : parent[key]
1419
+ if (cur === null || typeof cur !== 'object' || Array.isArray(cur)) {
1420
+ throw new Error('merge 的目标不是对象(先用 set 建对象)')
1421
+ }
1422
+ Object.assign(cur, raw.value || {})
1423
+ newVal = cur
1424
+ } else if (op === 'push') {
1425
+ const cur = isArr ? parent[idx] : parent[key]
1426
+ if (!Array.isArray(cur)) throw new Error('push 的目标不是数组')
1427
+ cur.push(raw.value)
1428
+ newVal = cur
1429
+ } else if (op === 'delete') {
1430
+ if (isArr) parent.splice(idx, 1)
1431
+ else delete parent[key]
1432
+ newVal = undefined
1433
+ } else {
1434
+ throw new Error(`未知 op:${op}(支持 set / merge / push / delete)`)
1435
+ }
1436
+ applied.push(`${path} [${op}]: ${irBrief(oldVal)} → ${irBrief(newVal)}`)
1437
+ } catch (e) {
1438
+ throw new Error(`第 ${i + 1} 条补丁失败(${path}):${e.message} —— 整份未写入,盘上文件保持原样`)
1439
+ }
1440
+ }
1441
+ const tmp = `${abs}.tmp-${process.pid}`
1442
+ fs.writeFileSync(tmp, JSON.stringify(ir, null, 2) + '\n')
1443
+ fs.renameSync(tmp, abs)
1444
+ return `已对 ${path.relative(root, abs)} 应用 ${applied.length} 条补丁(原子写入):\n${applied.join('\n')}\n下一步:skill_exec iterate.mjs(--ir ${path.relative(root, abs)} --out <落点卡> --design <设计稿>)拿门禁结论——ir_patch 不改 schema、不渲染、不校验。`
1052
1445
  }
1053
1446
 
1054
1447
  /**
@@ -1306,6 +1699,201 @@ async function runShellExec(root, args, res, approved) {
1306
1699
  )
1307
1700
  }
1308
1701
 
1702
+ /**
1703
+ * 技能脚本退出码语义(白名单,按脚本文件名维护)。
1704
+ *
1705
+ * 为什么必须标:模型把一切非 0 一律读成「工具坏了」,于是去修根本不存在的问题,甚至原样重试。
1706
+ * 而实测这条链路里大量非 0 是**脚本正常跑完、只是结论是负面的**:
1707
+ * - `diff_bands.mjs` 找到差异 = exit 1(诊断输出,不是故障);
1708
+ * - `check_fidelity.mjs` / `lint_card.mjs` 报出偏差 = exit 1;
1709
+ * - `iterate.mjs` 门禁未达标 = exit 2(报告照常回喂)。
1710
+ * 首行给机器可读(模型可正则)+ 人可读的标签,把这一类误判一次性消掉。
1711
+ * kind:ok(0)|diagnostic(正常产出、结论负面)|gate-fail(门禁未达标)|fail(真失败)
1712
+ */
1713
+ const SKILL_EXIT_SEMANTICS = {
1714
+ 'iterate.mjs': {
1715
+ 1: { kind: 'fail', note: '脚本报错收尾' },
1716
+ 2: { kind: 'gate-fail', note: '脚本正常跑完,门禁未达标——照 gate.failed 逐项改,不要原样重试' },
1717
+ },
1718
+ 'expand_ir.mjs': {
1719
+ 1: { kind: 'fail', note: 'validate 未通过,报错文本写了具体项' },
1720
+ 3: { kind: 'fail', note: 'IR 含非法字段(报错已给出合法值全集),改键名即可,不要重写整份 IR' },
1721
+ },
1722
+ 'diff_bands.mjs': {
1723
+ 1: { kind: 'diagnostic', note: '正常结果:差异非空,这是诊断输出,不是工具失败' },
1724
+ 2: { kind: 'fail', note: '脚本自身出错' },
1725
+ },
1726
+ 'check_fidelity.mjs': { 1: { kind: 'diagnostic', note: '正常结果:报出了偏差项,按报错逐项修' } },
1727
+ 'lint_card.mjs': { 1: { kind: 'diagnostic', note: '正常结果:有 lint 问题,按报错逐项修' } },
1728
+ 'ir_restore.mjs': {
1729
+ 1: { kind: 'fail', note: '参数或文件不对(多为快照/工作 IR 路径写错,先用 --list 看可用轮次)' },
1730
+ 3: { kind: 'fail', note: '快照不是合法 JSON,脚本拒绝写回工作 IR(换一份快照,别改这个脚本)' },
1731
+ },
1732
+ }
1733
+
1734
+ /** 退出码语义查询:0 返回 null(无需标注),未登记的非 0 按 fail 处理 */
1735
+ function skillExitInfo(scriptRel, code) {
1736
+ if (Number(code) === 0) return null
1737
+ const base = String(scriptRel || '').split('/').pop()
1738
+ return SKILL_EXIT_SEMANTICS[base]?.[code] || { kind: 'fail', note: '' }
1739
+ }
1740
+
1741
+ /** 给 `exit=N` 首行补语义标签;超时被杀单独标 timeout(那不是脚本的错) */
1742
+ function annotateSkillExit(text, scriptRel) {
1743
+ const s = String(text ?? '')
1744
+ const m = s.match(/^exit=(\d+)((超时已杀))?/)
1745
+ if (!m) return s
1746
+ if (m[2]) return s.replace(/^exit=\d+(超时已杀)/, `exit=${m[1]} [timeout:脚本超时被杀,调大 timeoutMs 或缩小任务范围,别原样重试]`)
1747
+ const info = skillExitInfo(scriptRel, Number(m[1]))
1748
+ if (!info) return s
1749
+ return s.replace(/^exit=\d+/, `exit=${m[1]} [${info.kind}${info.note ? `:${info.note}` : ''}]`)
1750
+ }
1751
+
1752
+ /** 从 skill_exec 的 stdout 里取出末尾那份 JSON 报告(前面可能混有 `[ir] 样式组归位` 这类日志行) */
1753
+ function parseSkillJsonReport(text) {
1754
+ const s = String(text || '')
1755
+ const i = s.lastIndexOf('\n{')
1756
+ for (const c of [i > 0 ? s.slice(i + 1) : '', s]) {
1757
+ const t = c.trim()
1758
+ if (!t.startsWith('{')) continue
1759
+ try {
1760
+ return JSON.parse(t)
1761
+ } catch {}
1762
+ }
1763
+ const a = s.indexOf('{')
1764
+ const b = s.lastIndexOf('}')
1765
+ if (a >= 0 && b > a) {
1766
+ try {
1767
+ return JSON.parse(s.slice(a, b + 1))
1768
+ } catch {}
1769
+ }
1770
+ return null
1771
+ }
1772
+
1773
+ /**
1774
+ * iterate 每轮的进度播报(推给**用户**,不是给模型)。
1775
+ *
1776
+ * 存在理由(2026-09-21 实测):那一轮建卡跑了 29 分 45 秒、85 步,用户全程只能看到一个转圈,
1777
+ * 最后是自己手动中止的。模型内部靠报告知道该停手,但用户侧是黑箱 —— 同一条事实必须同步给人看:
1778
+ * 「第几轮 / 现在多少分 / 历史最好多少 / 是否已经连续几轮没进展 / 门禁过没过」。
1779
+ * 复用既有 notice 通道,不加新协议;解析失败就静默跳过(播报是附加价值,不能影响主流程)。
1780
+ */
1781
+ function notifyIterateProgress(res, scriptRel, text) {
1782
+ if (!/iterate\.mjs$/.test(String(scriptRel || ''))) return
1783
+ const rep = parseSkillJsonReport(text)
1784
+ if (!rep || rep.ok !== true) return
1785
+ const match = rep.pixelDiff?.match
1786
+ if (match == null) return
1787
+ const round = rep.rounds ?? null
1788
+ const best = rep.best || null
1789
+ const bestNote = best?.match != null ? `,历史最好 ${best.match}% @ r${best.round}` : ''
1790
+ const heightDelta = rep.pixelDiff?.size?.heightDelta
1791
+ const hd = heightDelta != null && heightDelta !== 0 ? `,高度差 ${heightDelta}` : ''
1792
+ if (rep.stalled) {
1793
+ const s = rep.stalled
1794
+ sseNotice(
1795
+ res,
1796
+ `第 ${round ?? '?'} 轮:match ${match}%${bestNote}${hd} —— 已连续 ${s.rounds} 轮没刷新最好成绩${s.regressed ? '(本轮还在往下走)' : ''},已要求模型回退到最好版本并只改一个分项`,
1797
+ 'warn',
1798
+ 'iterate-stall',
1799
+ )
1800
+ return
1801
+ }
1802
+ sseNotice(
1803
+ res,
1804
+ `第 ${round ?? '?'} 轮:match ${match}%${bestNote}${hd}${rep.gate?.passed ? ' —— 门禁通过' : ''}`,
1805
+ 'info',
1806
+ 'iterate-progress',
1807
+ )
1808
+ }
1809
+
1810
+ /** 同一技能调用(脚本 + 归一化参数)的键:连续失败退避的判据 */
1811
+ function skillCallKey(args, root) {
1812
+ return `${String(args?.script || '').split('/').pop()}${stableStringify(args?.args ?? [])}${skillInputFingerprint(args, root)}`
1813
+ }
1814
+
1815
+ /**
1816
+ * 「这次调用的可变输入」指纹:只取 `--ir`(模型在两次调用之间真正会改的那个文件)。
1817
+ *
1818
+ * 为什么必须带上它:iterate.mjs 被门禁判失败后,模型的标准动作就是 ir_patch 改 IR、再**原样**
1819
+ * 重跑同一条命令 —— 参数一字未变、输入变了,结果当然可能不同。只按「脚本 + 参数」记账会把它
1820
+ * 误判成「原样重试」并短路,实测(2026-09-21 demo 建卡回合)连着短路 2 次,每次白付一整步
1821
+ * 全量 prompt(≈11 万 token),模型还得自己绕路(改用绝对路径)才跑得下去。
1822
+ *
1823
+ * 为什么不取 `--out`:iterate 每轮都会写 <out>/schema.json,把它算进指纹等于让记账永远清零,
1824
+ * 「报错一字未变就别再撞」这条保护也就没了。
1825
+ */
1826
+ function skillInputFingerprint(args, root) {
1827
+ const argv = Array.isArray(args?.args) ? args.args : []
1828
+ const parts = []
1829
+ for (let i = 0; i < argv.length - 1; i++) {
1830
+ if (String(argv[i]) !== '--ir') continue
1831
+ const raw = String(argv[i + 1] || '')
1832
+ const target = raw ? (path.isAbsolute(raw) ? raw : path.resolve(root, raw)) : ''
1833
+ try {
1834
+ const st = fs.statSync(target)
1835
+ parts.push(`#ir:${st.size}:${Math.round(st.mtimeMs)}`)
1836
+ } catch {
1837
+ parts.push('#ir:missing')
1838
+ }
1839
+ }
1840
+ return parts.join('')
1841
+ }
1842
+
1843
+ /** 连续失败多少次即不再执行:报错没变时第三次必然还是同样的错,白烧一整步 */
1844
+ const SKILL_FAIL_SHORT_CIRCUIT = 2
1845
+
1846
+ /** 这次算不算「连续失败」:诊断型退出码(差异/偏差非空)不算 */
1847
+ function skillExecFailure(scriptRel, text) {
1848
+ const m = String(text ?? '').match(/^exit=(\d+)/)
1849
+ if (!m) return null
1850
+ const info = skillExitInfo(scriptRel, Number(m[1]))
1851
+ if (!info || info.kind === 'diagnostic') return null
1852
+ return { code: Number(m[1]), kind: info.kind, note: info.note }
1853
+ }
1854
+
1855
+ /** 记账:连续失败累加、成功即清零 */
1856
+ function noteSkillExecOutcome(key, scriptRel, text, execFails) {
1857
+ const f = skillExecFailure(scriptRel, text)
1858
+ if (!f) {
1859
+ execFails.delete(key)
1860
+ return
1861
+ }
1862
+ const prev = execFails.get(key)
1863
+ execFails.set(key, { count: (prev?.count || 0) + 1, code: f.code, kind: f.kind, note: f.note })
1864
+ }
1865
+
1866
+ /** 连续失败达阈值的短路结果:给替代路径,而不是让模型再撞一次同样的墙 */
1867
+ function skillShortCircuit(args, fail) {
1868
+ const script = String(args?.script || '').split('/').pop()
1869
+ const argv = Array.isArray(args?.args) ? JSON.stringify(args.args) : '[]'
1870
+ return [
1871
+ `[系统] 该调用已连续失败 ${fail.count} 次,本次不再执行:${script} ${argv}`,
1872
+ `上一次失败:exit=${fail.code}${fail.note ? `(${fail.note})` : ''}`,
1873
+ '原样重试不会有不同结果。可选:① 照报错文本改(如 [ir] 非法样式键清单里逐条改键名 / gate.failed 里的分项);② 换证据链(diff_bands.mjs 拿逐带 dy/dh、references/generation_pitfalls.md 查静默丢弃);③ 收尾并如实汇报当前 gate 状态。',
1874
+ ].join('\n')
1875
+ }
1876
+
1877
+ /**
1878
+ * 这条工具调用落在哪张卡上:card_* 取 id;skill_exec 从 `--out src/cards/<id>/` 里认。
1879
+ *
1880
+ * 只服务于下面那条「写到了别的卡」提醒——目的不是拦(「新建一张」是合法需求),
1881
+ * 而是让用户当场看见:实测画板点选话术以「建卡:」开头,模型读成「用户明说新建」,
1882
+ * 自拟 id 建了 memberLevelNoLevel,而界面上只表现为凭空多出一张卡。
1883
+ */
1884
+ function cardIdFromToolArgs(toolName, args) {
1885
+ if (!args || typeof args !== 'object') return ''
1886
+ const id = typeof args.id === 'string' ? args.id.trim() : ''
1887
+ if (id && /^card_(write|patch|validate|screenshot|rename)$/.test(String(toolName))) return id
1888
+ const argv = Array.isArray(args.args) ? args.args : []
1889
+ for (let i = 0; i < argv.length - 1; i++) {
1890
+ if (String(argv[i]) !== '--out') continue
1891
+ const m = /(?:^|[\\/])src[\\/]cards[\\/]([^\\/\s]+)/.exec(String(argv[i + 1] || ''))
1892
+ if (m) return m[1]
1893
+ }
1894
+ return ''
1895
+ }
1896
+
1309
1897
  /** 技能脚本执行:路径形状校验 → 解释器解析 → 审批(或按策略免确认)→ 跑(cwd=工程根;
1310
1898
  * 脚本认工程根走 DSL_CARD_PROJECT_ROOT > cwd,**不按自身位置推算** —— 发布态脚本住在包内快照里,
1311
1899
  * 按自身位置推会把 UI/ 产物写进 node_modules,见 tools/tests/cardbuddy-project-anchor.test.mjs) */
@@ -1318,7 +1906,7 @@ async function runSkillExec(root, args, res, approved) {
1318
1906
  )
1319
1907
  const label = `${script.bin} ${script.rel}${argv.length ? ` ${argv.join(' ')}` : ''}`
1320
1908
  const cfg = readAiConfig(root)
1321
- return runGuarded(
1909
+ const out = await runGuarded(
1322
1910
  root,
1323
1911
  res,
1324
1912
  {
@@ -1333,6 +1921,7 @@ async function runSkillExec(root, args, res, approved) {
1333
1921
  PYTHONUNBUFFERED: '1',
1334
1922
  }),
1335
1923
  )
1924
+ return annotateSkillExit(out, script.rel)
1336
1925
  }
1337
1926
 
1338
1927
  // ---- 对话内审批:未命中免确认策略的高危执行(默认是 shell_exec)每次调用都要用户点确认 ----
@@ -1379,13 +1968,152 @@ function skillRead(root, args) {
1379
1968
  // 越界(绝对路径 / 任何 `..` 片段)先判非法,再谈存在性 —— 与 skill_exec 同一套闸。
1380
1969
  if (!normalizeSkillRel(rel)) throw new Error('非法路径:只能读 skills/ 目录内的文档')
1381
1970
  // 项目内 skills/ 优先,缺失时回退随包发布的技能快照(cardbuddy 安装态未跑 init 也能读文档)。
1382
- const full = resolveSkillPath(root, rel)
1383
- if (!full) throw new Error(`文档不存在:skills/${rel}`)
1971
+ let full = resolveSkillPath(root, rel)
1972
+ let resolvedNote = ''
1973
+ // 续读锚点里要回显的 path:短路径自动补全时用补全后的真实路径,模型能原样再喂一次
1974
+ let hintRel = rel
1975
+ if (full && !fs.statSync(full).isFile()) {
1976
+ // 传进来的是目录:直接把目录里的文档列出来,别只回一句「只能读文档」把模型赶去猜文件名
1977
+ // 递归列一层以上:文档常躺在子目录里(references/recipe_snippets/x.json),
1978
+ // 只列顶层文件等于让模型再猜一次子目录名
1979
+ const names = []
1980
+ const collectDocs = (abs, prefix, depth) => {
1981
+ if (depth > 3 || names.length >= 40) return
1982
+ let entries = []
1983
+ try {
1984
+ entries = fs.readdirSync(abs, { withFileTypes: true })
1985
+ } catch {
1986
+ return
1987
+ }
1988
+ for (const e of entries) {
1989
+ if (names.length >= 40) return
1990
+ if (e.name.startsWith('.') || e.name === 'node_modules') continue
1991
+ const rel = prefix ? `${prefix}/${e.name}` : e.name
1992
+ if (e.isDirectory()) collectDocs(path.join(abs, e.name), rel, depth + 1)
1993
+ else if (SKILL_DOC_EXT.has(path.extname(e.name).toLowerCase())) names.push(rel)
1994
+ }
1995
+ }
1996
+ collectDocs(full, '', 0)
1997
+ throw new Error(
1998
+ `skills/${rel} 是目录,不是文档。里面的文档(用 skill_read path=skills/${rel}/<文件名>):${names.join(' / ') || '(无文档类文件)'}`,
1999
+ )
2000
+ }
2001
+ if (!full) {
2002
+ const { hits, truncated } = findSkillDocCandidates(root, rel)
2003
+ if (hits.length === 1 && !truncated) {
2004
+ const resolved = resolveSkillPath(root, hits[0])
2005
+ if (resolved) {
2006
+ full = resolved
2007
+ resolvedNote = `> (已按 skills/${hits[0]} 解析:skill_read 的 path 一律相对 skills/ 根写,短路径会自动补全)\n\n`
2008
+ hintRel = hits[0]
2009
+ }
2010
+ }
2011
+ if (!full) {
2012
+ throw new Error(
2013
+ `文档不存在:skills/${rel}` +
2014
+ (hits.length
2015
+ ? `\n最接近的候选:${hits.map((h) => `skills/${h}`).join(' / ')}(原样用其中一个重试,不要再猜别的写法)`
2016
+ : '\n(用 fs_list skills 看目录结构;path 一律相对 skills/ 根,如 dsl-card-generator/references/rules.md)'),
2017
+ )
2018
+ }
2019
+ }
1384
2020
  if (!SKILL_DOC_EXT.has(path.extname(full).toLowerCase())) throw new Error('只能读文档(.md / .json / .txt / .csv);跑脚本用 skill_exec')
1385
- if (!fs.statSync(full).isFile()) throw new Error(`文档不存在:skills/${rel}`)
1386
2021
  const text = fs.readFileSync(full, 'utf8')
1387
- if (text.length <= SKILL_READ_MAX) return text
1388
- return `${text.slice(0, SKILL_READ_MAX)}\n…(已截断,省略 ${text.length - SKILL_READ_MAX} 字符)`
2022
+ // 补全提示也算进同一预算:否则「自动补全说明 + 正文 + 续读锚点」会比口径多出说明那几十字,
2023
+ // 外层再切一刀正好把续读锚点削掉,模型又回到「不知道还有后半段」的状态。
2024
+ return resolvedNote + clipSkillDoc(text, args?.offset, hintRel, SKILL_READ_MAX - resolvedNote.length)
2025
+ }
2026
+
2027
+ /**
2028
+ * 技能文档超长时的返回:一屏正文 + 尾巴上的续读锚点(读了哪段 / 下一段 offset / 下文小节)。
2029
+ *
2030
+ * 为什么不是「简单截断 + 省略 N 字符」:截断提示必须在**预算之内**。旧实现(skill_read 自截
2031
+ * 30000)与统一口径(12000)不一致,外层再切一刀连提示一起削掉,模型读到的就是 12000 字正文、
2032
+ * 且完全不知道后面还有 5000 字契约(实测 ir-format.md 17285 字符每次静默丢 31%,模型拿着
2033
+ * 前半段字段契约猜后半段)。现在超长文档由本函数自己收口,尾巴给出 `offset`,
2034
+ * 模型想读后半段只需带上同一个 offset 再调一次。
2035
+ */
2036
+ function clipSkillDoc(text, offset = 0, rel = '', budget = SKILL_READ_MAX) {
2037
+ const total = text.length
2038
+ if (total <= budget) return text
2039
+ let start = Math.floor(Number(offset) || 0)
2040
+ if (!(start > 0)) start = 0
2041
+ // 越界 offset(文档改短了 / 抄错数字):退到最后半屏,给最接近的内容比回一句「起点超范围」有用
2042
+ if (start >= total) start = Math.max(0, total - Math.max(1, Math.floor(budget / 2)))
2043
+ const head = start > 0 ? `…(续读:自第 ${start} 字符起)\n` : ''
2044
+ const trailer = (end) => {
2045
+ const heads = []
2046
+ for (const m of text.matchAll(/^#{1,4}[ \t]+(.+?)[ \t]*$/gm)) {
2047
+ if (m.index < end) continue
2048
+ heads.push(`${m[1].slice(0, 18)}@${m.index}`)
2049
+ if (heads.length >= 6) break
2050
+ }
2051
+ return (
2052
+ `\n…(已截断:全文 ${total} 字符,本次 ${start}..${end},余 ${total - end} 字符。` +
2053
+ `续读:skill_read path=${rel || '<同一个 path>'} offset=${end}` +
2054
+ (heads.length ? `;下文小节:${heads.join(' · ')}` : ';余下部分无小节标题') +
2055
+ ')'
2056
+ )
2057
+ }
2058
+ let end = Math.min(total, start + Math.max(1, budget - head.length - SKILL_READ_TRAILER_RESERVE))
2059
+ // 一屏就够读到尾:不加锚点(否则模型会以为还有下文,白跑一次)
2060
+ if (end >= total) return head + text.slice(start)
2061
+ let body = text.slice(start, end)
2062
+ let tail = trailer(end)
2063
+ // 锚点实际长度未必等于预留值(小节多少 / 数字位数都在变):差多少就再让多少,几次内收敛
2064
+ for (let i = 0; i < 3; i++) {
2065
+ const over = head.length + body.length + tail.length - budget
2066
+ if (over <= 0) break
2067
+ body = body.slice(0, Math.max(0, body.length - over))
2068
+ end = start + body.length
2069
+ tail = trailer(end)
2070
+ }
2071
+ // 硬保证:返回串一律 ≤ budget(超了就等于把锚点交给外层切掉,回到静默丢字的老毛病)
2072
+ const spare = budget - head.length - tail.length
2073
+ if (spare <= 0) return (head + tail).slice(0, budget)
2074
+ if (body.length > spare) body = body.slice(0, spare)
2075
+ return head + body + tail
2076
+ }
2077
+
2078
+ /**
2079
+ * 在 skills/ 下按「相对路径后缀命中 → 同名文件」找文档候选。
2080
+ * 为什么需要:模型常按 skill 内的相对路径写 path(recipe_snippets/x.json、references/rules.md),
2081
+ * 而 skill_read 是按 skills/ 根解析的(正解要带 <skill>/ 前缀)。实测为同一个路径连撞两次
2082
+ * 「文档不存在」(test-1 会话 seq728 / seq743),两轮全白烧——后缀唯一命中就直接读并按真实路径标注。
2083
+ */
2084
+ function findSkillDocCandidates(root, wanted, limit = 6) {
2085
+ const tail = `/${wanted}`
2086
+ const base = path.basename(wanted)
2087
+ const suffixHits = []
2088
+ const nameHits = []
2089
+ const seen = new Set()
2090
+ const walk = (abs, relPrefix, depth) => {
2091
+ if (depth > 5) return
2092
+ let entries = []
2093
+ try {
2094
+ entries = fs.readdirSync(abs, { withFileTypes: true })
2095
+ } catch {
2096
+ return
2097
+ }
2098
+ for (const e of entries) {
2099
+ if (e.name.startsWith('.') || e.name === 'node_modules') continue
2100
+ const full = path.join(abs, e.name)
2101
+ const rel = relPrefix ? `${relPrefix}/${e.name}` : e.name
2102
+ if (e.isDirectory()) {
2103
+ walk(full, rel, depth + 1)
2104
+ continue
2105
+ }
2106
+ if (!SKILL_DOC_EXT.has(path.extname(e.name).toLowerCase())) continue
2107
+ if (seen.has(rel)) continue
2108
+ if (!rel.endsWith(tail) && e.name !== base) continue
2109
+ seen.add(rel)
2110
+ if (rel.endsWith(tail)) suffixHits.push(rel)
2111
+ else nameHits.push(rel)
2112
+ }
2113
+ }
2114
+ for (const dir of skillsCandidates(root)) walk(dir, '', 0)
2115
+ const pool = suffixHits.length ? suffixHits : nameHits
2116
+ return { hits: pool.slice(0, limit), truncated: pool.length > limit }
1389
2117
  }
1390
2118
 
1391
2119
  // ---- 配置读取 / 写入 ----
@@ -1426,6 +2154,13 @@ function parseEnvFile(file) {
1426
2154
  const DEFAULT_TURN_TIMEOUT_MS = 60 * 60 * 1000
1427
2155
  /** 连续多少轮完全相同的工具调用即判定死循环(第 2 轮先警告,第 3 轮熔断) */
1428
2156
  const LOOP_BREAK_REPEATS = 3
2157
+ /**
2158
+ * 周期性打转的滑窗判据:最近多少轮里同一组调用出现多少次即熔断。
2159
+ * 只比对**相邻轮**抓不到 `fs_write(A) → expand_ir(B) → fs_write(A′) → iterate(B)` 这种交替循环
2160
+ * (每轮 A 的内容都在变 ⇒ 指纹刷新 ⇒ 计数回落),实测该形态跑满 85 步零熔断。
2161
+ */
2162
+ const LOOP_WINDOW_ROUNDS = 8
2163
+ const LOOP_REPEAT_IN_WINDOW = 3
1429
2164
  /**
1430
2165
  * 保活三阈值:长程任务(设计稿建卡一轮几十次调用、几十分钟)只应被「用户主动中止」或
1431
2166
  * 「模型服务明确报错」结束,网关瞬时抖动与模型长时间思考都不该整轮报废。
@@ -1436,7 +2171,11 @@ const LOOP_BREAK_REPEATS = 3
1436
2171
  */
1437
2172
  const DEFAULT_STALL_TIMEOUT_MS = 120 * 1000
1438
2173
  const DEFAULT_STALL_RETRIES = 3
1439
- const DEFAULT_REASONING_TIMEOUT_MS = 600 * 1000
2174
+ // 纯思考预算:默认 3.5 分钟(原 10 分钟)。实测(2026-09-21 权益活动建卡)三个回合都是
2175
+ // 一路思考到 10 分钟整点被熔断、各丢弃 4 万余字符思考后重来——那段思考 100% 打水漂。
2176
+ // 思考几分钟是正常的,但「只思考不落盘」超过三分半基本等于卡在细节里(像素对齐之类),
2177
+ // 早一点催比让它想完更省时间、也更省 token。
2178
+ const DEFAULT_REASONING_TIMEOUT_MS = 210 * 1000
1440
2179
  /** 纯思考催促上限:超过后停用思考熔断(继续跑,交给墙钟 / 死循环熔断兜底) */
1441
2180
  const REASONING_NUDGE_MAX = 3
1442
2181
  /**
@@ -1455,8 +2194,46 @@ function fmtDuration(ms) {
1455
2194
  const n = Number(ms) > 0 ? Number(ms) : 0
1456
2195
  return n >= 60000 ? `${Math.round(n / 60000)} 分钟` : `${Math.max(1, Math.round(n / 1000))} 秒`
1457
2196
  }
1458
- /** 每多少步注入一次「该收敛了」的软提醒(0 = 关闭) */
1459
- const SOFT_NUDGE_EVERY = 24
2197
+ /**
2198
+ * 软提醒的两条触发线(只提醒、不打断):
2199
+ * ① 重复线(主):同一个「工具 + 参数」在最近 LOOP_WINDOW_ROUNDS 轮里出现 SOFT_NUDGE_REPEAT_ROUNDS 轮
2200
+ * ⇒ 立刻提醒。旧版只有下面的固定步数线,实测 24 步才响 —— 提醒落地时大部分 input 已经烧掉,
2201
+ * 模型还在原地重复(同一 card_screenshot 连打 3 次,直到第 24 步才被点名)。
2202
+ * ② 步数线(兜底):不重复、也不收敛的长尾,每 SOFT_NUDGE_EVERY 步催一次(0 = 关闭这条线)。
2203
+ * 任一命中都只注入一条 user 提醒,绝不打断流程(步数多不等于空转,硬顶早被证明是纯负担)。
2204
+ */
2205
+ const SOFT_NUDGE_EVERY = 12
2206
+ /** 重复线阈值:同一调用在最近 LOOP_WINDOW_ROUNDS 轮里出现这么多轮即提醒(2 = 第二次看到就提醒) */
2207
+ const SOFT_NUDGE_REPEAT_ROUNDS = 2
2208
+ /** 单轮软提醒次数上限:重复线可能反复命中,设个顶防止「提醒本身」变成刷屏噪音 */
2209
+ const SOFT_NUDGE_MAX = 6
2210
+ /** shell_exec 里「只是读文件 / 列目录 / 搜索」攒到几次就单独提醒一次(这些 fs_read / fs_list / card_grep 更直接) */
2211
+ const SHELL_READ_LIKE_NUDGE = 3
2212
+ /**
2213
+ * 单轮「正文」长度上限(字符):超过且这一轮还要继续调工具 ⇒ 那是过程性分析,不是给用户的结论。
2214
+ * 实测(test-1 会话 144 轮)多轮单轮正文 1.5-2 万字,全是像素比对 / 逐行对齐推理,被原样存进对话记录,
2215
+ * 用户翻记录只看到一堵墙。
2216
+ *
2217
+ * 2026-09-21 补:只提醒不截断的行不通 —— 实测建卡回合里单轮正文 4,403 字,提醒之后照旧写,
2218
+ * 而这 4,403 字会随对话记录每步重发(进历史 = 每步都付一次 input),是典型的「既不省 token
2219
+ * 又没给用户结论」。现在**硬截断**:前 MAX 字保留(用户已看到的不回收),中间省略,
2220
+ * **尾部 TAIL 字补回来** —— 结论通常写在最后,只留头部会把唯一有用的那句砍掉。
2221
+ */
2222
+ const BODY_NARRATION_MAX_CHARS = 1500
2223
+ /** 正文超限时保留的尾部长(结论通常在这里) */
2224
+ const BODY_NARRATION_TAIL_CHARS = 300
2225
+ /**
2226
+ * 正文暴走硬闸:超限之后还在继续吐这么多字就直接掐断本步(而不是等它写完再丢)。
2227
+ *
2228
+ * 为什么要有它:截断发生在**生成之后**,而 token 是按生成量计费的。
2229
+ * 实测(2026-09-21 demo 建卡回合第 5 步)模型一口气写了 24754 字过程性分析,
2230
+ * 其中 23254 字被截断丢弃 —— 白付约 1.5 万 output token,白等 2 分 38 秒流式输出,
2231
+ * 用户界面上还表现为「卡住不动」。到点掐断 + 系统提醒纠偏,代价远小于陪它写完。
2232
+ * 只在「本回合已经跑过工具」时才启用(step > 0):纯问答回合的长回答是正常产物。
2233
+ */
2234
+ const BODY_BURST_ABORT_CHARS = 8000
2235
+ /** 单回合暴走掐断次数上限:掐断本身也要能收敛,别变成新的打转来源 */
2236
+ const BODY_BURST_MAX = 2
1460
2237
 
1461
2238
  /**
1462
2239
  * 免确认执行策略(AI_AUTO_APPROVE):
@@ -1851,6 +2628,7 @@ function listCcSwitchProviders() {
1851
2628
  function readBody(req, limitBytes = 8 * 1024 * 1024) {
1852
2629
  return new Promise((resolve, reject) => {
1853
2630
  let data = ''
2631
+ req.setEncoding('utf8')
1854
2632
  req.on('data', (chunk) => {
1855
2633
  data += chunk
1856
2634
  if (data.length > limitBytes) {
@@ -2057,16 +2835,19 @@ function describePath(target) {
2057
2835
  * Desktop / Downloads / Documents / 项目根」,命中且大小一致即采用(绝大多数拖拽走这条,零扫描);
2058
2836
  * 2) 兜底 BFS:按「文件名 + 大小」逐个根目录浅层优先搜索,命中即返回(根列表本身按可能性排序);
2059
2837
  * 异步分片扫描(每 400 条让出一次事件循环),未命中时也不会把本机服务卡住。
2060
- * 搜索根先剪掉「被其它根包含」的子根:否则同一文件会在父 / 子根各命中一次,被误判成重名歧义而返回空。
2838
+ * 扫描根**不折叠**成最外层:老实现把 Desktop / Downloads / Documents(都挂在 $HOME 下)剪掉只留 $HOME,
2839
+ * 于是唯一那 20000 条预算全耗在 Home 第一层,`~/Downloads/资料/已确认/消息助手0618` 这种三层深的拖入
2840
+ * 必然超预算 → 秒回 404 → 前端弹「未能定位本机路径」。现在每个根各自一份预算、按可能性排序,
2841
+ * 扫父根时跳过「另有根负责」的子树(既不重复扫,也不让 $HOME 吃掉别人)。
2061
2842
  * 同一个根里出现多个同名同尺寸文件才算歧义,此时不猜。
2062
2843
  */
2063
- async function locateDropped({ name, relPath = '', size = -1, root = '' }) {
2844
+ async function locateDropped({ name, relPath = '', size = -1, root = '', child = '', homeDir = '', limits = {} }) {
2064
2845
  const fileName = String(name || '').trim()
2065
2846
  if (!fileName) return null
2066
2847
  const wantSize = Number(size) >= 0 ? Number(size) : -1
2067
2848
  const tail = String(relPath || '').split('/').filter(Boolean)
2068
2849
  const SKIP = new Set(['node_modules', '.git', '.cache', 'dist', '.codex-tmp', '.local', '.history', 'UI', 'demo', 'release'])
2069
- const home = os.homedir()
2850
+ const home = homeDir ? path.resolve(homeDir) : os.homedir()
2070
2851
 
2071
2852
  const sizeMatches = (p) => {
2072
2853
  if (wantSize < 0) return true
@@ -2085,30 +2866,29 @@ async function locateDropped({ name, relPath = '', size = -1, root = '' }) {
2085
2866
  }
2086
2867
  }
2087
2868
 
2088
- const roots = []
2089
- const pushRoot = (r) => {
2090
- if (!r) return
2091
- const abs = path.resolve(r)
2092
- if (!roots.includes(abs)) roots.push(abs)
2869
+ const roots = collectLocateRoots({ home, root, recent: recentRoots })
2870
+
2871
+ // 顺序仍是可能性排序;同一目录(经符号链接指到同一处)只扫一次。
2872
+ // 扫描用调用方给的路径形态(返回给前端的路径就跟它一致,别把 /var 换成 /private/var),
2873
+ // 覆盖判定则两种形态都收:不然父根与子根的写法不一致时,同一棵子树会被搜两遍。
2874
+ const scanRoots = []
2875
+ const seenReal = new Set()
2876
+ /** 已被清单里某个根认领的目录:扫父根时跳过,别把同一棵子树搜两遍、也别让 $HOME 吃掉别人 */
2877
+ const owned = new Set()
2878
+ for (const r of roots) {
2879
+ const real = realOf(r)
2880
+ if (seenReal.has(real)) continue
2881
+ seenReal.add(real)
2882
+ scanRoots.push(r)
2883
+ owned.add(r)
2884
+ owned.add(real)
2885
+ }
2886
+
2887
+ /** 命中收口:把父目录记进 recentRoots——同一批文件夹里的下一个拖拽就能走直拼(零扫描) */
2888
+ const hit = (p) => {
2889
+ rememberRoot(path.dirname(p))
2890
+ return p
2093
2891
  }
2094
- for (const r of recentRoots) {
2095
- pushRoot(r)
2096
- pushRoot(path.dirname(r))
2097
- }
2098
- pushRoot(path.join(home, 'Desktop'))
2099
- pushRoot(path.join(home, 'Downloads'))
2100
- pushRoot(path.join(home, 'Documents'))
2101
- if (root) pushRoot(root)
2102
- pushRoot(REPO_ROOT)
2103
- pushRoot(path.dirname(REPO_ROOT))
2104
- pushRoot(home)
2105
-
2106
- // 只保留「最外层」的根用于扫描(recentRoots 常同时含某目录与其父目录)
2107
- const reals = roots.map(realOf)
2108
- const scanRoots = roots.filter((r, i) => {
2109
- const real = reals[i]
2110
- return !reals.some((other, j) => j !== i && other !== real && real.startsWith(other + path.sep))
2111
- })
2112
2892
 
2113
2893
  // 1) 直拼相对路径
2114
2894
  if (tail.length) {
@@ -2116,19 +2896,20 @@ async function locateDropped({ name, relPath = '', size = -1, root = '' }) {
2116
2896
  const cand = path.join(root, ...tail)
2117
2897
  try {
2118
2898
  const st = fs.statSync(cand)
2119
- if (st.isDirectory() || sizeMatches(cand)) return cand
2899
+ if (st.isDirectory() || sizeMatches(cand)) return hit(cand)
2120
2900
  } catch {
2121
2901
  /* 该根下没有这个相对路径,换下一个 */
2122
2902
  }
2123
2903
  }
2124
2904
  }
2125
2905
 
2126
- // 2) 逐根浅层优先 BFS(同一层全部展开后再下钻,拖入的目录通常在浅层)
2127
- const MAX_VISIT = 20000
2906
+ // 2) 逐根浅层优先 BFS(同一层全部展开后再下钻,拖入的目录通常在浅层);预算按根分摊
2907
+ const MAX_VISIT_PER_ROOT = Number(limits.perRoot) > 0 ? Number(limits.perRoot) : 20000
2908
+ const MAX_VISIT_TOTAL = Number(limits.total) > 0 ? Number(limits.total) : 120000
2128
2909
  const MAX_DEPTH = 4
2129
- let visited = 0
2910
+ let visitedTotal = 0
2130
2911
  for (const scanRoot of scanRoots) {
2131
- if (visited >= MAX_VISIT) break
2912
+ if (visitedTotal >= MAX_VISIT_TOTAL) break
2132
2913
  try {
2133
2914
  if (!fs.statSync(scanRoot).isDirectory()) continue
2134
2915
  } catch {
@@ -2137,7 +2918,10 @@ async function locateDropped({ name, relPath = '', size = -1, root = '' }) {
2137
2918
  const queue = [{ dir: scanRoot, depth: 0 }]
2138
2919
  /** realpath → 展示路径:同一文件经不同符号链接命中只算一次 */
2139
2920
  const hits = new Map()
2140
- while (queue.length && visited < MAX_VISIT && hits.size < 2) {
2921
+ /** 同名目录但特征(child)对不上的弱候选:整根扫完仍没人认领时退回第一个 */
2922
+ const weakDirHits = []
2923
+ let visited = 0
2924
+ while (queue.length && visited < MAX_VISIT_PER_ROOT && visitedTotal < MAX_VISIT_TOTAL && hits.size < 2) {
2141
2925
  const { dir, depth } = queue.shift()
2142
2926
  if (depth > MAX_DEPTH) continue
2143
2927
  let list
@@ -2149,19 +2933,18 @@ async function locateDropped({ name, relPath = '', size = -1, root = '' }) {
2149
2933
  const subdirs = []
2150
2934
  for (const ent of list) {
2151
2935
  visited++
2152
- if (visited > MAX_VISIT) break
2936
+ visitedTotal++
2937
+ if (visited > MAX_VISIT_PER_ROOT || visitedTotal > MAX_VISIT_TOTAL) break
2153
2938
  // 兜底扫描可能遍历上万条目:定期让出事件循环,别把 dev server 卡住
2154
- if (visited % 400 === 0) await new Promise((r) => setImmediate(r))
2939
+ if (visitedTotal % 400 === 0) await new Promise((r) => setImmediate(r))
2155
2940
  if (SKIP.has(ent.name) || ent.name === '.DS_Store') continue
2156
2941
  const full = path.join(dir, ent.name)
2157
2942
  if (ent.isDirectory()) {
2158
- // 拖入文件夹:同名目录直接命中(无需比大小)
2943
+ if (owned.has(full)) continue /* 这棵子树另有根负责,已在 / 将在它自己那轮扫到 */
2944
+ // 拖入文件夹:同名目录命中(无需比大小);带 child(文件夹里首个文件名)时顺手验一下
2159
2945
  if (tail.length && ent.name === tail[0] && depth < MAX_DEPTH) {
2160
- try {
2161
- if (fs.statSync(full).isDirectory()) return full
2162
- } catch {
2163
- /* 忽略不可读目录 */
2164
- }
2946
+ if (!child || fs.existsSync(path.join(full, child))) return hit(full)
2947
+ weakDirHits.push(full)
2165
2948
  }
2166
2949
  subdirs.push({ dir: full, depth: depth + 1 })
2167
2950
  } else if (ent.name === fileName && sizeMatches(full)) {
@@ -2171,12 +2954,194 @@ async function locateDropped({ name, relPath = '', size = -1, root = '' }) {
2171
2954
  }
2172
2955
  queue.push(...subdirs)
2173
2956
  }
2174
- if (hits.size === 1) return hits.values().next().value
2957
+ if (hits.size === 1) return hit(hits.values().next().value)
2175
2958
  if (hits.size > 1) return null /* 同根下重名同尺寸:不猜 */
2959
+ if (weakDirHits.length) return hit(weakDirHits[0])
2176
2960
  }
2177
2961
  return null
2178
2962
  }
2179
2963
 
2964
+ /**
2965
+ * 拖拽反查的扫描根清单:顺序 = 可能性排序,不是路径深浅。
2966
+ *
2967
+ * $HOME 必须垫底——它把 Desktop / Downloads / Documents / 最近目录全包含在内,
2968
+ * 排在前面就等于「最广的根先吃掉全部预算」(老现场的 20000 条全耗在 Home 第一层)。
2969
+ */
2970
+ function collectLocateRoots({ home, root, recent = [] }) {
2971
+ const roots = []
2972
+ const push = (p) => {
2973
+ if (!p) return
2974
+ const abs = path.resolve(p)
2975
+ if (abs === home) return /* 见上:$HOME 单独垫底 */
2976
+ if (!roots.includes(abs)) roots.push(abs)
2977
+ }
2978
+ for (const r of recent) {
2979
+ push(r)
2980
+ push(path.dirname(r))
2981
+ }
2982
+ if (root) push(root) /* 宿主工程根:已知且被 SKIP 剪过,扫一遍很便宜,排前面 */
2983
+ push(path.join(home, 'Desktop'))
2984
+ push(path.join(home, 'Downloads'))
2985
+ push(path.join(home, 'Documents'))
2986
+ push(REPO_ROOT)
2987
+ push(path.dirname(REPO_ROOT))
2988
+ if (!roots.includes(home)) roots.push(home)
2989
+ return roots
2990
+ }
2991
+
2992
+ /**
2993
+ * 「省略号缩写」路径:`…/积分助手-4-22我的优惠券.png`、`.../preview/a.png`。
2994
+ *
2995
+ * 来源:模型列画板清单时嫌长前缀吃 token,把整段目录砍成 `…/`(真实现场:设计稿在
2996
+ * `~/Downloads/<资料包>/UI设计文件-已确认的/<稿名>/preview/`,写全一次 60+ 字符)。砍完
2997
+ * `path.resolve` 把 `…` 当普通目录名 ⇒ 落到 `<cwd>/…/x.png` ⇒ 必然 404。用户看到的是
2998
+ * 「配图全没了」,而服务端一切正常 —— 与切片坏图同一类「静默丢图」。
2999
+ *
3000
+ * S1.5 提示词已禁止这种写法(治根);这里是渲染侧的兜底(治标),让已经写进对话记录的
3001
+ * 那些缩写路径当场就能显示出图,不必重开一轮对话。
3002
+ *
3003
+ * 文件名是唯一可靠抓手,故按「文件名反查」收口:候选根 = 最近交互过的目录 + 草稿 manifest
3004
+ * 记录的导出源目录(图片原产地 `<export>/preview`)+ 工程根。
3005
+ *
3006
+ * 两个点不算省略号(`\.{3,}`):`../a.png` 是父目录相对路径,`path.resolve` 本来就解得对,
3007
+ * 不能把它当成缩写去反查(否则同名的另一张图会被顶上来)。
3008
+ */
3009
+ const ABBREV_LEAD_RE = /^(?:…|\.{3,})[\\/]/
3010
+ /** 模型也可能把长文件名从中间砍断:`积分助手-26...png` */
3011
+ const ABBREV_INNER_RE = /…|\.{2,}/
3012
+
3013
+ /** 这条 path 是省略号缩写形态吗(`/__ai/fs/raw` 据此改走文件名反查) */
3014
+ export function isAbbrevPath(p) {
3015
+ return ABBREV_LEAD_RE.test(String(p || '').trim())
3016
+ }
3017
+
3018
+ /**
3019
+ * 缩写路径的候选根,按可能性排序:最近用过的目录 → 草稿导出源(含其 `preview/`)→ 草稿目录 → 工程根。
3020
+ * 导出源读的是 `<root>/UI/<稿名>/manifest.json` 的 `export` 字段 —— 导入时记下的原始出处,
3021
+ * 与用户当次拖进来的是同一个目录,不含任何业务名硬编码。
3022
+ */
3023
+ function abbrevImageRoots(root) {
3024
+ const out = []
3025
+ const push = (p) => {
3026
+ if (!p) return
3027
+ const abs = path.resolve(String(p))
3028
+ if (!out.includes(abs)) out.push(abs)
3029
+ }
3030
+ for (const r of recentRoots) push(r)
3031
+ let drafts = []
3032
+ try {
3033
+ drafts = fs.readdirSync(path.join(root, 'UI'), { withFileTypes: true }).filter((d) => d.isDirectory())
3034
+ } catch {
3035
+ /* 工程里还没有 UI/ 目录 */
3036
+ }
3037
+ for (const d of drafts) {
3038
+ const dir = path.join(root, 'UI', d.name)
3039
+ push(dir)
3040
+ let mf = null
3041
+ try {
3042
+ mf = JSON.parse(fs.readFileSync(path.join(dir, 'manifest.json'), 'utf8'))
3043
+ } catch {
3044
+ continue /* 没有 manifest:只留目录本身当候选根 */
3045
+ }
3046
+ const exp = mf && (mf.export || mf.source || mf.sourceDir)
3047
+ if (typeof exp !== 'string' || !exp.trim()) continue
3048
+ const e = path.resolve(expandHome(exp.trim()))
3049
+ push(path.join(e, 'preview'))
3050
+ push(e)
3051
+ }
3052
+ push(root)
3053
+ return out.slice(0, 16)
3054
+ }
3055
+
3056
+ const isFileAt = (p) => {
3057
+ try {
3058
+ return fs.statSync(p).isFile()
3059
+ } catch {
3060
+ return false
3061
+ }
3062
+ }
3063
+
3064
+ const escapeRe = (s) => String(s).replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
3065
+
3066
+ /**
3067
+ * 多个候选里挑唯一那个;挑不出返回 ''。
3068
+ *
3069
+ * 必须唯一,是因为剩下的线索只有文件名:`card-1.png` 这种名字**每份草稿各有一个**,
3070
+ * 按根顺序取第一个 = 静默给用户看错图,比显示「加载失败」坏得多。
3071
+ * 唯一豁免:同一份图的多处副本(尺寸 + mtime 都一样)视为同一张,取先命中的那个根。
3072
+ */
3073
+ function pickUniqueFile(cands) {
3074
+ if (cands.length === 1) return cands[0]
3075
+ if (cands.length !== 2) return ''
3076
+ try {
3077
+ const a = fs.statSync(cands[0])
3078
+ const b = fs.statSync(cands[1])
3079
+ if (a.size === b.size && Math.round(a.mtimeMs) === Math.round(b.mtimeMs)) return cands[0]
3080
+ } catch {
3081
+ /* stat 失败:按歧义处理 */
3082
+ }
3083
+ return ''
3084
+ }
3085
+
3086
+ /**
3087
+ * 缩写路径 → 真实绝对路径;反查不出唯一结果返回 ''(宁可 404 显示「加载失败」,也不赌一张错的图)。
3088
+ *
3089
+ * 三轮,由确定到模糊:
3090
+ * 1) 直接拼「剩余相对路径」——`…/preview/a.png` 保住了 `preview/` 这一级,命中率最高;
3091
+ * 2) 只拼文件名——`…/<名>` 是最常见形态;
3092
+ * 3) 文件名自带省略号(`积分助手-26...png`)⇒ 段匹配(`^积分助手-26.*\.png$`),**唯一**命中才认。
3093
+ */
3094
+ export async function resolveAbbrevImage(target, root) {
3095
+ const tail = String(target || '').trim().replace(ABBREV_LEAD_RE, '').replace(/\\/g, '/')
3096
+ const segs = tail.split('/').filter((s) => s && s !== '.')
3097
+ const base = segs[segs.length - 1] || ''
3098
+ if (!base) return ''
3099
+ const roots = abbrevImageRoots(root)
3100
+
3101
+ if (segs.length > 1) {
3102
+ const cands = []
3103
+ for (const r of roots) {
3104
+ const p = path.join(r, ...segs)
3105
+ if (isFileAt(p)) cands.push(p)
3106
+ }
3107
+ const one = pickUniqueFile(cands)
3108
+ if (one) return one
3109
+ }
3110
+ const named = []
3111
+ for (const r of roots) {
3112
+ const p = path.join(r, base)
3113
+ if (isFileAt(p)) named.push(p)
3114
+ }
3115
+ const hit = pickUniqueFile(named)
3116
+ if (hit) return hit
3117
+ if (named.length) return '' /* 同名多份、又不同一文件:不猜 */
3118
+ if (!ABBREV_INNER_RE.test(base)) return ''
3119
+
3120
+ let re
3121
+ try {
3122
+ re = new RegExp(`^${base.split(ABBREV_INNER_RE).map(escapeRe).join('.*')}$`, 'i')
3123
+ } catch {
3124
+ return ''
3125
+ }
3126
+ const hits = new Set()
3127
+ for (const r of roots) {
3128
+ let list
3129
+ try {
3130
+ list = fs.readdirSync(r)
3131
+ } catch {
3132
+ continue
3133
+ }
3134
+ for (const name of list.slice(0, 8000)) {
3135
+ if (!re.test(name)) continue
3136
+ hits.add(path.join(r, name))
3137
+ if (hits.size > 1) return '' /* 歧义:不猜 */
3138
+ }
3139
+ // 可能遍历上万条目:定期让出事件循环,别把 dev server 卡住
3140
+ await new Promise((r2) => setImmediate(r2))
3141
+ }
3142
+ return hits.size === 1 ? [...hits][0] : ''
3143
+ }
3144
+
2180
3145
  /** 系统选择器(macOS 用 osascript;其它平台返回明确提示,前端仍可拖拽 / 粘路径) */
2181
3146
  function pickPaths({ kind = 'file', multiple = true }) {
2182
3147
  if (process.platform !== 'darwin') {
@@ -2402,8 +3367,26 @@ async function* callLLM({ baseUrl, apiKey, model, messages, tools, maxTokens = 4
2402
3367
  throw err
2403
3368
  }
2404
3369
 
2405
- // 扩展字段合并成一份:观测用的 stream_options(看缓存命中)+ 可选的思考开关
2406
- const baseExtra = { ...USAGE_BODY, ...(extraBody || {}) }
3370
+ // 扩展字段合并成一份:观测用的 stream_options(看缓存命中)+ 可选的思考开关。
3371
+ // 先按网关能力缓存滤一遍:已经被拒过的字段不再每轮白撞一次 400。
3372
+ const { extra: baseExtra, skipped: capSkipped } = filterExtraByCapability(baseUrl, model, extraBody)
3373
+ // 「因缓存而没带」只提示一次:能力是网关属性,每轮都重复同一条 notice 纯属噪音
3374
+ //(本仓实测一个回合能攒 7 条 notice,刷屏本身就是可读性问题)。
3375
+ if (capSkipped.length) {
3376
+ const notifyKey = `${gatewayKey(baseUrl, model)}|${[...capSkipped].sort().join(',')}`
3377
+ if (!gatewayExtraNotified.has(notifyKey)) {
3378
+ gatewayExtraNotified.add(notifyKey)
3379
+ const usageGone = capSkipped.includes('stream_options')
3380
+ yield {
3381
+ type: 'notice',
3382
+ level: 'info',
3383
+ code: usageGone ? 'extra-unsupported' : 'thinking-unsupported',
3384
+ text: `该网关此前已拒绝 ${capSkipped.join(' / ')}:后续请求不再带上(${
3385
+ usageGone ? '缓存命中统计会缺席' : '深度思考关不掉,思考耗时可能变长'
3386
+ })`,
3387
+ }
3388
+ }
3389
+ }
2407
3390
  let curExtra = Object.keys(baseExtra).length ? baseExtra : undefined
2408
3391
  let resp = await postRetry(curExtra)
2409
3392
  for (const n of pendingNotices.splice(0)) yield { type: 'notice', text: n.text, level: n.level, code: n.code }
@@ -2458,6 +3441,9 @@ async function* callLLM({ baseUrl, apiKey, model, messages, tools, maxTokens = 4
2458
3441
  }
2459
3442
  extrasPeeled = true
2460
3443
  if (removed.length) {
3444
+ // 记进能力缓存:点名过的字段是网关自己说的「我不认它」,摘掉后才成的则反推为元凶。
3445
+ // 两种都记住,下一轮不再白撞同一个 400。
3446
+ rememberExtraReject(baseUrl, model, resp.ok ? removed : named)
2461
3447
  const thinkingGone = removed.includes('chat_template_kwargs')
2462
3448
  yield {
2463
3449
  type: 'notice',
@@ -2732,7 +3718,67 @@ function normalizeToolArgs(raw) {
2732
3718
  try {
2733
3719
  return JSON.stringify(JSON.parse(s))
2734
3720
  } catch {
2735
- return s
3721
+ // 解析失败**绝不能原样回放**:这条 assistant.tool_calls 会进下一次请求,网关直接
3722
+ // 400 `Assistant tool call function.arguments must be valid JSON.` 把整轮打成失败
3723
+ //(实测 2026-09-21 建卡回合:375.7s / 75 万 input 就是这么报废的)。
3724
+ // 「保留原文」的初衷是前缀缓存逐字节稳定,但稳定只能在**合法**的前提下谈。
3725
+ const repaired = repairToolArgs(s)
3726
+ if (repaired) return repaired
3727
+ // 修不动也必须是合法 JSON:原文完整塞进 _raw(safeParseArgs 已经认这个形状)
3728
+ return JSON.stringify({ _raw: s })
3729
+ }
3730
+ }
3731
+
3732
+ /**
3733
+ * 工具参数容错重建:把模型常见的几种手滑修回合法 JSON。
3734
+ *
3735
+ * 实测形态:长参数(整份 IR / 长正文)+ 中文 + 嵌套字符串,要么未转义、要么被输出上限截断。
3736
+ * 只做「机械且可判定」的修复,修不好就返回 null 交给调用方兜底 —— 宁可退成占位对象,
3737
+ * 也不要修出一个语义不对的合法 JSON 去跑工具。
3738
+ *
3739
+ * 修三类:
3740
+ * ① 尾逗号(`{"a":1,}` / `[1,2,]`);
3741
+ * ② 字符串里的裸控制字符(未转义的换行 / 制表 / 回车);
3742
+ * ③ 截断:按扫描结果补齐未闭合的字符串与括号,并处理停在 `"key":` / `"key",` 的断点。
3743
+ *
3744
+ * @returns {string|null} 合法 JSON 字符串;修不动返回 null
3745
+ */
3746
+ function repairToolArgs(input) {
3747
+ let t = String(input).trim()
3748
+ if (!t) return null
3749
+ t = t.replace(/,\s*([}\]])/g, '$1')
3750
+ t = t.replace(/[\u0000-\u001f]/g, (c) => (c === '\n' ? '\\n' : c === '\t' ? '\\t' : c === '\r' ? '\\r' : ''))
3751
+ let inStr = false
3752
+ let esc = false
3753
+ const stack = []
3754
+ for (let i = 0; i < t.length; i++) {
3755
+ const ch = t[i]
3756
+ if (inStr) {
3757
+ if (esc) esc = false
3758
+ else if (ch === '\\') esc = true
3759
+ else if (ch === '"') inStr = false
3760
+ continue
3761
+ }
3762
+ if (ch === '"') inStr = true
3763
+ else if (ch === '{' || ch === '[') stack.push(ch)
3764
+ else if (ch === '}' || ch === ']') stack.pop()
3765
+ }
3766
+ let out = t
3767
+ if (inStr) out += '"'
3768
+ // 断点常停在 `"key": `(缺值)或 `"key",`(多了逗号)
3769
+ const tail = out.match(/([,:])\s*$/)
3770
+ if (tail) {
3771
+ if (tail[1] === ',') out = out.replace(/,\s*$/, '')
3772
+ else out += ' null'
3773
+ }
3774
+ for (let i = stack.length - 1; i >= 0; i--) out += stack[i] === '{' ? '}' : ']'
3775
+ try {
3776
+ const parsed = JSON.parse(out)
3777
+ // 只接受对象/数组:修出个裸字符串或数字没有意义(工具参数按对象用)
3778
+ if (parsed && typeof parsed === 'object') return JSON.stringify(parsed)
3779
+ return null
3780
+ } catch {
3781
+ return null
2736
3782
  }
2737
3783
  }
2738
3784
 
@@ -3044,9 +4090,38 @@ function stableStringify(value) {
3044
4090
  return JSON.stringify(value) ?? 'null'
3045
4091
  }
3046
4092
 
4093
+ /**
4094
+ * 单个调用的指纹。大参数工具(fs_write 整份 IR)用**弱指纹**:目标文件 + 内容长度 + 前 200 字符。
4095
+ *
4096
+ * 理由(实测 2026-09-21):真实循环是**交替型**——
4097
+ * `fs_write(IR-v1) → iterate(A) → fs_write(IR-v2) → iterate(A) → …`
4098
+ * 每轮 fs_write 的内容都在变,若按全文比对,整轮指纹轮轮刷新、`repeatRounds` 永远回落 1 ⇒
4099
+ * 同一个 `expand_ir` 重复 13 次、同类失败 9 次,全程零熔断(只有 3 次 info 级 soft-nudge)。
4100
+ */
4101
+ function toolSignaturePart(tc, root) {
4102
+ const name = tc?.function?.name || '?'
4103
+ const args = safeParseArgs(tc?.function?.arguments)
4104
+ if (name === 'fs_write' && args && typeof args === 'object') {
4105
+ const content = typeof args.content === 'string' ? args.content : ''
4106
+ return `${name}:${args.path || ''}:${content.length}:${content.slice(0, 200)}`
4107
+ }
4108
+ // skill_exec 的「同一调用」必须带上**可变输入**(--ir)的指纹。
4109
+ //
4110
+ // 实测(2026-09-21 demo 建卡回合,15:35:54):模型的标准工作循环就是
4111
+ // ir_patch 改 IR → 原样重跑 expand_ir / iterate 看新指标 → 再改一版
4112
+ // 参数一字未变、输入每轮都在变,但指纹只按「工具 + 参数」算 ⇒ 被判定成
4113
+ // 「同一个调用在最近 8 轮里出现 3 轮(周期性打转)」,**直接熔断收尾**:
4114
+ // 那一步被跳过、回合在第 61 步结束,而模型当时正处在收敛轨道上。
4115
+ // 判据的本意是拦「输入没变还反复跑」,所以把输入指纹折进来:输入变了就不是打转。
4116
+ if (name === 'skill_exec' && args && typeof args === 'object') {
4117
+ return `${name}${stableStringify(args)}${skillInputFingerprint(args, root)}`
4118
+ }
4119
+ return `${name}${stableStringify(args)}`
4120
+ }
4121
+
3047
4122
  /** 一轮工具调用的指纹:同工具 + 同参数(键序无关)⇒ 指纹相同 */
3048
- function toolRoundSignature(toolCalls) {
3049
- return toolCalls.map((tc) => `${tc?.function?.name || '?'}${stableStringify(safeParseArgs(tc?.function?.arguments))}`).join('|')
4123
+ function toolRoundSignature(toolCalls, root) {
4124
+ return toolCalls.map((tc) => toolSignaturePart(tc, root)).join('|')
3050
4125
  }
3051
4126
 
3052
4127
  // ---- 前缀缓存观测:usage 归一 + 前缀漂移定位 ----
@@ -3200,6 +4275,62 @@ function openAiMsgTokens(m) {
3200
4275
  return t
3201
4276
  }
3202
4277
 
4278
+ /* ── 上下文尺子的在线自校准 ────────────────────────────────────
4279
+ * 问题(2026-09-21 实测,同一建卡回合):`estTokens` 是「CJK×1 + 非CJK×0.3 + 4」的粗估,
4280
+ * 而且**完全没算常驻的 tools 定义数组**(8 个工具带大段中文 description,约 5–6k token)。
4281
+ * 结果是服务端估算比网关真实 promptTokens 系统性小 26–49%:
4282
+ * step 1 估算 11,513 / 真实 16,769(1.46)
4283
+ * step 85 估算 95,534 / 真实 141,990(1.49)
4284
+ * 后果不是「预算设得太大」而是**尺子本身就是短的**:turnContextBudget=96000 实际对应网关侧 ~140k,
4285
+ * UI 圆环显示 99% 时网关已经 148%。所以不能靠「下调预算」修(那是在短尺子上再砍一刀、
4286
+ * 让折叠更早改写历史前缀、后续 token 全部按原价重算),要先校准尺子。
4287
+ *
4288
+ * 校准数据免费:网关每步回的 `usage.promptTokens` 就是该步真实的输入长度
4289
+ * (服务端把它累加后才变成前端看到的累计值),而同一轮「送进网关的 messages 估算 + tools」也算得出来
4290
+ * ⇒ 相除即得比例。按 baseUrl|model 做 EWMA,一两步内收敛,不改变任何协议字段。
4291
+ */
4292
+ const CTX_SCALE_MIN = 0.8
4293
+ const CTX_SCALE_MAX = 3
4294
+ const CTX_SCALE_EWMA = 0.35
4295
+ /** { key: {k, n, last} }:k = 估算→真实的换算系数(EWMA),n = 观测次数 */
4296
+ const _ctxScales = new Map()
4297
+
4298
+ const ctxScaleKey = (cfg) => `${cfg?.baseUrl || ''}|${cfg?.model || ''}`
4299
+
4300
+ /** 当前生效的估算→真实换算系数;未观测过时返回 1(等价于旧行为) */
4301
+ function ctxScale(key) {
4302
+ const c = _ctxScales.get(key)
4303
+ return c && c.n > 0 ? c.k : 1
4304
+ }
4305
+
4306
+ /** 观测一次 (估算, 网关实测) 并更新 EWMA;任一侧异常(<=500)则跳过 */
4307
+ function observeCtxScale(key, est, real) {
4308
+ const e = Number(est)
4309
+ const r = Number(real)
4310
+ if (!(e > 500) || !(r > 500)) return
4311
+ const ratio = Math.max(CTX_SCALE_MIN, Math.min(CTX_SCALE_MAX, r / e))
4312
+ const c = _ctxScales.get(key)
4313
+ if (!c) _ctxScales.set(key, { k: ratio, n: 1, last: ratio })
4314
+ else {
4315
+ c.k = c.k * (1 - CTX_SCALE_EWMA) + ratio * CTX_SCALE_EWMA
4316
+ c.n++
4317
+ c.last = ratio
4318
+ }
4319
+ }
4320
+
4321
+ /** 常驻 tools 定义的 token 估算(工具集在同一回合内不变 ⇒ 算一次缓存) */
4322
+ let _toolsTokensCache = { sig: '', tokens: 0 }
4323
+ function toolsTokens(tools) {
4324
+ const sig = Array.isArray(tools) ? `${tools.length}:${tools.map((t) => t?.function?.name || '').join(',')}` : ''
4325
+ if (_toolsTokensCache.sig === sig) return _toolsTokensCache.tokens
4326
+ let t = 0
4327
+ try {
4328
+ t = estTokens(JSON.stringify(tools || []))
4329
+ } catch {}
4330
+ _toolsTokensCache = { sig, tokens: t }
4331
+ return t
4332
+ }
4333
+
3203
4334
  /**
3204
4335
  * 单轮内上下文裁剪:超预算时折叠超长工具结果 / 注入图像,直到落回预算内。
3205
4336
  * 只改 content、绝不删消息 —— tool_calls 与 role:'tool' 的配对一旦被破坏,网关会直接 400。
@@ -3898,13 +5029,19 @@ export function createAiChatMiddleware(opts = {}) {
3898
5029
  return res.end()
3899
5030
  }
3900
5031
  const target = query.get('path') || ''
3901
- const abs = path.resolve(expandHome(target))
5032
+ let abs = path.resolve(expandHome(target))
3902
5033
  const ext = path.extname(abs).toLowerCase()
3903
5034
  if (!target || !IMAGE_EXT.has(ext)) {
3904
5035
  res.statusCode = 400
3905
5036
  res.setHeader('Content-Type', 'text/plain; charset=utf-8')
3906
5037
  return res.end('unsupported-media')
3907
5038
  }
5039
+ // 省略号缩写路径(`…/a.png`):resolve 已把它拼成 `<cwd>/…/a.png`,必不存在。
5040
+ // 改按文件名在「最近目录 + 草稿导出源」里反查——治的是模型为省 token 砍掉长前缀这一类丢图。
5041
+ if (isAbbrevPath(target)) {
5042
+ const found = await resolveAbbrevImage(target, root)
5043
+ if (found) abs = found
5044
+ }
3908
5045
  let st
3909
5046
  try {
3910
5047
  st = fs.statSync(abs)
@@ -3966,6 +5103,7 @@ export function createAiChatMiddleware(opts = {}) {
3966
5103
  name: body.name,
3967
5104
  relPath: body.relPath || '',
3968
5105
  size: Number.isFinite(Number(body.size)) ? Number(body.size) : -1,
5106
+ child: typeof body.child === 'string' ? body.child : '',
3969
5107
  root,
3970
5108
  })
3971
5109
  if (!found) return sendJson(req, res, 404, { error: 'not-located' })
@@ -4102,6 +5240,7 @@ export function createAiChatMiddleware(opts = {}) {
4102
5240
  FS_READ_TOOL,
4103
5241
  FS_LIST_TOOL,
4104
5242
  FS_WRITE_TOOL,
5243
+ IR_PATCH_TOOL,
4105
5244
  FS_COPY_TOOL,
4106
5245
  skillExecTool(autoApproves(defaults.autoApprove, 'skill_exec')),
4107
5246
  ]
@@ -4109,6 +5248,18 @@ export function createAiChatMiddleware(opts = {}) {
4109
5248
  if (defaults.shellAllowed) tools = [...tools, shellTool(autoApproves(defaults.autoApprove, 'shell_exec'))]
4110
5249
  /** 本轮已获「不再询问」授权的工具名(scope=turn):管线连跑多步时不必逐步点确认 */
4111
5250
  const approvedTools = new Set()
5251
+ /** 同轮 fs_write 记账:同一草稿文件被全量重写的次数与累计字节(全量重写闸门的依据) */
5252
+ const fsWriteLedger = new Map()
5253
+ /** 上下文尺子的校准键(按网关 + 模型)与常驻 tools 的估算量:预算判定、UI 上报、校准观测都用它 */
5254
+ const scaleKey = ctxScaleKey(finalConfig)
5255
+ const toolsTok = toolsTokens(tools)
5256
+ /**
5257
+ * 本轮生效的尺子系数 **回合开始取一次并冻结**(观测继续喂 EWMA,但只对**下一轮**生效)。
5258
+ * 不能每步现取:k 在同轮内一变,(a) UI 的「上下文占用」会在工具结果进来后反而回退 —— 环越用越少,
5259
+ * 明显是坏的;(b) `turnContextBudget / k` 的预算换算在同轮内自相矛盾,同一份 messages 一会儿超预算一会儿不超。
5260
+ * 冻结后 `used` 只随 messages 单调增长,k 的收敛体现在回合之间(与 `total` 同样按回合冻结的口径一致)。
5261
+ */
5262
+ const turnScale = ctxScale(scaleKey)
4112
5263
 
4113
5264
  let contextPrompt = systemPrompt
4114
5265
  // 只注入「当前卡片 ID」:此前额外塞 8KB schema 摘要,与模型自己 card_read 的全文重复,
@@ -4252,10 +5403,31 @@ export function createAiChatMiddleware(opts = {}) {
4252
5403
  /** 上一轮工具调用指纹与「连续相同」计数:死循环熔断的依据 */
4253
5404
  let lastRoundSig = ''
4254
5405
  let repeatRounds = 0
5406
+ /** 最近 LOOP_WINDOW_ROUNDS 轮的**单调用**指纹:抓交替型循环(按整轮拼接会漏掉它) */
5407
+ const recentCallRounds = []
5408
+ /** 技能调用(脚本 + 参数)→ 连续失败次数:同一调用连续失败 2 次即短路,别让它第三次白撞 */
5409
+ const execFails = new Map()
5410
+ /** 「AI 写到别的卡上」的提醒去重:同一目标 id 一轮只提示一次 */
5411
+ const cardTargetNotified = new Set()
4255
5412
  /** 熔断时展示给用户的调用名(比整串指纹可读) */
4256
5413
  let loopToolNames = ''
5414
+ /** 软提醒已点名过的重复调用指纹:同一组只提醒一次,免得每轮刷同样的话挤占上下文 */
5415
+ const softNudgedSigs = new Set()
5416
+ /** 软提醒已发次数(上限 SOFT_NUDGE_MAX) */
5417
+ let softNudgeCount = 0
5418
+ /**
5419
+ * 本轮 shell_exec 里「只是读文件 / 列目录 / 搜索」的次数。
5420
+ * 实测(2026-09-21 建卡回合)15 次 shell_exec 里绝大多数是 cat / ls / rg / find / jq ——
5421
+ * 同样的信息 fs_read / fs_list / card_grep 能直接给,还少一层引号转义与输出噪音。
5422
+ * 攒够几次就在软提醒里点一句,别去改工具面(改工具面风险远大于这点收益)。
5423
+ */
5424
+ let shellReadLikeCount = 0
5425
+ /** 上面这条只提醒一次:它是「习惯」不是「失控」,重复刷没有新信息 */
5426
+ let shellReadLikeNudged = false
4257
5427
  /** 墙钟用到 80% 时的一次性收敛提醒(不打断流程) */
4258
5428
  let timeNudged = false
5429
+ /** 正文超长提醒只发一次:同一轮反复刷「你太啰嗦了」只会挤占上下文 */
5430
+ let bodyNarrationNudged = false
4259
5431
  /** 单轮上下文裁剪是否已提示过(每轮都可能裁,提示只发一次免得刷屏) */
4260
5432
  let trimNoticed = false
4261
5433
  /** 单轮深度压缩是否已提示过(同 trimNoticed,免刷屏) */
@@ -4266,6 +5438,8 @@ export function createAiChatMiddleware(opts = {}) {
4266
5438
  let emptyRetries = 0
4267
5439
  /** 网关静默后已自动重试本步的次数:中转网关瞬时静默不该让跑了几十分钟的长程任务整轮报废 */
4268
5440
  let stallRetriesUsed = 0
5441
+ /** 本回合正文暴走被硬闸掐断的次数(上限 BODY_BURST_MAX) */
5442
+ let bodyBursts = 0
4269
5443
  /** 纯思考已催促次数:到 REASONING_NUDGE_MAX 仍无产出就停用思考熔断(reasoningGuardOff) */
4270
5444
  let reasoningNudges = 0
4271
5445
  /** 思考熔断是否已停用:停用后「只思考不产出」不再中止上游,交给墙钟 / 死循环熔断兜底 */
@@ -4311,22 +5485,78 @@ export function createAiChatMiddleware(opts = {}) {
4311
5485
  content: `[系统提醒] 本轮墙钟预算只剩 20%(上限 ${fmtDuration(turnTimeoutMs)}):立即收敛——优先 card_write / card_patch 落盘或给出正文结论与下一步,不要再读资料、不要开新战线。`,
4312
5486
  })
4313
5487
  }
4314
- // 熔断③软提醒:每 SOFT_NUDGE_EVERY 步催一次收敛,但**不打断**(步数多不等于空转)
4315
- if (SOFT_NUDGE_EVERY > 0 && step % SOFT_NUDGE_EVERY === 0) {
4316
- sseNotice(res, `本轮已用 ${step} 步工具调用(步数不设硬顶):提醒模型收敛,别在同类信息上重复取数`, 'info', 'soft-nudge')
4317
- currentMessages.push({
4318
- role: 'user',
4319
- content: `[系统提醒] 本轮已进行 ${step} 步工具调用(不设硬顶,但别空转):自查是否已拿到足够证据——能落盘就 card_write / card_patch / iterate.mjs 落盘并推进到下一门禁;同类信息只读一次,不要重复读同一份资料、不要重跑同一条命令。`,
4320
- })
5488
+ // 熔断③软提醒:两条触发线任一命中即提醒,但**不打断**(步数多不等于空转)。
5489
+ // ① 重复线(主):同一调用第 SOFT_NUDGE_REPEAT_ROUNDS 轮出现 ⇒ 立刻提醒,不等步数兜底。
5490
+ // ② 步数线(兜底):每 SOFT_NUDGE_EVERY 步催一次,兜住「不重复但也不收敛」的长尾。
5491
+ // 实测教训:原来只有固定步数线,且是无事实支撑的 info 级提醒,模型收完照旧(24/48/72 步
5492
+ // 各催一次都没改变行为)。所以现在升为 warn 并**附上它自己的重复调用与失败调用清单**——
5493
+ // 提醒一旦可被验证,才有机会真的改变下一步。
5494
+ {
5495
+ const sigRounds = new Map()
5496
+ for (const round of recentCallRounds) for (const sig of round) sigRounds.set(sig, (sigRounds.get(sig) || 0) + 1)
5497
+ const freshRepeats = [...sigRounds.entries()]
5498
+ .filter(([sig, n]) => n >= SOFT_NUDGE_REPEAT_ROUNDS && !softNudgedSigs.has(sig))
5499
+ .sort((a, b) => b[1] - a[1])
5500
+ const stepDue = SOFT_NUDGE_EVERY > 0 && step > 0 && step % SOFT_NUDGE_EVERY === 0
5501
+ // ③ shell_exec 读类线:把「拿 shell 干 fs_*/card_grep 的活」当成一个独立的提醒触发点,
5502
+ // 否则它只能搭在别两条线的便车上,重复不多、步数又没到 12 时永远不出现。
5503
+ const shellDue = shellReadLikeCount >= SHELL_READ_LIKE_NUDGE && !shellReadLikeNudged
5504
+ if ((freshRepeats.length || stepDue || shellDue) && softNudgeCount < SOFT_NUDGE_MAX) {
5505
+ softNudgeCount++
5506
+ for (const [sig] of freshRepeats) softNudgedSigs.add(sig)
5507
+ if (shellDue) shellReadLikeNudged = true
5508
+ const repeated = [...sigRounds.entries()]
5509
+ .filter(([, n]) => n >= SOFT_NUDGE_REPEAT_ROUNDS)
5510
+ .sort((a, b) => b[1] - a[1])
5511
+ .slice(0, 3)
5512
+ .map(([sig, n]) => `${sig.slice(0, 90)} ×${n} 轮`)
5513
+ const failing = [...execFails.entries()]
5514
+ .filter(([, v]) => v.count >= 1)
5515
+ .slice(0, 2)
5516
+ .map(([k, v]) => `${k.slice(0, 90)}(连续失败 ${v.count} 次${v.kind ? `,${v.kind}` : ''}${v.code ? ` exit=${v.code}` : ''})`)
5517
+ const facts = [
5518
+ repeated.length ? `最近 ${recentCallRounds.length} 轮里重复出现的调用:${repeated.join(';')}` : '',
5519
+ failing.length ? `已连续失败的调用:${failing.join(';')}` : '',
5520
+ shellReadLikeCount >= SHELL_READ_LIKE_NUDGE
5521
+ ? `本轮已有 ${shellReadLikeCount} 次 shell_exec 只是读文件 / 列目录 / 搜索 —— 这些用 fs_read / fs_list / card_grep 更直接(同样的信息,少一层引号转义与输出噪音)`
5522
+ : '',
5523
+ ]
5524
+ .filter(Boolean)
5525
+ .join(' / ')
5526
+ const why = freshRepeats.length
5527
+ ? `检测到重复调用(同一「工具 + 参数」在最近 ${recentCallRounds.length} 轮里已出现 ${freshRepeats[0][1]} 轮)`
5528
+ : shellDue
5529
+ ? `本轮已有 ${shellReadLikeCount} 次 shell_exec 只是读文件 / 列目录 / 搜索`
5530
+ : `本轮已用 ${step} 步工具调用(步数不设硬顶)`
5531
+ sseNotice(res, `${why}:提醒模型收敛,别在同类信息上重复取数${facts ? ` —— ${facts}` : ''}`, 'warn', 'soft-nudge')
5532
+ currentMessages.push({
5533
+ role: 'user',
5534
+ content: `[系统提醒] ${why}。自查是否已拿到足够证据——能落盘就 card_write / card_patch / ir_patch / iterate.mjs 推进到下一门禁;同类信息只读一次,不要重复读同一份资料、不要重跑同一条命令。${facts ? `\n事实(按你自己的调用记录统计):${facts}。重复调用不会带来新信息 —— 立刻换证据链(报告里的 contributors / diff_bands.mjs / generation_pitfalls.md),或直接落盘收尾。` : ''}`,
5535
+ })
5536
+ }
4321
5537
  }
4322
5538
 
4323
5539
  // 熔断④上下文:步数不限后工具结果会一路累积,撑爆网关窗口 = 整轮 400 报废。
4324
5540
  // 每轮发请求前先按预算折叠早期工具结果 / 注入图像(最近 TRIM_KEEP_RECENT 条保持原文)。
4325
5541
  // from = 上次折到哪:折叠只前进不回退,历史已折叠的部分保持原样 ⇒ 前缀缓存继续命中
4326
- const turnTrim = trimTurnContext(currentMessages, { budgetTokens: turnContextBudget, from: trimFrom })
5542
+ //
5543
+ // 预算换算:估算 × k ≈ 网关真实值,且**常驻 tools 数组每步都会发**但不属于 messages
5544
+ // (旧估算把它整个漏掉)。要让「真实」不超 turnContextBudget,等价于让「估算」不超
5545
+ // budget/k − tools。k 未观测到时为 1,即完全等价于旧行为。
5546
+ // k 取回合开始时冻结的 turnScale(同轮内不变,见其定义处的理由)。
5547
+ const estBudget = Math.max(4000, Math.floor(turnContextBudget / turnScale) - toolsTok)
5548
+ const turnTrim = trimTurnContext(currentMessages, { budgetTokens: estBudget, from: trimFrom })
4327
5549
  trimFrom = turnTrim.from
4328
- // 输入框圆环的数据源:本轮**实际送进网关**的上下文量(裁剪后),有窗口认知时前端按窗口画环
4329
- sseWrite(res, { type: 'context-usage', used: turnTrim.tokens, total: turnContextBudget, window: knownWindow })
5550
+ // 输入框圆环的数据源:本轮**实际送进网关**的上下文量(裁剪后),统一按校准后的真实口径上报
5551
+ // (旧行为:报估算值 ⇒ UI 显示 99% 时网关已经在 148%,用户和模型看到的都不是真相)
5552
+ sseWrite(res, {
5553
+ type: 'context-usage',
5554
+ used: Math.round((turnTrim.tokens + toolsTok) * turnScale),
5555
+ total: turnContextBudget,
5556
+ window: knownWindow,
5557
+ est: turnTrim.tokens + toolsTok,
5558
+ scale: Math.round(turnScale * 100) / 100,
5559
+ })
4330
5560
  if (clampNotice) sseNotice(res, clampNotice, 'warn', 'max-tokens-clamp')
4331
5561
  if (turnTrim.trimmed) {
4332
5562
  currentMessages = turnTrim.messages
@@ -4338,8 +5568,8 @@ export function createAiChatMiddleware(opts = {}) {
4338
5568
  // 深度压缩:折叠工具结果后仍超预算 ⇒ 整组丢弃最早的工具调用并留摘要。
4339
5569
  // 任务消息与最近工作记忆保留原文,压缩完循环照常继续(用户要求:主动压缩且不断任务)
4340
5570
  const estNow = currentMessages.reduce((a, m) => a + openAiMsgTokens(m), 0)
4341
- if (estNow > turnContextBudget) {
4342
- const deep = deepCompactTurn(currentMessages, turnContextBudget)
5571
+ if (estNow > estBudget) {
5572
+ const deep = deepCompactTurn(currentMessages, estBudget)
4343
5573
  if (deep.droppedGroups) {
4344
5574
  currentMessages = deep.messages
4345
5575
  // 丢消息会让下标整体左移:粘滞游标与漂移基准一起作废重来(这一步本来就改写了历史前缀)
@@ -4353,11 +5583,17 @@ export function createAiChatMiddleware(opts = {}) {
4353
5583
  }
4354
5584
 
4355
5585
  let assistantContent = ''
5586
+ /** 本轮正文全文:只为「超限时把尾部结论补回来」而留,本身不进历史、不推前端 */
5587
+ let bodyFull = ''
5588
+ /** 本轮被省略(未推前端、未进历史)的正文字符数 */
5589
+ let bodyOmitted = 0
4356
5590
  let assistantToolCalls = null
4357
5591
  let roundProgressAt = Date.now()
4358
5592
  let lastDeltaAt = Date.now()
4359
5593
  let roundTimedOut = false
4360
5594
  let roundStalled = false
5595
+ /** 本轮正文暴走被硬闸掐断(见 BODY_BURST_ABORT_CHARS):不结束回合,提醒后重跑本步 */
5596
+ let roundBodyBurst = false
4361
5597
  /** 本轮被墙钟打断:墙钟原本只在每轮开头检查,上游永不收尾时循环回不到轮首,整轮会挂住 */
4362
5598
  let roundWallClock = false
4363
5599
  let finishReason = ''
@@ -4428,11 +5664,38 @@ export function createAiChatMiddleware(opts = {}) {
4428
5664
  roundReasoningChars += String(evt.text || '').length
4429
5665
  sseWrite(res, { type: 'reasoning-delta', text: evt.text })
4430
5666
  break
4431
- case 'text-delta':
5667
+ case 'text-delta': {
4432
5668
  roundProgressAt = Date.now()
4433
- assistantContent += evt.text
4434
- sseWrite(res, { type: 'text-delta', text: evt.text })
5669
+ // 正文硬上限:**超限部分既不推给前端、也不进历史**。
5670
+ // 为什么必须在这里截、而不是只截进历史的那一份:前端会把对话记录原样回传成
5671
+ // 下一回合的上下文,两边文本差一个字符,从那条起的前缀缓存就全部失效
5672
+ // (live 与回放逐字节一致是硬约束)。用户已看到的前 BODY_NARRATION_MAX_CHARS 字保留。
5673
+ const piece = String(evt.text || '')
5674
+ bodyFull += piece
5675
+ const remain = BODY_NARRATION_MAX_CHARS - assistantContent.length
5676
+ if (remain <= 0) {
5677
+ bodyOmitted += piece.length
5678
+ } else {
5679
+ const kept = piece.length > remain ? piece.slice(0, remain) : piece
5680
+ assistantContent += kept
5681
+ bodyOmitted += piece.length - kept.length
5682
+ sseWrite(res, { type: 'text-delta', text: kept })
5683
+ }
5684
+ // 暴走硬闸:截断后还在继续吐 ⇒ 立刻掐断本步,别陪它写完再丢(output token 照付)。
5685
+ // 必须放在两路**之外**:封顶后走的是上面那条 `remain <= 0` 分支,
5686
+ // 把闸门写在 else 里等于永远不触发(第一版就是这么写错的,测试逮住)。
5687
+ if (
5688
+ step > 0 &&
5689
+ !roundBodyBurst &&
5690
+ bodyBursts < BODY_BURST_MAX &&
5691
+ bodyOmitted > BODY_BURST_ABORT_CHARS &&
5692
+ !assistantToolCalls?.length
5693
+ ) {
5694
+ roundBodyBurst = true
5695
+ roundCtl.abort()
5696
+ }
4435
5697
  break
5698
+ }
4436
5699
  case 'tool-call-start':
4437
5700
  roundProgressAt = Date.now()
4438
5701
  roundToolRows++
@@ -4449,6 +5712,12 @@ export function createAiChatMiddleware(opts = {}) {
4449
5712
  case 'usage': {
4450
5713
  // 缓存命中率的唯一观测口径:把网关回的 usage 累加成本轮总计,再推给前端
4451
5714
  const u = evt.usage || {}
5715
+ // 尺子校准:这一手的 promptTokens 就是该步真实输入长度(此刻 messages 还没 append 本步产物,
5716
+ // 正好等于「刚送进网关的输入」)。与同一轮的估算相除即得系数,免费、无额外请求。
5717
+ if (Number(u.promptTokens) > 0) {
5718
+ const estSent = currentMessages.reduce((a, m) => a + openAiMsgTokens(m), 0) + toolsTok
5719
+ observeCtxScale(scaleKey, estSent, u.promptTokens)
5720
+ }
4452
5721
  usageTotal.steps++
4453
5722
  usageTotal.promptTokens += Number(u.promptTokens) || 0
4454
5723
  usageTotal.completionTokens += Number(u.completionTokens) || 0
@@ -4481,13 +5750,13 @@ export function createAiChatMiddleware(opts = {}) {
4481
5750
  }
4482
5751
  break
4483
5752
  }
4484
- if (roundTimedOut || roundStalled) break
5753
+ if (roundTimedOut || roundStalled || roundBodyBurst) break
4485
5754
  }
4486
5755
  } catch (e) {
4487
5756
  // 熔断 / 看门狗主动 abort 时,上游生成器会抛 AbortError:这不是「用户中止」,
4488
5757
  // 吞掉后交给下面的 roundWallClock / roundStalled / roundTimedOut 分支给出准确原因
4489
5758
  // (漏了 roundWallClock 会让墙钟打断变成一条「请求已中止」错误,丢掉收尾汇总)
4490
- if (roundStalled || roundTimedOut || roundWallClock) {
5759
+ if (roundStalled || roundTimedOut || roundWallClock || roundBodyBurst) {
4491
5760
  /* 吞掉,走下面分支 */
4492
5761
  } else if (
4493
5762
  contextRetries < 2 &&
@@ -4497,7 +5766,8 @@ export function createAiChatMiddleware(opts = {}) {
4497
5766
  // 网关报上下文溢出:callLLM 已按详情分类(减半救不了的才带 context-overflow 标记抛出),
4498
5767
  // 说明是**输入**超窗。压缩后重试同一步(此刻尚未追加 assistant 消息,无悬空 tool_calls),任务不中断
4499
5768
  contextRetries++
4500
- const tight = Math.max(4000, Math.floor(turnContextBudget * 0.6))
5769
+ // 同样是估算口径:0.6×预算换算成「估算」尺度后再压缩(k 未观测到时与旧行为一致)
5770
+ const tight = Math.max(4000, Math.floor((turnContextBudget / turnScale) * 0.6) - toolsTok)
4501
5771
  const deep = deepCompactTurn(currentMessages, tight, Math.max(4, DEEP_KEEP_RECENT - 2), true)
4502
5772
  currentMessages = deep.droppedGroups
4503
5773
  ? deep.messages
@@ -4529,6 +5799,30 @@ export function createAiChatMiddleware(opts = {}) {
4529
5799
  budgetReason = 'timeout'
4530
5800
  break
4531
5801
  }
5802
+ if (roundBodyBurst) {
5803
+ // 正文暴走:本步零副作用(还没到工具执行),掐断后提醒一句重跑本步即可。
5804
+ // 截断发生在生成之后 ⇒ 不掐断就是「照付 output token 再丢弃」,实测一次 2.3 万字 / 2 分 38 秒。
5805
+ bodyBursts++
5806
+ sseWrite(res, {
5807
+ type: 'round-retry',
5808
+ reason: 'body-burst',
5809
+ attempt: bodyBursts,
5810
+ attempts: BODY_BURST_MAX,
5811
+ dropText: assistantContent.length,
5812
+ message: `本步正文已超过 ${BODY_BURST_ABORT_CHARS + BODY_NARRATION_MAX_CHARS} 字(过程性分析写太多):已掐断本步重来,请直接给结论或调用工具,别在正文里展开推理`,
5813
+ })
5814
+ currentMessages = [
5815
+ ...currentMessages,
5816
+ {
5817
+ role: 'user',
5818
+ content:
5819
+ `[系统提醒] 你上一步的正文写到 ${BODY_BURST_ABORT_CHARS + BODY_NARRATION_MAX_CHARS} 字仍未收尾,已被掐断(这一步没有产生任何工具调用)。` +
5820
+ '过程性推理(像素比对 / 逐行对齐 / 几何推算)不要写进正文:正文每轮 ≤2 句 / 300 字,' +
5821
+ '要么直接给出结论,要么直接调用下一个工具推进任务。',
5822
+ },
5823
+ ]
5824
+ continue
5825
+ }
4532
5826
  if (roundStalled) {
4533
5827
  // 网关静默不再「一刀结束整轮」:工具是在流结束后才执行的,此刻本步零副作用,
4534
5828
  // 原样重试即可——已执行的几十次调用、已落盘的产物、上下文全都不丢。
@@ -4569,22 +5863,26 @@ export function createAiChatMiddleware(opts = {}) {
4569
5863
  // 催促 REASONING_NUDGE_MAX 次仍无产出就停用本熔断,改由墙钟兜底——思考时长不再能中断任务。
4570
5864
  if (reasoningNudges < REASONING_NUDGE_MAX) {
4571
5865
  reasoningNudges++
4572
- retryNoThinking = true
5866
+ // 第一次只催不夺:保留思考能力、也不回滚已流出的思考(drop* 全 0)。
5867
+ // 「刚想热就被关思考 + 界面清空」实测等于让模型从头再探索一遍,纯浪费;
5868
+ // 第二次起才降级为「关思考 + 回滚半截产物」这套硬手段。
5869
+ const hard = reasoningNudges > 1
5870
+ if (hard) retryNoThinking = true
4573
5871
  sseWrite(res, {
4574
5872
  type: 'round-retry',
4575
5873
  reason: 'reasoning',
4576
5874
  attempt: reasoningNudges,
4577
5875
  attempts: REASONING_NUDGE_MAX,
4578
- dropReasoning: roundReasoningChars,
4579
- dropText: assistantContent.length,
4580
- dropTools: roundToolRows,
4581
- message: `本轮纯思考超过 ${fmtDuration(reasoningTimeoutMs)} 仍无产出:已关闭深度思考并催促模型直接落盘(第 ${reasoningNudges}/${REASONING_NUDGE_MAX} 次),任务不中断`,
5876
+ dropReasoning: hard ? roundReasoningChars : 0,
5877
+ dropText: hard ? assistantContent.length : 0,
5878
+ dropTools: hard ? roundToolRows : 0,
5879
+ message: `本轮纯思考超过 ${fmtDuration(reasoningTimeoutMs)} 仍无产出:已催促模型直接落盘(第 ${reasoningNudges}/${REASONING_NUDGE_MAX} 次${hard ? ',并关闭深度思考' : ''}),任务不中断`,
4582
5880
  })
4583
5881
  currentMessages = [
4584
5882
  ...currentMessages,
4585
5883
  {
4586
5884
  role: 'user',
4587
- content: `[系统催促] 思考预算已用完:立即停止分析,本轮**必须**以一次工具调用收尾(fs_write 写 IR / card_write / card_patch / card_scaffold),或直接用正文回复结论;不要再读取任何资料、不要复述卡片数据、不要在脑子里逐字草拟 JSON。${designFlow ? '建卡走 IR 路线:fs_write 把紧凑 IR 写到 .cache/ai-scratch/<id>.ir.json,再用 skill_exec 跑 expand_ir.mjs 展开,一次调用就能落盘整卡;落盘后仍须跑完 check_fidelity + iterate.mjs 像素自循环(数值指标,1-2 轮即可)才允许收尾。' : ''}`,
5885
+ content: `[系统催促] 思考预算已用完:立即停止分析,本轮**必须**以一次工具调用收尾(fs_write 写 IR / card_write / card_patch / card_scaffold),或直接用正文回复结论;不要复述卡片数据、不要在脑子里逐字草拟 JSON、**不要在思考里做像素算术**(逐行核对坐标 / 心算 gap 与对齐)。不确定的组件写法就直接 skill_read recipe_snippets/ 下对应片段(≤2KB、0.3s,读它不算浪费);几何细节按审计值先落盘,再用 render-status / check_fidelity 的数值指标迭代。${designFlow ? '建卡走 IR 路线:fs_write 把紧凑 IR 写到 .cache/ai-scratch/<id>.ir.json,再用 skill_exec 跑 expand_ir.mjs 展开,一次调用就能落盘整卡;落盘后仍须跑完 check_fidelity + iterate.mjs 像素自循环(数值指标,1-2 轮即可)才允许收尾。' : ''}`,
4588
5886
  },
4589
5887
  ]
4590
5888
  continue
@@ -4617,6 +5915,16 @@ export function createAiChatMiddleware(opts = {}) {
4617
5915
  // 否则一次思考熔断会把整轮后续所有步骤都降级成无思考模式,长程任务质量明显下滑
4618
5916
  if (assistantContent || assistantToolCalls?.length) retryNoThinking = false
4619
5917
 
5918
+ // 正文超限收尾:把尾部补回来(中间省略),推给前端与进历史的是**同一份文本**。
5919
+ // 顺序必须在 settled 判定与三处 append 之前:DONE_MARK / ASK_MARK 写在正文最后一行,
5920
+ // 只留头部会把标记一起截掉,那会让「已完成」的轮次被判成「未完成却停手」而自动续跑。
5921
+ if (bodyOmitted > 0) {
5922
+ const tail = bodyFull.slice(-BODY_NARRATION_TAIL_CHARS)
5923
+ const marker = `\n…[过程性分析省略 ${bodyOmitted} 字]…\n`
5924
+ assistantContent = assistantContent + marker + tail
5925
+ sseWrite(res, { type: 'text-delta', text: marker + tail })
5926
+ }
5927
+
4620
5928
  if (!assistantToolCalls?.length) {
4621
5929
  // 自动续跑:模型只吐正文就停手——实测最常见形态是输出「下一步(请确认或直接说「继续」)」
4622
5930
  // 把控制权甩回用户,而任务其实没做完(IR 写了但 expand_ir 没跑、门禁没过)。
@@ -4642,16 +5950,52 @@ export function createAiChatMiddleware(opts = {}) {
4642
5950
  // 有工具调用 = 任务在推进:重置「只吐正文」计数,下一次停手仍可自动续跑一次
4643
5951
  textOnlyRounds = 0
4644
5952
 
4645
- // 熔断①原地打转:同工具 + 同参数连续重复(实测最烧钱的失控形态,如反复 fs_read 同一份 spec.json、
4646
- // 反复跑同一条 iterate --set)。第 2 轮先警告,第 3 轮不再执行、直接收尾汇报。
4647
- const roundSig = toolRoundSignature(assistantToolCalls)
5953
+ // 正文纪律:这一轮还要继续调工具 ⇒ 上面的正文是过程性推理,不是给用户的结论。
5954
+ // 已硬截断(见 text-delta 分支与上面的补尾逻辑),这里只负责把纪律再讲一遍,
5955
+ // 并如实告知省下了多少(提醒带事实才可能改变下一步行为)。
5956
+ if (bodyOmitted > 0 && !bodyNarrationNudged) {
5957
+ bodyNarrationNudged = true
5958
+ sseNotice(
5959
+ res,
5960
+ `本轮正文超出上限 ${BODY_NARRATION_MAX_CHARS} 字:已截断中间 ${bodyOmitted} 字(保留开头与结尾结论),这部分不再进上下文`,
5961
+ 'warn',
5962
+ 'verbosity',
5963
+ )
5964
+ currentMessages.push({
5965
+ role: 'user',
5966
+ content:
5967
+ `[系统提醒] 你上一条正文超出 ${BODY_NARRATION_MAX_CHARS} 字上限,中间 ${bodyOmitted} 字已被截断(开头与结尾保留):` +
5968
+ '过程性分析(像素比对 / 逐行对齐 / 几何推算)写在内部即可,正文只写结论与关键进展(每轮 ≤2 句 / 300 字)。' +
5969
+ '继续推进任务,不要复述分析过程。',
5970
+ })
5971
+ }
5972
+
5973
+ // 熔断①原地打转:同工具 + 同参数重复(实测最烧钱的失控形态,如反复 fs_read 同一份 spec.json、
5974
+ // 反复跑同一条 iterate --set)。两条判据:
5975
+ // ① **连续**相邻轮整轮相同(第 2 轮警告、第 3 轮熔断);
5976
+ // ② 最近 LOOP_WINDOW_ROUNDS 轮里**某一个调用**出现 ≥ LOOP_REPEAT_IN_WINDOW 次。
5977
+ // ② 是必须的:真实循环是交替型 `fs_write(IR-v1) → expand_ir(A) → fs_write(IR-v2) → expand_ir(A)`,
5978
+ // 整轮指纹因 fs_write 每轮都变而轮轮刷新 —— 实测同一个 expand_ir 重复 13 次、同类失败 9 次零熔断。
5979
+ const roundSig = toolRoundSignature(assistantToolCalls, root)
4648
5980
  if (roundSig === lastRoundSig) repeatRounds++
4649
5981
  else {
4650
5982
  lastRoundSig = roundSig
4651
5983
  repeatRounds = 1
4652
5984
  }
5985
+ const callSigs = assistantToolCalls.map((tc) => toolSignaturePart(tc, root))
5986
+ recentCallRounds.push(callSigs)
5987
+ if (recentCallRounds.length > LOOP_WINDOW_ROUNDS) recentCallRounds.shift()
5988
+ // 按「有多少**轮**出现过该调用」计数,而不是出现次数:同一轮里重复 3 次只是手滑,
5989
+ // 「3 轮各出现一次」才是周期性打转(这正是要拦的形态)。
5990
+ let loopHit = null
5991
+ for (const sig of callSigs) {
5992
+ const roundsWith = recentCallRounds.filter((round) => round.includes(sig)).length
5993
+ if (roundsWith >= LOOP_REPEAT_IN_WINDOW && (!loopHit || roundsWith > loopHit.rounds)) {
5994
+ loopHit = { sig, rounds: roundsWith }
5995
+ }
5996
+ }
4653
5997
  loopToolNames = [...new Set(assistantToolCalls.map((tc) => tc?.function?.name || '?'))].join(' + ')
4654
- if (repeatRounds >= LOOP_BREAK_REPEATS) {
5998
+ if (repeatRounds >= LOOP_BREAK_REPEATS || loopHit) {
4655
5999
  budgetReason = 'loop'
4656
6000
  // 协议要求每个 tool_call 都有对应的 tool 结果:回填「已跳过」再收尾,别留下悬空 tool_calls
4657
6001
  currentMessages = [
@@ -4660,7 +6004,11 @@ export function createAiChatMiddleware(opts = {}) {
4660
6004
  ]
4661
6005
  // 跳过的原因只写一份、两处共用:前端把它存进对话记录、下一回合原样回传成 `Error: <原因>`,
4662
6006
  // 与这里推给模型的 live 文本必须逐字节相同(两处各写一份文案 = 又一处前缀漂移)
4663
- const skipNote = `已跳过——这是连续第 ${repeatRounds} 次完全相同的调用(同工具 + 同参数),说明当前思路拿不到新信息,本轮到此为止。`
6007
+ const loopWhy =
6008
+ repeatRounds >= LOOP_BREAK_REPEATS
6009
+ ? `连续第 ${repeatRounds} 次完全相同的调用(同工具 + 同参数)`
6010
+ : `同一个调用在最近 ${LOOP_WINDOW_ROUNDS} 轮里有 ${loopHit.rounds} 轮都出现(周期性打转:改一版 IR → 跑一次同样的检查 → 再改一版)`
6011
+ const skipNote = `已跳过——这是${loopWhy},说明当前思路拿不到新信息,本轮到此为止。`
4664
6012
  for (const tc of assistantToolCalls) {
4665
6013
  sseWrite(res, {
4666
6014
  type: 'tool-result',
@@ -4715,21 +6063,51 @@ export function createAiChatMiddleware(opts = {}) {
4715
6063
  result = fsList(root, parsedArgs, mountedRoots)
4716
6064
  break
4717
6065
  case 'fs_write':
4718
- result = fsWrite(root, parsedArgs)
6066
+ result = fsWrite(root, parsedArgs, fsWriteLedger)
6067
+ break
6068
+ case 'ir_patch':
6069
+ result = irPatch(root, parsedArgs)
4719
6070
  break
4720
6071
  case 'fs_copy':
4721
6072
  result = fsCopy(root, parsedArgs, mountedRoots)
4722
6073
  break
4723
- case 'skill_exec':
4724
- result = await runSkillExec(root, parsedArgs, res, approvedTools)
6074
+ case 'skill_exec': {
6075
+ // 连续失败退避:同一调用(脚本 + 参数)连续失败 2 次就不再执行。
6076
+ // 报错一字未变时第三次必然还是同样的错,而每撞一次都要多付一步全量 prompt。
6077
+ const callKey = skillCallKey(parsedArgs, root)
6078
+ const prevFail = execFails.get(callKey)
6079
+ if (prevFail && prevFail.count >= SKILL_FAIL_SHORT_CIRCUIT) {
6080
+ result = skillShortCircuit(parsedArgs, prevFail)
6081
+ } else {
6082
+ result = await runSkillExec(root, parsedArgs, res, approvedTools)
6083
+ noteSkillExecOutcome(callKey, parsedArgs.script, result, execFails)
6084
+ // 迭代进度播报:把人从「29 分钟黑箱等待」里救出来(只走真执行路径,短路不播)
6085
+ notifyIterateProgress(res, parsedArgs.script, result)
6086
+ }
4725
6087
  break
6088
+ }
4726
6089
  case 'shell_exec':
6090
+ if (/^\s*(cat|ls|head|tail|rg|grep|find|wc|jq|sed|awk)\b/.test(String(parsedArgs?.command || ''))) {
6091
+ shellReadLikeCount += 1
6092
+ }
4727
6093
  result = await runShellExec(root, parsedArgs, res, approvedTools)
4728
6094
  break
4729
6095
  default:
4730
6096
  result = await dispatch(toolName, parsedArgs)
4731
6097
  }
4732
6098
  const { text, image } = splitToolResult(result)
6099
+ // 写到了别的卡上(新建 / 另存)必须让用户当场看见:本轮目标卡与「当前卡」不一致
6100
+ // 时推一条 warn(一次一轮一条)。card_rename 已在上面更新 liveCardId,不会误报。
6101
+ const targetCardId = cardIdFromToolArgs(toolName, parsedArgs)
6102
+ if (targetCardId && liveCardId && targetCardId !== liveCardId && !cardTargetNotified.has(targetCardId)) {
6103
+ cardTargetNotified.add(targetCardId)
6104
+ sseNotice(
6105
+ res,
6106
+ `AI 这次写的是卡片 ${targetCardId}(不是当前卡 ${liveCardId})——属于新建 / 另存,当前卡没有被覆盖`,
6107
+ 'warn',
6108
+ 'card-target',
6109
+ )
6110
+ }
4733
6111
  // 推给前端的 result 与下面的模型上下文**同一截断口径**:前端会把它原样存进对话记录、
4734
6112
  // 下一回合再回传。给前端截得更短(旧值 4000)等于每轮都改写一次历史,前缀缓存从该条起全废
4735
6113
  sseWrite(res, {
@@ -4758,9 +6136,6 @@ export function createAiChatMiddleware(opts = {}) {
4758
6136
  // 报错文本同样按统一口径截断:回放侧(toOpenAiMessages)会 slice 到同一上限,
4759
6137
  // 两处不一致时,一条超长报错(路径回显 / 网关长详情)就会在轮边界改写历史、
4760
6138
  // 把「这条之后」的前缀缓存整段打掉。Error: 前缀在两侧都算进同一个上限里
4761
- // 报错文本同样按统一口径截断:回放侧(toOpenAiMessages)会 slice 到同一上限,
4762
- // 两处不一致时,一条超长报错(路径回显 / 网关长详情)就会在轮边界改写历史、
4763
- // 把「这条之后」的前缀缓存整段打掉。Error: 前缀在两侧都算进同一个上限里
4764
6139
  currentMessages.push({ role: 'tool', tool_call_id: tc.id, content: `Error: ${errMsg}`.slice(0, TOOL_RESULT_MAX_CHARS) })
4765
6140
  }
4766
6141
  }
@@ -4888,7 +6263,16 @@ export function createAiChatMiddleware(opts = {}) {
4888
6263
  console.error('[ai-chat] failed:', e)
4889
6264
  const msg = String((e && e.message) || e)
4890
6265
  turnError = msg
4891
- sseWrite(res, { type: 'error', message: /aborted/i.test(msg) ? '请求已中止' : msg, code: classifyErrorCode(e) })
6266
+ // 硬错误不该只甩一句技术报错:这一轮跑过的工具**结果都已经落盘**(card_write / ir_patch /
6267
+ // expand_ir / iterate 全是直接写盘),对话上下文也原样保留 —— 用户回一句「继续」就能从断点
6268
+ // 接着做。不说这句,用户会以为整轮白干、从头再来(实测最贵的那次网关 400 报废了
6269
+ // 375.7s / 75 万 input,而产物其实都还在盘上)。
6270
+ const resumeNote =
6271
+ step > 0
6272
+ ? `本轮已跑完 ${step} 步工具调用:产物与对话上下文都已保留,回一句「继续」即可从断点接着做,不必从头再来。`
6273
+ : ''
6274
+ const humanMsg = /aborted/i.test(msg) ? '请求已中止' : msg
6275
+ sseWrite(res, { type: 'error', message: resumeNote ? `${humanMsg}\n${resumeNote}` : humanMsg, code: classifyErrorCode(e) })
4892
6276
  }
4893
6277
  sseWrite(res, { type: 'done' })
4894
6278
  } finally {
@@ -4922,7 +6306,20 @@ export {
4922
6306
  classifyErrorCode,
4923
6307
  normalizeUsage,
4924
6308
  prefixDrift,
6309
+ locateDropped,
6310
+ collectLocateRoots,
4925
6311
  TOOL_RESULT_MAX_CHARS,
6312
+ // 以下为本轮新增能力的单测入口(草稿区写入闸门 / IR 最小写 / 上下文尺子校准 / 迭代进度播报 / 正文截断)
6313
+ fsWrite,
6314
+ irPatch,
6315
+ toolsTokens,
6316
+ ctxScale,
6317
+ observeCtxScale,
6318
+ parseSkillJsonReport,
6319
+ notifyIterateProgress,
6320
+ cardIdFromToolArgs,
6321
+ BODY_NARRATION_MAX_CHARS,
6322
+ BODY_NARRATION_TAIL_CHARS,
4926
6323
  }
4927
6324
 
4928
6325
  export default createAiChatMiddleware