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