cardbuddy 0.14.3 → 0.16.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.
- package/dist/cli.js +0 -0
- package/dist/preview-editor/assets/index-BkbBxDTT.css +1 -0
- package/dist/preview-editor/assets/index-Bro07DTa.js +313 -0
- package/dist/preview-editor/index.html +2 -2
- package/dist/standalone.js +3 -0
- package/dist/vendored/card-editor/runtime/adapter.js +2 -2
- package/dist/vendored/mcp/client.mjs +9 -2
- package/dist/vendored/server/core/ai-chat.js +1702 -134
- package/dist/vendored/server/core/browser.js +12 -3
- package/dist/vendored/server/core/cardFiles.js +3 -0
- package/dist/vendored/server/core/cardHistory.js +1 -0
- package/dist/vendored/server/core/cardImportMeta.js +7 -6
- package/dist/vendored/server/core/cardOpenFolder.js +1 -0
- package/dist/vendored/server/core/cardSave.js +1 -0
- package/dist/vendored/server/core/cardSync.js +3 -0
- package/dist/vendored/server/core/cardValidate.js +16 -5
- package/dist/vendored/server/core/codeBridge.js +5 -1
- package/dist/vendored/server/core/logStream.js +1 -0
- package/dist/vendored/server/core/sliceStatic.js +24 -4
- package/dist/vendored/server/core/sliceUpload.js +1 -0
- package/dist/vendored/server/core/turnJournal.js +6 -0
- package/dist/vendored/skills/card-skill-trainer/SKILL.md +257 -201
- package/dist/vendored/skills/card-skill-trainer/scripts/diagnose_case.mjs +14 -82
- package/dist/vendored/skills/card-skill-trainer/scripts/lib/diagnosis.mjs +207 -0
- package/dist/vendored/skills/card-skill-trainer/scripts/lib/layout-correction-sim.mjs +62 -0
- package/dist/vendored/skills/card-skill-trainer/scripts/lib/paradigm-router.mjs +76 -0
- package/dist/vendored/skills/card-skill-trainer/scripts/lib/pixel-worker-runner.mjs +31 -0
- package/dist/vendored/skills/card-skill-trainer/scripts/lib/pixel-worker.mjs +138 -0
- package/dist/vendored/skills/card-skill-trainer/scripts/lib/scoring.mjs +24 -20
- package/dist/vendored/skills/card-skill-trainer/scripts/lib/telemetry.mjs +46 -0
- package/dist/vendored/skills/card-skill-trainer/scripts/plan_next_round.mjs +4 -1
- package/dist/vendored/skills/card-skill-trainer/scripts/run_round.mjs +279 -90
- package/dist/vendored/skills/card-skill-trainer/scripts/score_round.mjs +74 -2
- package/dist/vendored/skills/card-skill-trainer/scripts/sink_rules.mjs +127 -0
- package/dist/vendored/skills/dsl-card-ai/SKILL.md +1 -1
- package/dist/vendored/skills/dsl-card-generator/SKILL.md +208 -380
- package/dist/vendored/skills/dsl-card-generator/references/dsl_structure.md +1 -15
- package/dist/vendored/skills/dsl-card-generator/references/expression_engine.md +11 -20
- package/dist/vendored/skills/dsl-card-generator/references/generation_pitfalls.md +233 -61
- package/dist/vendored/skills/dsl-card-generator/references/ir-format.md +202 -4
- package/dist/vendored/skills/dsl-card-generator/references/iteration_protocol.md +87 -0
- package/dist/vendored/skills/dsl-card-generator/references/previewer_contract.md +1 -1
- package/dist/vendored/skills/dsl-card-generator/references/quickref.md +42 -33
- package/dist/vendored/skills/dsl-card-generator/references/recipe_snippets/title-underline-overlay.json +59 -11
- package/dist/vendored/skills/dsl-card-generator/references/recipes.md +44 -8
- package/dist/vendored/skills/dsl-card-generator/references/rules.md +26 -6
- package/dist/vendored/skills/dsl-card-generator/references/scripts_reference.md +54 -0
- package/dist/vendored/skills/dsl-card-generator/references/semantic_adjudication.md +23 -0
- package/dist/vendored/skills/dsl-card-generator/references/slices_convention.md +6 -6
- package/dist/vendored/skills/dsl-card-generator/references/spec_to_schema_patterns.md +33 -4
- package/dist/vendored/skills/dsl-card-generator/references/ui_fidelity_rules.md +13 -1
- package/dist/vendored/skills/dsl-card-generator/scripts/add_component.mjs +5 -14
- package/dist/vendored/skills/dsl-card-generator/scripts/autofix_schema.py +133 -2
- package/dist/vendored/skills/dsl-card-generator/scripts/check_fidelity.mjs +226 -19
- package/dist/vendored/skills/dsl-card-generator/scripts/check_magic_values.mjs +6 -1
- package/dist/vendored/skills/dsl-card-generator/scripts/check_topology.py +0 -35
- package/dist/vendored/skills/dsl-card-generator/scripts/crop_slices.mjs +92 -6
- package/dist/vendored/skills/dsl-card-generator/scripts/expand_ir.mjs +277 -27
- package/dist/vendored/skills/dsl-card-generator/scripts/gate.mjs +147 -0
- package/dist/vendored/skills/dsl-card-generator/scripts/generate_cards.mjs +12 -8
- package/dist/vendored/skills/dsl-card-generator/scripts/ir_merge.mjs +288 -0
- package/dist/vendored/skills/dsl-card-generator/scripts/ir_restore.mjs +175 -0
- package/dist/vendored/skills/dsl-card-generator/scripts/iterate.mjs +1015 -54
- package/dist/vendored/skills/dsl-card-generator/scripts/lib/component_contract.mjs +315 -0
- package/dist/vendored/skills/dsl-card-generator/scripts/lib/geometry_audit.mjs +131 -0
- package/dist/vendored/skills/dsl-card-generator/scripts/lib/layout-tokens.mjs +1 -1
- package/dist/vendored/skills/dsl-card-generator/scripts/lib/layout_rules.mjs +160 -0
- package/dist/vendored/skills/dsl-card-generator/scripts/lib/paths.mjs +23 -0
- package/dist/vendored/skills/dsl-card-generator/scripts/lib/paths.py +0 -37
- package/dist/vendored/skills/dsl-card-generator/scripts/lib/python.mjs +99 -0
- package/dist/vendored/skills/dsl-card-generator/scripts/lib/rules/adaptive-width.mjs +105 -0
- package/dist/vendored/skills/dsl-card-generator/scripts/lib/rules/bindings.mjs +260 -0
- package/dist/vendored/skills/dsl-card-generator/scripts/lib/rules/expr.mjs +52 -0
- package/dist/vendored/skills/dsl-card-generator/scripts/lib/rules/image.mjs +96 -0
- package/dist/vendored/skills/dsl-card-generator/scripts/lib/rules/registry.mjs +166 -0
- package/dist/vendored/skills/dsl-card-generator/scripts/lib/rules/structure.mjs +127 -0
- package/dist/vendored/skills/dsl-card-generator/scripts/lib/rules/walk.mjs +61 -0
- package/dist/vendored/skills/dsl-card-generator/scripts/lib/scope_alias.mjs +125 -0
- package/dist/vendored/skills/dsl-card-generator/scripts/lib/slicePath.mjs +17 -5
- package/dist/vendored/skills/dsl-card-generator/scripts/lib/slice_dedupe.mjs +102 -0
- package/dist/vendored/skills/dsl-card-generator/scripts/lib/v3_assemble.mjs +5727 -0
- package/dist/vendored/skills/dsl-card-generator/scripts/lib/v3_extract.mjs +4214 -0
- package/dist/vendored/skills/dsl-card-generator/scripts/lib/v3_shared.mjs +1110 -0
- package/dist/vendored/skills/dsl-card-generator/scripts/lint_card.mjs +322 -71
- package/dist/vendored/skills/dsl-card-generator/scripts/preflight.mjs +87 -43
- package/dist/vendored/skills/dsl-card-generator/scripts/readiness.mjs +634 -0
- package/dist/vendored/skills/dsl-card-generator/scripts/register_card_template.mjs +5 -8
- package/dist/vendored/skills/dsl-card-generator/scripts/render_card.mjs +91 -43
- package/dist/vendored/skills/dsl-card-generator/scripts/route_input.mjs +7 -11
- package/dist/vendored/skills/dsl-card-generator/scripts/spec_audit.mjs +272 -46
- package/dist/vendored/skills/dsl-card-generator/scripts/spec_to_schema_v3.mjs +43 -10499
- package/dist/vendored/skills/dsl-card-generator/scripts/v3_regression.mjs +188 -0
- package/dist/vendored/skills/dsl-card-generator/scripts/validate_schema.py +23 -15
- package/dist/vendored/skills/shared/exit-codes.md +79 -0
- package/dist/vendored/skills/shared/spec-contract.md +118 -0
- package/dist/vendored/skills/sketch-spec-to-card/SKILL.md +43 -20
- package/dist/vendored/skills/sketch-spec-to-card/references/spec-schema.md +4 -4
- package/dist/vendored/skills/sketch-spec-to-card/scripts/build_prompt.py +47 -9
- package/dist/vendored/skills/sketch-spec-to-card/scripts/cluster_states.py +14 -25
- package/dist/vendored/skills/sketch-spec-to-card/scripts/parse_meaxure.py +546 -91
- package/dist/vendored/skills/sketch-spec-to-card/scripts/png_utils.py +51 -5
- package/dist/vendored/skills/sketch-spec-to-card/scripts/test_text_only_artboard.py +217 -0
- package/dist/vendored/skills/sketch-spec-to-card/scripts/vision.py +64 -34
- package/package.json +1 -1
- package/dist/preview-editor/assets/index-Cm0USikc.js +0 -307
- package/dist/preview-editor/assets/index-cbTCHp8J.css +0 -1
- package/dist/vendored/skills/card-skill-trainer/scripts/test_missing_slice.py +0 -122
- package/dist/vendored/skills/dsl-card-generator/scripts/analyze_component_usage.mjs +0 -153
|
@@ -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,54 @@ 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
|
+
`列表插槽的作用域别名($item.<字段> / $item / $item._index)**不需要你规避**:expand_ir 在扩展开头会自动归一化成规范形($<字段名> / $$ / $_index),schema 里只存规范形。样式键写错才会中止——两者别混。\n`
|
|
265
|
+
_styleKeyCache = { key: `${file}:${mtime}`, text, mtime }
|
|
266
|
+
return _styleKeyCache
|
|
267
|
+
}
|
|
268
|
+
|
|
183
269
|
/**
|
|
184
270
|
* 系统提示词只放「工作协议 + 参考索引」。
|
|
185
271
|
* 此前整份注入两份 SKILL.md + rules.md(约 125KB / 数万 token),上下文臃肿会显著放大
|
|
@@ -187,8 +273,10 @@ let _systemPromptCache = null
|
|
|
187
273
|
*/
|
|
188
274
|
function buildSystemPrompt({ shellAllowed = false, vision = false, autoApprove = DEFAULT_AUTO_APPROVE, root = '' } = {}) {
|
|
189
275
|
const refsDir = skillRefsDir(root)
|
|
190
|
-
|
|
191
|
-
|
|
276
|
+
const styleKeys = styleKeySection(root)
|
|
277
|
+
// 缓存键带上实际命中的技能库:仓库内与消费者项目(init 释放的副本)文档集可能不同;
|
|
278
|
+
// 再带上 style-meta 的 mtime:合法样式键表直接决定提示词内容,meta 改了必须重算。
|
|
279
|
+
const cacheKey = `${shellAllowed ? 'shell' : 'base'}${vision ? '+vision' : ''}+aa:${autoApprove}+sk:${skillsDir(root)}+skm:${styleKeys.mtime}`
|
|
192
280
|
if (_systemPromptCache && _systemPromptCache.key === cacheKey) return _systemPromptCache.text
|
|
193
281
|
// 免确认策略要如实写进协议:模型若以为「每次都要用户点确认」,会主动停下来等批准 / 把命令甩给用户
|
|
194
282
|
const skillAuto = autoApproves(autoApprove, 'skill_exec')
|
|
@@ -231,32 +319,45 @@ function buildSystemPrompt({ shellAllowed = false, vision = false, autoApprove =
|
|
|
231
319
|
const _text = `你是 CardBuddy 编辑器内置的卡片编辑助手:帮用户读取、修改、生成低代码卡片 schema.json。
|
|
232
320
|
工作协议:
|
|
233
321
|
1. 先用 card_read / card_node_models 了解卡片现状;修改保持最小 diff,不要重写无关节点。
|
|
234
|
-
2. 写之前按需 skill_read 参考文档(path = 下面索引里的文件名):常规修改只读最相关的 1-2 个文件——表达式 / 格式化问题先读 expression_engine.md;card_write 前读 rules.md
|
|
322
|
+
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
323
|
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
|
|
324
|
+
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
325
|
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
326
|
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
327
|
3. 改变量类型时要同步处理引用它的地方(绑定表达式 / mock 数据);金额字段配格式化表达式生成展示文本。
|
|
240
328
|
4. 改完用人话说明:改了什么、为什么、需要用户确认什么。
|
|
241
|
-
5. 信息足够后立即收敛:直接调用工具落盘或回复方案;同一内容不要重复读取。**思考里不要逐字草拟 schema / IR / 长 JSON
|
|
329
|
+
5. 信息足够后立即收敛:直接调用工具落盘或回复方案;同一内容不要重复读取。**思考里不要逐字草拟 schema / IR / 长 JSON,也不要复述卡片数据**——那会同时吃掉思考与输出额度,想清结构就立刻调工具把内容写出去。**同样禁止在思考里做像素算术**:逐行核对坐标、心算 gap / 对齐 / 层高(「文字 22 + gap 6 + 下划线 2 到底放不放得进 48」这种)实测让三个回合各烧掉 10 分钟思考并被熔断丢弃;几何细节照审计值先落盘,再用 render-status / check_fidelity / iterate 的数值指标迭代。
|
|
330
|
+
5b. **正文只写结论**:工具轮之间的正文最多两句 / 300 字(发现了什么、改了什么、下一步调哪个工具);像素比对、逐行对齐、几何推算这类过程性推理在内部推理里做完直接调工具,不要写进正文。实测一次建卡把 35 万字过程分析写进了正文(144 轮里多轮单轮 2 万字),用户要的只是结论与门禁数值。
|
|
242
331
|
6. 读文件用 fs_read(仓库内任意文本文件 / 挂载附件),列目录用 fs_list —— fs_read 只能读文件,对目录一定报错,别拿它试目录;被中止过的轮次按上下文里的中止标记从中断处继续,不要从零重做。
|
|
243
332
|
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
333
|
|
|
334
|
+
${styleKeys.text}
|
|
335
|
+
## 卡片检查 / 校验(对已有卡跑门禁:**不是**建卡协议,别调 S1-S6 那套)
|
|
336
|
+
- **脚本里没有 \`validate.mjs\` 这个东西**(2026-09-21 实测:card_validate 已返回 ok 之后模型又去跑它,白烧一步)。校验入口就三个,各答一个问题:
|
|
337
|
+
① 契约校验(组件/属性/样式/表达式/data.json 一致性)= 工具 \`card_validate({id})\`;② 体验与布局规则 = skill_exec script=skills/dsl-card-generator/scripts/lint_card.mjs;
|
|
338
|
+
③ 收敛门禁(需该卡跑过 iterate)= skill_exec script=skills/dsl-card-generator/scripts/iterate.mjs args=["--gate","--out","src/cards/<id>/"]。
|
|
339
|
+
- 渲染自检 / 组件统计用 \`card_render_status\`,看卡内容用 \`card_read\`(或 describe_card.mjs)。**它们不互相替代,但也不必串成一串全跑**:用户只说「检查一下」时跑 ①(卡正在改就加 ②),别把 ③ 也拉上——③ 读的是上一轮落盘报告,没跑过 iterate 的卡它只会判 FAIL。
|
|
340
|
+
- 脚本名一律以磁盘为准:\`skill_exec\` 只跑 skills/<skill>/scripts/ 下真实存在的文件;报错会附上同目录的真实候选,照着里面的名字重发,**别继续猜下一个名字**。
|
|
341
|
+
|
|
245
342
|
## 设计稿建卡协议(Stage Machine:逐阶段推进,上一阶段产物齐了才进下一阶段;用户挂载设计稿目录 / 图片并要求建卡时严格执行,不要自由发挥)
|
|
246
343
|
S0 路由:输入形态已由服务端机械判定,写在「用户挂载的本地文件」段(kind / preprocess / next):照它执行,不要再调 card_route_input;该段缺失才自己调 card_route_input({ path })。
|
|
247
344
|
S1 要素包:kind=meaxure-export(Sketch MeaXure 标注导出)必须先有 sketch-spec-to-card 要素包,禁止直接读 preview/ 整图建卡。
|
|
248
345
|
- 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","<稿名>","--
|
|
346
|
+
① skill_exec script=skills/sketch-spec-to-card/scripts/parse_meaxure.py,args=["--src","<导出目录>","--list"] 拿画板清单(**每行行尾自带该画板的 --pick,板号 1 起**);
|
|
347
|
+
② args=["--src","<导出目录>","--out","UI","--draft","<稿名>","--pick","<照抄清单行尾>","--build"] 只产该画板(**照抄行尾的 --pick,禁止把序号换算成 0 起的 --indices**——实测换算错一位,白跑 34s 解析 + 一步 LLM 才发现;多张逗号分隔,连板写 7~9、同板连卡写 8.1~8.2);
|
|
251
348
|
③ script=skills/sketch-spec-to-card/scripts/cluster_states.py,args=["--out","UI/<稿名>"]。
|
|
252
349
|
- 要素包落点记作 <bundle>:传了 --draft 在 UI/<稿名>/<画板名>/,否则 UI/<画板名>/;以 parse_meaxure.py 打印的落盘路径为准。仓库可能两种布局并存,manifest 说的画板在 UI/ 下找不到就用 fs_list UI 找同名目录,别急着判定产物缺失。
|
|
350
|
+
S1.4 批量建卡(用户一次点了多张卡 / 回了「全部」):**一次** parse_meaxure --pick 出全部要素包(别每卡重跑一遍解析),然后**同一个回合内**按「S2→S5 走完一张,再走下一张」连续推进——每张卡各写各的 IR、各跑各的 expand + iterate。禁止每张卡各起一个回合:新回合的首步要把整段对话重发一遍(实测 1.3-3 万 token 一步),还要把同一个要素包重读一遍,纯浪费。
|
|
253
351
|
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
352
|
- 只有 1 张卡、或用户已明说目标(序号如 2.3 / 画板名 / 卡片名)⇒ 直接进 S2,不要为走流程停下来问。
|
|
255
|
-
- 多张卡且用户未指定 ⇒ **本轮到此为止**:把清单写进正文 +
|
|
256
|
-
|
|
353
|
+
- 多张卡且用户未指定 ⇒ **本轮到此为止**:把清单写进正文 + 明确提问,正文最后**单独一行输出 [需用户确认]**,然后停止输出、等用户回序号再继续。没有这行标记会被判定「任务未完成就停手」而自动续跑(实测多花一整轮重发清单)。聊天面板**没有**弹选项工具(不存在 request_user_input),不要去调用或寻找弹窗工具,也不要替用户挑。
|
|
354
|
+
清单**逐行配图**:每个卡片行下紧跟一行 Markdown 图片,写成 !\[画板名\](该卡 card-N.png 的完整绝对路径),与序号一一对应——**必须是 !\[\](...) 包起来的图片语法**;parse_meaxure 输出里的 "图: <路径>" 行是给你看的提示,照抄成裸路径渲染端不显示(实测照抄后用户 0 张图可看);缺图显式标缺失,禁止把图堆到清单末尾。末尾追加一项「全部」,用户回「全部」才算显式批量授权(产了几张卡要在正文报)。
|
|
355
|
+
**清单必须以普通 Markdown 列表逐行输出,禁止用代码块围栏(\`\`\` 或 ~~~)或 4 空格缩进包裹清单的任何部分**——渲染端把代码块当纯文本展示,里面的图片语法一行都不会转成预览图,选卡协议整段失效(实测 8 张配图全灭)。parse_meaxure --list 的输出**照抄行内容即可,围栏与缩进不要照抄**。
|
|
356
|
+
**路径必须写完整绝对路径**(以 /Users/… 或 C:\… 打头,一字不改照抄),**禁止用省略号(… 或 ...)代替前缀**(实测「…/积分助手-4-xx.png」这种写法渲染端取不到图,用户那几行就只剩一串路径=配图全丢),**禁止把路径包进反引号或写进代码块**(反引号 / 代码块里的是给人抄的文本,不会渲染成图片)。清单长不是省前缀的理由:图显示不出来才是真的白写。
|
|
257
357
|
只回画板序号默认取该画板第 1 张卡。
|
|
258
358
|
- **禁止**自行挑画板 / 挑卡、**禁止**「先建一张看看」、**禁止**未经同意批量产卡。
|
|
259
359
|
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"]。
|
|
360
|
+
切片引用名先查 <bundle>/slices/index.json(--build 已生成:每张切图的真实相对路径与大小),照它写引用、按 R31 命名规则消毒(禁用字符 # ? % & + 不进引用名),expand_ir 会递归命中嵌套子目录里的真身;**禁止 fs_list 逐层摸索**(实测 6 次才找到嵌套真名,纯浪费)。
|
|
260
361
|
它交叉审计 spec + 聚焦截图 + 切图目录,给出:bands(水平行与行内元素序)、gaps(padLeft / padRight / padTop / padBottom / rowGaps)、texts(文案 / 字号 / 字重 / 颜色 / 行高 / 对齐)、shapes(装饰条)、slices(存在性 / 复用 / 是否烘焙背景与标签文字)、colors(背景采样点)、missing(缺哪张切图 + 建议裁剪矩形)。
|
|
261
362
|
它交叉审计 spec + 聚焦截图 + 切图目录,另给 bgGradientHint(顶部渐变带的起止色/跨度 + IR 正确写法)与 slices[].crops(reused-differs 时「矩形 → 该用哪张 _alt 图」的映射,exists=true 直接引用、false 才需裁)。
|
|
262
363
|
IR 的几何与样式一律照抄审计值;**禁止 fs_read spec.json**(几百 KB 只会拿到截断片段,重复读纯浪费步数),禁止反复 fs_list 摸索结构。要看层级树才跑 spec_tree.mjs。
|
|
@@ -264,29 +365,43 @@ S2 审计(一步代替反复探索):skill_exec script=skills/dsl-card-gene
|
|
|
264
365
|
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
366
|
只缺一两张时用 --crop <名>@x,y,w,h(可多条),x/y/w/h 直接抄审计 missing[].crop 的**卡片本地 1x 坐标**,脚本自己按 design 宽 ÷ 卡宽换算像素。**禁止用 sips / convert / magick 等系统图像命令裁图**:它们不在 shell 白名单,且要手算缩放比与裁剪偏移,实测必错。
|
|
266
367
|
切图用法看 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
|
|
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>/,即覆盖当前卡 /
|
|
368
|
+
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 硬拦))。
|
|
369
|
+
**类型开关铁律(渲染器静默丢弃类缺陷的唯一防线)**: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)。
|
|
370
|
+
落点卡默认 = 当前正在编辑的卡片 ID(见「当前卡片上下文」):IR 的 id 与 iterate 的 --out 一律 src/cards/<当前卡片ID>/,即覆盖当前卡 / 空卡是默认行为。**「建卡」「生成卡片」「用这张画板做一张卡」都不是「新建一张」**——那是在说「用设计稿出一张卡」,落点仍是当前卡;只有用户明确说「新建一张 / 另存为新卡 / 换个新 id / 不要覆盖当前卡」才自拟 ID,且自拟前先用一句正文写明「将新建卡片 <新id>,不覆盖当前卡 <当前卡id>」,让用户当场看得见(实测:把「建卡」读成「新建」,界面里凭空多出一张卡而对话里没有任何交代)。当前卡已有内容时,动手前一句正文说明将覆盖、原内容留 .history 快照可回滚。
|
|
270
371
|
S4.5 变量抽取(真实卡片不是截图:这步漏了,产物就是 dictionary / data.json 全空的死图):先 skill_read ir-format.md 的「2b. 变量绑定」看契约,再决定哪些文本绑变量——
|
|
271
372
|
下限 = S2 审计的 bindHints[](amount / percent / date / masked / count / level),再按语义补正则认不出的动态值(用户名 / 客户层级 / 卡片名称 / 状态词);静态标签(区块标题 / 按钮 / 说明文字)才写字面量。
|
|
272
373
|
- 动态值:Text 主值用 v:"$key"(条件文案写表达式如 "$showAll ? '收起' : '查看更多'",金额展示照 recipe_snippets/money-format.json,别把「¥7,000.00」拆成字面量)。
|
|
273
374
|
- IR 顶层**显式写 dictionary.inputParams**:{key,name,type,defaultValue,desc,rule:{type:"",options:{}},children:[]},defaultValue 一律取设计稿字面量——expand_ir 用它生成 data.json,渲染即设计稿文案,S5 像素闭环才不受绑定影响。
|
|
274
|
-
- 重复行 / tile 组(审计 listCandidates[]、或用户明说要列表):数组变量 + listScope
|
|
375
|
+
- 重复行 / tile 组(审计 listCandidates[]、或用户明说要列表):数组变量 + listScope 插槽。item 的字段**已逐个展开到作用域** ⇒ 规范形三种:$<字段名>(字段名语义化自取)、$$(整个 item)、$_index(行索引)。写成 $item.<字段> 也不影响结果——expand_ir 在扩展开头会把它自动归一化成规范形(作用域里本来就没有 item 这个键,schema 只存规范形;schema 级另有 autofix_schema.py 兜底)。范式照 recipe_snippets/list-expand-collapse.json 或 src/cards/account 的 account_details。结构确实不规则时允许保持静态,但要在正文说明理由。
|
|
275
376
|
- 命名语义化(memberLevel / totalAmount / assetRows),禁 text1 / val2;写完在正文列一句绑定清单(key → 设计稿文案)。
|
|
276
|
-
|
|
377
|
+
S4.9 内容就绪门禁(**像素自循环的前置条件**;不达标时 iterate 会把本轮整体挡掉——不 render、不比对像素、不消耗迭代轮次):
|
|
378
|
+
IR 写完、变量抽完后,先跑 skill_exec script=skills/dsl-card-generator/scripts/readiness.mjs,args=["--id","<落点卡>","--spec","<bundle>/spec.json","--design","<bundle>/card-N.png","--json"]。退出码 0=就绪 / 3=未就绪(**3 不是脚本报错,是内容没准备好**)。
|
|
379
|
+
五道**声明性**判定(不需要渲染,所以能挡在最贵的一步之前):A 组件契约(属性键 / 样式组 / 样式子键必须在 component-meta.json + style-meta.json 白名单内)· B 要素齐全(设计稿每条文案在 schema 里找得到)· C 变量绑定完备($var 的根变量必须在 dictionary.inputParams / mocks / data.json / 事件 setData 里存在)· D 图片地址已配置(每个 Image 必须有非空 value)· E 切图路径已配置(每个 /slices/... 引用在 src/slices 里有对应文件)。
|
|
380
|
+
blockers 非空 ⇒ **先补内容,别进 S5**。这一步存在的唯一理由:内容没齐时像素分毫无参考价值。实测事故(2026-09-24 gen-1):跳过这步,在图片地址都没配的情况下连刷 10 轮像素(match 卡在 87-90.46),两回合烧掉 623 万 prompt token 全部作废。
|
|
381
|
+
三条高频坑(readiness 会在 blockers 里逐条点名,不要靠猜):① Image 的图片地址属性键**只能是 value**,写成 image 会被运行时按 itemKey 绑定失败后**整条静默丢弃** ⇒ value 为空 ⇒ 破图,而 validate / lint / preflight 全绿;② from:"2" 的值必须是静态路径、from:"1" 的值必须是 $表达式,写反 ⇒ 字符串直接进 url(...) 或表达式求值失败 ⇒ 画布空白;③ 切图**引用名与磁盘文件名必须同源**(crop_slices 按引用名落盘;引用一个 src/slices 里不存在的名字 ⇒ 渲染 404、图空白、控制台零报错)。
|
|
382
|
+
**iterate 每轮会自动再跑这道门禁**:报告里 gate.stage=content(或 verdict 以 BLOCKED(内容未就绪)开头)表示本轮被挡——没跑 render、没跑 pixelDiff、**没消耗迭代轮次**。按 gate.verdict 里的 blockers 补齐内容后**直接重跑 iterate**即可;禁止在被挡时反复重跑空转,更禁止去调像素(那时候没有像素分可调)。
|
|
383
|
+
逃生门 --no-readiness(或环境变量 DSL_CARD_SKIP_READINESS=1)只在排查「门禁本身误判」时用,正常流程禁止。
|
|
384
|
+
S5 收敛循环(唯一门禁 = iterate.mjs 报告里的 gate 字段:内部已跑 autofix + 真 validate + lint + preflight + **内容就绪(S4.9)** + render + 并集像素对比,别再单独跑 validate_schema.py):
|
|
277
385
|
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
|
|
279
|
-
**收敛 = 报告 gate.passed === true
|
|
280
|
-
|
|
281
|
-
|
|
386
|
+
修 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")。
|
|
387
|
+
**收敛 = 报告 gate.passed === true**(**内容就绪(S4.9)** ∧ validate/lint/preflight 零 error ∧ **elements.missing=0** ∧ match ≥ 95 ∧ heightDelta=0 ∧ widthDelta=0 ∧ |contentOffset1x| ≤ 2)。gate 是脚本判的,正文只能引用它的 verdict / failed,不得自行宣布达标。像素对比按并集画布算,删内容 / 高度塌缩只会降分,不可能凑分。
|
|
388
|
+
**要素是固定真值:先内容、后骨架、最后样式(三段式门禁,逐段推进)**。S2 审计里的每条文本 / 每张切图都是这张卡的既定要素,S5 只许改**样式与布局**(尺寸 / 间距 / 颜色 / 字号 / flex / 位置),**不许丢要素、不许自造内容**。报告带 gate.stage 与 gate.checks[].name === 'readiness.content':
|
|
389
|
+
· stage=content(gate.blocked=true)⇒ 内容层没齐(要素 / 变量绑定 / 图片地址 / 切图落盘),**本轮根本没 render**。照 verdict 里的 blockers 补内容,别调像素、别重跑空转。
|
|
390
|
+
· stage=structure(elements.missing>0 或 validate/lint/preflight 未过)⇒ **先补要素,别调像素**:把设计稿的文案补回对应节点;绑定变量的把 defaultValue 改成设计稿真值。改样式不会把丢掉的文案变回来。
|
|
391
|
+
· stage=pixel ⇒ 内容与结构都齐了,才进入样式 / 布局收敛(一轮只改一个分项)。
|
|
392
|
+
实测事故(2026-09-21):模型把设计稿的产品名 / 利率 / 期限整批换成自己编的占位值(「理财优选1号 / 2.45 / 每日申购」),像素照样刷到 89%、validate/lint 全绿,而卡的**内容真值整批丢失**,直到人工比对设计稿才发现。报告 elements.invented 会把这类「schema 里有、设计稿里没有」的自造文案逐条点名,elements.missing 列出漏掉的要素。
|
|
393
|
+
报告还带:contentOffset1x + contentOffsetHint(内容整体平移时先修对齐,别逐行调间距)、**contributors + contributorsNote(分项归因:每个差异热区给出最可能出问题的具体节点)**、widthMismatch(--width 与设计稿卡面宽不符 ⇒ 先改 --width,否则 match/dy 都不可信)、lint.warningDetails、preflight.details、renderImage(渲染原图路径)、diffImageNote(并排图顶部色条是工具标题栏,不是卡片内容)、stalled(**连续 3 轮未刷新历史最好成绩**——震荡下滑同样触发——附 best 与回退命令)、best(历史最好成绩 {match,round})。
|
|
394
|
+
**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 与同类中位判断,**不要自己编设计稿高度**。
|
|
395
|
+
**报告里出现 envBlocked / verdict 以 BLOCKED 开头 ⇒ 本机没有可用的 python,validate 根本没跑**:这是环境问题,改 schema、继续 --set 都修不好它——立刻停手如实告诉用户(装 Python 或设 DSL_CARD_PYTHON=<解释器路径>)后重跑,别把它当门禁缺口反复重试。
|
|
396
|
+
**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
397
|
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
|
|
398
|
+
**取证禁令**:禁止用 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
399
|
每轮用一两句正文汇报:gate.verdict / match% / heightDelta / 这轮修了什么,不要闷头连调。
|
|
285
|
-
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
|
|
400
|
+
S6 收尾门禁:① skill_exec script=skills/dsl-card-generator/scripts/readiness.mjs,args=["--id","<落点卡>","--spec","<bundle>/spec.json","--design","<bundle>/card-N.png"],**必须 exit 0(就绪)**——内容层的四类缺口(要素 / 变量绑定 / 图片地址 / 切图落盘)在收尾时同样不许放过;② 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;结论可能是 pass / stalled / **blocked-content** / iterating 四种,blocked-content 表示内容仍未就绪,不是收敛)。
|
|
286
401
|
正文汇报必须**逐字引用 --gate 的 verdict 与 failed 列表** + 落点卡 + 补了哪些切图 + 回滚方式。gate 未过禁止写「门禁全绿 / 已完成 / 验收通过」,必须列出未达标项、原因与下一步;禁止把未达标归因于「测量噪声 / 工具合成产物」——pixelDiff 只用渲染原图与设计原图,合成图色条不参与计算(见 diffImageNote)。
|
|
287
402
|
绑定对齐:check_fidelity 会按 data.json / defaultValue 把 $ 绑定解析回文案再对齐——报 missing-text 而该文本已绑定 ⇒ 是 mock 与设计稿不一致,改 defaultValue / data.json,**不是拆回字面量**。收尾自查:bindHints 每一项要么已绑定、要么正文说明为何保持字面量。
|
|
288
403
|
${vision ? '你有视觉能力:S5 里 match 不达标可直接读注入的并排对比图定位差异(哪一行 / 哪个颜色 / 哪段间距)再 --set;数值指标仍是收敛判据,图只用于定位。' : '当前模型无视觉:不要声称看过图;S2 的审计值 + S5 的数值指标就是你的眼睛,所有尺寸颜色文案取审计值,缺时按 references/ui_fidelity_rules.md 降级规则处理,禁止凭空估数值。'}
|
|
289
|
-
步数不设硬顶(除非用户在设置里配了上限),但有四道闸:①连续多轮「同工具 +
|
|
404
|
+
步数不设硬顶(除非用户在设置里配了上限),但有四道闸:①连续多轮「同工具 + 同参数」会被判定原地打转并强制收尾(判据含 --ir 文件的指纹:**改过 IR 再原样重跑同一条命令不算打转**,按「改一版 → 重跑一次拿新指标」的节奏走是安全的;输入一字未变还反复跑同一条命令才会被拦) ②单轮墙钟到点会强制收尾 ③每 24 步会收到一次收敛提醒 ④单轮上下文超预算时**早期工具结果会被折叠成摘要**并标注「已折叠」——看到折叠标记就用工具重读,不要拿摘要当结论。所以别把额度当免费:S2 / S3 / S5 各尽量一次调用解决;同类信息只读一次;收到重复调用警告就立刻换证据链(diff_bands.mjs / generation_pitfalls.md),不要把同一条调用原样再发一次;被中止过的轮次按上下文里的中止标记从中断处继续,不要从零重做。
|
|
290
405
|
|
|
291
406
|
## 收尾协议(不要停下来等「继续」)
|
|
292
407
|
- 任务没做完就不要交还控制权:接着调用工具推进到下一个门禁。**禁止**输出「下一步(请确认或直接说「继续」)」这类等待式收尾——用户已开启自动续跑,停手只会让任务白等一轮。
|
|
@@ -306,15 +421,26 @@ const SKILL_READ_TOOL = {
|
|
|
306
421
|
type: 'function',
|
|
307
422
|
function: {
|
|
308
423
|
name: 'skill_read',
|
|
309
|
-
description: '
|
|
424
|
+
description: '读取系统提示词「按需参考索引」中列出的建卡参考文档(文件名),返回正文。文档比一屏长时只返回一段,尾巴上给出下一段 offset——需要后半段就直接带 offset 再读一次,不要凭半截内容猜。只在需要时读,不要全读。',
|
|
310
425
|
parameters: {
|
|
311
426
|
type: 'object',
|
|
312
|
-
properties: {
|
|
427
|
+
properties: {
|
|
428
|
+
path: { type: 'string', description: '索引中的文件名,如 expression_engine.md' },
|
|
429
|
+
offset: { type: 'integer', description: '续读起点(字符偏移)。照抄上次返回尾巴里的 offset,不要自己估算。' },
|
|
430
|
+
},
|
|
313
431
|
required: ['path'],
|
|
314
432
|
},
|
|
315
433
|
},
|
|
316
434
|
}
|
|
317
|
-
|
|
435
|
+
// skill_read 的自有上限**必须**等于统一截断口径:以前这里写 30000 > TOOL_RESULT_MAX_CHARS,
|
|
436
|
+
// 文案「已截断」还没送出就被外层按 12000 又切一刀——模型拿到的是 12000 字、且**看不到任何截断提示**,
|
|
437
|
+
// 实测 ir-format.md(17285 字符)每次读都静默丢 31%,模型拿着前半段契约猜后半段字段。
|
|
438
|
+
const SKILL_READ_MAX = TOOL_RESULT_MAX_CHARS
|
|
439
|
+
/**
|
|
440
|
+
* 续读锚点预留:尾巴要写「读了哪段 / 下一段 offset / 下文小节索引」,正文得让出这点地方。
|
|
441
|
+
* 取 240 是「够尾巴写满 6 个小节标题」的量级;真超了 clipSkillDoc 会再让正文,不漏字。
|
|
442
|
+
*/
|
|
443
|
+
const SKILL_READ_TRAILER_RESERVE = 240
|
|
318
444
|
|
|
319
445
|
/** 大卡定位/最小写工具:card_read 只回喂 12KB、card_write 要整包替换,
|
|
320
446
|
* 400KB 级卡片上二者都不可行(模型只能空转)。card_grep 给 JSON 路径,card_patch 按路径最小写。 */
|
|
@@ -490,7 +616,7 @@ function shellTool(auto = false) {
|
|
|
490
616
|
type: 'function',
|
|
491
617
|
function: {
|
|
492
618
|
name: 'shell_exec',
|
|
493
|
-
description: `在仓库根经真实 shell(/bin/sh -c)执行一条本地命令并返回输出(60s 超时)。cwd 已是仓库根,命令里不要写 cd(要切目录用脚本自己的 --src / --out 参数)。支持管道 |、链接 && / ;、重定向、引号与循环 / 条件 / 子shell / $( ) 等 shell 语法;其中真正执行的每个外部命令(含管道各段与替换体内部)必须在白名单(${[...SHELL_ALLOW_BIN].join(' / ')}
|
|
619
|
+
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
620
|
parameters: {
|
|
495
621
|
type: 'object',
|
|
496
622
|
properties: {
|
|
@@ -517,7 +643,7 @@ function skillExecTool(auto = false) {
|
|
|
517
643
|
type: 'function',
|
|
518
644
|
function: {
|
|
519
645
|
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。' : '每次调用先请用户确认(用户可选「本回合不再询问」)。'}`,
|
|
646
|
+
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
647
|
parameters: {
|
|
522
648
|
type: 'object',
|
|
523
649
|
properties: {
|
|
@@ -542,11 +668,23 @@ const SKILL_EXEC_DEFAULT_TIMEOUT = 180000
|
|
|
542
668
|
const SKILL_EXEC_MAX_TIMEOUT = 600000
|
|
543
669
|
let _pythonBin = ''
|
|
544
670
|
|
|
545
|
-
/**
|
|
671
|
+
/**
|
|
672
|
+
* Python 解释器:环境变量链 + 探测 python3 / python / py(管线脚本只用标准库,无需装依赖)。
|
|
673
|
+
*
|
|
674
|
+
* **能力必须与 `skills/dsl-card-generator/scripts/lib/python.mjs` 对齐**(那边是权威实现,
|
|
675
|
+
* 也是 skill 脚本侧真正用的那个)。两边不一致的症状很隐蔽:同一台机器上 skill 脚本跑得通、
|
|
676
|
+
* 而这里的 `skill_exec` 判「没有 Python」。之所以不做 import 而是照搬:`server/` 与 `skills/`
|
|
677
|
+
* 是两个可独立分发的单元(skills 要能整体复制到任意项目),代码级互相依赖会把这个前提破掉。
|
|
678
|
+
* 这里此前只认 `DSL_CARD_PYTHON` 且不认 `py` —— Windows 上 Python 从 Store 安装时往往只有
|
|
679
|
+
* `py` 启动器(`python3` 是应用执行别名:能启动、立刻 9009 退出)。
|
|
680
|
+
*/
|
|
546
681
|
function pythonBin() {
|
|
547
|
-
|
|
682
|
+
for (const key of ['DSL_CARD_PYTHON', 'WORKBUDDY_PYTHON', 'DSL_PYTHON', 'PYTHON']) {
|
|
683
|
+
const val = process.env[key]
|
|
684
|
+
if (val) return val
|
|
685
|
+
}
|
|
548
686
|
if (_pythonBin) return _pythonBin
|
|
549
|
-
for (const bin of ['python3', 'python']) {
|
|
687
|
+
for (const bin of ['python3', 'python', 'py']) {
|
|
550
688
|
try {
|
|
551
689
|
if (spawnSync(bin, ['--version'], { stdio: 'ignore' }).status === 0) {
|
|
552
690
|
_pythonBin = bin
|
|
@@ -560,6 +698,53 @@ function pythonBin() {
|
|
|
560
698
|
return _pythonBin
|
|
561
699
|
}
|
|
562
700
|
|
|
701
|
+
/** 候选清单长度上限:够模型认出真名即可,别把整目录灌进上下文。 */
|
|
702
|
+
const SCRIPT_HINT_MAX = 12
|
|
703
|
+
|
|
704
|
+
/**
|
|
705
|
+
* 「脚本不存在」时的真实候选清单。
|
|
706
|
+
*
|
|
707
|
+
* 为什么必须有:模型会凭记忆编脚本名(实测 2026-09-21 信用卡账单卡:`card_validate` 已经返回
|
|
708
|
+
* ok 之后,又去 skill_exec 一个不存在的 `skills/dsl-card-generator/scripts/validate.mjs`)。
|
|
709
|
+
* 只回一句「脚本不存在」,它要么接着猜下一个名字(一路试到熔断),要么误判「这个能力不可用」而放弃。
|
|
710
|
+
* 带上同目录的真实文件名,一次报错就能自我纠正。
|
|
711
|
+
*
|
|
712
|
+
* 排序:同名干优先(validate.mjs → validate_schema.py),其余按字典序补齐。
|
|
713
|
+
* skill 目录本身不存在时,退化成列出技能库里真实的 skill 名。
|
|
714
|
+
*/
|
|
715
|
+
function skillScriptHint(root, clean) {
|
|
716
|
+
const segs = String(clean || '').split('/')
|
|
717
|
+
const skill = segs[0] || ''
|
|
718
|
+
const stem = (segs[segs.length - 1] || '').replace(/\.[^.]+$/, '').toLowerCase()
|
|
719
|
+
for (const base of skillsCandidates(root)) {
|
|
720
|
+
const dir = path.join(base, skill, 'scripts')
|
|
721
|
+
let files = []
|
|
722
|
+
try {
|
|
723
|
+
if (!fs.existsSync(dir)) continue
|
|
724
|
+
files = fs.readdirSync(dir).filter((f) => /\.(mjs|cjs|js|py)$/.test(f))
|
|
725
|
+
} catch {
|
|
726
|
+
continue // 候选根不可读(权限 / 竞态)→ 换下一个
|
|
727
|
+
}
|
|
728
|
+
if (!files.length) continue
|
|
729
|
+
const stemOf = (f) => f.toLowerCase().replace(/\.[^.]+$/, '')
|
|
730
|
+
const near = files.filter((f) => stemOf(f).includes(stem) || stem.includes(stemOf(f)))
|
|
731
|
+
const names = [...near.sort(), ...files.filter((f) => !near.includes(f)).sort()].slice(0, SCRIPT_HINT_MAX)
|
|
732
|
+
return `\n${skill}/scripts 下真实存在的脚本(共 ${files.length} 个,只能挑这些):${names.join(' / ')}${files.length > names.length ? ' …' : ''}`
|
|
733
|
+
}
|
|
734
|
+
const found = new Set()
|
|
735
|
+
for (const base of skillsCandidates(root)) {
|
|
736
|
+
try {
|
|
737
|
+
for (const d of fs.readdirSync(base)) {
|
|
738
|
+
if (fs.statSync(path.join(base, d)).isDirectory()) found.add(d)
|
|
739
|
+
}
|
|
740
|
+
} catch {
|
|
741
|
+
/* 换下一个候选根 */
|
|
742
|
+
}
|
|
743
|
+
}
|
|
744
|
+
if (!found.size) return ''
|
|
745
|
+
return `\n技能库里没有 ${skill} 这个技能;现有技能:${[...found].sort().join(' / ')}`
|
|
746
|
+
}
|
|
747
|
+
|
|
563
748
|
function resolveSkillScript(root, script) {
|
|
564
749
|
const rel = String(script || '').trim().replace(/\\/g, '/').replace(/^\.\//, '')
|
|
565
750
|
if (!rel) throw new Error('缺少 script')
|
|
@@ -571,7 +756,13 @@ function resolveSkillScript(root, script) {
|
|
|
571
756
|
const clean = normalizeSkillRel(rel)
|
|
572
757
|
if (!clean) throw new Error('非法路径:脚本必须在仓库 skills/ 目录内')
|
|
573
758
|
const abs = resolveSkillPath(root, clean)
|
|
574
|
-
if (!abs || !fs.statSync(abs).isFile())
|
|
759
|
+
if (!abs || !fs.statSync(abs).isFile()) {
|
|
760
|
+
throw new Error(
|
|
761
|
+
`脚本不存在:${rel}${skillScriptHint(root, clean)}\n` +
|
|
762
|
+
'**别再猜下一个名字**:清单里有近似的就直接用那个名字重发(脚本名一律以磁盘为准,禁止凭记忆拼);' +
|
|
763
|
+
'清单里没有的能力换个入口——卡片契约校验用工具 card_validate,其余本地命令用 shell_exec。',
|
|
764
|
+
)
|
|
765
|
+
}
|
|
575
766
|
return { rel, abs, bin: path.extname(abs).toLowerCase() === '.py' ? pythonBin() : 'node' }
|
|
576
767
|
}
|
|
577
768
|
|
|
@@ -594,8 +785,32 @@ function normalizeSkillArgs(args) {
|
|
|
594
785
|
const SHELL_ALLOW_BIN = new Set([
|
|
595
786
|
'npm', 'node', 'npx', 'git', 'rg', 'grep', 'cat', 'ls', 'pwd', 'cd', 'find',
|
|
596
787
|
'head', 'tail', 'wc', 'sort', 'uniq', 'cut', 'tr', 'sed', 'awk', 'jq', 'diff', 'echo', 'mkdir',
|
|
788
|
+
// 文件搬运与跨平台小工具(2026-09-17 起放行):这些拦下来只换来「换个同类命令再撞一次」——
|
|
789
|
+
// 实测 cp 被拒 → 提示指去 fs_write → fs_write 只写草稿区 → 又回 cp,一轮白烧两次。
|
|
790
|
+
// mv / rm 仍不在白名单(那才是不可逆操作,见 SHELL_BIN_HINTS)。
|
|
791
|
+
'cp', 'touch', 'dirname', 'basename', 'which', 'env', 'date', 'printf', 'xargs', 'tee',
|
|
597
792
|
])
|
|
598
793
|
|
|
794
|
+
/**
|
|
795
|
+
* 透明命令:段首是它时,真正执行的外部命令在它后面(env FOO=1 node x / xargs -I{} node y)。
|
|
796
|
+
* 放行它们就必须接着往下看一层,否则 `env rm -rf x`、`xargs rm` 会借这两个词绕过白名单。
|
|
797
|
+
*/
|
|
798
|
+
const SHELL_TRANSPARENT_WORDS = new Set(['env', 'xargs'])
|
|
799
|
+
|
|
800
|
+
/** 透明命令后第一个「真命令词」:跳过选项与 VAR=value 赋值;找不到(如纯 env / 纯 xargs)返回 null */
|
|
801
|
+
function innerCommandWord(seg) {
|
|
802
|
+
const flat = seg.includes('$(') || seg.includes('`') ? shellFlatSegments(seg)[0] || '' : seg
|
|
803
|
+
const toks = tokenizeCommand(flat)
|
|
804
|
+
for (let i = 1; i < toks.length; i++) {
|
|
805
|
+
const t = toks[i]
|
|
806
|
+
if (!t || t.startsWith('-') || t.startsWith(SUBST_PLACEHOLDER)) continue
|
|
807
|
+
if (/^[A-Za-z_][A-Za-z0-9_]*=/.test(t)) continue // env 的 FOO=1
|
|
808
|
+
if (SHELL_SYNTAX_WORDS.has(t) || SHELL_TAIL_WORDS.has(t)) continue
|
|
809
|
+
return t
|
|
810
|
+
}
|
|
811
|
+
return null
|
|
812
|
+
}
|
|
813
|
+
|
|
599
814
|
/**
|
|
600
815
|
* 白名单外的高频误用 → 指回范式工具。
|
|
601
816
|
* 报错文本是模型唯一的路标:只说「不在白名单」它下一轮会换个同类命令再撞一次
|
|
@@ -611,10 +826,7 @@ const SHELL_BIN_HINTS = {
|
|
|
611
826
|
python3: 'Python 脚本用 skill_exec(script=skills/<skill>/scripts/x.py,args 传参数数组):白名单不含解释器',
|
|
612
827
|
rm: '不要删文件:覆盖写用 fs_write,卡片改动用 card_patch / card_write,历史版本由 .history 快照保管',
|
|
613
828
|
mv: '移动 = fs_copy 复制到新落点;删旧文件不支持(留给用户或 .history 快照)',
|
|
614
|
-
cp: '复制文件用 fs_copy 工具(from / to 两个路径参数,二进制安全、自动建父目录);改写文本内容才用 fs_write(仅草稿区)',
|
|
615
829
|
dir: 'Windows 下用 ls 列目录(Git Bash 自带);或直接用自己的领域知识判断文件位置',
|
|
616
|
-
mkdir: '目录由写入工具自动创建(fs_write / fs_copy / crop_slices.mjs --out),不需要手动 mkdir',
|
|
617
|
-
touch: '写空文件用 fs_write(content 传空串,仅草稿区);仓库内落占位文件用 fs_copy',
|
|
618
830
|
curl: '助手不联网:网关调用由后端代理,不要自己发 HTTP 请求',
|
|
619
831
|
wget: '助手不联网:网关调用由后端代理,不要自己发 HTTP 请求',
|
|
620
832
|
open: '不要打开 GUI 程序:产物路径写进正文回复,用户自己点开',
|
|
@@ -825,6 +1037,19 @@ function shellSubCommands(command) {
|
|
|
825
1037
|
* 循环 / 条件 / 子shell / $( ) 等 shell 语法都可用,但语法之内跑的外部命令(含管道各段、
|
|
826
1038
|
* 替换体内部)逐个过白名单,ls; rm -rf x 依旧在 rm 处被拦。
|
|
827
1039
|
*/
|
|
1040
|
+
/**
|
|
1041
|
+
* sed 的 -i / --in-place:shell 是只读通道,这类「原地写文件」必须拦下。
|
|
1042
|
+
* 实测(test-1 会话):模型拿 sed -i 逐行改 IR,改出过 JSON 语法错再回修,
|
|
1043
|
+
* 既绕开 iterate.mjs --set 的原子写与快照,又把「白名单 = 读多写少」的约定破掉。
|
|
1044
|
+
* 判据用 tokenizeCommand 去引号后的参数位,sed 脚本正文里的 -i 字样不会误伤。
|
|
1045
|
+
*/
|
|
1046
|
+
function sedWritesInPlace(seg) {
|
|
1047
|
+
const flat = seg.includes('$(') || seg.includes('`') ? shellFlatSegments(seg)[0] || '' : seg
|
|
1048
|
+
const toks = tokenizeCommand(flat)
|
|
1049
|
+
if (toks[0] !== 'sed') return false
|
|
1050
|
+
return toks.slice(1).some((t) => /^--in-place(=.*)?$/.test(t) || /^-[A-Za-z.]*i[A-Za-z.]*$/.test(t))
|
|
1051
|
+
}
|
|
1052
|
+
|
|
828
1053
|
function assertShellAllowlist(command) {
|
|
829
1054
|
const top = scanShellText(command)
|
|
830
1055
|
// 顶层用扁平段(替换体折叠成占位符,别把赋值体里的词当段首命令);每个替换体再走一遍同样的闸
|
|
@@ -842,12 +1067,30 @@ function assertShellAllowlist(command) {
|
|
|
842
1067
|
if (/^[<>]/.test(head)) {
|
|
843
1068
|
throw new Error(`不支持以重定向开头的片段:${seg.slice(0, 60)}(把 > / < 放到命令后面)`)
|
|
844
1069
|
}
|
|
1070
|
+
// 白名单里的 sed 是「读 + 过滤」用的;-i 是原地写文件,单独拦(报错里给正路)
|
|
1071
|
+
if (head === 'sed' && sedWritesInPlace(seg)) {
|
|
1072
|
+
throw new Error(
|
|
1073
|
+
'正路:shell 是只读通道,不要用 sed -i 原地改文件——卡片改动用 card_patch / card_write;' +
|
|
1074
|
+
'草稿(IR / 审计 JSON)首次写用 fs_write;**改已存在的 IR 用 ir_patch(点路径最小写,原子、不需引号转义)**,或 skill_exec iterate.mjs --set;不要整份重写。',
|
|
1075
|
+
)
|
|
1076
|
+
}
|
|
1077
|
+
if (SHELL_TRANSPARENT_WORDS.has(head)) {
|
|
1078
|
+
const inner = innerCommandWord(seg)
|
|
1079
|
+
if (inner && !SHELL_ALLOW_BIN.has(inner)) {
|
|
1080
|
+
const hint = SHELL_BIN_HINTS[inner]
|
|
1081
|
+
throw new Error(
|
|
1082
|
+
(hint ? `正路:${hint}\n` : '') +
|
|
1083
|
+
`${head} 后面真正执行的是 ${inner},它不在白名单(允许:${[...SHELL_ALLOW_BIN].join(' / ')})——` +
|
|
1084
|
+
'不要让透明命令替别的命令开路,直接调用白名单里的命令。',
|
|
1085
|
+
)
|
|
1086
|
+
}
|
|
1087
|
+
}
|
|
845
1088
|
if (!SHELL_ALLOW_BIN.has(head)) {
|
|
846
1089
|
const hint = SHELL_BIN_HINTS[head]
|
|
847
1090
|
throw new Error(
|
|
848
|
-
|
|
1091
|
+
(hint ? `正路:${hint}\n` : '') +
|
|
1092
|
+
`命令不在白名单:${head}(允许:${[...SHELL_ALLOW_BIN].join(' / ')})。循环 / 条件 / 子shell / $( ) 等 shell 语法都支持,` +
|
|
849
1093
|
`但其中真正执行的每个外部命令(含管道各段与替换体内部)都必须在白名单内` +
|
|
850
|
-
(hint ? `\n正路:${hint}` : '') +
|
|
851
1094
|
(group.quotedSub
|
|
852
1095
|
? '\n(它写在双引号里:双引号内的 $( ) 与反引号会被 shell 真实执行,所以同样过闸;只想写字面文本请转义或改用单引号)'
|
|
853
1096
|
: '') +
|
|
@@ -1018,12 +1261,17 @@ const FS_WRITE_TOOL = {
|
|
|
1018
1261
|
function: {
|
|
1019
1262
|
name: 'fs_write',
|
|
1020
1263
|
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
|
|
1264
|
+
'把 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
1265
|
parameters: {
|
|
1023
1266
|
type: 'object',
|
|
1024
1267
|
properties: {
|
|
1025
1268
|
name: { type: 'string', description: '草稿文件名,如 riskAssessTip.ir.json' },
|
|
1026
1269
|
content: { type: 'string', description: '文件内容(文本)' },
|
|
1270
|
+
rewrite: {
|
|
1271
|
+
type: 'string',
|
|
1272
|
+
enum: ['true'],
|
|
1273
|
+
description: '确实需要整体重排结构时才传 "true"(并在正文说明为何必须重排);只改几个字段请用 ir_patch',
|
|
1274
|
+
},
|
|
1027
1275
|
},
|
|
1028
1276
|
required: ['name', 'content'],
|
|
1029
1277
|
},
|
|
@@ -1032,8 +1280,13 @@ const FS_WRITE_TOOL = {
|
|
|
1032
1280
|
|
|
1033
1281
|
const SCRATCH_RELPATH = path.join('.cache', 'ai-scratch')
|
|
1034
1282
|
const FS_WRITE_MAX_BYTES = 2 * 1024 * 1024
|
|
1283
|
+
/** 草稿区不收可执行脚本:实测模型写 .cjs 自造像素/几何取证与 JSON 补丁脚本,绕过现成工具与校验 */
|
|
1284
|
+
const FS_WRITE_DENY_EXT = new Set(['.js', '.cjs', '.mjs', '.py', '.sh', '.ps1', '.bat', '.cmd'])
|
|
1285
|
+
/** 同轮内同一草稿文件全量写的提醒 / 拒绝阈值(第 2 次提醒、第 3 次起须显式 rewrite:"true") */
|
|
1286
|
+
const FS_WRITE_REWRITE_WARN = 2
|
|
1287
|
+
const FS_WRITE_REWRITE_MAX = 3
|
|
1035
1288
|
|
|
1036
|
-
function fsWrite(root, { name, content = '' }) {
|
|
1289
|
+
function fsWrite(root, { name, content = '', rewrite = '' }, ledger) {
|
|
1037
1290
|
const rel = String(name || '').trim().replace(/^[/\\]+/, '')
|
|
1038
1291
|
if (!rel) throw new Error('缺少 name')
|
|
1039
1292
|
const scratch = path.join(root, SCRATCH_RELPATH)
|
|
@@ -1041,14 +1294,244 @@ function fsWrite(root, { name, content = '' }) {
|
|
|
1041
1294
|
if (abs !== scratch && !abs.startsWith(scratch + path.sep)) {
|
|
1042
1295
|
throw new Error('非法文件名:只能写 .cache/ai-scratch/ 草稿区内的路径')
|
|
1043
1296
|
}
|
|
1297
|
+
const ext = path.extname(abs).toLowerCase()
|
|
1298
|
+
if (FS_WRITE_DENY_EXT.has(ext)) {
|
|
1299
|
+
throw new Error(
|
|
1300
|
+
`草稿区不收可执行脚本(${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。`,
|
|
1301
|
+
)
|
|
1302
|
+
}
|
|
1044
1303
|
const text = String(content ?? '')
|
|
1045
1304
|
const bytes = Buffer.byteLength(text, 'utf8')
|
|
1046
1305
|
if (bytes > FS_WRITE_MAX_BYTES) throw new Error('内容超过 2MB 上限')
|
|
1306
|
+
const relRepo = path.relative(root, abs)
|
|
1307
|
+
|
|
1308
|
+
// 无变化的重写:与盘上逐字节相同 ⇒ 不写、如实告知(实测出现过原样重发同一份 IR 的步)。
|
|
1309
|
+
// expand_ir 之后模型会看到 schema,很容易把「IR 原文」再倒一遍,白付一整步的 output。
|
|
1310
|
+
let onDisk = null
|
|
1311
|
+
try {
|
|
1312
|
+
onDisk = fs.readFileSync(abs, 'utf8')
|
|
1313
|
+
} catch {}
|
|
1314
|
+
if (onDisk !== null && onDisk === text) {
|
|
1315
|
+
return `未写入 ${relRepo}:内容与盘上现有文件**逐字节相同**(无变化的重写)。要改 IR 用 ir_patch(点路径 JSON 补丁,可一次多条)或 skill_exec iterate.mjs --set '<path>=<value>',不要整份重发。`
|
|
1316
|
+
}
|
|
1317
|
+
|
|
1318
|
+
// 全量重写闸门(实测事故:同一份 IR 被全量重写 16 次、共 124,561 字符 ≈ 全部 completion 的 45%,
|
|
1319
|
+
// 每次 12–25 秒纯生成;而「唯一合法增量路径」iterate --set 在 90 次调用里用了 0 次 ——
|
|
1320
|
+
// 根因是 fs_write 语义最简、一定「能跑」,而服务端对这个退化零感知、零成本)。
|
|
1321
|
+
// 这里给退化成木加两道:第 2 次附事实、第 3 次起拒绝并给出替代路径。
|
|
1322
|
+
const forced = String(rewrite || '').toLowerCase() === 'true'
|
|
1323
|
+
let rewriteNote = ''
|
|
1324
|
+
if (ledger) {
|
|
1325
|
+
const rec = ledger.get(relRepo)
|
|
1326
|
+
const nth = (rec?.count || 0) + 1
|
|
1327
|
+
const totalBytes = (rec?.totalBytes || 0) + bytes
|
|
1328
|
+
ledger.set(relRepo, { count: nth, totalBytes })
|
|
1329
|
+
if (nth >= FS_WRITE_REWRITE_MAX && !forced) {
|
|
1330
|
+
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"(并在正文说明为何必须重排)。`
|
|
1331
|
+
}
|
|
1332
|
+
if (nth >= FS_WRITE_REWRITE_WARN) {
|
|
1333
|
+
rewriteNote = ` ⚠️ 这是本轮第 ${nth} 次全量写入同一文件(累计 ${totalBytes} 字节):只改几个字段请改用 ir_patch / iterate.mjs --set,别整份重发。`
|
|
1334
|
+
}
|
|
1335
|
+
}
|
|
1336
|
+
|
|
1047
1337
|
fs.mkdirSync(path.dirname(abs), { recursive: true })
|
|
1048
1338
|
fs.writeFileSync(abs, text, 'utf8')
|
|
1049
|
-
const relRepo = path.relative(root, abs)
|
|
1050
1339
|
const slicesNote = text.includes('/slices/') ? ';IR 引用 /slices/ 切图时加 --slices-dir <要素包>/slices,免拷贝' : ''
|
|
1051
|
-
|
|
1340
|
+
const nextStep = /\.json$/i.test(rel) ? `下一步可用 skill_exec 跑 expand_ir.mjs --ir ${relRepo} --out src/cards/<id>/` : '(非 IR:按用途继续,别把它当脚本跑)'
|
|
1341
|
+
return `已写入 ${relRepo}(${bytes} 字节)。${nextStep}` + slicesNote + rewriteNote
|
|
1342
|
+
}
|
|
1343
|
+
|
|
1344
|
+
/**
|
|
1345
|
+
* ir_patch:对草稿区里的紧凑 IR 做**点路径最小写**(服务端原生,不 spawn、不跑渲染)。
|
|
1346
|
+
*
|
|
1347
|
+
* 补 P1-3 的根因:模型不是不想最小写,是 `iterate --set` 是**字符串字面量语法**
|
|
1348
|
+
* (`--set 'children.0.s.margin.marginTop="7"'`,引号/转义易错),而 fs_write 语义最简单、一定「能跑」,
|
|
1349
|
+
* 于是路径依赖地退化成整份重写。给一个结构化 JSON 参数的最小写工具,这条退化路径才有替代品。
|
|
1350
|
+
*
|
|
1351
|
+
* 语义与 card_patch 同构:点路径(数字段=数组下标)+ 原子写(任一条失败则整份不写)+ 返回旧值→新值摘要。
|
|
1352
|
+
*/
|
|
1353
|
+
const IR_PATCH_TOOL = {
|
|
1354
|
+
type: 'function',
|
|
1355
|
+
function: {
|
|
1356
|
+
name: 'ir_patch',
|
|
1357
|
+
description:
|
|
1358
|
+
'对草稿区 .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 不渲染、不校验)。',
|
|
1359
|
+
parameters: {
|
|
1360
|
+
type: 'object',
|
|
1361
|
+
properties: {
|
|
1362
|
+
file: { type: 'string', description: '草稿区文件名,如 settlement.ir.json' },
|
|
1363
|
+
ops: {
|
|
1364
|
+
type: 'array',
|
|
1365
|
+
description: '补丁列表,按序应用',
|
|
1366
|
+
items: {
|
|
1367
|
+
type: 'object',
|
|
1368
|
+
properties: {
|
|
1369
|
+
path: { type: 'string', description: '点路径,数字段是数组下标' },
|
|
1370
|
+
op: { type: 'string', enum: ['set', 'merge', 'push', 'delete'], description: '默认 set' },
|
|
1371
|
+
value: { description: 'set/merge/push 的值(任意 JSON)' },
|
|
1372
|
+
},
|
|
1373
|
+
required: ['path'],
|
|
1374
|
+
},
|
|
1375
|
+
},
|
|
1376
|
+
},
|
|
1377
|
+
required: ['file', 'ops'],
|
|
1378
|
+
},
|
|
1379
|
+
},
|
|
1380
|
+
}
|
|
1381
|
+
|
|
1382
|
+
/** 编辑距离是否 ≤1(仅插入 / 删除 / 替换一次;键名短,贪心足够) */
|
|
1383
|
+
function irEditDistance1(a, b) {
|
|
1384
|
+
if (a === b) return false
|
|
1385
|
+
if (Math.abs(a.length - b.length) > 1) return false
|
|
1386
|
+
let i = 0
|
|
1387
|
+
let j = 0
|
|
1388
|
+
let diff = 0
|
|
1389
|
+
while (i < a.length && j < b.length) {
|
|
1390
|
+
if (a[i] === b[j]) {
|
|
1391
|
+
i++
|
|
1392
|
+
j++
|
|
1393
|
+
continue
|
|
1394
|
+
}
|
|
1395
|
+
if (++diff > 1) return false
|
|
1396
|
+
if (a.length === b.length) {
|
|
1397
|
+
i++
|
|
1398
|
+
j++
|
|
1399
|
+
} else if (a.length > b.length) {
|
|
1400
|
+
i++
|
|
1401
|
+
} else {
|
|
1402
|
+
j++
|
|
1403
|
+
}
|
|
1404
|
+
}
|
|
1405
|
+
return true
|
|
1406
|
+
}
|
|
1407
|
+
|
|
1408
|
+
/** irResolve 报错的现场提示:数组报长度,对象报键列表;段名与现有键编辑距离 ≤1 时点名 */
|
|
1409
|
+
function irNodeHint(node, k) {
|
|
1410
|
+
if (Array.isArray(node)) {
|
|
1411
|
+
return node.length
|
|
1412
|
+
? `该层是数组(长度 ${node.length},下标 0~${node.length - 1}),没有下标 ${k}`
|
|
1413
|
+
: '该层是空数组'
|
|
1414
|
+
}
|
|
1415
|
+
if (node === null || typeof node !== 'object') {
|
|
1416
|
+
return `该层是 ${node === null ? 'null' : typeof node},不能继续下钻`
|
|
1417
|
+
}
|
|
1418
|
+
if (Object.prototype.hasOwnProperty.call(node, k)) {
|
|
1419
|
+
const v = node[k]
|
|
1420
|
+
const kind = Array.isArray(v) ? '数组' : typeof v === 'string' ? `字符串 "${irBrief(v)}"` : typeof v
|
|
1421
|
+
return `路径段 "${k}" 存在但不是对象(是 ${kind}),不能继续下钻`
|
|
1422
|
+
}
|
|
1423
|
+
const keys = Object.keys(node)
|
|
1424
|
+
const keyNote = keys.length ? `该层现有键: [${keys.join(', ')}]` : '该层是空对象 {}'
|
|
1425
|
+
const near = keys.find((key) => {
|
|
1426
|
+
if (key === k) return false
|
|
1427
|
+
if (irEditDistance1(key, k)) return true
|
|
1428
|
+
// 复数化 / 截断型互混(child↔children、item↔items 是实测高频,编辑距离却是 3):
|
|
1429
|
+
// 前缀关系且差 ≤3 字符也点名
|
|
1430
|
+
const [a, b] = key.length < k.length ? [k, key] : [key, k]
|
|
1431
|
+
return b.length >= 3 && a.startsWith(b) && a.length - b.length <= 3
|
|
1432
|
+
})
|
|
1433
|
+
return near
|
|
1434
|
+
? `路径段 "${k}" 不存在,${keyNote}——是不是想写 "${near}"?`
|
|
1435
|
+
: `路径段 "${k}" 不存在,${keyNote}`
|
|
1436
|
+
}
|
|
1437
|
+
|
|
1438
|
+
/** 点路径寻址:返回 { parent, key };中段缺失时抛错并指明第几段(报错必须带现场:只回显错误路径时模型只能瞎猜重试) */
|
|
1439
|
+
function irResolve(root, dottedPath) {
|
|
1440
|
+
const segs = String(dottedPath || '')
|
|
1441
|
+
.split('.')
|
|
1442
|
+
.filter((s) => s !== '')
|
|
1443
|
+
if (!segs.length) throw new Error('空路径')
|
|
1444
|
+
let node = root
|
|
1445
|
+
for (let i = 0; i < segs.length - 1; i++) {
|
|
1446
|
+
const k = segs[i]
|
|
1447
|
+
const next = Array.isArray(node) ? node[Number(k)] : node[k]
|
|
1448
|
+
if (next === undefined || next === null || typeof next !== 'object') {
|
|
1449
|
+
const hint = irNodeHint(node, k)
|
|
1450
|
+
throw new Error(`路径中段不存在或不是对象:${segs.slice(0, i + 1).join('.')}${hint ? `(${hint})` : ''}`)
|
|
1451
|
+
}
|
|
1452
|
+
node = next
|
|
1453
|
+
}
|
|
1454
|
+
return { parent: node, key: segs[segs.length - 1] }
|
|
1455
|
+
}
|
|
1456
|
+
|
|
1457
|
+
const irBrief = (v) => {
|
|
1458
|
+
const s = typeof v === 'string' ? v : JSON.stringify(v)
|
|
1459
|
+
return s === undefined ? 'undefined' : s.length > 60 ? `${s.slice(0, 60)}…` : s
|
|
1460
|
+
}
|
|
1461
|
+
|
|
1462
|
+
function irPatch(root, { file, ops }) {
|
|
1463
|
+
const rel = String(file || '').trim().replace(/^[/\\]+/, '')
|
|
1464
|
+
if (!rel) throw new Error('缺少 file')
|
|
1465
|
+
const scratch = path.join(root, SCRATCH_RELPATH)
|
|
1466
|
+
const abs = path.resolve(scratch, rel)
|
|
1467
|
+
if (abs !== scratch && !abs.startsWith(scratch + path.sep)) {
|
|
1468
|
+
throw new Error('非法文件名:只能改 .cache/ai-scratch/ 草稿区内的路径')
|
|
1469
|
+
}
|
|
1470
|
+
if (!fs.existsSync(abs)) throw new Error(`文件不存在:${path.relative(root, abs)}(先 fs_write 写出 IR)`)
|
|
1471
|
+
const list = Array.isArray(ops) ? ops : []
|
|
1472
|
+
if (!list.length) throw new Error('ops 不能为空')
|
|
1473
|
+
|
|
1474
|
+
let ir
|
|
1475
|
+
try {
|
|
1476
|
+
ir = JSON.parse(fs.readFileSync(abs, 'utf8'))
|
|
1477
|
+
} catch (e) {
|
|
1478
|
+
throw new Error(`目标不是合法 JSON,拒绝改:${e.message}`)
|
|
1479
|
+
}
|
|
1480
|
+
const applied = []
|
|
1481
|
+
// 先在内存副本上全部应用,任一条失败就整体放弃(不写盘 = 不留半状态)
|
|
1482
|
+
for (const [i, raw] of list.entries()) {
|
|
1483
|
+
const op = String(raw?.op || 'set').toLowerCase()
|
|
1484
|
+
const path = String(raw?.path || '')
|
|
1485
|
+
try {
|
|
1486
|
+
const { parent, key } = irResolve(ir, path)
|
|
1487
|
+
const isArr = Array.isArray(parent)
|
|
1488
|
+
const idx = isArr ? Number(key) : null
|
|
1489
|
+
const oldVal = isArr ? parent[idx] : parent[key]
|
|
1490
|
+
let newVal
|
|
1491
|
+
if (op === 'set') {
|
|
1492
|
+
// 末段允许新建(与 iterate --set 一致):IR 里补一个样式键是常见动作,
|
|
1493
|
+
// 强行要求先建父节点只会逼模型改用整份重写
|
|
1494
|
+
if (isArr) parent[idx] = raw.value
|
|
1495
|
+
else parent[key] = raw.value
|
|
1496
|
+
newVal = raw.value
|
|
1497
|
+
// t/v 互斥联动(实测 gen-1:set v 后旧 t 残留 → iterate exit=1 硬报错多烧一轮):
|
|
1498
|
+
// Text 节点主值二选一,set 其一时自动清掉另一个,摘要里写明
|
|
1499
|
+
if ((key === 'v' || key === 't') && raw.value !== null && raw.value !== undefined && parent && typeof parent === 'object' && !Array.isArray(parent)) {
|
|
1500
|
+
const rival = key === 'v' ? 't' : 'v'
|
|
1501
|
+
if (Object.prototype.hasOwnProperty.call(parent, rival)) {
|
|
1502
|
+
const oldRival = irBrief(parent[rival])
|
|
1503
|
+
delete parent[rival]
|
|
1504
|
+
applied.push(`${path.replace(/\.[^.]+$/, '') || '.'}.${rival} [delete]: ${oldRival} → undefined(与新设的 ${key} 互斥,自动清除)`)
|
|
1505
|
+
}
|
|
1506
|
+
}
|
|
1507
|
+
} else if (op === 'merge') {
|
|
1508
|
+
const cur = isArr ? parent[idx] : parent[key]
|
|
1509
|
+
if (cur === null || typeof cur !== 'object' || Array.isArray(cur)) {
|
|
1510
|
+
throw new Error('merge 的目标不是对象(先用 set 建对象)')
|
|
1511
|
+
}
|
|
1512
|
+
Object.assign(cur, raw.value || {})
|
|
1513
|
+
newVal = cur
|
|
1514
|
+
} else if (op === 'push') {
|
|
1515
|
+
const cur = isArr ? parent[idx] : parent[key]
|
|
1516
|
+
if (!Array.isArray(cur)) throw new Error('push 的目标不是数组')
|
|
1517
|
+
cur.push(raw.value)
|
|
1518
|
+
newVal = cur
|
|
1519
|
+
} else if (op === 'delete') {
|
|
1520
|
+
if (isArr) parent.splice(idx, 1)
|
|
1521
|
+
else delete parent[key]
|
|
1522
|
+
newVal = undefined
|
|
1523
|
+
} else {
|
|
1524
|
+
throw new Error(`未知 op:${op}(支持 set / merge / push / delete)`)
|
|
1525
|
+
}
|
|
1526
|
+
applied.push(`${path} [${op}]: ${irBrief(oldVal)} → ${irBrief(newVal)}`)
|
|
1527
|
+
} catch (e) {
|
|
1528
|
+
throw new Error(`第 ${i + 1} 条补丁失败(${path}):${e.message} —— 整份未写入,盘上文件保持原样`)
|
|
1529
|
+
}
|
|
1530
|
+
}
|
|
1531
|
+
const tmp = `${abs}.tmp-${process.pid}`
|
|
1532
|
+
fs.writeFileSync(tmp, JSON.stringify(ir, null, 2) + '\n')
|
|
1533
|
+
fs.renameSync(tmp, abs)
|
|
1534
|
+
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
1535
|
}
|
|
1053
1536
|
|
|
1054
1537
|
/**
|
|
@@ -1306,6 +1789,231 @@ async function runShellExec(root, args, res, approved) {
|
|
|
1306
1789
|
)
|
|
1307
1790
|
}
|
|
1308
1791
|
|
|
1792
|
+
/**
|
|
1793
|
+
* 技能脚本退出码语义(白名单,按脚本文件名维护)。
|
|
1794
|
+
*
|
|
1795
|
+
* **权威来源是 `skills/shared/exit-codes.md`** —— 本表是它的服务端实现,改语义要按
|
|
1796
|
+
* 「先改文档 → 再改本表 → 跑 tools/tests/exit-code-contract.test.mjs」的顺序走。
|
|
1797
|
+
*
|
|
1798
|
+
* 为什么必须标:模型把一切非 0 一律读成「工具坏了」,于是去修根本不存在的问题,甚至原样重试。
|
|
1799
|
+
* 而实测这条链路里大量非 0 是**脚本正常跑完、只是结论是负面的**:
|
|
1800
|
+
* - `diff_bands.mjs` 找到差异 = exit 1(诊断输出,不是故障);
|
|
1801
|
+
* - `check_fidelity.mjs` / `lint_card.mjs` / `preflight.mjs` 报出偏差 = exit 1;
|
|
1802
|
+
* - `iterate.mjs` 门禁未达标 = exit 2(报告照常回喂);
|
|
1803
|
+
* - `readiness.mjs` 内容未就绪 = exit 3(**不是**脚本报错);
|
|
1804
|
+
* - `parse_meaxure.py` 多画板门禁 = exit 4(脚本故意退出,等用户选画板)。
|
|
1805
|
+
* 首行给机器可读(模型可正则)+ 人可读的标签,把这一类误判一次性消掉。
|
|
1806
|
+
*
|
|
1807
|
+
* kind:ok(0)|diagnostic(正常产出、结论负面)|gate-fail(门禁未达标)
|
|
1808
|
+
* |not-ready(内容未就绪)|needs-user(必须由用户选择)|fail(真失败)
|
|
1809
|
+
* 语义按 (脚本名, code) 二元组查 —— **同一个数字在不同脚本里含义可以不同**
|
|
1810
|
+
* (`2` 在 iterate 是 gate-fail、在 gate/ir_merge 是用法错误),不要只看数字。
|
|
1811
|
+
*/
|
|
1812
|
+
const SKILL_EXIT_SEMANTICS = {
|
|
1813
|
+
'iterate.mjs': {
|
|
1814
|
+
1: { kind: 'fail', note: '脚本报错收尾' },
|
|
1815
|
+
2: { kind: 'gate-fail', note: '脚本正常跑完,门禁未达标——照 gate.failed 逐项改,不要原样重试' },
|
|
1816
|
+
},
|
|
1817
|
+
'expand_ir.mjs': {
|
|
1818
|
+
1: { kind: 'fail', note: 'validate 未通过,报错文本写了具体项' },
|
|
1819
|
+
3: { kind: 'fail', note: 'IR 含非法字段(报错已给出合法值全集),改键名即可,不要重写整份 IR' },
|
|
1820
|
+
},
|
|
1821
|
+
'diff_bands.mjs': {
|
|
1822
|
+
1: { kind: 'diagnostic', note: '正常结果:差异非空,这是诊断输出,不是工具失败' },
|
|
1823
|
+
2: { kind: 'fail', note: '脚本自身出错' },
|
|
1824
|
+
},
|
|
1825
|
+
'check_fidelity.mjs': { 1: { kind: 'diagnostic', note: '正常结果:报出了偏差项,按报错逐项修' } },
|
|
1826
|
+
'lint_card.mjs': { 1: { kind: 'diagnostic', note: '正常结果:有 lint 问题,按报错逐项修' } },
|
|
1827
|
+
'ir_restore.mjs': {
|
|
1828
|
+
1: { kind: 'fail', note: '参数或文件不对(多为快照/工作 IR 路径写错,先用 --list 看可用轮次)' },
|
|
1829
|
+
3: { kind: 'fail', note: '快照不是合法 JSON,脚本拒绝写回工作 IR(换一份快照,别改这个脚本)' },
|
|
1830
|
+
},
|
|
1831
|
+
// ── 2026-09-24 补齐(此前缺失 ⇒ 被误标成 fail,与文档「3 不是脚本报错」直接矛盾)──
|
|
1832
|
+
'readiness.mjs': {
|
|
1833
|
+
1: { kind: 'fail', note: '用法/参数错误' },
|
|
1834
|
+
3: { kind: 'not-ready', note: '内容未就绪(要素/变量绑定/图片地址/切图落盘四类缺口)——不是脚本报错,按报告逐项补内容后重跑' },
|
|
1835
|
+
},
|
|
1836
|
+
'route_input.mjs': {
|
|
1837
|
+
1: { kind: 'fail', note: '用法/参数错误' },
|
|
1838
|
+
3: { kind: 'needs-user', note: '输入形态判为 unknown——先问用户这是哪类输入,不要猜、不要自己挑' },
|
|
1839
|
+
},
|
|
1840
|
+
'parse_meaxure.py': {
|
|
1841
|
+
4: { kind: 'needs-user', note: '多画板选择门禁(rules.md §十三):脚本故意打印 1..n + 全部 清单后退出——把清单交给用户、停下等回复,再用 --pick / --scope-all 重跑' },
|
|
1842
|
+
},
|
|
1843
|
+
'gate.mjs': {
|
|
1844
|
+
1: { kind: 'gate-fail', note: '门禁未过(autofix→validate→lint→preflight 任一步 FAIL)——按 lint/validate 的规则号逐条修后重跑' },
|
|
1845
|
+
2: { kind: 'fail', note: '用法/参数错误' },
|
|
1846
|
+
},
|
|
1847
|
+
'preflight.mjs': { 1: { kind: 'diagnostic', note: '正常结果:列出 Pre-flight 问题,按 code 逐条修' } },
|
|
1848
|
+
'check_magic_values.mjs': { 70: { kind: 'diagnostic', note: '正常结果:发现魔法值,改用 lib/layout-tokens.mjs 的 T.*' } },
|
|
1849
|
+
'render_baseline.mjs': { 70: { kind: 'diagnostic', note: '正常结果:发现魔法值(历史取值,勿按数字猜语义)' } },
|
|
1850
|
+
}
|
|
1851
|
+
|
|
1852
|
+
/** 退出码语义查询:0 返回 null(无需标注),未登记的非 0 按 fail 处理 */
|
|
1853
|
+
function skillExitInfo(scriptRel, code) {
|
|
1854
|
+
if (Number(code) === 0) return null
|
|
1855
|
+
const base = String(scriptRel || '').split('/').pop()
|
|
1856
|
+
return SKILL_EXIT_SEMANTICS[base]?.[code] || { kind: 'fail', note: '' }
|
|
1857
|
+
}
|
|
1858
|
+
|
|
1859
|
+
/** 给 `exit=N` 首行补语义标签;超时被杀单独标 timeout(那不是脚本的错) */
|
|
1860
|
+
function annotateSkillExit(text, scriptRel) {
|
|
1861
|
+
const s = String(text ?? '')
|
|
1862
|
+
const m = s.match(/^exit=(\d+)((超时已杀))?/)
|
|
1863
|
+
if (!m) return s
|
|
1864
|
+
if (m[2]) return s.replace(/^exit=\d+(超时已杀)/, `exit=${m[1]} [timeout:脚本超时被杀,调大 timeoutMs 或缩小任务范围,别原样重试]`)
|
|
1865
|
+
const info = skillExitInfo(scriptRel, Number(m[1]))
|
|
1866
|
+
if (!info) return s
|
|
1867
|
+
return s.replace(/^exit=\d+/, `exit=${m[1]} [${info.kind}${info.note ? `:${info.note}` : ''}]`)
|
|
1868
|
+
}
|
|
1869
|
+
|
|
1870
|
+
/** 从 skill_exec 的 stdout 里取出末尾那份 JSON 报告(前面可能混有 `[ir] 样式组归位` 这类日志行) */
|
|
1871
|
+
function parseSkillJsonReport(text) {
|
|
1872
|
+
const s = String(text || '')
|
|
1873
|
+
const i = s.lastIndexOf('\n{')
|
|
1874
|
+
for (const c of [i > 0 ? s.slice(i + 1) : '', s]) {
|
|
1875
|
+
const t = c.trim()
|
|
1876
|
+
if (!t.startsWith('{')) continue
|
|
1877
|
+
try {
|
|
1878
|
+
return JSON.parse(t)
|
|
1879
|
+
} catch {}
|
|
1880
|
+
}
|
|
1881
|
+
const a = s.indexOf('{')
|
|
1882
|
+
const b = s.lastIndexOf('}')
|
|
1883
|
+
if (a >= 0 && b > a) {
|
|
1884
|
+
try {
|
|
1885
|
+
return JSON.parse(s.slice(a, b + 1))
|
|
1886
|
+
} catch {}
|
|
1887
|
+
}
|
|
1888
|
+
return null
|
|
1889
|
+
}
|
|
1890
|
+
|
|
1891
|
+
/**
|
|
1892
|
+
* iterate 每轮的进度播报(推给**用户**,不是给模型)。
|
|
1893
|
+
*
|
|
1894
|
+
* 存在理由(2026-09-21 实测):那一轮建卡跑了 29 分 45 秒、85 步,用户全程只能看到一个转圈,
|
|
1895
|
+
* 最后是自己手动中止的。模型内部靠报告知道该停手,但用户侧是黑箱 —— 同一条事实必须同步给人看:
|
|
1896
|
+
* 「第几轮 / 现在多少分 / 历史最好多少 / 是否已经连续几轮没进展 / 门禁过没过」。
|
|
1897
|
+
* 复用既有 notice 通道,不加新协议;解析失败就静默跳过(播报是附加价值,不能影响主流程)。
|
|
1898
|
+
* 返回解析出的 rep(调用方据此做还原度熔断记账;跳过 / 解析失败返回 null)。
|
|
1899
|
+
*/
|
|
1900
|
+
function notifyIterateProgress(res, scriptRel, text) {
|
|
1901
|
+
if (!/iterate\.mjs$/.test(String(scriptRel || ''))) return null
|
|
1902
|
+
const rep = parseSkillJsonReport(text)
|
|
1903
|
+
if (!rep || rep.ok !== true) return null
|
|
1904
|
+
const match = rep.pixelDiff?.match
|
|
1905
|
+
if (match == null) return null
|
|
1906
|
+
const round = rep.rounds ?? null
|
|
1907
|
+
const best = rep.best || null
|
|
1908
|
+
const bestNote = best?.match != null ? `,历史最好 ${best.match}% @ r${best.round}` : ''
|
|
1909
|
+
const heightDelta = rep.pixelDiff?.size?.heightDelta
|
|
1910
|
+
const hd = heightDelta != null && heightDelta !== 0 ? `,高度差 ${heightDelta}` : ''
|
|
1911
|
+
if (rep.stalled) {
|
|
1912
|
+
const s = rep.stalled
|
|
1913
|
+
sseNotice(
|
|
1914
|
+
res,
|
|
1915
|
+
`第 ${round ?? '?'} 轮:match ${match}%${bestNote}${hd} —— 已连续 ${s.rounds} 轮没刷新最好成绩${s.regressed ? '(本轮还在往下走)' : ''},已要求模型回退到最好版本并只改一个分项`,
|
|
1916
|
+
'warn',
|
|
1917
|
+
'iterate-stall',
|
|
1918
|
+
)
|
|
1919
|
+
return rep
|
|
1920
|
+
}
|
|
1921
|
+
sseNotice(
|
|
1922
|
+
res,
|
|
1923
|
+
`第 ${round ?? '?'} 轮:match ${match}%${bestNote}${hd}${rep.gate?.passed ? ' —— 门禁通过' : ''}`,
|
|
1924
|
+
'info',
|
|
1925
|
+
'iterate-progress',
|
|
1926
|
+
)
|
|
1927
|
+
return rep
|
|
1928
|
+
}
|
|
1929
|
+
|
|
1930
|
+
/** 同一技能调用(脚本 + 归一化参数)的键:连续失败退避的判据 */
|
|
1931
|
+
function skillCallKey(args, root) {
|
|
1932
|
+
return `${String(args?.script || '').split('/').pop()}${stableStringify(args?.args ?? [])}${skillInputFingerprint(args, root)}`
|
|
1933
|
+
}
|
|
1934
|
+
|
|
1935
|
+
/**
|
|
1936
|
+
* 「这次调用的可变输入」指纹:只取 `--ir`(模型在两次调用之间真正会改的那个文件)。
|
|
1937
|
+
*
|
|
1938
|
+
* 为什么必须带上它:iterate.mjs 被门禁判失败后,模型的标准动作就是 ir_patch 改 IR、再**原样**
|
|
1939
|
+
* 重跑同一条命令 —— 参数一字未变、输入变了,结果当然可能不同。只按「脚本 + 参数」记账会把它
|
|
1940
|
+
* 误判成「原样重试」并短路,实测(2026-09-21 demo 建卡回合)连着短路 2 次,每次白付一整步
|
|
1941
|
+
* 全量 prompt(≈11 万 token),模型还得自己绕路(改用绝对路径)才跑得下去。
|
|
1942
|
+
*
|
|
1943
|
+
* 为什么不取 `--out`:iterate 每轮都会写 <out>/schema.json,把它算进指纹等于让记账永远清零,
|
|
1944
|
+
* 「报错一字未变就别再撞」这条保护也就没了。
|
|
1945
|
+
*/
|
|
1946
|
+
function skillInputFingerprint(args, root) {
|
|
1947
|
+
const argv = Array.isArray(args?.args) ? args.args : []
|
|
1948
|
+
const parts = []
|
|
1949
|
+
for (let i = 0; i < argv.length - 1; i++) {
|
|
1950
|
+
if (String(argv[i]) !== '--ir') continue
|
|
1951
|
+
const raw = String(argv[i + 1] || '')
|
|
1952
|
+
const target = raw ? (path.isAbsolute(raw) ? raw : path.resolve(root, raw)) : ''
|
|
1953
|
+
try {
|
|
1954
|
+
const st = fs.statSync(target)
|
|
1955
|
+
parts.push(`#ir:${st.size}:${Math.round(st.mtimeMs)}`)
|
|
1956
|
+
} catch {
|
|
1957
|
+
parts.push('#ir:missing')
|
|
1958
|
+
}
|
|
1959
|
+
}
|
|
1960
|
+
return parts.join('')
|
|
1961
|
+
}
|
|
1962
|
+
|
|
1963
|
+
/** 连续失败多少次即不再执行:报错没变时第三次必然还是同样的错,白烧一整步 */
|
|
1964
|
+
const SKILL_FAIL_SHORT_CIRCUIT = 2
|
|
1965
|
+
|
|
1966
|
+
/** 这次算不算「连续失败」:诊断型退出码(差异/偏差非空)不算 */
|
|
1967
|
+
function skillExecFailure(scriptRel, text) {
|
|
1968
|
+
const m = String(text ?? '').match(/^exit=(\d+)/)
|
|
1969
|
+
if (!m) return null
|
|
1970
|
+
const info = skillExitInfo(scriptRel, Number(m[1]))
|
|
1971
|
+
if (!info || info.kind === 'diagnostic') return null
|
|
1972
|
+
return { code: Number(m[1]), kind: info.kind, note: info.note }
|
|
1973
|
+
}
|
|
1974
|
+
|
|
1975
|
+
/** 记账:连续失败累加、成功即清零 */
|
|
1976
|
+
function noteSkillExecOutcome(key, scriptRel, text, execFails) {
|
|
1977
|
+
const f = skillExecFailure(scriptRel, text)
|
|
1978
|
+
if (!f) {
|
|
1979
|
+
execFails.delete(key)
|
|
1980
|
+
return
|
|
1981
|
+
}
|
|
1982
|
+
const prev = execFails.get(key)
|
|
1983
|
+
execFails.set(key, { count: (prev?.count || 0) + 1, code: f.code, kind: f.kind, note: f.note })
|
|
1984
|
+
}
|
|
1985
|
+
|
|
1986
|
+
/** 连续失败达阈值的短路结果:给替代路径,而不是让模型再撞一次同样的墙 */
|
|
1987
|
+
function skillShortCircuit(args, fail) {
|
|
1988
|
+
const script = String(args?.script || '').split('/').pop()
|
|
1989
|
+
const argv = Array.isArray(args?.args) ? JSON.stringify(args.args) : '[]'
|
|
1990
|
+
return [
|
|
1991
|
+
`[系统] 该调用已连续失败 ${fail.count} 次,本次不再执行:${script} ${argv}`,
|
|
1992
|
+
`上一次失败:exit=${fail.code}${fail.note ? `(${fail.note})` : ''}`,
|
|
1993
|
+
'原样重试不会有不同结果。可选:① 照报错文本改(如 [ir] 非法样式键清单里逐条改键名 / gate.failed 里的分项);② 换证据链(diff_bands.mjs 拿逐带 dy/dh、references/generation_pitfalls.md 查静默丢弃);③ 收尾并如实汇报当前 gate 状态。',
|
|
1994
|
+
].join('\n')
|
|
1995
|
+
}
|
|
1996
|
+
|
|
1997
|
+
/**
|
|
1998
|
+
* 这条工具调用落在哪张卡上:card_* 取 id;skill_exec 从 `--out src/cards/<id>/` 里认。
|
|
1999
|
+
*
|
|
2000
|
+
* 只服务于下面那条「写到了别的卡」提醒——目的不是拦(「新建一张」是合法需求),
|
|
2001
|
+
* 而是让用户当场看见:实测画板点选话术以「建卡:」开头,模型读成「用户明说新建」,
|
|
2002
|
+
* 自拟 id 建了 memberLevelNoLevel,而界面上只表现为凭空多出一张卡。
|
|
2003
|
+
*/
|
|
2004
|
+
function cardIdFromToolArgs(toolName, args) {
|
|
2005
|
+
if (!args || typeof args !== 'object') return ''
|
|
2006
|
+
const id = typeof args.id === 'string' ? args.id.trim() : ''
|
|
2007
|
+
if (id && /^card_(write|patch|validate|screenshot|rename)$/.test(String(toolName))) return id
|
|
2008
|
+
const argv = Array.isArray(args.args) ? args.args : []
|
|
2009
|
+
for (let i = 0; i < argv.length - 1; i++) {
|
|
2010
|
+
if (String(argv[i]) !== '--out') continue
|
|
2011
|
+
const m = /(?:^|[\\/])src[\\/]cards[\\/]([^\\/\s]+)/.exec(String(argv[i + 1] || ''))
|
|
2012
|
+
if (m) return m[1]
|
|
2013
|
+
}
|
|
2014
|
+
return ''
|
|
2015
|
+
}
|
|
2016
|
+
|
|
1309
2017
|
/** 技能脚本执行:路径形状校验 → 解释器解析 → 审批(或按策略免确认)→ 跑(cwd=工程根;
|
|
1310
2018
|
* 脚本认工程根走 DSL_CARD_PROJECT_ROOT > cwd,**不按自身位置推算** —— 发布态脚本住在包内快照里,
|
|
1311
2019
|
* 按自身位置推会把 UI/ 产物写进 node_modules,见 tools/tests/cardbuddy-project-anchor.test.mjs) */
|
|
@@ -1318,7 +2026,7 @@ async function runSkillExec(root, args, res, approved) {
|
|
|
1318
2026
|
)
|
|
1319
2027
|
const label = `${script.bin} ${script.rel}${argv.length ? ` ${argv.join(' ')}` : ''}`
|
|
1320
2028
|
const cfg = readAiConfig(root)
|
|
1321
|
-
|
|
2029
|
+
const out = await runGuarded(
|
|
1322
2030
|
root,
|
|
1323
2031
|
res,
|
|
1324
2032
|
{
|
|
@@ -1333,6 +2041,7 @@ async function runSkillExec(root, args, res, approved) {
|
|
|
1333
2041
|
PYTHONUNBUFFERED: '1',
|
|
1334
2042
|
}),
|
|
1335
2043
|
)
|
|
2044
|
+
return annotateSkillExit(out, script.rel)
|
|
1336
2045
|
}
|
|
1337
2046
|
|
|
1338
2047
|
// ---- 对话内审批:未命中免确认策略的高危执行(默认是 shell_exec)每次调用都要用户点确认 ----
|
|
@@ -1379,13 +2088,152 @@ function skillRead(root, args) {
|
|
|
1379
2088
|
// 越界(绝对路径 / 任何 `..` 片段)先判非法,再谈存在性 —— 与 skill_exec 同一套闸。
|
|
1380
2089
|
if (!normalizeSkillRel(rel)) throw new Error('非法路径:只能读 skills/ 目录内的文档')
|
|
1381
2090
|
// 项目内 skills/ 优先,缺失时回退随包发布的技能快照(cardbuddy 安装态未跑 init 也能读文档)。
|
|
1382
|
-
|
|
1383
|
-
|
|
2091
|
+
let full = resolveSkillPath(root, rel)
|
|
2092
|
+
let resolvedNote = ''
|
|
2093
|
+
// 续读锚点里要回显的 path:短路径自动补全时用补全后的真实路径,模型能原样再喂一次
|
|
2094
|
+
let hintRel = rel
|
|
2095
|
+
if (full && !fs.statSync(full).isFile()) {
|
|
2096
|
+
// 传进来的是目录:直接把目录里的文档列出来,别只回一句「只能读文档」把模型赶去猜文件名
|
|
2097
|
+
// 递归列一层以上:文档常躺在子目录里(references/recipe_snippets/x.json),
|
|
2098
|
+
// 只列顶层文件等于让模型再猜一次子目录名
|
|
2099
|
+
const names = []
|
|
2100
|
+
const collectDocs = (abs, prefix, depth) => {
|
|
2101
|
+
if (depth > 3 || names.length >= 40) return
|
|
2102
|
+
let entries = []
|
|
2103
|
+
try {
|
|
2104
|
+
entries = fs.readdirSync(abs, { withFileTypes: true })
|
|
2105
|
+
} catch {
|
|
2106
|
+
return
|
|
2107
|
+
}
|
|
2108
|
+
for (const e of entries) {
|
|
2109
|
+
if (names.length >= 40) return
|
|
2110
|
+
if (e.name.startsWith('.') || e.name === 'node_modules') continue
|
|
2111
|
+
const rel = prefix ? `${prefix}/${e.name}` : e.name
|
|
2112
|
+
if (e.isDirectory()) collectDocs(path.join(abs, e.name), rel, depth + 1)
|
|
2113
|
+
else if (SKILL_DOC_EXT.has(path.extname(e.name).toLowerCase())) names.push(rel)
|
|
2114
|
+
}
|
|
2115
|
+
}
|
|
2116
|
+
collectDocs(full, '', 0)
|
|
2117
|
+
throw new Error(
|
|
2118
|
+
`skills/${rel} 是目录,不是文档。里面的文档(用 skill_read path=skills/${rel}/<文件名>):${names.join(' / ') || '(无文档类文件)'}`,
|
|
2119
|
+
)
|
|
2120
|
+
}
|
|
2121
|
+
if (!full) {
|
|
2122
|
+
const { hits, truncated } = findSkillDocCandidates(root, rel)
|
|
2123
|
+
if (hits.length === 1 && !truncated) {
|
|
2124
|
+
const resolved = resolveSkillPath(root, hits[0])
|
|
2125
|
+
if (resolved) {
|
|
2126
|
+
full = resolved
|
|
2127
|
+
resolvedNote = `> (已按 skills/${hits[0]} 解析:skill_read 的 path 一律相对 skills/ 根写,短路径会自动补全)\n\n`
|
|
2128
|
+
hintRel = hits[0]
|
|
2129
|
+
}
|
|
2130
|
+
}
|
|
2131
|
+
if (!full) {
|
|
2132
|
+
throw new Error(
|
|
2133
|
+
`文档不存在:skills/${rel}` +
|
|
2134
|
+
(hits.length
|
|
2135
|
+
? `\n最接近的候选:${hits.map((h) => `skills/${h}`).join(' / ')}(原样用其中一个重试,不要再猜别的写法)`
|
|
2136
|
+
: '\n(用 fs_list skills 看目录结构;path 一律相对 skills/ 根,如 dsl-card-generator/references/rules.md)'),
|
|
2137
|
+
)
|
|
2138
|
+
}
|
|
2139
|
+
}
|
|
1384
2140
|
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
2141
|
const text = fs.readFileSync(full, 'utf8')
|
|
1387
|
-
|
|
1388
|
-
|
|
2142
|
+
// 补全提示也算进同一预算:否则「自动补全说明 + 正文 + 续读锚点」会比口径多出说明那几十字,
|
|
2143
|
+
// 外层再切一刀正好把续读锚点削掉,模型又回到「不知道还有后半段」的状态。
|
|
2144
|
+
return resolvedNote + clipSkillDoc(text, args?.offset, hintRel, SKILL_READ_MAX - resolvedNote.length)
|
|
2145
|
+
}
|
|
2146
|
+
|
|
2147
|
+
/**
|
|
2148
|
+
* 技能文档超长时的返回:一屏正文 + 尾巴上的续读锚点(读了哪段 / 下一段 offset / 下文小节)。
|
|
2149
|
+
*
|
|
2150
|
+
* 为什么不是「简单截断 + 省略 N 字符」:截断提示必须在**预算之内**。旧实现(skill_read 自截
|
|
2151
|
+
* 30000)与统一口径(12000)不一致,外层再切一刀连提示一起削掉,模型读到的就是 12000 字正文、
|
|
2152
|
+
* 且完全不知道后面还有 5000 字契约(实测 ir-format.md 17285 字符每次静默丢 31%,模型拿着
|
|
2153
|
+
* 前半段字段契约猜后半段)。现在超长文档由本函数自己收口,尾巴给出 `offset`,
|
|
2154
|
+
* 模型想读后半段只需带上同一个 offset 再调一次。
|
|
2155
|
+
*/
|
|
2156
|
+
function clipSkillDoc(text, offset = 0, rel = '', budget = SKILL_READ_MAX) {
|
|
2157
|
+
const total = text.length
|
|
2158
|
+
if (total <= budget) return text
|
|
2159
|
+
let start = Math.floor(Number(offset) || 0)
|
|
2160
|
+
if (!(start > 0)) start = 0
|
|
2161
|
+
// 越界 offset(文档改短了 / 抄错数字):退到最后半屏,给最接近的内容比回一句「起点超范围」有用
|
|
2162
|
+
if (start >= total) start = Math.max(0, total - Math.max(1, Math.floor(budget / 2)))
|
|
2163
|
+
const head = start > 0 ? `…(续读:自第 ${start} 字符起)\n` : ''
|
|
2164
|
+
const trailer = (end) => {
|
|
2165
|
+
const heads = []
|
|
2166
|
+
for (const m of text.matchAll(/^#{1,4}[ \t]+(.+?)[ \t]*$/gm)) {
|
|
2167
|
+
if (m.index < end) continue
|
|
2168
|
+
heads.push(`${m[1].slice(0, 18)}@${m.index}`)
|
|
2169
|
+
if (heads.length >= 6) break
|
|
2170
|
+
}
|
|
2171
|
+
return (
|
|
2172
|
+
`\n…(已截断:全文 ${total} 字符,本次 ${start}..${end},余 ${total - end} 字符。` +
|
|
2173
|
+
`续读:skill_read path=${rel || '<同一个 path>'} offset=${end}` +
|
|
2174
|
+
(heads.length ? `;下文小节:${heads.join(' · ')}` : ';余下部分无小节标题') +
|
|
2175
|
+
')'
|
|
2176
|
+
)
|
|
2177
|
+
}
|
|
2178
|
+
let end = Math.min(total, start + Math.max(1, budget - head.length - SKILL_READ_TRAILER_RESERVE))
|
|
2179
|
+
// 一屏就够读到尾:不加锚点(否则模型会以为还有下文,白跑一次)
|
|
2180
|
+
if (end >= total) return head + text.slice(start)
|
|
2181
|
+
let body = text.slice(start, end)
|
|
2182
|
+
let tail = trailer(end)
|
|
2183
|
+
// 锚点实际长度未必等于预留值(小节多少 / 数字位数都在变):差多少就再让多少,几次内收敛
|
|
2184
|
+
for (let i = 0; i < 3; i++) {
|
|
2185
|
+
const over = head.length + body.length + tail.length - budget
|
|
2186
|
+
if (over <= 0) break
|
|
2187
|
+
body = body.slice(0, Math.max(0, body.length - over))
|
|
2188
|
+
end = start + body.length
|
|
2189
|
+
tail = trailer(end)
|
|
2190
|
+
}
|
|
2191
|
+
// 硬保证:返回串一律 ≤ budget(超了就等于把锚点交给外层切掉,回到静默丢字的老毛病)
|
|
2192
|
+
const spare = budget - head.length - tail.length
|
|
2193
|
+
if (spare <= 0) return (head + tail).slice(0, budget)
|
|
2194
|
+
if (body.length > spare) body = body.slice(0, spare)
|
|
2195
|
+
return head + body + tail
|
|
2196
|
+
}
|
|
2197
|
+
|
|
2198
|
+
/**
|
|
2199
|
+
* 在 skills/ 下按「相对路径后缀命中 → 同名文件」找文档候选。
|
|
2200
|
+
* 为什么需要:模型常按 skill 内的相对路径写 path(recipe_snippets/x.json、references/rules.md),
|
|
2201
|
+
* 而 skill_read 是按 skills/ 根解析的(正解要带 <skill>/ 前缀)。实测为同一个路径连撞两次
|
|
2202
|
+
* 「文档不存在」(test-1 会话 seq728 / seq743),两轮全白烧——后缀唯一命中就直接读并按真实路径标注。
|
|
2203
|
+
*/
|
|
2204
|
+
function findSkillDocCandidates(root, wanted, limit = 6) {
|
|
2205
|
+
const tail = `/${wanted}`
|
|
2206
|
+
const base = path.basename(wanted)
|
|
2207
|
+
const suffixHits = []
|
|
2208
|
+
const nameHits = []
|
|
2209
|
+
const seen = new Set()
|
|
2210
|
+
const walk = (abs, relPrefix, depth) => {
|
|
2211
|
+
if (depth > 5) return
|
|
2212
|
+
let entries = []
|
|
2213
|
+
try {
|
|
2214
|
+
entries = fs.readdirSync(abs, { withFileTypes: true })
|
|
2215
|
+
} catch {
|
|
2216
|
+
return
|
|
2217
|
+
}
|
|
2218
|
+
for (const e of entries) {
|
|
2219
|
+
if (e.name.startsWith('.') || e.name === 'node_modules') continue
|
|
2220
|
+
const full = path.join(abs, e.name)
|
|
2221
|
+
const rel = relPrefix ? `${relPrefix}/${e.name}` : e.name
|
|
2222
|
+
if (e.isDirectory()) {
|
|
2223
|
+
walk(full, rel, depth + 1)
|
|
2224
|
+
continue
|
|
2225
|
+
}
|
|
2226
|
+
if (!SKILL_DOC_EXT.has(path.extname(e.name).toLowerCase())) continue
|
|
2227
|
+
if (seen.has(rel)) continue
|
|
2228
|
+
if (!rel.endsWith(tail) && e.name !== base) continue
|
|
2229
|
+
seen.add(rel)
|
|
2230
|
+
if (rel.endsWith(tail)) suffixHits.push(rel)
|
|
2231
|
+
else nameHits.push(rel)
|
|
2232
|
+
}
|
|
2233
|
+
}
|
|
2234
|
+
for (const dir of skillsCandidates(root)) walk(dir, '', 0)
|
|
2235
|
+
const pool = suffixHits.length ? suffixHits : nameHits
|
|
2236
|
+
return { hits: pool.slice(0, limit), truncated: pool.length > limit }
|
|
1389
2237
|
}
|
|
1390
2238
|
|
|
1391
2239
|
// ---- 配置读取 / 写入 ----
|
|
@@ -1426,6 +2274,13 @@ function parseEnvFile(file) {
|
|
|
1426
2274
|
const DEFAULT_TURN_TIMEOUT_MS = 60 * 60 * 1000
|
|
1427
2275
|
/** 连续多少轮完全相同的工具调用即判定死循环(第 2 轮先警告,第 3 轮熔断) */
|
|
1428
2276
|
const LOOP_BREAK_REPEATS = 3
|
|
2277
|
+
/**
|
|
2278
|
+
* 周期性打转的滑窗判据:最近多少轮里同一组调用出现多少次即熔断。
|
|
2279
|
+
* 只比对**相邻轮**抓不到 `fs_write(A) → expand_ir(B) → fs_write(A′) → iterate(B)` 这种交替循环
|
|
2280
|
+
* (每轮 A 的内容都在变 ⇒ 指纹刷新 ⇒ 计数回落),实测该形态跑满 85 步零熔断。
|
|
2281
|
+
*/
|
|
2282
|
+
const LOOP_WINDOW_ROUNDS = 8
|
|
2283
|
+
const LOOP_REPEAT_IN_WINDOW = 3
|
|
1429
2284
|
/**
|
|
1430
2285
|
* 保活三阈值:长程任务(设计稿建卡一轮几十次调用、几十分钟)只应被「用户主动中止」或
|
|
1431
2286
|
* 「模型服务明确报错」结束,网关瞬时抖动与模型长时间思考都不该整轮报废。
|
|
@@ -1436,7 +2291,11 @@ const LOOP_BREAK_REPEATS = 3
|
|
|
1436
2291
|
*/
|
|
1437
2292
|
const DEFAULT_STALL_TIMEOUT_MS = 120 * 1000
|
|
1438
2293
|
const DEFAULT_STALL_RETRIES = 3
|
|
1439
|
-
|
|
2294
|
+
// 纯思考预算:默认 3.5 分钟(原 10 分钟)。实测(2026-09-21 权益活动建卡)三个回合都是
|
|
2295
|
+
// 一路思考到 10 分钟整点被熔断、各丢弃 4 万余字符思考后重来——那段思考 100% 打水漂。
|
|
2296
|
+
// 思考几分钟是正常的,但「只思考不落盘」超过三分半基本等于卡在细节里(像素对齐之类),
|
|
2297
|
+
// 早一点催比让它想完更省时间、也更省 token。
|
|
2298
|
+
const DEFAULT_REASONING_TIMEOUT_MS = 210 * 1000
|
|
1440
2299
|
/** 纯思考催促上限:超过后停用思考熔断(继续跑,交给墙钟 / 死循环熔断兜底) */
|
|
1441
2300
|
const REASONING_NUDGE_MAX = 3
|
|
1442
2301
|
/**
|
|
@@ -1455,8 +2314,57 @@ function fmtDuration(ms) {
|
|
|
1455
2314
|
const n = Number(ms) > 0 ? Number(ms) : 0
|
|
1456
2315
|
return n >= 60000 ? `${Math.round(n / 60000)} 分钟` : `${Math.max(1, Math.round(n / 1000))} 秒`
|
|
1457
2316
|
}
|
|
1458
|
-
/**
|
|
1459
|
-
|
|
2317
|
+
/**
|
|
2318
|
+
* 软提醒的两条触发线(只提醒、不打断):
|
|
2319
|
+
* ① 重复线(主):同一个「工具 + 参数」在最近 LOOP_WINDOW_ROUNDS 轮里出现 SOFT_NUDGE_REPEAT_ROUNDS 轮
|
|
2320
|
+
* ⇒ 立刻提醒。旧版只有下面的固定步数线,实测 24 步才响 —— 提醒落地时大部分 input 已经烧掉,
|
|
2321
|
+
* 模型还在原地重复(同一 card_screenshot 连打 3 次,直到第 24 步才被点名)。
|
|
2322
|
+
* ② 步数线(兜底):不重复、也不收敛的长尾,每 SOFT_NUDGE_EVERY 步催一次(0 = 关闭这条线)。
|
|
2323
|
+
* 任一命中都只注入一条 user 提醒,绝不打断流程(步数多不等于空转,硬顶早被证明是纯负担)。
|
|
2324
|
+
*/
|
|
2325
|
+
const SOFT_NUDGE_EVERY = 12
|
|
2326
|
+
/** 重复线阈值:同一调用在最近 LOOP_WINDOW_ROUNDS 轮里出现这么多轮即提醒(2 = 第二次看到就提醒) */
|
|
2327
|
+
const SOFT_NUDGE_REPEAT_ROUNDS = 2
|
|
2328
|
+
/** 单轮软提醒次数上限:重复线可能反复命中,设个顶防止「提醒本身」变成刷屏噪音 */
|
|
2329
|
+
const SOFT_NUDGE_MAX = 6
|
|
2330
|
+
/** shell_exec 里「只是读文件 / 列目录 / 搜索」攒到几次就单独提醒一次(这些 fs_read / fs_list / card_grep 更直接) */
|
|
2331
|
+
const SHELL_READ_LIKE_NUDGE = 3
|
|
2332
|
+
/**
|
|
2333
|
+
* 单轮「正文」软提醒阈值(字符):超过且这一轮还要继续调工具 ⇒ 那是过程性分析,不是给用户的结论。
|
|
2334
|
+
*
|
|
2335
|
+
* 2026-09-24 用户拍板:**不再硬截断**。旧版「截断 + 过程性分析省略 N 字」吞掉了不该吞的内容
|
|
2336
|
+
* (gen-1 画板清单 463 字整块消失、用户被要求回复序号却看不到选项;C1 结构化回捞修好了清单,
|
|
2337
|
+
* 但散文里的关键信息仍可能被吞,且截断-回捞-闭合栅栏的补丁链越来越复杂)。
|
|
2338
|
+
* 现在正文完整推给前端、完整进历史;超长只做一次性软提醒(见 bodyNarrationNudged),
|
|
2339
|
+
* 真正的暴走防线是 BODY_BURST_ABORT_CHARS 硬闸(见下)。
|
|
2340
|
+
*/
|
|
2341
|
+
const BODY_NARRATION_MAX_CHARS = 1500
|
|
2342
|
+
/**
|
|
2343
|
+
* 正文暴走硬闸:正文总长超过这么多字就直接掐断本步(而不是陪它写完)。
|
|
2344
|
+
*
|
|
2345
|
+
* 为什么要有它:截断发生在**生成之后**,而 token 是按生成量计费的。
|
|
2346
|
+
* 实测(2026-09-21 demo 建卡回合第 5 步)模型一口气写了 24754 字过程性分析,
|
|
2347
|
+
* 白付约 1.5 万 output token,白等 2 分 38 秒流式输出,用户界面上还表现为「卡住不动」。
|
|
2348
|
+
* 到点掐断 + 系统提醒纠偏,代价远小于陪它写完。掐断时该步正文整段丢弃重试。
|
|
2349
|
+
* 只在「本回合已经跑过工具」时才启用(step > 0):纯问答回合的长回答是正常产物。
|
|
2350
|
+
*/
|
|
2351
|
+
const BODY_BURST_ABORT_CHARS = 8000
|
|
2352
|
+
/** 单回合暴走掐断次数上限:掐断本身也要能收敛,别变成新的打转来源 */
|
|
2353
|
+
const BODY_BURST_MAX = 2
|
|
2354
|
+
|
|
2355
|
+
|
|
2356
|
+
/**
|
|
2357
|
+
* 还原度熔断线:连续 stalled 轮数达到即熔断收尾。
|
|
2358
|
+
* 5 = 给「iterate.mjs 回退 best + 验证一轮」的最小窗口(stall=3 触发回退指令),仍无刷新即微调无收益。
|
|
2359
|
+
* gen-1 实测:best 91.64% @ r6 之后 11 轮 stalled 零提升、43 步 / 约 500 万 prompt tokens 白烧,
|
|
2360
|
+
* 收尾是被上下文极限挤出来的——现有 stall 提醒(只发用户)与逐字节熔断(振荡微调指纹轮轮变)全部失效。
|
|
2361
|
+
*/
|
|
2362
|
+
const STALL_WRAPUP_STALL_ROUNDS = 5
|
|
2363
|
+
/** 还原度熔断兜底线:iterate 总轮数绝对上限,防「缓慢爬升」型长跑 */
|
|
2364
|
+
const ITERATE_ROUNDS_HARD_CAP = 12
|
|
2365
|
+
/** 熔断后工具层拒执行时合成的 tool-result(ir_restore 不在禁用列表——回退 best 是熔断指令的一部分) */
|
|
2366
|
+
const FIDELITY_FUSE_REJECT =
|
|
2367
|
+
'已拒绝(还原度熔断):本回合禁止像素微调,iterate.mjs 与 ir_patch 已被禁用。按熔断指令收尾——确认盘上是 best 版本(必要时 ir_restore 回退),正文报告最终状态(match / 高度差 / 剩余差异点 / 未做成的项),最后单独一行输出 [任务完成]。'
|
|
1460
2368
|
|
|
1461
2369
|
/**
|
|
1462
2370
|
* 免确认执行策略(AI_AUTO_APPROVE):
|
|
@@ -1851,6 +2759,7 @@ function listCcSwitchProviders() {
|
|
|
1851
2759
|
function readBody(req, limitBytes = 8 * 1024 * 1024) {
|
|
1852
2760
|
return new Promise((resolve, reject) => {
|
|
1853
2761
|
let data = ''
|
|
2762
|
+
req.setEncoding('utf8')
|
|
1854
2763
|
req.on('data', (chunk) => {
|
|
1855
2764
|
data += chunk
|
|
1856
2765
|
if (data.length > limitBytes) {
|
|
@@ -2057,16 +2966,19 @@ function describePath(target) {
|
|
|
2057
2966
|
* Desktop / Downloads / Documents / 项目根」,命中且大小一致即采用(绝大多数拖拽走这条,零扫描);
|
|
2058
2967
|
* 2) 兜底 BFS:按「文件名 + 大小」逐个根目录浅层优先搜索,命中即返回(根列表本身按可能性排序);
|
|
2059
2968
|
* 异步分片扫描(每 400 条让出一次事件循环),未命中时也不会把本机服务卡住。
|
|
2060
|
-
*
|
|
2969
|
+
* 扫描根**不折叠**成最外层:老实现把 Desktop / Downloads / Documents(都挂在 $HOME 下)剪掉只留 $HOME,
|
|
2970
|
+
* 于是唯一那 20000 条预算全耗在 Home 第一层,`~/Downloads/资料/已确认/消息助手0618` 这种三层深的拖入
|
|
2971
|
+
* 必然超预算 → 秒回 404 → 前端弹「未能定位本机路径」。现在每个根各自一份预算、按可能性排序,
|
|
2972
|
+
* 扫父根时跳过「另有根负责」的子树(既不重复扫,也不让 $HOME 吃掉别人)。
|
|
2061
2973
|
* 同一个根里出现多个同名同尺寸文件才算歧义,此时不猜。
|
|
2062
2974
|
*/
|
|
2063
|
-
async function locateDropped({ name, relPath = '', size = -1, root = '' }) {
|
|
2975
|
+
async function locateDropped({ name, relPath = '', size = -1, root = '', child = '', homeDir = '', limits = {} }) {
|
|
2064
2976
|
const fileName = String(name || '').trim()
|
|
2065
2977
|
if (!fileName) return null
|
|
2066
2978
|
const wantSize = Number(size) >= 0 ? Number(size) : -1
|
|
2067
2979
|
const tail = String(relPath || '').split('/').filter(Boolean)
|
|
2068
2980
|
const SKIP = new Set(['node_modules', '.git', '.cache', 'dist', '.codex-tmp', '.local', '.history', 'UI', 'demo', 'release'])
|
|
2069
|
-
const home = os.homedir()
|
|
2981
|
+
const home = homeDir ? path.resolve(homeDir) : os.homedir()
|
|
2070
2982
|
|
|
2071
2983
|
const sizeMatches = (p) => {
|
|
2072
2984
|
if (wantSize < 0) return true
|
|
@@ -2085,30 +2997,29 @@ async function locateDropped({ name, relPath = '', size = -1, root = '' }) {
|
|
|
2085
2997
|
}
|
|
2086
2998
|
}
|
|
2087
2999
|
|
|
2088
|
-
const roots =
|
|
2089
|
-
|
|
2090
|
-
|
|
2091
|
-
|
|
2092
|
-
|
|
3000
|
+
const roots = collectLocateRoots({ home, root, recent: recentRoots })
|
|
3001
|
+
|
|
3002
|
+
// 顺序仍是可能性排序;同一目录(经符号链接指到同一处)只扫一次。
|
|
3003
|
+
// 扫描用调用方给的路径形态(返回给前端的路径就跟它一致,别把 /var 换成 /private/var),
|
|
3004
|
+
// 覆盖判定则两种形态都收:不然父根与子根的写法不一致时,同一棵子树会被搜两遍。
|
|
3005
|
+
const scanRoots = []
|
|
3006
|
+
const seenReal = new Set()
|
|
3007
|
+
/** 已被清单里某个根认领的目录:扫父根时跳过,别把同一棵子树搜两遍、也别让 $HOME 吃掉别人 */
|
|
3008
|
+
const owned = new Set()
|
|
3009
|
+
for (const r of roots) {
|
|
3010
|
+
const real = realOf(r)
|
|
3011
|
+
if (seenReal.has(real)) continue
|
|
3012
|
+
seenReal.add(real)
|
|
3013
|
+
scanRoots.push(r)
|
|
3014
|
+
owned.add(r)
|
|
3015
|
+
owned.add(real)
|
|
3016
|
+
}
|
|
3017
|
+
|
|
3018
|
+
/** 命中收口:把父目录记进 recentRoots——同一批文件夹里的下一个拖拽就能走直拼(零扫描) */
|
|
3019
|
+
const hit = (p) => {
|
|
3020
|
+
rememberRoot(path.dirname(p))
|
|
3021
|
+
return p
|
|
2093
3022
|
}
|
|
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
3023
|
|
|
2113
3024
|
// 1) 直拼相对路径
|
|
2114
3025
|
if (tail.length) {
|
|
@@ -2116,19 +3027,20 @@ async function locateDropped({ name, relPath = '', size = -1, root = '' }) {
|
|
|
2116
3027
|
const cand = path.join(root, ...tail)
|
|
2117
3028
|
try {
|
|
2118
3029
|
const st = fs.statSync(cand)
|
|
2119
|
-
if (st.isDirectory() || sizeMatches(cand)) return cand
|
|
3030
|
+
if (st.isDirectory() || sizeMatches(cand)) return hit(cand)
|
|
2120
3031
|
} catch {
|
|
2121
3032
|
/* 该根下没有这个相对路径,换下一个 */
|
|
2122
3033
|
}
|
|
2123
3034
|
}
|
|
2124
3035
|
}
|
|
2125
3036
|
|
|
2126
|
-
// 2) 逐根浅层优先 BFS
|
|
2127
|
-
const
|
|
3037
|
+
// 2) 逐根浅层优先 BFS(同一层全部展开后再下钻,拖入的目录通常在浅层);预算按根分摊
|
|
3038
|
+
const MAX_VISIT_PER_ROOT = Number(limits.perRoot) > 0 ? Number(limits.perRoot) : 20000
|
|
3039
|
+
const MAX_VISIT_TOTAL = Number(limits.total) > 0 ? Number(limits.total) : 120000
|
|
2128
3040
|
const MAX_DEPTH = 4
|
|
2129
|
-
let
|
|
3041
|
+
let visitedTotal = 0
|
|
2130
3042
|
for (const scanRoot of scanRoots) {
|
|
2131
|
-
if (
|
|
3043
|
+
if (visitedTotal >= MAX_VISIT_TOTAL) break
|
|
2132
3044
|
try {
|
|
2133
3045
|
if (!fs.statSync(scanRoot).isDirectory()) continue
|
|
2134
3046
|
} catch {
|
|
@@ -2137,7 +3049,10 @@ async function locateDropped({ name, relPath = '', size = -1, root = '' }) {
|
|
|
2137
3049
|
const queue = [{ dir: scanRoot, depth: 0 }]
|
|
2138
3050
|
/** realpath → 展示路径:同一文件经不同符号链接命中只算一次 */
|
|
2139
3051
|
const hits = new Map()
|
|
2140
|
-
|
|
3052
|
+
/** 同名目录但特征(child)对不上的弱候选:整根扫完仍没人认领时退回第一个 */
|
|
3053
|
+
const weakDirHits = []
|
|
3054
|
+
let visited = 0
|
|
3055
|
+
while (queue.length && visited < MAX_VISIT_PER_ROOT && visitedTotal < MAX_VISIT_TOTAL && hits.size < 2) {
|
|
2141
3056
|
const { dir, depth } = queue.shift()
|
|
2142
3057
|
if (depth > MAX_DEPTH) continue
|
|
2143
3058
|
let list
|
|
@@ -2149,19 +3064,18 @@ async function locateDropped({ name, relPath = '', size = -1, root = '' }) {
|
|
|
2149
3064
|
const subdirs = []
|
|
2150
3065
|
for (const ent of list) {
|
|
2151
3066
|
visited++
|
|
2152
|
-
|
|
3067
|
+
visitedTotal++
|
|
3068
|
+
if (visited > MAX_VISIT_PER_ROOT || visitedTotal > MAX_VISIT_TOTAL) break
|
|
2153
3069
|
// 兜底扫描可能遍历上万条目:定期让出事件循环,别把 dev server 卡住
|
|
2154
|
-
if (
|
|
3070
|
+
if (visitedTotal % 400 === 0) await new Promise((r) => setImmediate(r))
|
|
2155
3071
|
if (SKIP.has(ent.name) || ent.name === '.DS_Store') continue
|
|
2156
3072
|
const full = path.join(dir, ent.name)
|
|
2157
3073
|
if (ent.isDirectory()) {
|
|
2158
|
-
|
|
3074
|
+
if (owned.has(full)) continue /* 这棵子树另有根负责,已在 / 将在它自己那轮扫到 */
|
|
3075
|
+
// 拖入文件夹:同名目录命中(无需比大小);带 child(文件夹里首个文件名)时顺手验一下
|
|
2159
3076
|
if (tail.length && ent.name === tail[0] && depth < MAX_DEPTH) {
|
|
2160
|
-
|
|
2161
|
-
|
|
2162
|
-
} catch {
|
|
2163
|
-
/* 忽略不可读目录 */
|
|
2164
|
-
}
|
|
3077
|
+
if (!child || fs.existsSync(path.join(full, child))) return hit(full)
|
|
3078
|
+
weakDirHits.push(full)
|
|
2165
3079
|
}
|
|
2166
3080
|
subdirs.push({ dir: full, depth: depth + 1 })
|
|
2167
3081
|
} else if (ent.name === fileName && sizeMatches(full)) {
|
|
@@ -2171,12 +3085,218 @@ async function locateDropped({ name, relPath = '', size = -1, root = '' }) {
|
|
|
2171
3085
|
}
|
|
2172
3086
|
queue.push(...subdirs)
|
|
2173
3087
|
}
|
|
2174
|
-
if (hits.size === 1) return hits.values().next().value
|
|
3088
|
+
if (hits.size === 1) return hit(hits.values().next().value)
|
|
2175
3089
|
if (hits.size > 1) return null /* 同根下重名同尺寸:不猜 */
|
|
3090
|
+
if (weakDirHits.length) return hit(weakDirHits[0])
|
|
2176
3091
|
}
|
|
2177
3092
|
return null
|
|
2178
3093
|
}
|
|
2179
3094
|
|
|
3095
|
+
/**
|
|
3096
|
+
* 拖拽反查的扫描根清单:顺序 = 可能性排序,不是路径深浅。
|
|
3097
|
+
*
|
|
3098
|
+
* $HOME 必须垫底——它把 Desktop / Downloads / Documents / 最近目录全包含在内,
|
|
3099
|
+
* 排在前面就等于「最广的根先吃掉全部预算」(老现场的 20000 条全耗在 Home 第一层)。
|
|
3100
|
+
*/
|
|
3101
|
+
function collectLocateRoots({ home, root, recent = [] }) {
|
|
3102
|
+
const roots = []
|
|
3103
|
+
const push = (p) => {
|
|
3104
|
+
if (!p) return
|
|
3105
|
+
const abs = path.resolve(p)
|
|
3106
|
+
if (abs === home) return /* 见上:$HOME 单独垫底 */
|
|
3107
|
+
if (!roots.includes(abs)) roots.push(abs)
|
|
3108
|
+
}
|
|
3109
|
+
for (const r of recent) {
|
|
3110
|
+
push(r)
|
|
3111
|
+
push(path.dirname(r))
|
|
3112
|
+
}
|
|
3113
|
+
if (root) push(root) /* 宿主工程根:已知且被 SKIP 剪过,扫一遍很便宜,排前面 */
|
|
3114
|
+
push(path.join(home, 'Desktop'))
|
|
3115
|
+
push(path.join(home, 'Downloads'))
|
|
3116
|
+
push(path.join(home, 'Documents'))
|
|
3117
|
+
push(REPO_ROOT)
|
|
3118
|
+
push(path.dirname(REPO_ROOT))
|
|
3119
|
+
if (!roots.includes(home)) roots.push(home)
|
|
3120
|
+
return roots
|
|
3121
|
+
}
|
|
3122
|
+
|
|
3123
|
+
/**
|
|
3124
|
+
* 「省略号缩写」路径:`…/积分助手-4-22我的优惠券.png`、`.../preview/a.png`。
|
|
3125
|
+
*
|
|
3126
|
+
* 来源:模型列画板清单时嫌长前缀吃 token,把整段目录砍成 `…/`(真实现场:设计稿在
|
|
3127
|
+
* `~/Downloads/<资料包>/UI设计文件-已确认的/<稿名>/preview/`,写全一次 60+ 字符)。砍完
|
|
3128
|
+
* `path.resolve` 把 `…` 当普通目录名 ⇒ 落到 `<cwd>/…/x.png` ⇒ 必然 404。用户看到的是
|
|
3129
|
+
* 「配图全没了」,而服务端一切正常 —— 与切片坏图同一类「静默丢图」。
|
|
3130
|
+
*
|
|
3131
|
+
* S1.5 提示词已禁止这种写法(治根);这里是渲染侧的兜底(治标),让已经写进对话记录的
|
|
3132
|
+
* 那些缩写路径当场就能显示出图,不必重开一轮对话。
|
|
3133
|
+
*
|
|
3134
|
+
* 文件名是唯一可靠抓手,故按「文件名反查」收口:候选根 = 最近交互过的目录 + 草稿 manifest
|
|
3135
|
+
* 记录的导出源目录(图片原产地 `<export>/preview`)+ 工程根。
|
|
3136
|
+
*
|
|
3137
|
+
* 两个点不算省略号(`\.{3,}`):`../a.png` 是父目录相对路径,`path.resolve` 本来就解得对,
|
|
3138
|
+
* 不能把它当成缩写去反查(否则同名的另一张图会被顶上来)。
|
|
3139
|
+
*/
|
|
3140
|
+
const ABBREV_LEAD_RE = /^(?:…|\.{3,})[\\/]/
|
|
3141
|
+
/** 模型也可能把长文件名从中间砍断:`积分助手-26...png` */
|
|
3142
|
+
const ABBREV_INNER_RE = /…|\.{2,}/
|
|
3143
|
+
|
|
3144
|
+
/** 这条 path 是省略号缩写形态吗(`/__ai/fs/raw` 据此改走文件名反查) */
|
|
3145
|
+
export function isAbbrevPath(p) {
|
|
3146
|
+
return ABBREV_LEAD_RE.test(String(p || '').trim())
|
|
3147
|
+
}
|
|
3148
|
+
|
|
3149
|
+
/**
|
|
3150
|
+
* 非绝对路径的图片目标(`preview/a.png`、`UI/<稿名>/artboard.png`):
|
|
3151
|
+
* 模型照抄 manifest / spec / slices/index.json 的相对路径(2026-09-24 实测:
|
|
3152
|
+
* 提示词明令「写绝对路径」两次失守后,模型仍从 JSON 产物抄相对路径进清单)。
|
|
3153
|
+
* 这类路径直出必 404——交给 resolveAbbrevImage 反查(第 1 轮拼剩余相对路径正好对它友好,
|
|
3154
|
+
* 候选根里就有草稿导出源与其 preview/,以及工程根本身,`UI/…` 直接命中)。
|
|
3155
|
+
*
|
|
3156
|
+
* 防御:`..` 段一律拒(候选根拼接会被逃出根目录,不能给绝对路径之外的空间开反查口子);
|
|
3157
|
+
* 有 scheme(http:/data:/blob:…)或以 `/`、盘符、UNC 打头的不算相对路径。
|
|
3158
|
+
*/
|
|
3159
|
+
export function isRelativeImagePath(p) {
|
|
3160
|
+
const s = String(p || '').trim()
|
|
3161
|
+
if (!s || s.length > 1024) return false
|
|
3162
|
+
if (/^(?:[A-Za-z]:[\\/]|\\\\|\/|file:|[A-Za-z][A-Za-z0-9+.-]*:)/.test(s)) return false
|
|
3163
|
+
if (s.split(/[\\/]/).includes('..')) return false
|
|
3164
|
+
return IMAGE_EXT.has(path.extname(s).toLowerCase())
|
|
3165
|
+
}
|
|
3166
|
+
|
|
3167
|
+
/**
|
|
3168
|
+
* 缩写路径的候选根,按可能性排序:最近用过的目录 → 草稿导出源(含其 `preview/`)→ 草稿目录 → 工程根。
|
|
3169
|
+
* 导出源读的是 `<root>/UI/<稿名>/manifest.json` 的 `export` 字段 —— 导入时记下的原始出处,
|
|
3170
|
+
* 与用户当次拖进来的是同一个目录,不含任何业务名硬编码。
|
|
3171
|
+
*/
|
|
3172
|
+
function abbrevImageRoots(root) {
|
|
3173
|
+
const out = []
|
|
3174
|
+
const push = (p) => {
|
|
3175
|
+
if (!p) return
|
|
3176
|
+
const abs = path.resolve(String(p))
|
|
3177
|
+
if (!out.includes(abs)) out.push(abs)
|
|
3178
|
+
}
|
|
3179
|
+
for (const r of recentRoots) push(r)
|
|
3180
|
+
let drafts = []
|
|
3181
|
+
try {
|
|
3182
|
+
drafts = fs.readdirSync(path.join(root, 'UI'), { withFileTypes: true }).filter((d) => d.isDirectory())
|
|
3183
|
+
} catch {
|
|
3184
|
+
/* 工程里还没有 UI/ 目录 */
|
|
3185
|
+
}
|
|
3186
|
+
for (const d of drafts) {
|
|
3187
|
+
const dir = path.join(root, 'UI', d.name)
|
|
3188
|
+
push(dir)
|
|
3189
|
+
let mf = null
|
|
3190
|
+
try {
|
|
3191
|
+
mf = JSON.parse(fs.readFileSync(path.join(dir, 'manifest.json'), 'utf8'))
|
|
3192
|
+
} catch {
|
|
3193
|
+
continue /* 没有 manifest:只留目录本身当候选根 */
|
|
3194
|
+
}
|
|
3195
|
+
const exp = mf && (mf.export || mf.source || mf.sourceDir)
|
|
3196
|
+
if (typeof exp !== 'string' || !exp.trim()) continue
|
|
3197
|
+
const e = path.resolve(expandHome(exp.trim()))
|
|
3198
|
+
push(path.join(e, 'preview'))
|
|
3199
|
+
push(e)
|
|
3200
|
+
}
|
|
3201
|
+
push(root)
|
|
3202
|
+
// 不设数量上限(原 slice(0, 16)):UI 草稿随库存增长,2026-09-24 实测 18 目录 / 41 根时
|
|
3203
|
+
// 字典序靠后的稿子(收支分析)export 根被悄悄截到 16 之外 ⇒ 其预览图反查全 404——
|
|
3204
|
+
// 「之前能看、现在全挂」的静默截断,且用户无从归因。反查只是内存 stat,根多无感;
|
|
3205
|
+
// 第 3 轮 readdir 有 8000 条/根的既有上限 + setImmediate 让出,不会失控。
|
|
3206
|
+
return out
|
|
3207
|
+
}
|
|
3208
|
+
|
|
3209
|
+
const isFileAt = (p) => {
|
|
3210
|
+
try {
|
|
3211
|
+
return fs.statSync(p).isFile()
|
|
3212
|
+
} catch {
|
|
3213
|
+
return false
|
|
3214
|
+
}
|
|
3215
|
+
}
|
|
3216
|
+
|
|
3217
|
+
const escapeRe = (s) => String(s).replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
|
3218
|
+
|
|
3219
|
+
/**
|
|
3220
|
+
* 多个候选里挑唯一那个;挑不出返回 ''。
|
|
3221
|
+
*
|
|
3222
|
+
* 必须唯一,是因为剩下的线索只有文件名:`card-1.png` 这种名字**每份草稿各有一个**,
|
|
3223
|
+
* 按根顺序取第一个 = 静默给用户看错图,比显示「加载失败」坏得多。
|
|
3224
|
+
* 唯一豁免:同一份图的多处副本(尺寸 + mtime 都一样)视为同一张,取先命中的那个根。
|
|
3225
|
+
*/
|
|
3226
|
+
function pickUniqueFile(cands) {
|
|
3227
|
+
if (cands.length === 1) return cands[0]
|
|
3228
|
+
if (cands.length !== 2) return ''
|
|
3229
|
+
try {
|
|
3230
|
+
const a = fs.statSync(cands[0])
|
|
3231
|
+
const b = fs.statSync(cands[1])
|
|
3232
|
+
if (a.size === b.size && Math.round(a.mtimeMs) === Math.round(b.mtimeMs)) return cands[0]
|
|
3233
|
+
} catch {
|
|
3234
|
+
/* stat 失败:按歧义处理 */
|
|
3235
|
+
}
|
|
3236
|
+
return ''
|
|
3237
|
+
}
|
|
3238
|
+
|
|
3239
|
+
/**
|
|
3240
|
+
* 缩写路径 → 真实绝对路径;反查不出唯一结果返回 ''(宁可 404 显示「加载失败」,也不赌一张错的图)。
|
|
3241
|
+
*
|
|
3242
|
+
* 三轮,由确定到模糊:
|
|
3243
|
+
* 1) 直接拼「剩余相对路径」——`…/preview/a.png` 保住了 `preview/` 这一级,命中率最高;
|
|
3244
|
+
* 2) 只拼文件名——`…/<名>` 是最常见形态;
|
|
3245
|
+
* 3) 文件名自带省略号(`积分助手-26...png`)⇒ 段匹配(`^积分助手-26.*\.png$`),**唯一**命中才认。
|
|
3246
|
+
*/
|
|
3247
|
+
export async function resolveAbbrevImage(target, root) {
|
|
3248
|
+
const tail = String(target || '').trim().replace(ABBREV_LEAD_RE, '').replace(/\\/g, '/')
|
|
3249
|
+
const segs = tail.split('/').filter((s) => s && s !== '.')
|
|
3250
|
+
// `..` 段一律拒:候选根拼接会被逃出根目录(fs/raw 的缩写入口不经 isRelativeImagePath 的 .. 过滤)
|
|
3251
|
+
if (segs.includes('..')) return ''
|
|
3252
|
+
const base = segs[segs.length - 1] || ''
|
|
3253
|
+
if (!base) return ''
|
|
3254
|
+
const roots = abbrevImageRoots(root)
|
|
3255
|
+
|
|
3256
|
+
if (segs.length > 1) {
|
|
3257
|
+
const cands = []
|
|
3258
|
+
for (const r of roots) {
|
|
3259
|
+
const p = path.join(r, ...segs)
|
|
3260
|
+
if (isFileAt(p)) cands.push(p)
|
|
3261
|
+
}
|
|
3262
|
+
const one = pickUniqueFile(cands)
|
|
3263
|
+
if (one) return one
|
|
3264
|
+
}
|
|
3265
|
+
const named = []
|
|
3266
|
+
for (const r of roots) {
|
|
3267
|
+
const p = path.join(r, base)
|
|
3268
|
+
if (isFileAt(p)) named.push(p)
|
|
3269
|
+
}
|
|
3270
|
+
const hit = pickUniqueFile(named)
|
|
3271
|
+
if (hit) return hit
|
|
3272
|
+
if (named.length) return '' /* 同名多份、又不同一文件:不猜 */
|
|
3273
|
+
if (!ABBREV_INNER_RE.test(base)) return ''
|
|
3274
|
+
|
|
3275
|
+
let re
|
|
3276
|
+
try {
|
|
3277
|
+
re = new RegExp(`^${base.split(ABBREV_INNER_RE).map(escapeRe).join('.*')}$`, 'i')
|
|
3278
|
+
} catch {
|
|
3279
|
+
return ''
|
|
3280
|
+
}
|
|
3281
|
+
const hits = new Set()
|
|
3282
|
+
for (const r of roots) {
|
|
3283
|
+
let list
|
|
3284
|
+
try {
|
|
3285
|
+
list = fs.readdirSync(r)
|
|
3286
|
+
} catch {
|
|
3287
|
+
continue
|
|
3288
|
+
}
|
|
3289
|
+
for (const name of list.slice(0, 8000)) {
|
|
3290
|
+
if (!re.test(name)) continue
|
|
3291
|
+
hits.add(path.join(r, name))
|
|
3292
|
+
if (hits.size > 1) return '' /* 歧义:不猜 */
|
|
3293
|
+
}
|
|
3294
|
+
// 可能遍历上万条目:定期让出事件循环,别把 dev server 卡住
|
|
3295
|
+
await new Promise((r2) => setImmediate(r2))
|
|
3296
|
+
}
|
|
3297
|
+
return hits.size === 1 ? [...hits][0] : ''
|
|
3298
|
+
}
|
|
3299
|
+
|
|
2180
3300
|
/** 系统选择器(macOS 用 osascript;其它平台返回明确提示,前端仍可拖拽 / 粘路径) */
|
|
2181
3301
|
function pickPaths({ kind = 'file', multiple = true }) {
|
|
2182
3302
|
if (process.platform !== 'darwin') {
|
|
@@ -2402,8 +3522,26 @@ async function* callLLM({ baseUrl, apiKey, model, messages, tools, maxTokens = 4
|
|
|
2402
3522
|
throw err
|
|
2403
3523
|
}
|
|
2404
3524
|
|
|
2405
|
-
// 扩展字段合并成一份:观测用的 stream_options(看缓存命中)+
|
|
2406
|
-
|
|
3525
|
+
// 扩展字段合并成一份:观测用的 stream_options(看缓存命中)+ 可选的思考开关。
|
|
3526
|
+
// 先按网关能力缓存滤一遍:已经被拒过的字段不再每轮白撞一次 400。
|
|
3527
|
+
const { extra: baseExtra, skipped: capSkipped } = filterExtraByCapability(baseUrl, model, extraBody)
|
|
3528
|
+
// 「因缓存而没带」只提示一次:能力是网关属性,每轮都重复同一条 notice 纯属噪音
|
|
3529
|
+
//(本仓实测一个回合能攒 7 条 notice,刷屏本身就是可读性问题)。
|
|
3530
|
+
if (capSkipped.length) {
|
|
3531
|
+
const notifyKey = `${gatewayKey(baseUrl, model)}|${[...capSkipped].sort().join(',')}`
|
|
3532
|
+
if (!gatewayExtraNotified.has(notifyKey)) {
|
|
3533
|
+
gatewayExtraNotified.add(notifyKey)
|
|
3534
|
+
const usageGone = capSkipped.includes('stream_options')
|
|
3535
|
+
yield {
|
|
3536
|
+
type: 'notice',
|
|
3537
|
+
level: 'info',
|
|
3538
|
+
code: usageGone ? 'extra-unsupported' : 'thinking-unsupported',
|
|
3539
|
+
text: `该网关此前已拒绝 ${capSkipped.join(' / ')}:后续请求不再带上(${
|
|
3540
|
+
usageGone ? '缓存命中统计会缺席' : '深度思考关不掉,思考耗时可能变长'
|
|
3541
|
+
})`,
|
|
3542
|
+
}
|
|
3543
|
+
}
|
|
3544
|
+
}
|
|
2407
3545
|
let curExtra = Object.keys(baseExtra).length ? baseExtra : undefined
|
|
2408
3546
|
let resp = await postRetry(curExtra)
|
|
2409
3547
|
for (const n of pendingNotices.splice(0)) yield { type: 'notice', text: n.text, level: n.level, code: n.code }
|
|
@@ -2458,6 +3596,9 @@ async function* callLLM({ baseUrl, apiKey, model, messages, tools, maxTokens = 4
|
|
|
2458
3596
|
}
|
|
2459
3597
|
extrasPeeled = true
|
|
2460
3598
|
if (removed.length) {
|
|
3599
|
+
// 记进能力缓存:点名过的字段是网关自己说的「我不认它」,摘掉后才成的则反推为元凶。
|
|
3600
|
+
// 两种都记住,下一轮不再白撞同一个 400。
|
|
3601
|
+
rememberExtraReject(baseUrl, model, resp.ok ? removed : named)
|
|
2461
3602
|
const thinkingGone = removed.includes('chat_template_kwargs')
|
|
2462
3603
|
yield {
|
|
2463
3604
|
type: 'notice',
|
|
@@ -2732,7 +3873,67 @@ function normalizeToolArgs(raw) {
|
|
|
2732
3873
|
try {
|
|
2733
3874
|
return JSON.stringify(JSON.parse(s))
|
|
2734
3875
|
} catch {
|
|
2735
|
-
|
|
3876
|
+
// 解析失败**绝不能原样回放**:这条 assistant.tool_calls 会进下一次请求,网关直接
|
|
3877
|
+
// 400 `Assistant tool call function.arguments must be valid JSON.` 把整轮打成失败
|
|
3878
|
+
//(实测 2026-09-21 建卡回合:375.7s / 75 万 input 就是这么报废的)。
|
|
3879
|
+
// 「保留原文」的初衷是前缀缓存逐字节稳定,但稳定只能在**合法**的前提下谈。
|
|
3880
|
+
const repaired = repairToolArgs(s)
|
|
3881
|
+
if (repaired) return repaired
|
|
3882
|
+
// 修不动也必须是合法 JSON:原文完整塞进 _raw(safeParseArgs 已经认这个形状)
|
|
3883
|
+
return JSON.stringify({ _raw: s })
|
|
3884
|
+
}
|
|
3885
|
+
}
|
|
3886
|
+
|
|
3887
|
+
/**
|
|
3888
|
+
* 工具参数容错重建:把模型常见的几种手滑修回合法 JSON。
|
|
3889
|
+
*
|
|
3890
|
+
* 实测形态:长参数(整份 IR / 长正文)+ 中文 + 嵌套字符串,要么未转义、要么被输出上限截断。
|
|
3891
|
+
* 只做「机械且可判定」的修复,修不好就返回 null 交给调用方兜底 —— 宁可退成占位对象,
|
|
3892
|
+
* 也不要修出一个语义不对的合法 JSON 去跑工具。
|
|
3893
|
+
*
|
|
3894
|
+
* 修三类:
|
|
3895
|
+
* ① 尾逗号(`{"a":1,}` / `[1,2,]`);
|
|
3896
|
+
* ② 字符串里的裸控制字符(未转义的换行 / 制表 / 回车);
|
|
3897
|
+
* ③ 截断:按扫描结果补齐未闭合的字符串与括号,并处理停在 `"key":` / `"key",` 的断点。
|
|
3898
|
+
*
|
|
3899
|
+
* @returns {string|null} 合法 JSON 字符串;修不动返回 null
|
|
3900
|
+
*/
|
|
3901
|
+
function repairToolArgs(input) {
|
|
3902
|
+
let t = String(input).trim()
|
|
3903
|
+
if (!t) return null
|
|
3904
|
+
t = t.replace(/,\s*([}\]])/g, '$1')
|
|
3905
|
+
t = t.replace(/[\u0000-\u001f]/g, (c) => (c === '\n' ? '\\n' : c === '\t' ? '\\t' : c === '\r' ? '\\r' : ''))
|
|
3906
|
+
let inStr = false
|
|
3907
|
+
let esc = false
|
|
3908
|
+
const stack = []
|
|
3909
|
+
for (let i = 0; i < t.length; i++) {
|
|
3910
|
+
const ch = t[i]
|
|
3911
|
+
if (inStr) {
|
|
3912
|
+
if (esc) esc = false
|
|
3913
|
+
else if (ch === '\\') esc = true
|
|
3914
|
+
else if (ch === '"') inStr = false
|
|
3915
|
+
continue
|
|
3916
|
+
}
|
|
3917
|
+
if (ch === '"') inStr = true
|
|
3918
|
+
else if (ch === '{' || ch === '[') stack.push(ch)
|
|
3919
|
+
else if (ch === '}' || ch === ']') stack.pop()
|
|
3920
|
+
}
|
|
3921
|
+
let out = t
|
|
3922
|
+
if (inStr) out += '"'
|
|
3923
|
+
// 断点常停在 `"key": `(缺值)或 `"key",`(多了逗号)
|
|
3924
|
+
const tail = out.match(/([,:])\s*$/)
|
|
3925
|
+
if (tail) {
|
|
3926
|
+
if (tail[1] === ',') out = out.replace(/,\s*$/, '')
|
|
3927
|
+
else out += ' null'
|
|
3928
|
+
}
|
|
3929
|
+
for (let i = stack.length - 1; i >= 0; i--) out += stack[i] === '{' ? '}' : ']'
|
|
3930
|
+
try {
|
|
3931
|
+
const parsed = JSON.parse(out)
|
|
3932
|
+
// 只接受对象/数组:修出个裸字符串或数字没有意义(工具参数按对象用)
|
|
3933
|
+
if (parsed && typeof parsed === 'object') return JSON.stringify(parsed)
|
|
3934
|
+
return null
|
|
3935
|
+
} catch {
|
|
3936
|
+
return null
|
|
2736
3937
|
}
|
|
2737
3938
|
}
|
|
2738
3939
|
|
|
@@ -3044,9 +4245,38 @@ function stableStringify(value) {
|
|
|
3044
4245
|
return JSON.stringify(value) ?? 'null'
|
|
3045
4246
|
}
|
|
3046
4247
|
|
|
4248
|
+
/**
|
|
4249
|
+
* 单个调用的指纹。大参数工具(fs_write 整份 IR)用**弱指纹**:目标文件 + 内容长度 + 前 200 字符。
|
|
4250
|
+
*
|
|
4251
|
+
* 理由(实测 2026-09-21):真实循环是**交替型**——
|
|
4252
|
+
* `fs_write(IR-v1) → iterate(A) → fs_write(IR-v2) → iterate(A) → …`
|
|
4253
|
+
* 每轮 fs_write 的内容都在变,若按全文比对,整轮指纹轮轮刷新、`repeatRounds` 永远回落 1 ⇒
|
|
4254
|
+
* 同一个 `expand_ir` 重复 13 次、同类失败 9 次,全程零熔断(只有 3 次 info 级 soft-nudge)。
|
|
4255
|
+
*/
|
|
4256
|
+
function toolSignaturePart(tc, root) {
|
|
4257
|
+
const name = tc?.function?.name || '?'
|
|
4258
|
+
const args = safeParseArgs(tc?.function?.arguments)
|
|
4259
|
+
if (name === 'fs_write' && args && typeof args === 'object') {
|
|
4260
|
+
const content = typeof args.content === 'string' ? args.content : ''
|
|
4261
|
+
return `${name}:${args.path || ''}:${content.length}:${content.slice(0, 200)}`
|
|
4262
|
+
}
|
|
4263
|
+
// skill_exec 的「同一调用」必须带上**可变输入**(--ir)的指纹。
|
|
4264
|
+
//
|
|
4265
|
+
// 实测(2026-09-21 demo 建卡回合,15:35:54):模型的标准工作循环就是
|
|
4266
|
+
// ir_patch 改 IR → 原样重跑 expand_ir / iterate 看新指标 → 再改一版
|
|
4267
|
+
// 参数一字未变、输入每轮都在变,但指纹只按「工具 + 参数」算 ⇒ 被判定成
|
|
4268
|
+
// 「同一个调用在最近 8 轮里出现 3 轮(周期性打转)」,**直接熔断收尾**:
|
|
4269
|
+
// 那一步被跳过、回合在第 61 步结束,而模型当时正处在收敛轨道上。
|
|
4270
|
+
// 判据的本意是拦「输入没变还反复跑」,所以把输入指纹折进来:输入变了就不是打转。
|
|
4271
|
+
if (name === 'skill_exec' && args && typeof args === 'object') {
|
|
4272
|
+
return `${name}${stableStringify(args)}${skillInputFingerprint(args, root)}`
|
|
4273
|
+
}
|
|
4274
|
+
return `${name}${stableStringify(args)}`
|
|
4275
|
+
}
|
|
4276
|
+
|
|
3047
4277
|
/** 一轮工具调用的指纹:同工具 + 同参数(键序无关)⇒ 指纹相同 */
|
|
3048
|
-
function toolRoundSignature(toolCalls) {
|
|
3049
|
-
return toolCalls.map((tc) =>
|
|
4278
|
+
function toolRoundSignature(toolCalls, root) {
|
|
4279
|
+
return toolCalls.map((tc) => toolSignaturePart(tc, root)).join('|')
|
|
3050
4280
|
}
|
|
3051
4281
|
|
|
3052
4282
|
// ---- 前缀缓存观测:usage 归一 + 前缀漂移定位 ----
|
|
@@ -3200,6 +4430,62 @@ function openAiMsgTokens(m) {
|
|
|
3200
4430
|
return t
|
|
3201
4431
|
}
|
|
3202
4432
|
|
|
4433
|
+
/* ── 上下文尺子的在线自校准 ────────────────────────────────────
|
|
4434
|
+
* 问题(2026-09-21 实测,同一建卡回合):`estTokens` 是「CJK×1 + 非CJK×0.3 + 4」的粗估,
|
|
4435
|
+
* 而且**完全没算常驻的 tools 定义数组**(8 个工具带大段中文 description,约 5–6k token)。
|
|
4436
|
+
* 结果是服务端估算比网关真实 promptTokens 系统性小 26–49%:
|
|
4437
|
+
* step 1 估算 11,513 / 真实 16,769(1.46)
|
|
4438
|
+
* step 85 估算 95,534 / 真实 141,990(1.49)
|
|
4439
|
+
* 后果不是「预算设得太大」而是**尺子本身就是短的**:turnContextBudget=96000 实际对应网关侧 ~140k,
|
|
4440
|
+
* UI 圆环显示 99% 时网关已经 148%。所以不能靠「下调预算」修(那是在短尺子上再砍一刀、
|
|
4441
|
+
* 让折叠更早改写历史前缀、后续 token 全部按原价重算),要先校准尺子。
|
|
4442
|
+
*
|
|
4443
|
+
* 校准数据免费:网关每步回的 `usage.promptTokens` 就是该步真实的输入长度
|
|
4444
|
+
* (服务端把它累加后才变成前端看到的累计值),而同一轮「送进网关的 messages 估算 + tools」也算得出来
|
|
4445
|
+
* ⇒ 相除即得比例。按 baseUrl|model 做 EWMA,一两步内收敛,不改变任何协议字段。
|
|
4446
|
+
*/
|
|
4447
|
+
const CTX_SCALE_MIN = 0.8
|
|
4448
|
+
const CTX_SCALE_MAX = 3
|
|
4449
|
+
const CTX_SCALE_EWMA = 0.35
|
|
4450
|
+
/** { key: {k, n, last} }:k = 估算→真实的换算系数(EWMA),n = 观测次数 */
|
|
4451
|
+
const _ctxScales = new Map()
|
|
4452
|
+
|
|
4453
|
+
const ctxScaleKey = (cfg) => `${cfg?.baseUrl || ''}|${cfg?.model || ''}`
|
|
4454
|
+
|
|
4455
|
+
/** 当前生效的估算→真实换算系数;未观测过时返回 1(等价于旧行为) */
|
|
4456
|
+
function ctxScale(key) {
|
|
4457
|
+
const c = _ctxScales.get(key)
|
|
4458
|
+
return c && c.n > 0 ? c.k : 1
|
|
4459
|
+
}
|
|
4460
|
+
|
|
4461
|
+
/** 观测一次 (估算, 网关实测) 并更新 EWMA;任一侧异常(<=500)则跳过 */
|
|
4462
|
+
function observeCtxScale(key, est, real) {
|
|
4463
|
+
const e = Number(est)
|
|
4464
|
+
const r = Number(real)
|
|
4465
|
+
if (!(e > 500) || !(r > 500)) return
|
|
4466
|
+
const ratio = Math.max(CTX_SCALE_MIN, Math.min(CTX_SCALE_MAX, r / e))
|
|
4467
|
+
const c = _ctxScales.get(key)
|
|
4468
|
+
if (!c) _ctxScales.set(key, { k: ratio, n: 1, last: ratio })
|
|
4469
|
+
else {
|
|
4470
|
+
c.k = c.k * (1 - CTX_SCALE_EWMA) + ratio * CTX_SCALE_EWMA
|
|
4471
|
+
c.n++
|
|
4472
|
+
c.last = ratio
|
|
4473
|
+
}
|
|
4474
|
+
}
|
|
4475
|
+
|
|
4476
|
+
/** 常驻 tools 定义的 token 估算(工具集在同一回合内不变 ⇒ 算一次缓存) */
|
|
4477
|
+
let _toolsTokensCache = { sig: '', tokens: 0 }
|
|
4478
|
+
function toolsTokens(tools) {
|
|
4479
|
+
const sig = Array.isArray(tools) ? `${tools.length}:${tools.map((t) => t?.function?.name || '').join(',')}` : ''
|
|
4480
|
+
if (_toolsTokensCache.sig === sig) return _toolsTokensCache.tokens
|
|
4481
|
+
let t = 0
|
|
4482
|
+
try {
|
|
4483
|
+
t = estTokens(JSON.stringify(tools || []))
|
|
4484
|
+
} catch {}
|
|
4485
|
+
_toolsTokensCache = { sig, tokens: t }
|
|
4486
|
+
return t
|
|
4487
|
+
}
|
|
4488
|
+
|
|
3203
4489
|
/**
|
|
3204
4490
|
* 单轮内上下文裁剪:超预算时折叠超长工具结果 / 注入图像,直到落回预算内。
|
|
3205
4491
|
* 只改 content、绝不删消息 —— tool_calls 与 role:'tool' 的配对一旦被破坏,网关会直接 400。
|
|
@@ -3898,13 +5184,20 @@ export function createAiChatMiddleware(opts = {}) {
|
|
|
3898
5184
|
return res.end()
|
|
3899
5185
|
}
|
|
3900
5186
|
const target = query.get('path') || ''
|
|
3901
|
-
|
|
5187
|
+
let abs = path.resolve(expandHome(target))
|
|
3902
5188
|
const ext = path.extname(abs).toLowerCase()
|
|
3903
5189
|
if (!target || !IMAGE_EXT.has(ext)) {
|
|
3904
5190
|
res.statusCode = 400
|
|
3905
5191
|
res.setHeader('Content-Type', 'text/plain; charset=utf-8')
|
|
3906
5192
|
return res.end('unsupported-media')
|
|
3907
5193
|
}
|
|
5194
|
+
// 省略号缩写路径(`…/a.png`):resolve 已把它拼成 `<cwd>/…/a.png`,必不存在。
|
|
5195
|
+
// 相对路径图片(`preview/a.png`、`UI/<稿>/artboard.png`):模型照抄 manifest/spec 的
|
|
5196
|
+
// 相对路径,直出也必 404。两者都改走「候选根反查」——治的是模型转述产物里的丢前缀形态。
|
|
5197
|
+
if (isAbbrevPath(target) || isRelativeImagePath(target)) {
|
|
5198
|
+
const found = await resolveAbbrevImage(target, root)
|
|
5199
|
+
if (found) abs = found
|
|
5200
|
+
}
|
|
3908
5201
|
let st
|
|
3909
5202
|
try {
|
|
3910
5203
|
st = fs.statSync(abs)
|
|
@@ -3966,6 +5259,7 @@ export function createAiChatMiddleware(opts = {}) {
|
|
|
3966
5259
|
name: body.name,
|
|
3967
5260
|
relPath: body.relPath || '',
|
|
3968
5261
|
size: Number.isFinite(Number(body.size)) ? Number(body.size) : -1,
|
|
5262
|
+
child: typeof body.child === 'string' ? body.child : '',
|
|
3969
5263
|
root,
|
|
3970
5264
|
})
|
|
3971
5265
|
if (!found) return sendJson(req, res, 404, { error: 'not-located' })
|
|
@@ -4102,6 +5396,7 @@ export function createAiChatMiddleware(opts = {}) {
|
|
|
4102
5396
|
FS_READ_TOOL,
|
|
4103
5397
|
FS_LIST_TOOL,
|
|
4104
5398
|
FS_WRITE_TOOL,
|
|
5399
|
+
IR_PATCH_TOOL,
|
|
4105
5400
|
FS_COPY_TOOL,
|
|
4106
5401
|
skillExecTool(autoApproves(defaults.autoApprove, 'skill_exec')),
|
|
4107
5402
|
]
|
|
@@ -4109,6 +5404,18 @@ export function createAiChatMiddleware(opts = {}) {
|
|
|
4109
5404
|
if (defaults.shellAllowed) tools = [...tools, shellTool(autoApproves(defaults.autoApprove, 'shell_exec'))]
|
|
4110
5405
|
/** 本轮已获「不再询问」授权的工具名(scope=turn):管线连跑多步时不必逐步点确认 */
|
|
4111
5406
|
const approvedTools = new Set()
|
|
5407
|
+
/** 同轮 fs_write 记账:同一草稿文件被全量重写的次数与累计字节(全量重写闸门的依据) */
|
|
5408
|
+
const fsWriteLedger = new Map()
|
|
5409
|
+
/** 上下文尺子的校准键(按网关 + 模型)与常驻 tools 的估算量:预算判定、UI 上报、校准观测都用它 */
|
|
5410
|
+
const scaleKey = ctxScaleKey(finalConfig)
|
|
5411
|
+
const toolsTok = toolsTokens(tools)
|
|
5412
|
+
/**
|
|
5413
|
+
* 本轮生效的尺子系数 **回合开始取一次并冻结**(观测继续喂 EWMA,但只对**下一轮**生效)。
|
|
5414
|
+
* 不能每步现取:k 在同轮内一变,(a) UI 的「上下文占用」会在工具结果进来后反而回退 —— 环越用越少,
|
|
5415
|
+
* 明显是坏的;(b) `turnContextBudget / k` 的预算换算在同轮内自相矛盾,同一份 messages 一会儿超预算一会儿不超。
|
|
5416
|
+
* 冻结后 `used` 只随 messages 单调增长,k 的收敛体现在回合之间(与 `total` 同样按回合冻结的口径一致)。
|
|
5417
|
+
*/
|
|
5418
|
+
const turnScale = ctxScale(scaleKey)
|
|
4112
5419
|
|
|
4113
5420
|
let contextPrompt = systemPrompt
|
|
4114
5421
|
// 只注入「当前卡片 ID」:此前额外塞 8KB schema 摘要,与模型自己 card_read 的全文重复,
|
|
@@ -4252,10 +5559,31 @@ export function createAiChatMiddleware(opts = {}) {
|
|
|
4252
5559
|
/** 上一轮工具调用指纹与「连续相同」计数:死循环熔断的依据 */
|
|
4253
5560
|
let lastRoundSig = ''
|
|
4254
5561
|
let repeatRounds = 0
|
|
5562
|
+
/** 最近 LOOP_WINDOW_ROUNDS 轮的**单调用**指纹:抓交替型循环(按整轮拼接会漏掉它) */
|
|
5563
|
+
const recentCallRounds = []
|
|
5564
|
+
/** 技能调用(脚本 + 参数)→ 连续失败次数:同一调用连续失败 2 次即短路,别让它第三次白撞 */
|
|
5565
|
+
const execFails = new Map()
|
|
5566
|
+
/** 「AI 写到别的卡上」的提醒去重:同一目标 id 一轮只提示一次 */
|
|
5567
|
+
const cardTargetNotified = new Set()
|
|
4255
5568
|
/** 熔断时展示给用户的调用名(比整串指纹可读) */
|
|
4256
5569
|
let loopToolNames = ''
|
|
5570
|
+
/** 软提醒已点名过的重复调用指纹:同一组只提醒一次,免得每轮刷同样的话挤占上下文 */
|
|
5571
|
+
const softNudgedSigs = new Set()
|
|
5572
|
+
/** 软提醒已发次数(上限 SOFT_NUDGE_MAX) */
|
|
5573
|
+
let softNudgeCount = 0
|
|
5574
|
+
/**
|
|
5575
|
+
* 本轮 shell_exec 里「只是读文件 / 列目录 / 搜索」的次数。
|
|
5576
|
+
* 实测(2026-09-21 建卡回合)15 次 shell_exec 里绝大多数是 cat / ls / rg / find / jq ——
|
|
5577
|
+
* 同样的信息 fs_read / fs_list / card_grep 能直接给,还少一层引号转义与输出噪音。
|
|
5578
|
+
* 攒够几次就在软提醒里点一句,别去改工具面(改工具面风险远大于这点收益)。
|
|
5579
|
+
*/
|
|
5580
|
+
let shellReadLikeCount = 0
|
|
5581
|
+
/** 上面这条只提醒一次:它是「习惯」不是「失控」,重复刷没有新信息 */
|
|
5582
|
+
let shellReadLikeNudged = false
|
|
4257
5583
|
/** 墙钟用到 80% 时的一次性收敛提醒(不打断流程) */
|
|
4258
5584
|
let timeNudged = false
|
|
5585
|
+
/** 正文超长提醒只发一次:同一轮反复刷「你太啰嗦了」只会挤占上下文 */
|
|
5586
|
+
let bodyNarrationNudged = false
|
|
4259
5587
|
/** 单轮上下文裁剪是否已提示过(每轮都可能裁,提示只发一次免得刷屏) */
|
|
4260
5588
|
let trimNoticed = false
|
|
4261
5589
|
/** 单轮深度压缩是否已提示过(同 trimNoticed,免刷屏) */
|
|
@@ -4266,6 +5594,8 @@ export function createAiChatMiddleware(opts = {}) {
|
|
|
4266
5594
|
let emptyRetries = 0
|
|
4267
5595
|
/** 网关静默后已自动重试本步的次数:中转网关瞬时静默不该让跑了几十分钟的长程任务整轮报废 */
|
|
4268
5596
|
let stallRetriesUsed = 0
|
|
5597
|
+
/** 本回合正文暴走被硬闸掐断的次数(上限 BODY_BURST_MAX) */
|
|
5598
|
+
let bodyBursts = 0
|
|
4269
5599
|
/** 纯思考已催促次数:到 REASONING_NUDGE_MAX 仍无产出就停用思考熔断(reasoningGuardOff) */
|
|
4270
5600
|
let reasoningNudges = 0
|
|
4271
5601
|
/** 思考熔断是否已停用:停用后「只思考不产出」不再中止上游,交给墙钟 / 死循环熔断兜底 */
|
|
@@ -4274,6 +5604,8 @@ export function createAiChatMiddleware(opts = {}) {
|
|
|
4274
5604
|
let autoContinueLeft = Math.max(0, Number(finalConfig.autoContinueMax) || 0)
|
|
4275
5605
|
/** 连续「只吐正文不调工具」的轮数:连续两次就尊重模型停手,避免纯问答被反复续跑 */
|
|
4276
5606
|
let textOnlyRounds = 0
|
|
5607
|
+
/** 还原度熔断:stalled.rounds ≥ 5 或 iterate 总轮数 ≥ 12 触发,触发后禁用 iterate/ir_patch 并让 auto-continue 让位 */
|
|
5608
|
+
let fidelityFused = false
|
|
4277
5609
|
/** 熔断收尾的人话原因:随 turn-ended 事件落进对话记录,重开面板仍看得见「这轮为什么停」 */
|
|
4278
5610
|
let budgetWhy = ''
|
|
4279
5611
|
let currentMessages = fullMessages
|
|
@@ -4311,22 +5643,78 @@ export function createAiChatMiddleware(opts = {}) {
|
|
|
4311
5643
|
content: `[系统提醒] 本轮墙钟预算只剩 20%(上限 ${fmtDuration(turnTimeoutMs)}):立即收敛——优先 card_write / card_patch 落盘或给出正文结论与下一步,不要再读资料、不要开新战线。`,
|
|
4312
5644
|
})
|
|
4313
5645
|
}
|
|
4314
|
-
//
|
|
4315
|
-
|
|
4316
|
-
|
|
4317
|
-
|
|
4318
|
-
|
|
4319
|
-
|
|
4320
|
-
|
|
5646
|
+
// 熔断③软提醒:两条触发线任一命中即提醒,但**不打断**(步数多不等于空转)。
|
|
5647
|
+
// ① 重复线(主):同一调用第 SOFT_NUDGE_REPEAT_ROUNDS 轮出现 ⇒ 立刻提醒,不等步数兜底。
|
|
5648
|
+
// ② 步数线(兜底):每 SOFT_NUDGE_EVERY 步催一次,兜住「不重复但也不收敛」的长尾。
|
|
5649
|
+
// 实测教训:原来只有固定步数线,且是无事实支撑的 info 级提醒,模型收完照旧(24/48/72 步
|
|
5650
|
+
// 各催一次都没改变行为)。所以现在升为 warn 并**附上它自己的重复调用与失败调用清单**——
|
|
5651
|
+
// 提醒一旦可被验证,才有机会真的改变下一步。
|
|
5652
|
+
{
|
|
5653
|
+
const sigRounds = new Map()
|
|
5654
|
+
for (const round of recentCallRounds) for (const sig of round) sigRounds.set(sig, (sigRounds.get(sig) || 0) + 1)
|
|
5655
|
+
const freshRepeats = [...sigRounds.entries()]
|
|
5656
|
+
.filter(([sig, n]) => n >= SOFT_NUDGE_REPEAT_ROUNDS && !softNudgedSigs.has(sig))
|
|
5657
|
+
.sort((a, b) => b[1] - a[1])
|
|
5658
|
+
const stepDue = SOFT_NUDGE_EVERY > 0 && step > 0 && step % SOFT_NUDGE_EVERY === 0
|
|
5659
|
+
// ③ shell_exec 读类线:把「拿 shell 干 fs_*/card_grep 的活」当成一个独立的提醒触发点,
|
|
5660
|
+
// 否则它只能搭在别两条线的便车上,重复不多、步数又没到 12 时永远不出现。
|
|
5661
|
+
const shellDue = shellReadLikeCount >= SHELL_READ_LIKE_NUDGE && !shellReadLikeNudged
|
|
5662
|
+
if ((freshRepeats.length || stepDue || shellDue) && softNudgeCount < SOFT_NUDGE_MAX) {
|
|
5663
|
+
softNudgeCount++
|
|
5664
|
+
for (const [sig] of freshRepeats) softNudgedSigs.add(sig)
|
|
5665
|
+
if (shellDue) shellReadLikeNudged = true
|
|
5666
|
+
const repeated = [...sigRounds.entries()]
|
|
5667
|
+
.filter(([, n]) => n >= SOFT_NUDGE_REPEAT_ROUNDS)
|
|
5668
|
+
.sort((a, b) => b[1] - a[1])
|
|
5669
|
+
.slice(0, 3)
|
|
5670
|
+
.map(([sig, n]) => `${sig.slice(0, 90)} ×${n} 轮`)
|
|
5671
|
+
const failing = [...execFails.entries()]
|
|
5672
|
+
.filter(([, v]) => v.count >= 1)
|
|
5673
|
+
.slice(0, 2)
|
|
5674
|
+
.map(([k, v]) => `${k.slice(0, 90)}(连续失败 ${v.count} 次${v.kind ? `,${v.kind}` : ''}${v.code ? ` exit=${v.code}` : ''})`)
|
|
5675
|
+
const facts = [
|
|
5676
|
+
repeated.length ? `最近 ${recentCallRounds.length} 轮里重复出现的调用:${repeated.join(';')}` : '',
|
|
5677
|
+
failing.length ? `已连续失败的调用:${failing.join(';')}` : '',
|
|
5678
|
+
shellReadLikeCount >= SHELL_READ_LIKE_NUDGE
|
|
5679
|
+
? `本轮已有 ${shellReadLikeCount} 次 shell_exec 只是读文件 / 列目录 / 搜索 —— 这些用 fs_read / fs_list / card_grep 更直接(同样的信息,少一层引号转义与输出噪音)`
|
|
5680
|
+
: '',
|
|
5681
|
+
]
|
|
5682
|
+
.filter(Boolean)
|
|
5683
|
+
.join(' / ')
|
|
5684
|
+
const why = freshRepeats.length
|
|
5685
|
+
? `检测到重复调用(同一「工具 + 参数」在最近 ${recentCallRounds.length} 轮里已出现 ${freshRepeats[0][1]} 轮)`
|
|
5686
|
+
: shellDue
|
|
5687
|
+
? `本轮已有 ${shellReadLikeCount} 次 shell_exec 只是读文件 / 列目录 / 搜索`
|
|
5688
|
+
: `本轮已用 ${step} 步工具调用(步数不设硬顶)`
|
|
5689
|
+
sseNotice(res, `${why}:提醒模型收敛,别在同类信息上重复取数${facts ? ` —— ${facts}` : ''}`, 'warn', 'soft-nudge')
|
|
5690
|
+
currentMessages.push({
|
|
5691
|
+
role: 'user',
|
|
5692
|
+
content: `[系统提醒] ${why}。自查是否已拿到足够证据——能落盘就 card_write / card_patch / ir_patch / iterate.mjs 推进到下一门禁;同类信息只读一次,不要重复读同一份资料、不要重跑同一条命令。${facts ? `\n事实(按你自己的调用记录统计):${facts}。重复调用不会带来新信息 —— 立刻换证据链(报告里的 contributors / diff_bands.mjs / generation_pitfalls.md),或直接落盘收尾。` : ''}`,
|
|
5693
|
+
})
|
|
5694
|
+
}
|
|
4321
5695
|
}
|
|
4322
5696
|
|
|
4323
5697
|
// 熔断④上下文:步数不限后工具结果会一路累积,撑爆网关窗口 = 整轮 400 报废。
|
|
4324
5698
|
// 每轮发请求前先按预算折叠早期工具结果 / 注入图像(最近 TRIM_KEEP_RECENT 条保持原文)。
|
|
4325
5699
|
// from = 上次折到哪:折叠只前进不回退,历史已折叠的部分保持原样 ⇒ 前缀缓存继续命中
|
|
4326
|
-
|
|
5700
|
+
//
|
|
5701
|
+
// 预算换算:估算 × k ≈ 网关真实值,且**常驻 tools 数组每步都会发**但不属于 messages
|
|
5702
|
+
// (旧估算把它整个漏掉)。要让「真实」不超 turnContextBudget,等价于让「估算」不超
|
|
5703
|
+
// budget/k − tools。k 未观测到时为 1,即完全等价于旧行为。
|
|
5704
|
+
// k 取回合开始时冻结的 turnScale(同轮内不变,见其定义处的理由)。
|
|
5705
|
+
const estBudget = Math.max(4000, Math.floor(turnContextBudget / turnScale) - toolsTok)
|
|
5706
|
+
const turnTrim = trimTurnContext(currentMessages, { budgetTokens: estBudget, from: trimFrom })
|
|
4327
5707
|
trimFrom = turnTrim.from
|
|
4328
|
-
//
|
|
4329
|
-
|
|
5708
|
+
// 输入框圆环的数据源:本轮**实际送进网关**的上下文量(裁剪后),统一按校准后的真实口径上报
|
|
5709
|
+
// (旧行为:报估算值 ⇒ UI 显示 99% 时网关已经在 148%,用户和模型看到的都不是真相)
|
|
5710
|
+
sseWrite(res, {
|
|
5711
|
+
type: 'context-usage',
|
|
5712
|
+
used: Math.round((turnTrim.tokens + toolsTok) * turnScale),
|
|
5713
|
+
total: turnContextBudget,
|
|
5714
|
+
window: knownWindow,
|
|
5715
|
+
est: turnTrim.tokens + toolsTok,
|
|
5716
|
+
scale: Math.round(turnScale * 100) / 100,
|
|
5717
|
+
})
|
|
4330
5718
|
if (clampNotice) sseNotice(res, clampNotice, 'warn', 'max-tokens-clamp')
|
|
4331
5719
|
if (turnTrim.trimmed) {
|
|
4332
5720
|
currentMessages = turnTrim.messages
|
|
@@ -4338,8 +5726,8 @@ export function createAiChatMiddleware(opts = {}) {
|
|
|
4338
5726
|
// 深度压缩:折叠工具结果后仍超预算 ⇒ 整组丢弃最早的工具调用并留摘要。
|
|
4339
5727
|
// 任务消息与最近工作记忆保留原文,压缩完循环照常继续(用户要求:主动压缩且不断任务)
|
|
4340
5728
|
const estNow = currentMessages.reduce((a, m) => a + openAiMsgTokens(m), 0)
|
|
4341
|
-
if (estNow >
|
|
4342
|
-
const deep = deepCompactTurn(currentMessages,
|
|
5729
|
+
if (estNow > estBudget) {
|
|
5730
|
+
const deep = deepCompactTurn(currentMessages, estBudget)
|
|
4343
5731
|
if (deep.droppedGroups) {
|
|
4344
5732
|
currentMessages = deep.messages
|
|
4345
5733
|
// 丢消息会让下标整体左移:粘滞游标与漂移基准一起作废重来(这一步本来就改写了历史前缀)
|
|
@@ -4358,6 +5746,8 @@ export function createAiChatMiddleware(opts = {}) {
|
|
|
4358
5746
|
let lastDeltaAt = Date.now()
|
|
4359
5747
|
let roundTimedOut = false
|
|
4360
5748
|
let roundStalled = false
|
|
5749
|
+
/** 本轮正文暴走被硬闸掐断(见 BODY_BURST_ABORT_CHARS):不结束回合,提醒后重跑本步 */
|
|
5750
|
+
let roundBodyBurst = false
|
|
4361
5751
|
/** 本轮被墙钟打断:墙钟原本只在每轮开头检查,上游永不收尾时循环回不到轮首,整轮会挂住 */
|
|
4362
5752
|
let roundWallClock = false
|
|
4363
5753
|
let finishReason = ''
|
|
@@ -4428,11 +5818,27 @@ export function createAiChatMiddleware(opts = {}) {
|
|
|
4428
5818
|
roundReasoningChars += String(evt.text || '').length
|
|
4429
5819
|
sseWrite(res, { type: 'reasoning-delta', text: evt.text })
|
|
4430
5820
|
break
|
|
4431
|
-
case 'text-delta':
|
|
5821
|
+
case 'text-delta': {
|
|
4432
5822
|
roundProgressAt = Date.now()
|
|
4433
|
-
|
|
4434
|
-
|
|
5823
|
+
// 正文完整推给前端、完整进历史(2026-09-24 用户拍板取消截断:折叠会吞不该吞的内容)。
|
|
5824
|
+
// live 与回放逐字节一致是硬约束——同一条文本走同一份字符串,这里天然满足。
|
|
5825
|
+
const piece = String(evt.text || '')
|
|
5826
|
+
assistantContent += piece
|
|
5827
|
+
sseWrite(res, { type: 'text-delta', text: piece })
|
|
5828
|
+
// 暴走硬闸:正文总长超限还在继续吐 ⇒ 立刻掐断本步(output token 照付,别陪它写完)。
|
|
5829
|
+
// 必须放在 sseWrite 之后逐 piece 检查:闸门写进某条分支里会漏触发(第一版就是这么写错的)。
|
|
5830
|
+
if (
|
|
5831
|
+
step > 0 &&
|
|
5832
|
+
!roundBodyBurst &&
|
|
5833
|
+
bodyBursts < BODY_BURST_MAX &&
|
|
5834
|
+
assistantContent.length > BODY_BURST_ABORT_CHARS &&
|
|
5835
|
+
!assistantToolCalls?.length
|
|
5836
|
+
) {
|
|
5837
|
+
roundBodyBurst = true
|
|
5838
|
+
roundCtl.abort()
|
|
5839
|
+
}
|
|
4435
5840
|
break
|
|
5841
|
+
}
|
|
4436
5842
|
case 'tool-call-start':
|
|
4437
5843
|
roundProgressAt = Date.now()
|
|
4438
5844
|
roundToolRows++
|
|
@@ -4449,6 +5855,12 @@ export function createAiChatMiddleware(opts = {}) {
|
|
|
4449
5855
|
case 'usage': {
|
|
4450
5856
|
// 缓存命中率的唯一观测口径:把网关回的 usage 累加成本轮总计,再推给前端
|
|
4451
5857
|
const u = evt.usage || {}
|
|
5858
|
+
// 尺子校准:这一手的 promptTokens 就是该步真实输入长度(此刻 messages 还没 append 本步产物,
|
|
5859
|
+
// 正好等于「刚送进网关的输入」)。与同一轮的估算相除即得系数,免费、无额外请求。
|
|
5860
|
+
if (Number(u.promptTokens) > 0) {
|
|
5861
|
+
const estSent = currentMessages.reduce((a, m) => a + openAiMsgTokens(m), 0) + toolsTok
|
|
5862
|
+
observeCtxScale(scaleKey, estSent, u.promptTokens)
|
|
5863
|
+
}
|
|
4452
5864
|
usageTotal.steps++
|
|
4453
5865
|
usageTotal.promptTokens += Number(u.promptTokens) || 0
|
|
4454
5866
|
usageTotal.completionTokens += Number(u.completionTokens) || 0
|
|
@@ -4481,13 +5893,13 @@ export function createAiChatMiddleware(opts = {}) {
|
|
|
4481
5893
|
}
|
|
4482
5894
|
break
|
|
4483
5895
|
}
|
|
4484
|
-
if (roundTimedOut || roundStalled) break
|
|
5896
|
+
if (roundTimedOut || roundStalled || roundBodyBurst) break
|
|
4485
5897
|
}
|
|
4486
5898
|
} catch (e) {
|
|
4487
5899
|
// 熔断 / 看门狗主动 abort 时,上游生成器会抛 AbortError:这不是「用户中止」,
|
|
4488
5900
|
// 吞掉后交给下面的 roundWallClock / roundStalled / roundTimedOut 分支给出准确原因
|
|
4489
5901
|
// (漏了 roundWallClock 会让墙钟打断变成一条「请求已中止」错误,丢掉收尾汇总)
|
|
4490
|
-
if (roundStalled || roundTimedOut || roundWallClock) {
|
|
5902
|
+
if (roundStalled || roundTimedOut || roundWallClock || roundBodyBurst) {
|
|
4491
5903
|
/* 吞掉,走下面分支 */
|
|
4492
5904
|
} else if (
|
|
4493
5905
|
contextRetries < 2 &&
|
|
@@ -4497,7 +5909,8 @@ export function createAiChatMiddleware(opts = {}) {
|
|
|
4497
5909
|
// 网关报上下文溢出:callLLM 已按详情分类(减半救不了的才带 context-overflow 标记抛出),
|
|
4498
5910
|
// 说明是**输入**超窗。压缩后重试同一步(此刻尚未追加 assistant 消息,无悬空 tool_calls),任务不中断
|
|
4499
5911
|
contextRetries++
|
|
4500
|
-
|
|
5912
|
+
// 同样是估算口径:0.6×预算换算成「估算」尺度后再压缩(k 未观测到时与旧行为一致)
|
|
5913
|
+
const tight = Math.max(4000, Math.floor((turnContextBudget / turnScale) * 0.6) - toolsTok)
|
|
4501
5914
|
const deep = deepCompactTurn(currentMessages, tight, Math.max(4, DEEP_KEEP_RECENT - 2), true)
|
|
4502
5915
|
currentMessages = deep.droppedGroups
|
|
4503
5916
|
? deep.messages
|
|
@@ -4529,6 +5942,30 @@ export function createAiChatMiddleware(opts = {}) {
|
|
|
4529
5942
|
budgetReason = 'timeout'
|
|
4530
5943
|
break
|
|
4531
5944
|
}
|
|
5945
|
+
if (roundBodyBurst) {
|
|
5946
|
+
// 正文暴走:本步零副作用(还没到工具执行),掐断后提醒一句重跑本步即可。
|
|
5947
|
+
// 截断发生在生成之后 ⇒ 不掐断就是「照付 output token 再丢弃」,实测一次 2.3 万字 / 2 分 38 秒。
|
|
5948
|
+
bodyBursts++
|
|
5949
|
+
sseWrite(res, {
|
|
5950
|
+
type: 'round-retry',
|
|
5951
|
+
reason: 'body-burst',
|
|
5952
|
+
attempt: bodyBursts,
|
|
5953
|
+
attempts: BODY_BURST_MAX,
|
|
5954
|
+
dropText: assistantContent.length,
|
|
5955
|
+
message: `本步正文已超过 ${BODY_BURST_ABORT_CHARS} 字(过程性分析写太多):已掐断本步重来,请直接给结论或调用工具,别在正文里展开推理`,
|
|
5956
|
+
})
|
|
5957
|
+
currentMessages = [
|
|
5958
|
+
...currentMessages,
|
|
5959
|
+
{
|
|
5960
|
+
role: 'user',
|
|
5961
|
+
content:
|
|
5962
|
+
`[系统提醒] 你上一步的正文写到 ${BODY_BURST_ABORT_CHARS} 字仍未收尾,已被掐断(这一步没有产生任何工具调用)。` +
|
|
5963
|
+
'过程性推理(像素比对 / 逐行对齐 / 几何推算)不要写进正文:正文每轮 ≤2 句 / 300 字,' +
|
|
5964
|
+
'要么直接给出结论,要么直接调用下一个工具推进任务。',
|
|
5965
|
+
},
|
|
5966
|
+
]
|
|
5967
|
+
continue
|
|
5968
|
+
}
|
|
4532
5969
|
if (roundStalled) {
|
|
4533
5970
|
// 网关静默不再「一刀结束整轮」:工具是在流结束后才执行的,此刻本步零副作用,
|
|
4534
5971
|
// 原样重试即可——已执行的几十次调用、已落盘的产物、上下文全都不丢。
|
|
@@ -4569,22 +6006,26 @@ export function createAiChatMiddleware(opts = {}) {
|
|
|
4569
6006
|
// 催促 REASONING_NUDGE_MAX 次仍无产出就停用本熔断,改由墙钟兜底——思考时长不再能中断任务。
|
|
4570
6007
|
if (reasoningNudges < REASONING_NUDGE_MAX) {
|
|
4571
6008
|
reasoningNudges++
|
|
4572
|
-
|
|
6009
|
+
// 第一次只催不夺:保留思考能力、也不回滚已流出的思考(drop* 全 0)。
|
|
6010
|
+
// 「刚想热就被关思考 + 界面清空」实测等于让模型从头再探索一遍,纯浪费;
|
|
6011
|
+
// 第二次起才降级为「关思考 + 回滚半截产物」这套硬手段。
|
|
6012
|
+
const hard = reasoningNudges > 1
|
|
6013
|
+
if (hard) retryNoThinking = true
|
|
4573
6014
|
sseWrite(res, {
|
|
4574
6015
|
type: 'round-retry',
|
|
4575
6016
|
reason: 'reasoning',
|
|
4576
6017
|
attempt: reasoningNudges,
|
|
4577
6018
|
attempts: REASONING_NUDGE_MAX,
|
|
4578
|
-
dropReasoning: roundReasoningChars,
|
|
4579
|
-
dropText: assistantContent.length,
|
|
4580
|
-
dropTools: roundToolRows,
|
|
4581
|
-
message: `本轮纯思考超过 ${fmtDuration(reasoningTimeoutMs)}
|
|
6019
|
+
dropReasoning: hard ? roundReasoningChars : 0,
|
|
6020
|
+
dropText: hard ? assistantContent.length : 0,
|
|
6021
|
+
dropTools: hard ? roundToolRows : 0,
|
|
6022
|
+
message: `本轮纯思考超过 ${fmtDuration(reasoningTimeoutMs)} 仍无产出:已催促模型直接落盘(第 ${reasoningNudges}/${REASONING_NUDGE_MAX} 次${hard ? ',并关闭深度思考' : ''}),任务不中断`,
|
|
4582
6023
|
})
|
|
4583
6024
|
currentMessages = [
|
|
4584
6025
|
...currentMessages,
|
|
4585
6026
|
{
|
|
4586
6027
|
role: 'user',
|
|
4587
|
-
content: `[系统催促] 思考预算已用完:立即停止分析,本轮**必须**以一次工具调用收尾(fs_write 写 IR / card_write / card_patch / card_scaffold
|
|
6028
|
+
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
6029
|
},
|
|
4589
6030
|
]
|
|
4590
6031
|
continue
|
|
@@ -4617,6 +6058,9 @@ export function createAiChatMiddleware(opts = {}) {
|
|
|
4617
6058
|
// 否则一次思考熔断会把整轮后续所有步骤都降级成无思考模式,长程任务质量明显下滑
|
|
4618
6059
|
if (assistantContent || assistantToolCalls?.length) retryNoThinking = false
|
|
4619
6060
|
|
|
6061
|
+
// 正文超限收尾:中段结构化行回捞 + 尾部补回(中间散文省略),推给前端与进历史的是**同一份文本**。
|
|
6062
|
+
// 顺序必须在 settled 判定与三处 append 之前:DONE_MARK / ASK_MARK 写在正文最后一行,
|
|
6063
|
+
// 只留头部会把标记一起截掉,那会让「已完成」的轮次被判成「未完成却停手」而自动续跑。
|
|
4620
6064
|
if (!assistantToolCalls?.length) {
|
|
4621
6065
|
// 自动续跑:模型只吐正文就停手——实测最常见形态是输出「下一步(请确认或直接说「继续」)」
|
|
4622
6066
|
// 把控制权甩回用户,而任务其实没做完(IR 写了但 expand_ir 没跑、门禁没过)。
|
|
@@ -4624,7 +6068,8 @@ export function createAiChatMiddleware(opts = {}) {
|
|
|
4624
6068
|
// 或已经连续停手两次(尊重模型判断),否则自动注入提醒接着推进。
|
|
4625
6069
|
textOnlyRounds++
|
|
4626
6070
|
const settled = TASK_SETTLED_RE.test(assistantContent)
|
|
4627
|
-
|
|
6071
|
+
// 熔断后让位:模型按熔断指令收尾(停手报状态)正是正确动作,绝不能再被「未完成」推回循环
|
|
6072
|
+
if (!settled && !aborted && step > 1 && textOnlyRounds < 2 && autoContinueLeft > 0 && !fidelityFused) {
|
|
4628
6073
|
autoContinueLeft--
|
|
4629
6074
|
sseNotice(res, `任务未完成就停手(没有 ${DONE_MARK} / ${ASK_MARK} 标记):已自动续跑,不用手动输入「继续」(本回合剩余 ${autoContinueLeft} 次)`, 'info', 'auto-continue')
|
|
4630
6075
|
currentMessages = [
|
|
@@ -4632,7 +6077,7 @@ export function createAiChatMiddleware(opts = {}) {
|
|
|
4632
6077
|
{ role: 'assistant', content: assistantContent || null },
|
|
4633
6078
|
{
|
|
4634
6079
|
role: 'user',
|
|
4635
|
-
content: `[系统提醒] 你没有完成任务就停下了,也没给出 ${DONE_MARK} / ${ASK_MARK} 标记。不要征求确认、不要输出「下一步请说继续」——直接接着调用工具推进到下一个门禁。全部要求都已落地且门禁通过时,才在正文最后单独一行输出 ${DONE_MARK};确实缺只有用户能提供的信息时,才在正文最后单独一行输出 ${ASK_MARK} 并写清要用户回答什么。${designFlow ? '建卡未跑完 expand_ir + iterate --gate
|
|
6080
|
+
content: `[系统提醒] 你没有完成任务就停下了,也没给出 ${DONE_MARK} / ${ASK_MARK} 标记。不要征求确认、不要输出「下一步请说继续」——直接接着调用工具推进到下一个门禁。全部要求都已落地且门禁通过时,才在正文最后单独一行输出 ${DONE_MARK};确实缺只有用户能提供的信息时,才在正文最后单独一行输出 ${ASK_MARK} 并写清要用户回答什么。${designFlow ? '建卡未跑完 expand_ir + iterate --gate 就不算完成(还原度熔断已触发时除外——此时收尾就是正确动作)。' : ''}`,
|
|
4636
6081
|
},
|
|
4637
6082
|
]
|
|
4638
6083
|
continue
|
|
@@ -4642,16 +6087,52 @@ export function createAiChatMiddleware(opts = {}) {
|
|
|
4642
6087
|
// 有工具调用 = 任务在推进:重置「只吐正文」计数,下一次停手仍可自动续跑一次
|
|
4643
6088
|
textOnlyRounds = 0
|
|
4644
6089
|
|
|
4645
|
-
//
|
|
4646
|
-
//
|
|
4647
|
-
|
|
6090
|
+
// 正文纪律:这一轮还要继续调工具 ⇒ 上面的正文是过程性推理,不是给用户的结论。
|
|
6091
|
+
// 已取消截断(2026-09-24 用户拍板),这里只做一次性软提醒:超长正文会随每步整轮重发,
|
|
6092
|
+
// 明说代价才可能改变下一步行为(实测只提醒不截断时模型仍会写长,但这正是用户选择的取舍)。
|
|
6093
|
+
if (assistantContent.length > BODY_NARRATION_MAX_CHARS && !bodyNarrationNudged) {
|
|
6094
|
+
bodyNarrationNudged = true
|
|
6095
|
+
sseNotice(
|
|
6096
|
+
res,
|
|
6097
|
+
`本轮正文 ${assistantContent.length} 字(提醒阈值 ${BODY_NARRATION_MAX_CHARS}):不截断,但会随每步整轮重发、推高上下文成本`,
|
|
6098
|
+
'warn',
|
|
6099
|
+
'verbosity',
|
|
6100
|
+
)
|
|
6101
|
+
currentMessages.push({
|
|
6102
|
+
role: 'user',
|
|
6103
|
+
content:
|
|
6104
|
+
`[系统提醒] 你上一条正文写了 ${assistantContent.length} 字(阈值 ${BODY_NARRATION_MAX_CHARS}):` +
|
|
6105
|
+
'过程性分析(像素比对 / 逐行对齐 / 几何推算)写在内部即可,正文只写结论与关键进展(每轮 ≤2 句 / 300 字)。' +
|
|
6106
|
+
'超长正文会随对话每一步整轮重发,显著推高上下文成本。继续推进任务,不要复述分析过程。',
|
|
6107
|
+
})
|
|
6108
|
+
}
|
|
6109
|
+
|
|
6110
|
+
// 熔断①原地打转:同工具 + 同参数重复(实测最烧钱的失控形态,如反复 fs_read 同一份 spec.json、
|
|
6111
|
+
// 反复跑同一条 iterate --set)。两条判据:
|
|
6112
|
+
// ① **连续**相邻轮整轮相同(第 2 轮警告、第 3 轮熔断);
|
|
6113
|
+
// ② 最近 LOOP_WINDOW_ROUNDS 轮里**某一个调用**出现 ≥ LOOP_REPEAT_IN_WINDOW 次。
|
|
6114
|
+
// ② 是必须的:真实循环是交替型 `fs_write(IR-v1) → expand_ir(A) → fs_write(IR-v2) → expand_ir(A)`,
|
|
6115
|
+
// 整轮指纹因 fs_write 每轮都变而轮轮刷新 —— 实测同一个 expand_ir 重复 13 次、同类失败 9 次零熔断。
|
|
6116
|
+
const roundSig = toolRoundSignature(assistantToolCalls, root)
|
|
4648
6117
|
if (roundSig === lastRoundSig) repeatRounds++
|
|
4649
6118
|
else {
|
|
4650
6119
|
lastRoundSig = roundSig
|
|
4651
6120
|
repeatRounds = 1
|
|
4652
6121
|
}
|
|
6122
|
+
const callSigs = assistantToolCalls.map((tc) => toolSignaturePart(tc, root))
|
|
6123
|
+
recentCallRounds.push(callSigs)
|
|
6124
|
+
if (recentCallRounds.length > LOOP_WINDOW_ROUNDS) recentCallRounds.shift()
|
|
6125
|
+
// 按「有多少**轮**出现过该调用」计数,而不是出现次数:同一轮里重复 3 次只是手滑,
|
|
6126
|
+
// 「3 轮各出现一次」才是周期性打转(这正是要拦的形态)。
|
|
6127
|
+
let loopHit = null
|
|
6128
|
+
for (const sig of callSigs) {
|
|
6129
|
+
const roundsWith = recentCallRounds.filter((round) => round.includes(sig)).length
|
|
6130
|
+
if (roundsWith >= LOOP_REPEAT_IN_WINDOW && (!loopHit || roundsWith > loopHit.rounds)) {
|
|
6131
|
+
loopHit = { sig, rounds: roundsWith }
|
|
6132
|
+
}
|
|
6133
|
+
}
|
|
4653
6134
|
loopToolNames = [...new Set(assistantToolCalls.map((tc) => tc?.function?.name || '?'))].join(' + ')
|
|
4654
|
-
if (repeatRounds >= LOOP_BREAK_REPEATS) {
|
|
6135
|
+
if (repeatRounds >= LOOP_BREAK_REPEATS || loopHit) {
|
|
4655
6136
|
budgetReason = 'loop'
|
|
4656
6137
|
// 协议要求每个 tool_call 都有对应的 tool 结果:回填「已跳过」再收尾,别留下悬空 tool_calls
|
|
4657
6138
|
currentMessages = [
|
|
@@ -4660,7 +6141,11 @@ export function createAiChatMiddleware(opts = {}) {
|
|
|
4660
6141
|
]
|
|
4661
6142
|
// 跳过的原因只写一份、两处共用:前端把它存进对话记录、下一回合原样回传成 `Error: <原因>`,
|
|
4662
6143
|
// 与这里推给模型的 live 文本必须逐字节相同(两处各写一份文案 = 又一处前缀漂移)
|
|
4663
|
-
const
|
|
6144
|
+
const loopWhy =
|
|
6145
|
+
repeatRounds >= LOOP_BREAK_REPEATS
|
|
6146
|
+
? `连续第 ${repeatRounds} 次完全相同的调用(同工具 + 同参数)`
|
|
6147
|
+
: `同一个调用在最近 ${LOOP_WINDOW_ROUNDS} 轮里有 ${loopHit.rounds} 轮都出现(周期性打转:改一版 IR → 跑一次同样的检查 → 再改一版)`
|
|
6148
|
+
const skipNote = `已跳过——这是${loopWhy},说明当前思路拿不到新信息,本轮到此为止。`
|
|
4664
6149
|
for (const tc of assistantToolCalls) {
|
|
4665
6150
|
sseWrite(res, {
|
|
4666
6151
|
type: 'tool-result',
|
|
@@ -4715,21 +6200,80 @@ export function createAiChatMiddleware(opts = {}) {
|
|
|
4715
6200
|
result = fsList(root, parsedArgs, mountedRoots)
|
|
4716
6201
|
break
|
|
4717
6202
|
case 'fs_write':
|
|
4718
|
-
result = fsWrite(root, parsedArgs)
|
|
6203
|
+
result = fsWrite(root, parsedArgs, fsWriteLedger)
|
|
6204
|
+
break
|
|
6205
|
+
case 'ir_patch':
|
|
6206
|
+
result = fidelityFused ? FIDELITY_FUSE_REJECT : irPatch(root, parsedArgs)
|
|
4719
6207
|
break
|
|
4720
6208
|
case 'fs_copy':
|
|
4721
6209
|
result = fsCopy(root, parsedArgs, mountedRoots)
|
|
4722
6210
|
break
|
|
4723
|
-
case 'skill_exec':
|
|
4724
|
-
|
|
6211
|
+
case 'skill_exec': {
|
|
6212
|
+
// 还原度熔断:iterate 不再真执行(合成拒绝结果让循环继续,模型还能说出收尾词)
|
|
6213
|
+
if (fidelityFused && /iterate\.mjs$/.test(String(parsedArgs?.script || ''))) {
|
|
6214
|
+
result = FIDELITY_FUSE_REJECT
|
|
6215
|
+
break
|
|
6216
|
+
}
|
|
6217
|
+
// 连续失败退避:同一调用(脚本 + 参数)连续失败 2 次就不再执行。
|
|
6218
|
+
// 报错一字未变时第三次必然还是同样的错,而每撞一次都要多付一步全量 prompt。
|
|
6219
|
+
const callKey = skillCallKey(parsedArgs, root)
|
|
6220
|
+
const prevFail = execFails.get(callKey)
|
|
6221
|
+
if (prevFail && prevFail.count >= SKILL_FAIL_SHORT_CIRCUIT) {
|
|
6222
|
+
result = skillShortCircuit(parsedArgs, prevFail)
|
|
6223
|
+
} else {
|
|
6224
|
+
result = await runSkillExec(root, parsedArgs, res, approvedTools)
|
|
6225
|
+
noteSkillExecOutcome(callKey, parsedArgs.script, result, execFails)
|
|
6226
|
+
// 迭代进度播报:把人从「29 分钟黑箱等待」里救出来(只走真执行路径,短路不播)
|
|
6227
|
+
const itRep = notifyIterateProgress(res, parsedArgs.script, result)
|
|
6228
|
+
// 还原度熔断记账:stalled 连续轮数 / 总轮数到线 ⇒ 一次性注入硬提醒 + 禁用微调工具。
|
|
6229
|
+
// 为什么是硬熔断而不是再提醒:stall 提醒实测 8 次 0 次改变行为(只发用户);逐字节
|
|
6230
|
+
// 指纹熔断被振荡式微调(lineHeight 10↔12↔16)结构性绕开——只有工具层禁用能兜住。
|
|
6231
|
+
// gate.passed 的轮不触发:门禁过了自然收尾,不需要熔断。
|
|
6232
|
+
if (
|
|
6233
|
+
itRep &&
|
|
6234
|
+
!fidelityFused &&
|
|
6235
|
+
itRep.gate?.passed !== true &&
|
|
6236
|
+
((itRep.stalled?.rounds ?? 0) >= STALL_WRAPUP_STALL_ROUNDS ||
|
|
6237
|
+
(itRep.rounds ?? 0) >= ITERATE_ROUNDS_HARD_CAP)
|
|
6238
|
+
) {
|
|
6239
|
+
fidelityFused = true
|
|
6240
|
+
const stallRounds = itRep.stalled?.rounds ?? 0
|
|
6241
|
+
const fuseWhy =
|
|
6242
|
+
stallRounds >= STALL_WRAPUP_STALL_ROUNDS
|
|
6243
|
+
? `已连续 ${stallRounds} 轮未刷新最好成绩`
|
|
6244
|
+
: `总轮数 ${itRep.rounds} 已到上限 ${ITERATE_ROUNDS_HARD_CAP}`
|
|
6245
|
+
const bestNote = itRep.best?.match != null ? `best=${itRep.best.match}% @ 第 ${itRep.best.round} 轮` : '无有效 best'
|
|
6246
|
+
sseNotice(res, `还原度熔断:${bestNote},${fuseWhy}——已禁用 iterate / ir_patch,模型将直接收尾`, 'warn', 'fidelity-fuse')
|
|
6247
|
+
currentMessages.push({
|
|
6248
|
+
role: 'user',
|
|
6249
|
+
content: `[还原度熔断] ${bestNote},${fuseWhy},像素级微调已无收益。立即收尾:① 若盘上 schema 不是 best 版本,跑一次 ir_restore 回退到 best;② 正文报告最终状态(match / 高度差 / 剩余差异点 / 未做成的项);③ 正文最后单独一行输出 ${DONE_MARK}。禁止再调用 iterate.mjs 与 ir_patch(本回合已被禁用,调用会直接被拒)。`,
|
|
6250
|
+
})
|
|
6251
|
+
}
|
|
6252
|
+
}
|
|
4725
6253
|
break
|
|
6254
|
+
}
|
|
4726
6255
|
case 'shell_exec':
|
|
6256
|
+
if (/^\s*(cat|ls|head|tail|rg|grep|find|wc|jq|sed|awk)\b/.test(String(parsedArgs?.command || ''))) {
|
|
6257
|
+
shellReadLikeCount += 1
|
|
6258
|
+
}
|
|
4727
6259
|
result = await runShellExec(root, parsedArgs, res, approvedTools)
|
|
4728
6260
|
break
|
|
4729
6261
|
default:
|
|
4730
6262
|
result = await dispatch(toolName, parsedArgs)
|
|
4731
6263
|
}
|
|
4732
6264
|
const { text, image } = splitToolResult(result)
|
|
6265
|
+
// 写到了别的卡上(新建 / 另存)必须让用户当场看见:本轮目标卡与「当前卡」不一致
|
|
6266
|
+
// 时推一条 warn(一次一轮一条)。card_rename 已在上面更新 liveCardId,不会误报。
|
|
6267
|
+
const targetCardId = cardIdFromToolArgs(toolName, parsedArgs)
|
|
6268
|
+
if (targetCardId && liveCardId && targetCardId !== liveCardId && !cardTargetNotified.has(targetCardId)) {
|
|
6269
|
+
cardTargetNotified.add(targetCardId)
|
|
6270
|
+
sseNotice(
|
|
6271
|
+
res,
|
|
6272
|
+
`AI 这次写的是卡片 ${targetCardId}(不是当前卡 ${liveCardId})——属于新建 / 另存,当前卡没有被覆盖`,
|
|
6273
|
+
'warn',
|
|
6274
|
+
'card-target',
|
|
6275
|
+
)
|
|
6276
|
+
}
|
|
4733
6277
|
// 推给前端的 result 与下面的模型上下文**同一截断口径**:前端会把它原样存进对话记录、
|
|
4734
6278
|
// 下一回合再回传。给前端截得更短(旧值 4000)等于每轮都改写一次历史,前缀缓存从该条起全废
|
|
4735
6279
|
sseWrite(res, {
|
|
@@ -4758,9 +6302,6 @@ export function createAiChatMiddleware(opts = {}) {
|
|
|
4758
6302
|
// 报错文本同样按统一口径截断:回放侧(toOpenAiMessages)会 slice 到同一上限,
|
|
4759
6303
|
// 两处不一致时,一条超长报错(路径回显 / 网关长详情)就会在轮边界改写历史、
|
|
4760
6304
|
// 把「这条之后」的前缀缓存整段打掉。Error: 前缀在两侧都算进同一个上限里
|
|
4761
|
-
// 报错文本同样按统一口径截断:回放侧(toOpenAiMessages)会 slice 到同一上限,
|
|
4762
|
-
// 两处不一致时,一条超长报错(路径回显 / 网关长详情)就会在轮边界改写历史、
|
|
4763
|
-
// 把「这条之后」的前缀缓存整段打掉。Error: 前缀在两侧都算进同一个上限里
|
|
4764
6305
|
currentMessages.push({ role: 'tool', tool_call_id: tc.id, content: `Error: ${errMsg}`.slice(0, TOOL_RESULT_MAX_CHARS) })
|
|
4765
6306
|
}
|
|
4766
6307
|
}
|
|
@@ -4888,7 +6429,16 @@ export function createAiChatMiddleware(opts = {}) {
|
|
|
4888
6429
|
console.error('[ai-chat] failed:', e)
|
|
4889
6430
|
const msg = String((e && e.message) || e)
|
|
4890
6431
|
turnError = msg
|
|
4891
|
-
|
|
6432
|
+
// 硬错误不该只甩一句技术报错:这一轮跑过的工具**结果都已经落盘**(card_write / ir_patch /
|
|
6433
|
+
// expand_ir / iterate 全是直接写盘),对话上下文也原样保留 —— 用户回一句「继续」就能从断点
|
|
6434
|
+
// 接着做。不说这句,用户会以为整轮白干、从头再来(实测最贵的那次网关 400 报废了
|
|
6435
|
+
// 375.7s / 75 万 input,而产物其实都还在盘上)。
|
|
6436
|
+
const resumeNote =
|
|
6437
|
+
step > 0
|
|
6438
|
+
? `本轮已跑完 ${step} 步工具调用:产物与对话上下文都已保留,回一句「继续」即可从断点接着做,不必从头再来。`
|
|
6439
|
+
: ''
|
|
6440
|
+
const humanMsg = /aborted/i.test(msg) ? '请求已中止' : msg
|
|
6441
|
+
sseWrite(res, { type: 'error', message: resumeNote ? `${humanMsg}\n${resumeNote}` : humanMsg, code: classifyErrorCode(e) })
|
|
4892
6442
|
}
|
|
4893
6443
|
sseWrite(res, { type: 'done' })
|
|
4894
6444
|
} finally {
|
|
@@ -4922,7 +6472,25 @@ export {
|
|
|
4922
6472
|
classifyErrorCode,
|
|
4923
6473
|
normalizeUsage,
|
|
4924
6474
|
prefixDrift,
|
|
6475
|
+
locateDropped,
|
|
6476
|
+
collectLocateRoots,
|
|
4925
6477
|
TOOL_RESULT_MAX_CHARS,
|
|
6478
|
+
// 以下为本轮新增能力的单测入口(草稿区写入闸门 / IR 最小写 / 上下文尺子校准 / 迭代进度播报 / 正文截断)
|
|
6479
|
+
fsWrite,
|
|
6480
|
+
irPatch,
|
|
6481
|
+
toolsTokens,
|
|
6482
|
+
ctxScale,
|
|
6483
|
+
observeCtxScale,
|
|
6484
|
+
parseSkillJsonReport,
|
|
6485
|
+
notifyIterateProgress,
|
|
6486
|
+
irResolve,
|
|
6487
|
+
irNodeHint,
|
|
6488
|
+
irEditDistance1,
|
|
6489
|
+
STALL_WRAPUP_STALL_ROUNDS,
|
|
6490
|
+
ITERATE_ROUNDS_HARD_CAP,
|
|
6491
|
+
FIDELITY_FUSE_REJECT,
|
|
6492
|
+
cardIdFromToolArgs,
|
|
6493
|
+
BODY_NARRATION_MAX_CHARS,
|
|
4926
6494
|
}
|
|
4927
6495
|
|
|
4928
6496
|
export default createAiChatMiddleware
|