cardbuddy 0.22.0 → 0.24.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/README.md +11 -11
- package/dist/cli.js +2 -2
- package/dist/config.js +3 -3
- package/dist/init.js +13 -13
- package/dist/mcp.js +1 -1
- package/dist/preview-editor/assets/{index-CPpLZZyi.js → index-BumNBo-n.js} +81 -80
- package/dist/preview-editor/assets/{index-CU8zVA2P.css → index-MWKPP9nd.css} +1 -1
- package/dist/preview-editor/index.html +2 -2
- package/dist/skills-upgrade.js +1 -1
- package/dist/standalone.js +2 -2
- package/dist/vendored/card-editor/runtime/adapter.js +10 -11
- package/dist/vendored/mcp/client.mjs +50 -13
- package/dist/vendored/mcp/tools.mjs +13 -7
- package/dist/vendored/server/core/ai-chat.js +540 -183
- package/dist/vendored/server/core/assetProxy.js +1 -1
- package/dist/vendored/server/core/browser.js +1 -1
- package/dist/vendored/server/core/cardCompile.js +202 -0
- package/dist/vendored/server/core/cardDigest.js +324 -0
- package/dist/vendored/server/core/cardExport.js +5 -5
- package/dist/vendored/server/core/cardFiles.js +1 -1
- package/dist/vendored/server/core/cardHistoryRemote.js +1 -1
- package/dist/vendored/server/core/cardImportMeta.js +1 -1
- package/dist/vendored/server/core/cardListCache.js +92 -10
- package/dist/vendored/server/core/cardPublish.js +11 -6
- package/dist/vendored/server/core/cardPublishVersions.js +8 -8
- package/dist/vendored/server/core/cardSave.js +14 -10
- package/dist/vendored/server/core/cardSidecar.js +3 -3
- package/dist/vendored/server/core/cardSync.js +35 -23
- package/dist/vendored/server/core/cardVersion.js +1 -1
- package/dist/vendored/server/core/flameCompile/client.js +466 -0
- package/dist/vendored/server/core/flameCompile/clientElements.js +372 -0
- package/dist/vendored/server/core/flameCompile/common.js +253 -0
- package/dist/vendored/server/core/flameCompile/h5.js +326 -0
- package/dist/vendored/server/core/flameCompile/index.js +126 -0
- package/dist/vendored/server/core/flameCompile/model.js +162 -0
- package/dist/vendored/server/core/index.js +12 -6
- package/dist/vendored/server/core/logger.js +1 -1
- package/dist/vendored/server/core/remoteCard.js +5 -5
- package/dist/vendored/server/core/skillPaths.js +1 -1
- package/dist/vendored/server/core/sliceUpload.js +4 -4
- package/dist/vendored/skills/README.md +13 -13
- package/dist/vendored/skills/card-doctor/SKILL.md +2 -2
- package/dist/vendored/skills/dsl-card-ai/SKILL.md +4 -4
- package/dist/vendored/skills/dsl-card-generator/SKILL.md +2 -2
- package/dist/vendored/skills/dsl-card-generator/references/amount_formatting.md +1 -1
- package/dist/vendored/skills/dsl-card-generator/references/build_protocol.md +54 -0
- package/dist/vendored/skills/dsl-card-generator/references/component_rules.md +2 -2
- package/dist/vendored/skills/dsl-card-generator/references/converge_protocol.md +36 -0
- package/dist/vendored/skills/dsl-card-generator/references/event_patterns.md +1 -1
- package/dist/vendored/skills/dsl-card-generator/references/generation_pitfalls.md +1 -1
- package/dist/vendored/skills/dsl-card-generator/references/image_to_card.md +1 -1
- package/dist/vendored/skills/dsl-card-generator/references/quickref.md +1 -1
- package/dist/vendored/skills/dsl-card-generator/references/recipes.md +4 -4
- package/dist/vendored/skills/dsl-card-generator/references/rules.md +1 -1
- package/dist/vendored/skills/dsl-card-generator/references/slices_convention.md +6 -6
- package/dist/vendored/skills/dsl-card-generator/scripts/inventory_schemas.py +1 -1
- package/dist/vendored/skills/dsl-card-generator/scripts/lib/scaffold.mjs +1 -1
- package/dist/vendored/skills/dsl-card-generator/scripts/lib/slice_dedupe.mjs +2 -2
- package/dist/vendored/skills/dsl-card-generator/vendor/preview/assets/standalone-CeVX_y-M.js +137 -0
- package/dist/vendored/skills/dsl-card-generator/vendor/preview/assets/standalone-DiEiY59H.css +1 -0
- package/dist/vendored/skills/dsl-card-generator/vendor/preview/standalone.html +2 -2
- package/dist/vendored/skills/{finmall-card-sync → platform-card-sync}/SKILL.md +26 -26
- package/dist/vendored/skills/{finmall-card-sync → platform-card-sync}/scripts/api_batch_sync.mjs +184 -162
- package/dist/vendored/skills/{finmall-card-sync → platform-card-sync}/scripts/slice_upload.mjs +48 -29
- package/dist/vendored/skills/{finmall-session-navigator → platform-session-navigator}/SKILL.md +13 -13
- package/dist/vendored/skills/{finmall-session-navigator → platform-session-navigator}/scripts/package-lock.json +2 -2
- package/dist/vendored/skills/{finmall-session-navigator → platform-session-navigator}/scripts/package.json +2 -2
- package/dist/vendored/skills/{finmall-session-navigator/scripts/finmall_login.mjs → platform-session-navigator/scripts/platform_login.mjs} +41 -31
- package/dist/vendored/skills/{finmall-session-navigator → platform-session-navigator}/scripts/session-manager.mjs +17 -17
- package/dist/vendored/skills/shared/README.md +21 -21
- package/dist/vendored/skills/shared/auth-model.md +8 -10
- package/dist/vendored/skills/shared/{finmall-config.mjs → platform-config.mjs} +10 -10
- package/dist/vendored/tools/{finmall-env.mjs → platform-env.mjs} +13 -13
- package/package.json +1 -1
- package/dist/vendored/skills/dsl-card-generator/vendor/preview/assets/standalone-BCLshIzC.css +0 -1
- package/dist/vendored/skills/dsl-card-generator/vendor/preview/assets/standalone-pP5kTKiI.js +0 -88
|
@@ -82,6 +82,7 @@ import {
|
|
|
82
82
|
// 回合日志(P2):事件流按 seq 落盘 .cache/ai-turns/<cardId>.jsonl —— 断线续跑的回放源、
|
|
83
83
|
// 收尾时 reduce 成服务端权威历史;客户端崩溃 / 关标签页不再丢整轮轨迹。
|
|
84
84
|
import { openJournal, getLiveJournal, readJournalLines, listTurns, deleteJournal } from './turnJournal.js'
|
|
85
|
+
import { digestCard, resolveComponentPath } from './cardDigest.js'
|
|
85
86
|
|
|
86
87
|
/**
|
|
87
88
|
* 快照根:仓库内运行 = 真仓库根;cardbuddy 发布态 = 包内 dist/vendored(构建期内联的迷你仓库快照,
|
|
@@ -115,7 +116,9 @@ const REPO_ROOT = SNAPSHOT_ROOT
|
|
|
115
116
|
* 实测 gen-1 那一轮103 步、平均 98737 token/步,这就是主因(不是系统提示词)。
|
|
116
117
|
*
|
|
117
118
|
* 各档取值与依据:
|
|
118
|
-
* - card_read 12000
|
|
119
|
+
* - card_read 12000:它是「读全卡」的唯一入口,所以档位要给足;但**大卡在这一档之前就被
|
|
120
|
+
* 折叠成结构图**(见 foldOversizeCardRead),所以这 12000 实际是「结构图 + 参数表 + 下一步」
|
|
121
|
+
* 的预算,不再承担「整份 JSON 能装多少」的责任;
|
|
119
122
|
* - skill_read 10000:与 clipSkillDoc 的续读锚点协议配套(见 SKILL_READ_MAX 注释),
|
|
120
123
|
* 砍太多会让 ir-format.md(17KB)从 2 次读变成 3 次读,反而更贵;
|
|
121
124
|
* - skill_exec 3000:脚本 stdout 大头是 JSON 报告,而报告已摘要化(见 briefSkillReport),
|
|
@@ -155,15 +158,96 @@ const TOOL_RESULT_MAX_DEFAULT = 6000
|
|
|
155
158
|
function toolResultLimit(toolName) {
|
|
156
159
|
return TOOL_RESULT_MAX_BY_TOOL[toolName] || TOOL_RESULT_MAX_DEFAULT
|
|
157
160
|
}
|
|
161
|
+
/**
|
|
162
|
+
* 截断尾巴的**下一步指引**(按工具定制):光说「已截断」不够,模型会原地重发同一条调用。
|
|
163
|
+
* 每条都必须是**能直接执行的动作**,否则等于没给。
|
|
164
|
+
*/
|
|
165
|
+
const TRUNCATE_HINT = {
|
|
166
|
+
card_read:
|
|
167
|
+
'先看该结果的 tree 段(大卡会自动折叠成结构图);要细节用 card_read {id, node:"<componentId>"} 定向读一棵子树;不知道是哪个节点时用 card_grep 按文案反查',
|
|
168
|
+
card_grep: '换个更窄的 pattern,或加 className / componentId 过滤',
|
|
169
|
+
fs_read: '用 offset 分段读(返回里带锚点,按它继续)',
|
|
170
|
+
skill_read: '用 offset 分段读(返回里带锚点,按它继续)',
|
|
171
|
+
card_node_models: '用 card_grep 精确定位到节点后再读',
|
|
172
|
+
}
|
|
173
|
+
const TRUNCATE_HINT_DEFAULT = '缩小范围再取:换更窄的查询参数,或按返回里的锚点分段读'
|
|
174
|
+
|
|
158
175
|
/**
|
|
159
176
|
* 按工具档位截断的唯一出口(三处调用点共用)。
|
|
177
|
+
*
|
|
178
|
+
* **必须带截断标记**:旧实现是纯 `slice(0, limit)`,被截断的工具结果与「真的只有这么长」
|
|
179
|
+
* 在模型看来一模一样 —— 实测 card_read 对 100KB~1MB 的 schema 只给前 12,000 字符(约 2%),
|
|
180
|
+
* 而卡片库里有 167 个组件的卡,模型据此得出「没有这个字段」的结论后转去 shell 反复摸索。
|
|
181
|
+
* 静默截断省下的是提示语,付掉的是一到两步的探索(每步 20k+ 输入)。
|
|
182
|
+
*
|
|
183
|
+
* 标记本身也占档位,所以正文要按「留出标记后的余量」切,并保证总长 ≤ limit(外层还有尺子);
|
|
184
|
+
* 档位小到放不下标记时退回纯截断,不能为了提示把正文挤成负数。
|
|
185
|
+
*
|
|
160
186
|
* @param {string} toolName
|
|
161
187
|
* @param {string} text 工具产出的完整文本
|
|
162
|
-
* @returns {string}
|
|
188
|
+
* @returns {string} 截断后的文本(未超限时与入参逐字相同)
|
|
163
189
|
*/
|
|
164
190
|
function clipToolResult(toolName, text) {
|
|
165
191
|
const s = typeof text === 'string' ? text : String(text ?? '')
|
|
166
|
-
|
|
192
|
+
const limit = toolResultLimit(toolName)
|
|
193
|
+
if (s.length <= limit) return s
|
|
194
|
+
const hint = TRUNCATE_HINT[toolName] || TRUNCATE_HINT_DEFAULT
|
|
195
|
+
const tailFor = (kept) =>
|
|
196
|
+
`\n\n…(已截断:原文 ${s.length} 字符,上面只给了前 ${kept} 字符。${hint}。**不要因为这里没看到就断言「没有」**。)`
|
|
197
|
+
// 位数会随 kept 变化,迭代到不动点,保证 head + tail ≤ limit
|
|
198
|
+
let kept = Math.max(0, limit - tailFor(limit).length)
|
|
199
|
+
for (let i = 0; i < 4; i++) {
|
|
200
|
+
const next = Math.max(0, limit - tailFor(kept).length)
|
|
201
|
+
if (next === kept) break
|
|
202
|
+
kept = next
|
|
203
|
+
}
|
|
204
|
+
if (kept <= 0) return s.slice(0, limit)
|
|
205
|
+
const out = s.slice(0, kept) + tailFor(kept)
|
|
206
|
+
return out.length <= limit ? out : out.slice(0, limit)
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/**
|
|
210
|
+
* card_read 的**结构化折叠**:超档位时,把「JSON 前 N 个字节」换成「全部节点的结构图」。
|
|
211
|
+
*
|
|
212
|
+
* 为什么不能只靠 clipToolResult:schema 的键序是 `…dictionary / view`,而 dictionary 排在 view 前面。
|
|
213
|
+
* 实测 overseaTransfer(239KB / 167 节点)取前 12000 字符 = 30 条 inputParams(10.5KB)+ 根节点的
|
|
214
|
+
* 半张样式表,**componentId 出现 0 次**——模型花满一个档位却一个组件都没看到,只能转头 card_grep
|
|
215
|
+
* 反复试路径(每多一步 20~40k 输入)。同一个预算下全节点骨架只要 4.7k,所以这里换掉的是
|
|
216
|
+
* 「取哪段」而不是「取多少」:6k 字符换 167 个可定位的节点,比 12k 字符换 0 个节点便宜且有用。
|
|
217
|
+
*
|
|
218
|
+
* 只在 card_read 上做:别的工具产出是文本(日志/报告),没有「结构 vs 字节」这层选择。
|
|
219
|
+
* @param {string} toolName
|
|
220
|
+
* @param {string} text 已 stringify 的工具结果
|
|
221
|
+
* @param {object} args 该次工具调用的入参(折叠文案里要回填卡片 id)
|
|
222
|
+
* @returns {string} 折叠后的 JSON 文本;不满足条件时原样返回(逐字相同,不扰动前缀缓存)
|
|
223
|
+
*/
|
|
224
|
+
function foldOversizeCardRead(toolName, text, args) {
|
|
225
|
+
if (toolName !== 'card_read') return text
|
|
226
|
+
const limit = toolResultLimit('card_read')
|
|
227
|
+
if (typeof text !== 'string' || text.length <= limit) return text
|
|
228
|
+
let parsed
|
|
229
|
+
try {
|
|
230
|
+
parsed = JSON.parse(text)
|
|
231
|
+
} catch {
|
|
232
|
+
return text
|
|
233
|
+
}
|
|
234
|
+
if (!parsed || typeof parsed !== 'object' || !parsed.schema) return text
|
|
235
|
+
const id = String(parsed.id || args?.id || '')
|
|
236
|
+
const d = digestCard(parsed.schema, parsed.data, { id, budget: limit })
|
|
237
|
+
if (!d) return text
|
|
238
|
+
const folded = {
|
|
239
|
+
source: parsed.source,
|
|
240
|
+
mode: 'digest',
|
|
241
|
+
reason: `schema ${d.meta.compactBytes} 字节 / ${d.meta.nodes} 节点,超过单次读取档位 ${limit} 字符——返回结构图而非整份 JSON`,
|
|
242
|
+
meta: d.meta,
|
|
243
|
+
tree: d.tree,
|
|
244
|
+
inputParams: d.params,
|
|
245
|
+
next: d.next,
|
|
246
|
+
}
|
|
247
|
+
const out = JSON.stringify(folded)
|
|
248
|
+
// 极端情况(节点极多导致骨架 + 参数表仍超档位):退回原文本,让 clipToolResult 按老规矩截。
|
|
249
|
+
// 不在这里二次裁剪——两处裁剪会让「到底给了多少」变得没人说得清。
|
|
250
|
+
return out.length <= limit ? out : text
|
|
167
251
|
}
|
|
168
252
|
|
|
169
253
|
/**
|
|
@@ -249,46 +333,13 @@ function skillRefsDir(root) {
|
|
|
249
333
|
}
|
|
250
334
|
|
|
251
335
|
/**
|
|
252
|
-
* references
|
|
336
|
+
* references 的索引说明(SKILL_REF_HINTS)是**全量列出**的:不再按 buildFlow 分档隐藏。
|
|
253
337
|
*
|
|
254
|
-
*
|
|
255
|
-
*
|
|
256
|
-
*
|
|
338
|
+
* 以前这里有一张 REF_STAGE 分档表,改卡轮会隐去 11 条建卡专属文档(IR 契约 / 设计稿映射 / 读图规则…)。
|
|
339
|
+
* 分档确实省几百字符,但它让 system 随判定结果增删索引段 ⇒ 改卡轮 ↔ 建卡轮一切换,整段前缀作废。
|
|
340
|
+
* 实测 step-1 命中率只有 49.6%,主要就是这类「system 随轮次变形」造成的。多列 11 行(常驻命中价)
|
|
341
|
+
* 换回前缀稳定,明显更划算;模型也不会因为「看不到索引」而在建卡时找不到该读的文档。
|
|
257
342
|
*/
|
|
258
|
-
const REF_STAGE = {
|
|
259
|
-
always: [
|
|
260
|
-
'rules.md',
|
|
261
|
-
'quickref.md',
|
|
262
|
-
'component_specs.md',
|
|
263
|
-
'component_rules.md',
|
|
264
|
-
'dsl_structure.md',
|
|
265
|
-
'event_patterns.md',
|
|
266
|
-
'expression_engine.md',
|
|
267
|
-
'amount_formatting.md',
|
|
268
|
-
'field_naming.md',
|
|
269
|
-
'field_name_dict.json',
|
|
270
|
-
'platform_jsapi.md',
|
|
271
|
-
'scripts_reference.md',
|
|
272
|
-
'previewer_contract.md',
|
|
273
|
-
'card_index.json',
|
|
274
|
-
'card_templates.json',
|
|
275
|
-
'message-center-pattern.md',
|
|
276
|
-
],
|
|
277
|
-
// 只有走建卡流程(读设计稿 / 写 IR / 视觉闭环)才用得上
|
|
278
|
-
build: [
|
|
279
|
-
'ir-format.md',
|
|
280
|
-
'spec_to_schema_patterns.md',
|
|
281
|
-
'image_to_card.md',
|
|
282
|
-
'slices_convention.md',
|
|
283
|
-
'semantic_adjudication.md',
|
|
284
|
-
'iteration_protocol.md',
|
|
285
|
-
'image_diff.md',
|
|
286
|
-
'generation_pitfalls.md',
|
|
287
|
-
'card_to_summary.md',
|
|
288
|
-
'ui_fidelity_rules.md',
|
|
289
|
-
'recipes.md',
|
|
290
|
-
],
|
|
291
|
-
}
|
|
292
343
|
/** 参考索引的一句话说明:够模型选对文件即可,全文不进上下文 */
|
|
293
344
|
const SKILL_REF_HINTS = {
|
|
294
345
|
'rules.md': '卡片生成铁律(card_write 前必读:结构 / 绑定 / 样式红线)',
|
|
@@ -308,6 +359,8 @@ const SKILL_REF_HINTS = {
|
|
|
308
359
|
'card_templates.json': '卡片模板库:card_scaffold 的 from 模板来源',
|
|
309
360
|
'generation_pitfalls.md': '生成高频坑位清单(IR / schema 写完自查一遍;长文用 skill_read section= 取节)',
|
|
310
361
|
'previewer_contract.md': '预览器渲染契约:绝对布局 AbsoluteLayout 等字段形状的唯一权威说明',
|
|
362
|
+
'build_protocol.md': '建卡协议全文 S0~S4.5(路由 / 要素包 / 选卡 / 审计 / 切图 / 写 IR / 变量抽取):建卡动手前必读',
|
|
363
|
+
'converge_protocol.md': '建卡协议全文 S4.9~S6(内容就绪 / 迭代收敛 / 收尾门禁):进 iterate 前必读',
|
|
311
364
|
'ir-format.md': '紧凑 IR 字段契约(大卡建新卡时省 token 的中间表示)',
|
|
312
365
|
'ui_fidelity_rules.md': '保真规则:间距 / 字号 / 行高 / 媒体降级等还原度纪律',
|
|
313
366
|
'image_to_card.md': '设计稿 → 卡片的读图与语义块划分规则',
|
|
@@ -451,98 +504,75 @@ function isCardEmptyForBuildIntent(root, cardId) {
|
|
|
451
504
|
}
|
|
452
505
|
|
|
453
506
|
/**
|
|
454
|
-
*
|
|
507
|
+
* 建卡协议硬门禁:「判定为建卡流程」且「本回合还没读过 build_protocol.md」时,
|
|
508
|
+
* 拒绝一切「把卡片内容写出去」的动作。
|
|
509
|
+
*
|
|
510
|
+
* 为什么需要服务端兜底:协议外置成文档后,模型完全可能跳过 skill_read 直接 fs_write 硬写 IR
|
|
511
|
+
* (历史实测:正文里写着「先读协议」,手上已经先写了),而一旦硬写出去,S2~S5 的产物全部错位,
|
|
512
|
+
* 这一轮就白烧了。驳回比放行便宜——被驳回只需补一次 skill_read,放行则要整轮重来。
|
|
455
513
|
*
|
|
456
|
-
*
|
|
457
|
-
*
|
|
458
|
-
* 用户只是「改个颜色 / 查一下报错」时它整段是噪音;反之少了它,模型不知道要走 S0~S6,
|
|
459
|
-
* 会自己去摸索(实测十几个工具步骤零产出)。判据见 classifyBuildIntent(刻意偏保守)。
|
|
514
|
+
* 只拦「写出」不拦「读取 / 取证」:读 spec.json、跑 spec_audit、fs_list 都是无害的探索,
|
|
515
|
+
* 拦了只会逼模型在没协议的情况下瞎试。card_patch 也不拦——改已有卡常与本流程交叉。
|
|
460
516
|
*
|
|
461
|
-
*
|
|
462
|
-
*
|
|
517
|
+
* @param {{buildFlow?: boolean, protocolRead?: boolean, toolName?: string, args?: object}} [o]
|
|
518
|
+
* @returns {string} 非空 = 该驳回,内容就是回给模型的错误文本
|
|
463
519
|
*/
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
三条高频坑(readiness 会在 blockers 里逐条点名,不要靠猜):① Image 的图片地址属性键**只能是 value**,写成 image 会被运行时按 itemKey 绑定失败后**整条静默丢弃** ⇒ value 为空 ⇒ 破图,而 validate / lint / preflight 全绿;② from:"2" 的值必须是静态路径、from:"1" 的值必须是 $表达式,写反 ⇒ 字符串直接进 url(...) 或表达式求值失败 ⇒ 画布空白;③ 切图**引用名与磁盘文件名必须同源**(crop_slices 按引用名落盘;引用一个 src/slices 里不存在的名字 ⇒ 渲染 404、图空白、控制台零报错)。
|
|
504
|
-
**iterate 每轮会自动再跑这道门禁**:报告里 gate.stage=content(或 verdict 以 BLOCKED(内容未就绪)开头)表示本轮被挡——没跑 render、没跑 pixelDiff、**没消耗迭代轮次**。按 gate.verdict 里的 blockers 补齐内容后**直接重跑 iterate**即可;禁止在被挡时反复重跑空转,更禁止去调像素(那时候没有像素分可调)。
|
|
505
|
-
逃生门 --no-readiness(或环境变量 DSL_CARD_SKIP_READINESS=1)只在排查「门禁本身误判」时用,正常流程禁止。
|
|
506
|
-
S5 收敛循环(唯一门禁 = iterate.mjs 报告里的 gate 字段:内部已跑 autofix + 真 validate + lint + preflight + **内容就绪(S4.9)** + render + 并集像素对比,别再单独跑 validate_schema.py):
|
|
507
|
-
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"]。
|
|
508
|
-
修 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")。
|
|
509
|
-
**收敛 = 报告 gate.passed === true**(**内容就绪(S4.9)** ∧ validate/lint/preflight 零 error ∧ **elements.missing=0** ∧ match ≥ 95 ∧ heightDelta=0 ∧ widthDelta=0 ∧ |contentOffset1x| ≤ 2)。gate 是脚本判的,正文只能引用它的 verdict / failed,不得自行宣布达标。像素对比按并集画布算,删内容 / 高度塌缩只会降分,不可能凑分。
|
|
510
|
-
**要素是固定真值:先内容、后骨架、最后样式(三段式门禁,逐段推进)**。S2 审计里的每条文本 / 每张切图都是这张卡的既定要素,S5 只许改**样式与布局**(尺寸 / 间距 / 颜色 / 字号 / flex / 位置),**不许丢要素、不许自造内容**。报告带 gate.stage 与 gate.checks[].name === 'readiness.content':
|
|
511
|
-
· stage=content(gate.blocked=true)⇒ 内容层没齐(要素 / 变量绑定 / 图片地址 / 切图落盘),**本轮根本没 render**。照 verdict 里的 blockers 补内容,别调像素、别重跑空转。
|
|
512
|
-
· stage=structure(elements.missing>0 或 validate/lint/preflight 未过)⇒ **先补要素,别调像素**:把设计稿的文案补回对应节点;绑定变量的把 defaultValue 改成设计稿真值。改样式不会把丢掉的文案变回来。
|
|
513
|
-
· stage=pixel ⇒ 内容与结构都齐了,才进入样式 / 布局收敛(一轮只改一个分项)。
|
|
514
|
-
实测事故(2026-09-21):模型把设计稿的产品名 / 利率 / 期限整批换成自己编的占位值(「理财优选1号 / 2.45 / 每日申购」),像素照样刷到 89%、validate/lint 全绿,而卡的**内容真值整批丢失**,直到人工比对设计稿才发现。报告 elements.invented 会把这类「schema 里有、设计稿里没有」的自造文案逐条点名,elements.missing 列出漏掉的要素。
|
|
515
|
-
报告还带:contentOffset1x + contentOffsetHint(内容整体平移时先修对齐,别逐行调间距)、**contributors + contributorsNote(分项归因:每个差异热区给出最可能出问题的具体节点)**、widthMismatch(--width 与设计稿卡面宽不符 ⇒ 先改 --width,否则 match/dy 都不可信)、lint.warningDetails、preflight.details、renderImage(渲染原图路径)、diffImageNote(并排图顶部色条是工具标题栏,不是卡片内容)、stalled(**连续 3 轮未刷新历史最好成绩**——震荡下滑同样触发——附 best 与回退命令)、best(历史最好成绩 {match,round})。
|
|
516
|
-
**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 与同类中位判断,**不要自己编设计稿高度**。
|
|
517
|
-
**报告里出现 envBlocked / verdict 以 BLOCKED 开头 ⇒ 本机没有可用的 python,validate 根本没跑**:这是环境问题,改 schema、继续 --set 都修不好它——立刻停手如实告诉用户(装 Python 或设 DSL_CARD_PYTHON=<解释器路径>)后重跑,别把它当门禁缺口反复重试。
|
|
518
|
-
**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 才被人工中止——正因为旧判据非要「完全不变」才触发,震荡下滑落在盲区里。
|
|
519
|
-
match 不升反降、heightDelta 非 0、或 hotspots 集中在原本已有内容的区域 ⇒ 说明在删内容凑分:回退该轮 --set 换思路。diffImage 每轮重写(报告里的路径),只给用户预览。
|
|
520
|
-
**取证禁令**:禁止用 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。
|
|
521
|
-
每轮用一两句正文汇报:gate.verdict / match% / heightDelta / 这轮修了什么,不要闷头连调。
|
|
522
|
-
S6 收尾门禁:① skill_exec script=skills/dsl-card-generator/scripts/readiness.mjs,args=["--id","<落点卡>","--spec","<bundle>/spec.json","--design","<bundle>/card-N.png"],**必须 exit 0(就绪)**——内容层的四类缺口(要素 / 变量绑定 / 图片地址 / 切图落盘)在收尾时同样不许放过;② skill_exec script=skills/dsl-card-generator/scripts/check_fidelity.mjs,args=["--spec","<bundle>/spec.json","--schema","src/cards/<落点卡>/schema.json","--card","N"],按它报的文本缺失 / 字号偏差 / 颜色偏差修到无 ERROR;③ skill_exec script=skills/dsl-card-generator/scripts/iterate.mjs,args=["--gate","--out","src/cards/<落点卡>/"] 拿机器判定(读最近一轮落盘报告,schema 在报告之后被改过会直接判 FAIL;结论可能是 pass / stalled / **blocked-content** / iterating 四种,blocked-content 表示内容仍未就绪,不是收敛)。
|
|
523
|
-
正文汇报必须**逐字引用 --gate 的 verdict 与 failed 列表** + 落点卡 + 补了哪些切图 + 回滚方式。gate 未过禁止写「门禁全绿 / 已完成 / 验收通过」,必须列出未达标项、原因与下一步;禁止把未达标归因于「测量噪声 / 工具合成产物」——pixelDiff 只用渲染原图与设计原图,合成图色条不参与计算(见 diffImageNote)。
|
|
524
|
-
绑定对齐:check_fidelity 会按 data.json / defaultValue 把 $ 绑定解析回文案再对齐——报 missing-text 而该文本已绑定 ⇒ 是 mock 与设计稿不一致,改 defaultValue / data.json,**不是拆回字面量**。收尾自查:bindHints 每一项要么已绑定、要么正文说明为何保持字面量。
|
|
525
|
-
`
|
|
520
|
+
function needBuildProtocol({ buildFlow, protocolRead, toolName, args } = {}) {
|
|
521
|
+
if (!buildFlow || protocolRead) return ''
|
|
522
|
+
const script = String((args && args.script) || '')
|
|
523
|
+
const isWriteIr = toolName === 'fs_write' && /\.ir\.json$/i.test(String((args && args.path) || ''))
|
|
524
|
+
const isExpand = toolName === 'skill_exec' && /(?:^|[\\/])(?:expand_ir|ir_merge)\.mjs$/.test(script)
|
|
525
|
+
if (!isWriteIr && !isExpand && toolName !== 'card_write') return ''
|
|
526
|
+
return (
|
|
527
|
+
'Error: 本轮判定为「建卡流程」,但还没读过建卡协议全文,所以这次写入被服务端驳回(卡片内容未被改动)。\n' +
|
|
528
|
+
'下一步:先 skill_read path=dsl-card-generator/references/build_protocol.md(S0~S4.5 全文,一次读完),' +
|
|
529
|
+
'照它走完 S1~S4.5 再回来写 IR;进入收敛前再读 converge_protocol.md(S4.9~S6)。\n' +
|
|
530
|
+
'不要换 card_patch / shell 重定向 / 自造脚本绕过——绕过去的产物一定错位。'
|
|
531
|
+
)
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
/**
|
|
535
|
+
* 建卡协议「骨架」:只列阶段机与门禁名,**全文不在 system 里**。
|
|
536
|
+
*
|
|
537
|
+
* 为什么要外置:协议全文 13,818 字符(≈7.7k token),每一步都被整段重发;更糟的是
|
|
538
|
+
* buildFlow 翻转会让 system 在 22.1k / 7.7k 之间跳,77% 的前缀当场失效——实测 step-1
|
|
539
|
+
* 命中率只有 49.6%,正是这个跳变造成的。骨架是**恒定**内容:改卡轮 / 建卡轮注入同一份,
|
|
540
|
+
* system 不再随判定结果变化,跨回合缓存才连得上。
|
|
541
|
+
*
|
|
542
|
+
* 全文按阶段拆成两份文档(各 <10KB,一次 skill_read 读完,不用 offset 续读):
|
|
543
|
+
* references/build_protocol.md S0~S4.5(路由 / 要素包 / 选卡 / 审计 / 切图 / 写 IR / 变量抽取)
|
|
544
|
+
* references/converge_protocol.md S4.9~S6(内容就绪 / 迭代收敛 / 收尾门禁)
|
|
545
|
+
* 光靠「请你先读」约束不住(实测会直接 fs_write 硬写 IR),所以另配服务端硬门禁
|
|
546
|
+
* needBuildProtocol():判定为建卡流程、本回合还没读过 build_protocol.md 时,
|
|
547
|
+
* 写 IR / expand_ir / ir_merge / card_write 一律驳回。
|
|
548
|
+
*
|
|
549
|
+
* ⚠️ 改动纪律:这段是反引号模板字面量,插反引号会炸语法而 Edit 照样报 success ——
|
|
550
|
+
* 改完必须跑 node --check server/core/ai-chat.js。
|
|
551
|
+
*/
|
|
552
|
+
const BUILD_DIGEST_SECTION = `## 建卡流程骨架(S0→S6;动手之前先读全文协议)
|
|
553
|
+
阶段机:上一阶段的产物齐了才进下一阶段。骨架只给阶段名与门禁,**命令参数 / 字段契约 / 历史事故全在全文协议里**,按阶段 skill_read:
|
|
554
|
+
- 动手建卡前:skill_read path=dsl-card-generator/references/build_protocol.md(S0 路由 · S1 要素包 · S1.4 批量 · S1.5 选卡 · S2 审计 · S3 补切图 · S4 写 IR · S4.5 变量抽取)
|
|
555
|
+
- 进 S5 迭代前:skill_read path=dsl-card-generator/references/converge_protocol.md(S4.9 内容就绪 · S5 收敛循环 · S6 收尾门禁)
|
|
556
|
+
两份都在一次 skill_read 的读取上限内(各约 7 千字符);一次读一份,不要合并读、不要按 offset 顺序读。
|
|
557
|
+
产物链:S0 路由 → S1 要素包(<bundle>)→ S1.5 选卡(多张卡先问,正文末行单独输出 [需用户确认])→ S2 审计(spec_audit 一次拿齐 bands/gaps/texts/slices/colors/missing,落盘 .cache/ai-scratch/<id>.audit.json)→ S3 补切图(crop_slices --from-audit,落点 src/slices)→ S4 写 IR(fs_write,**全文只写一次**,超 10KB 走分片 + ir_merge)→ S4.5 变量抽取(dictionary.inputParams + $ 绑定)→ S4.9 内容就绪门禁(readiness.mjs exit 0)→ S5 收敛(iterate.mjs 报告的 gate 是唯一判据)→ S6 收尾(readiness + check_fidelity + iterate --gate)。
|
|
558
|
+
**硬门禁**:本回合还没读过 build_protocol.md 时,服务端会直接驳回写 IR(fs_write *.ir.json)、expand_ir / ir_merge、card_write —— 被驳回就说明该先读协议,别换个写法绕。`
|
|
526
559
|
|
|
527
560
|
function buildSystemPrompt({ shellAllowed = false, vision = false, autoApprove = DEFAULT_AUTO_APPROVE, root = '', buildFlow = false, buildReason = '' } = {}) {
|
|
528
561
|
const refsDir = skillRefsDir(root)
|
|
529
562
|
const styleKeys = styleKeySection(root)
|
|
530
563
|
// 缓存键带上实际命中的技能库:仓库内与消费者项目(init 释放的副本)文档集可能不同;
|
|
531
564
|
// 再带上 style-meta 的 mtime:合法样式键表直接决定提示词内容,meta 改了必须重算。
|
|
532
|
-
// buildFlow
|
|
533
|
-
//
|
|
565
|
+
// buildFlow 必须进键(进程内缓存不能串味),但它只决定**末尾**要不要追加约 1KB 的建卡骨架:
|
|
566
|
+
// 正文与索引段逐字节恒定 ⇒ 翻转时服务端前缀缓存只丢末尾这一段(以前是丢 77%)。
|
|
534
567
|
const cacheKey = `${shellAllowed ? 'shell' : 'base'}${vision ? '+vision' : ''}+bf:${buildFlow ? 1 : 0}+aa:${autoApprove}+sk:${skillsDir(root)}+skm:${styleKeys.mtime}`
|
|
535
568
|
if (_systemPromptCache && _systemPromptCache.key === cacheKey) return _systemPromptCache.text
|
|
536
569
|
/**
|
|
537
|
-
*
|
|
538
|
-
*
|
|
539
|
-
*
|
|
540
|
-
* 变成不可排查的灵异问题:用户挂个新附件让它建卡、它却按改已有卡的套路硬写 schema。
|
|
541
|
-
* 那行字把「本轮没注入建卡协议」与「可以 skill_read SKILL.md 取回」同时说清。
|
|
570
|
+
* 建卡协议的**恒定指针**(改卡轮 / 建卡轮都有,约 150 字符):保证任何一轮都找得到全文。
|
|
571
|
+
* 判定结果(本轮要不要走建卡)不写进 system —— 它只在**末尾**追加骨架段,翻转时失效的
|
|
572
|
+
* 也只有末尾那一小段,不会像以前那样把 77% 的前缀一起带走。
|
|
542
573
|
*/
|
|
543
|
-
const
|
|
544
|
-
|
|
545
|
-
: '## 建卡协议(本轮未注入)\n本轮判定为「修改已有卡片」,故未注入 S0~S6 设计稿建卡协议。若用户其实要**从零建卡**(挂设计稿 / 描述一张新卡 / 当前卡片是空的),先 skill_read dsl-card-generator/SKILL.md 取回完整建卡协议再动手,不要按「改已有卡」的套路 card_write 整份硬写。'
|
|
574
|
+
const buildPointer =
|
|
575
|
+
'## 建卡(从零建卡 / 设计稿建卡)\n从零建卡不要 card_write 整份硬写:先 skill_read path=dsl-card-generator/references/build_protocol.md(S0~S4.5)与 converge_protocol.md(S4.9~S6),照里面的阶段机推进。'
|
|
546
576
|
// 免确认策略要如实写进协议:模型若以为「每次都要用户点确认」,会主动停下来等批准 / 把命令甩给用户
|
|
547
577
|
const skillAuto = autoApproves(autoApprove, 'skill_exec')
|
|
548
578
|
const shellAuto = autoApproves(autoApprove, 'shell_exec')
|
|
@@ -558,9 +588,9 @@ function buildSystemPrompt({ shellAllowed = false, vision = false, autoApprove =
|
|
|
558
588
|
lines.push(`- ${entry.path}(${Math.max(1, Math.round(size / 1024))}KB):${entry.hint}`)
|
|
559
589
|
}
|
|
560
590
|
try {
|
|
561
|
-
//
|
|
562
|
-
//
|
|
563
|
-
|
|
591
|
+
// 全量列出 references(**不按 buildFlow 过滤**):索引段一旦随判定结果增删,system 字节就跟着变,
|
|
592
|
+
// 改卡轮 ↔ 建卡轮一切换整段前缀白丢。多列十几行的代价是每轮几百字符(且常驻命中价),
|
|
593
|
+
// 比每次翻转重付 1.0 倍前缀便宜得多;同时模型不会因为「看不到索引」而在建卡时找不到该读的文档。
|
|
564
594
|
for (const name of fs.readdirSync(refsDir)) {
|
|
565
595
|
let st
|
|
566
596
|
try {
|
|
@@ -569,7 +599,6 @@ function buildSystemPrompt({ shellAllowed = false, vision = false, autoApprove =
|
|
|
569
599
|
continue
|
|
570
600
|
}
|
|
571
601
|
if (!st.isFile()) continue
|
|
572
|
-
if (!buildFlow && buildSet.has(name)) continue
|
|
573
602
|
lines.push(`- ${name}(${Math.max(1, Math.round(st.size / 1024))}KB):${SKILL_REF_HINTS[name] || '特定主题参考'}`)
|
|
574
603
|
}
|
|
575
604
|
// 范式片段是子目录,不列出来模型根本不知道存在(曾经为此反推 expand_ir 源码猜字段形状)。
|
|
@@ -593,7 +622,7 @@ function buildSystemPrompt({ shellAllowed = false, vision = false, autoApprove =
|
|
|
593
622
|
工作协议:
|
|
594
623
|
1. 先用 card_read / card_node_models 了解卡片现状;修改保持最小 diff,不要重写无关节点。
|
|
595
624
|
2. 写之前按需 skill_read 参考文档(path = 下面索引里的文件名):常规修改只读最相关的 1-2 个文件——表达式 / 格式化问题先读 expression_engine.md;card_write 前读 rules.md;不要一次读全部。**「不要一次读全部」针对的是整份大文档**:recipe_snippets/*.json 这类小片段(常 <2KB、读一次 0.3s)不确定就直接读,别为了「省着读」而在思考里猜字段形状——实测模型因为「被告知别读太多文件」而拒绝读一个 1KB 片段,转而在思考里硬推十几分钟。**长文档一律用 skill_read 的 section= 参数按节取,不要从 0 开始按 offset 顺序读**(section 传小节序号或标题关键词;读到的截断锚点里会带完整小节目录,序号照着数即可):实测 generation_pitfalls.md 有 9 万字符、顺序读要 9-10 次 tool_result 共 10 万字符,按节读一节平均 1.9k 字符、省 90%+,而且不会「读到半截内容猜后半段字段」。
|
|
596
|
-
2b.
|
|
625
|
+
2b. 大卡修改流程(**读也是分层的**):大卡的 card_read 回 mode:"digest" 的**结构图**——tree 段一行一个「componentId(ClassName) 「主文本」 ⚡事件」,⚡ 后面是 eventKey:actionType(代码字数);**带 ⚡ 的节点才有事件**,代码本身不在结构图里(全库 366 个节点有事件、单卡最多 27.5KB),要看就定向读。inputParams 是参数表。先读它定位 componentId;看某节点的样式/属性/**事件代码**用 card_read {id, node:"<componentId>"}(返回那棵子树含完整 JS,比整卡小一到两个数量级);改的时候用 card_patch {id, patches:[{path:"@<componentId>/eventactions.0.action.properties.0.itemValue.code", value:"…"}]} —— @ 前缀由服务端解析成真实 JSON 路径,**不要自己数 view.components[0].slots[1]… 下标**(动辄十层,手数必错),也不必为了拿路径先 card_grep。**事件代码只写 .code 一份**:compiled 由服务端自动同步(运行时取 code||compiled),不要手写第二份,也不必 grep 它的语义。card_patch 返回的 changed 给的是改动处真实路径 + 长度 + 开头预览,据此确认写对了即可,**不必再读一次同一路径验证**。card_grep 只在「不知道是哪个节点」时按文案反查。严禁 card_write 整包重写大卡(239KB 的卡整包写一次就够烧掉整轮输出上限)。小卡 card_read 直接给全文,照旧可 card_write。
|
|
597
626
|
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/步),小卡别拆。
|
|
598
627
|
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)。
|
|
599
628
|
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 之类)或凭记忆猜字段形状**;范式里确实没有才允许自己设计,并在正文里说明为什么没有套用范式。
|
|
@@ -609,18 +638,14 @@ ${styleKeys.text}
|
|
|
609
638
|
- **脚本里没有 \`validate.mjs\` 这个东西**(2026-09-21 实测:card_validate 已返回 ok 之后模型又去跑它,白烧一步)。校验入口就三个,各答一个问题:
|
|
610
639
|
① 契约校验(组件/属性/样式/表达式/data.json 一致性)= 工具 \`card_validate({id})\`;② 体验与布局规则 = skill_exec script=skills/dsl-card-generator/scripts/lint_card.mjs;
|
|
611
640
|
③ 收敛门禁(需该卡跑过 iterate)= skill_exec script=skills/dsl-card-generator/scripts/iterate.mjs args=["--gate","--out","src/cards/<id>/"]。
|
|
612
|
-
- 渲染自检 / 组件统计用 \`card_render_status\`,看卡内容用 \`card_read
|
|
641
|
+
- 渲染自检 / 组件统计用 \`card_render_status\`,看卡内容用 \`card_read\`(大卡会自动折叠成结构图,再 node 定向读;describe_card.mjs 仍可作离线备选)。**它们不互相替代,但也不必串成一串全跑**:用户只说「检查一下」时跑 ①(卡正在改就加 ②),别把 ③ 也拉上——③ 读的是上一轮落盘报告,没跑过 iterate 的卡它只会判 FAIL。
|
|
613
642
|
- 脚本名一律以磁盘为准:\`skill_exec\` 只跑 skills/<skill>/scripts/ 下真实存在的文件;报错会附上同目录的真实候选,照着里面的名字重发,**别继续猜下一个名字**。
|
|
614
643
|
|
|
615
|
-
${
|
|
616
|
-
${
|
|
617
|
-
?
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
: vision
|
|
621
|
-
? '你有视觉能力:card_screenshot 产出的渲染截图可直接读,用来核对布局与配色是否贴合设计稿。'
|
|
622
|
-
: '当前模型无视觉:不要声称看过卡片截图;一切以 card_read / card_validate 的文本结果为准,颜色与间距不要凭空估数值。'}
|
|
623
|
-
步数不设硬顶(除非用户在设置里配了上限),但有四道闸:①连续多轮「同工具 + 同参数」会被判定原地打转并强制收尾(判据含 --ir 文件的指纹:**改过 IR 再原样重跑同一条命令不算打转**,按「改一版 → 重跑一次拿新指标」的节奏走是安全的;输入一字未变还反复跑同一条命令才会被拦) ②单轮墙钟到点会强制收尾 ③每 24 步会收到一次收敛提醒 ④单轮上下文超预算时**早期工具结果会被折叠成摘要**并标注「已折叠」——看到折叠标记就用工具重读,不要拿摘要当结论。所以别把额度当免费:${buildFlow ? 'S2 / S3 / S5 各尽量一次调用解决;' : ''}同类信息只读一次;收到重复调用警告就立刻换证据链(diff_bands.mjs / generation_pitfalls.md),不要把同一条调用原样再发一次;被中止过的轮次按上下文里的中止标记从中断处继续,不要从零重做。
|
|
644
|
+
${buildPointer}
|
|
645
|
+
${vision
|
|
646
|
+
? '你有视觉能力:建卡收敛(S5)时可读注入的并排对比图定位差异(哪一行 / 哪个颜色 / 哪段间距),改卡时可读 card_screenshot 的渲染图核对布局配色;但数值指标仍是收敛判据,图只用于定位。'
|
|
647
|
+
: '当前模型无视觉:不要声称看过图;一切以 card_read / card_validate / S2 审计值 / iterate 报告的数值指标为准(尺寸颜色文案取审计值,缺时按 references/ui_fidelity_rules.md 的降级规则处理),禁止凭空估数值。'}
|
|
648
|
+
步数不设硬顶(除非用户在设置里配了上限),但有四道闸:①连续多轮「同工具 + 同参数」会被判定原地打转并强制收尾(判据含 --ir 文件的指纹:**改过 IR 再原样重跑同一条命令不算打转**,按「改一版 → 重跑一次拿新指标」的节奏走是安全的;输入一字未变还反复跑同一条命令才会被拦) ②单轮墙钟到点会强制收尾 ③每 24 步会收到一次收敛提醒 ④单轮上下文超预算时**早期工具结果会被折叠成摘要**并标注「已折叠」——看到折叠标记就用工具重读,不要拿摘要当结论。所以别把额度当免费:S2 / S3 / S5 各尽量一次调用解决;同类信息只读一次;收到重复调用警告就立刻换证据链(diff_bands.mjs / generation_pitfalls.md),不要把同一条调用原样再发一次;被中止过的轮次按上下文里的中止标记从中断处继续,不要从零重做。
|
|
624
649
|
|
|
625
650
|
## 收尾协议(不要停下来等「继续」)
|
|
626
651
|
- 任务没做完就不要交还控制权:接着调用工具推进到下一个门禁。**禁止**输出「下一步(请确认或直接说「继续」)」这类等待式收尾——用户已开启自动续跑,停手只会让任务白等一轮。
|
|
@@ -630,7 +655,7 @@ ${buildFlow
|
|
|
630
655
|
- 别因为「怕超时」就把活儿切成「下一轮再做」:网关瞬时静默会自动重试本步、纯思考超时只会收到催促而不会中断任务、墙钟默认 60 分钟——一次把该跑的工具跑完,比拆成几轮等用户说「继续」又快又省。
|
|
631
656
|
|
|
632
657
|
## 按需参考索引(skill_read)
|
|
633
|
-
${lines.join('\n')}`
|
|
658
|
+
${lines.join('\n')}${buildFlow ? `\n\n${BUILD_DIGEST_SECTION}` : ''}`
|
|
634
659
|
_systemPromptCache = { key: cacheKey, text: _text }
|
|
635
660
|
return _text
|
|
636
661
|
}
|
|
@@ -679,7 +704,7 @@ const CARD_GREP_TOOL = {
|
|
|
679
704
|
type: 'function',
|
|
680
705
|
function: {
|
|
681
706
|
name: 'card_grep',
|
|
682
|
-
description: '在卡片 schema/data 里按关键字搜键名与字符串值,返回匹配处的 JSON 点路径 +
|
|
707
|
+
description: '在卡片 schema/data 里按关键字搜键名与字符串值,返回匹配处的 JSON 点路径 + 片段。**用在「不知道是哪个节点」的时候**(按文案、按变量名反查);大卡的 card_read 会回结构图(全节点 + 事件标记),已知 componentId 就直接 card_read {node} / card_patch {path:@cid/…},不必先 grep。',
|
|
683
708
|
parameters: {
|
|
684
709
|
type: 'object',
|
|
685
710
|
properties: {
|
|
@@ -695,7 +720,8 @@ const CARD_PATCH_TOOL = {
|
|
|
695
720
|
type: 'function',
|
|
696
721
|
function: {
|
|
697
722
|
name: 'card_patch',
|
|
698
|
-
description:
|
|
723
|
+
description:
|
|
724
|
+
'按 JSON 点路径(数组用数字段)对卡片 schema/data 做最小修改并写回;大卡必用本工具,禁止用 card_write 整包重写。路径首选 `@<componentId>` 写法:`@text_dw` 指节点自身,`@text_dw/s.properties` 是它下面的相对段——componentId 由 card_read 的结构图直接给出,服务端自己解析成真实 JSON 点路径,不需要先 card_grep 数下标(真实路径动辄十层 slots/components 嵌套,手数必错)。也可直接给完整点路径。路径中段不存在会直接报错(不再静默新建分支),确要新建传 create:true。patches(改/增)与 deletes(删)至少给一个,可只删不改(清理无用样式项、多余节点)。**改事件代码只写 `.code` 一份**:`function` 值的 `compiled` 由服务端自动同步,不要手写第二份。返回里的 `changed` 是改动处的真实路径 + 长度 + 开头预览——据此确认写对了,不必再读一次同一路径。仓库根下走 card_write 管线(带历史快照与校验)。',
|
|
699
725
|
parameters: {
|
|
700
726
|
type: 'object',
|
|
701
727
|
properties: {
|
|
@@ -774,8 +800,17 @@ function applyByPath(doc, p, setter, { create = false } = {}) {
|
|
|
774
800
|
setter(cur, segs[segs.length - 1])
|
|
775
801
|
}
|
|
776
802
|
|
|
777
|
-
|
|
778
|
-
|
|
803
|
+
/** 按点路径取值(applyByPath 的只读姊妹;取不到返回 undefined,不抛错) */
|
|
804
|
+
function readByPath(doc, p) {
|
|
805
|
+
let cur = doc
|
|
806
|
+
for (const k of String(p).split('.').filter(Boolean)) {
|
|
807
|
+
if (cur == null || typeof cur !== 'object') return undefined
|
|
808
|
+
cur = Array.isArray(cur) ? cur[Number(k)] : cur[k]
|
|
809
|
+
}
|
|
810
|
+
return cur
|
|
811
|
+
}
|
|
812
|
+
|
|
813
|
+
async function cardPatch(root, args, dispatch, { allowDispatch = false } = {}) { const { id, patches = [], deletes = [], target = 'schema' } = args || {}
|
|
779
814
|
if (!Array.isArray(patches)) throw new Error('patches 必须是数组')
|
|
780
815
|
if (!Array.isArray(deletes)) throw new Error('deletes 必须是数组')
|
|
781
816
|
// 「只删不改」是合法操作(清理无用样式项 / 多余节点);真正的错误是两个都空 —— 空补丁什么都不做,
|
|
@@ -784,20 +819,41 @@ async function cardPatch(root, args, dispatch, { allowDispatch = false } = {}) {
|
|
|
784
819
|
const file = path.join(root, 'src', 'cards', String(id || ''), target === 'data' ? 'data.json' : 'schema.json')
|
|
785
820
|
const doc = JSON.parse(fs.readFileSync(file, 'utf8'))
|
|
786
821
|
const create = args.create === true
|
|
787
|
-
|
|
822
|
+
// `@<componentId>` 前缀在**这里**解析成真实 JSON 点路径:结构图(card_read 的大卡折叠)
|
|
823
|
+
// 只给 componentId,让模型自己数 `view.components[0].slots[1].components[3]` 既易错又多一步;
|
|
824
|
+
// @ 前缀把「找路径」这件事收进服务端。解析失败响亮报错,不静默新建分支。
|
|
825
|
+
const realPath = (p) => (target === 'schema' ? resolveComponentPath(doc, p) : String(p))
|
|
826
|
+
// 先解析成真实路径再写:后面同步 compiled 与回显改动都要用真实路径(@ 前缀已展开)
|
|
827
|
+
const resolvedPatches = patches.map((pt) => ({ path: realPath(pt.path), value: pt.value }))
|
|
828
|
+
// 写之前先记下原有 code/compiled:用来判断「这份 compiled 原本是不是跟着 code 走的」。
|
|
829
|
+
const compiledBefore = new Map()
|
|
830
|
+
if (target === 'schema') {
|
|
831
|
+
for (const pt of resolvedPatches) {
|
|
832
|
+
if (!/\.code$/.test(pt.path)) continue
|
|
833
|
+
const holderPath = pt.path.replace(/\.code$/, '')
|
|
834
|
+
if (compiledBefore.has(holderPath)) continue
|
|
835
|
+
const h = readByPath(doc, holderPath)
|
|
836
|
+
if (h && typeof h === 'object' && !Array.isArray(h)) compiledBefore.set(holderPath, { code: h.code, compiled: h.compiled })
|
|
837
|
+
}
|
|
838
|
+
}
|
|
839
|
+
for (const pt of resolvedPatches) applyByPath(doc, pt.path, (obj, k) => (obj[k] = pt.value), { create })
|
|
788
840
|
// 删数组元素必须 splice:早先用 delete obj[k],在数组上只留一个 null 空洞
|
|
789
841
|
// (properties 变成 [null, {...}]),补丁报「成功」却把 schema 写成稀疏数组,渲染与校验随后就崩。
|
|
790
842
|
// 同一父数组里删多项时按原始下标从大到小删 —— splice 会让后面的索引左移,升序删会误删邻居。
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
843
|
+
// 先解析 @componentId 再排序:排序依赖真实的下标,@ 路径里没有下标可排。
|
|
844
|
+
// 解析全部基于同一份未改动的 doc(此刻还没删任何东西),所以同父数组的原始下标仍然有效。
|
|
845
|
+
const orderedDeletes = deletes
|
|
846
|
+
.map((d) => realPath(d))
|
|
847
|
+
.sort((a, b) => {
|
|
848
|
+
const sa = String(a).split('.')
|
|
849
|
+
const sb = String(b).split('.')
|
|
850
|
+
const pa = sa.slice(0, -1).join('.')
|
|
851
|
+
const pb = sb.slice(0, -1).join('.')
|
|
852
|
+
if (pa !== pb) return pa < pb ? -1 : 1
|
|
853
|
+
const na = Number(sa[sa.length - 1])
|
|
854
|
+
const nb = Number(sb[sb.length - 1])
|
|
855
|
+
return Number.isFinite(na) && Number.isFinite(nb) ? nb - na : 0
|
|
856
|
+
})
|
|
801
857
|
for (const d of orderedDeletes)
|
|
802
858
|
applyByPath(
|
|
803
859
|
doc,
|
|
@@ -808,15 +864,93 @@ async function cardPatch(root, args, dispatch, { allowDispatch = false } = {}) {
|
|
|
808
864
|
},
|
|
809
865
|
{ create },
|
|
810
866
|
)
|
|
867
|
+
// ---- 写 code 时自动同步 compiled(E2)----
|
|
868
|
+
// customAction 的 function 值是 `{code, compiled}` 双份。运行时取 `code || compiled`
|
|
869
|
+
// (preview 的 actionCode),所以 compiled 缺了不会立刻坏;但编辑器读它,
|
|
870
|
+
// 且历史卡里两者已经失步(livePayPasswordDialog: code 4456 / compiled 3639)。
|
|
871
|
+
//
|
|
872
|
+
// 以前这条约束只写在文档里(6 处以上「必须同步,compiled 是 var 形式」),靠模型手抄第二份代码:
|
|
873
|
+
// 历史日志里同一路径被写 3 次(code 一次、compiled 一次、修正一次),还有回合专门花 2 步
|
|
874
|
+
// grep `.compiled` 的用法去逆向语义。所以把它收进服务端 —— code 一改,compiled 跟着走,
|
|
875
|
+
// 模型既不必写第二份,也不必知道这个字段存在。
|
|
876
|
+
//
|
|
877
|
+
// 只处理「显式写到 .code」的情形:写 compiled 而不写 code 时不动 code(可能是刻意只改编译产物)。
|
|
878
|
+
// 若同一批 patch 里模型**自己写了 .compiled**,也不再代劳 —— 那是它明确表达的转译结果,
|
|
879
|
+
// 覆盖掉等于把「ES5 版 compiled」硬改成源码版;运行时取 code || compiled,让步只影响编辑器侧。
|
|
880
|
+
const explicitCompiled = new Set(
|
|
881
|
+
resolvedPatches.filter((pt) => /\.compiled$/.test(pt.path)).map((pt) => pt.path.replace(/\.compiled$/, '')),
|
|
882
|
+
)
|
|
883
|
+
const syncedCompiled = []
|
|
884
|
+
const compiledDrift = []
|
|
885
|
+
if (target === 'schema') {
|
|
886
|
+
for (const pt of resolvedPatches) {
|
|
887
|
+
if (!/\.code$/.test(pt.path)) continue
|
|
888
|
+
const holderPath = pt.path.replace(/\.code$/, '')
|
|
889
|
+
if (explicitCompiled.has(holderPath)) continue
|
|
890
|
+
const holder = readByPath(doc, holderPath)
|
|
891
|
+
if (!holder || typeof holder !== 'object' || Array.isArray(holder)) continue
|
|
892
|
+
if (typeof holder.code !== 'string' || !holder.code) continue
|
|
893
|
+
// 只在「原本就同步」时代劳。历史卡里存在真正的转译版 compiled
|
|
894
|
+
// (livePayPasswordDialog: code 4,456 / compiled 3,639,前者带 try 包裹),
|
|
895
|
+
// 无条件覆盖等于把 ES5 版换成源码版、丢一次转译。判据用写入前的快照:
|
|
896
|
+
// compiled 为空或等于旧 code ⇒ 之前是同步的,代劳安全;否则只报警不动手。
|
|
897
|
+
const before = compiledBefore.get(holderPath)
|
|
898
|
+
const wasInSync = !before || before.compiled === undefined || before.compiled === '' || before.compiled === before.code
|
|
899
|
+
if (!wasInSync) {
|
|
900
|
+
compiledDrift.push(holderPath)
|
|
901
|
+
continue
|
|
902
|
+
}
|
|
903
|
+
if (holder.compiled === holder.code) continue
|
|
904
|
+
holder.compiled = holder.code
|
|
905
|
+
syncedCompiled.push(holderPath)
|
|
906
|
+
}
|
|
907
|
+
}
|
|
908
|
+
// ---- 回显改动(E3)----
|
|
909
|
+
// 旧返回只有 {ok, patches:N}:模型改完不知道值到底落没落,于是再读一次同一路径验证
|
|
910
|
+
// (历史日志里 transfersjump 第 30 步 patch、第 35 步又 jq 读同一处,白走一步)。
|
|
911
|
+
// 回显只给「真实路径 + 长度 + 开头一段」:够确认写对了,又不把 3KB 的代码搬两遍。
|
|
912
|
+
const CHANGED_MAX = 12
|
|
913
|
+
const PREVIEW_MAX = 120
|
|
914
|
+
const previewOf = (v) => {
|
|
915
|
+
const s = typeof v === 'string' ? v : JSON.stringify(v)
|
|
916
|
+
if (s == null) return String(v ?? '')
|
|
917
|
+
return s.length > PREVIEW_MAX ? `${s.slice(0, PREVIEW_MAX)}…` : s
|
|
918
|
+
}
|
|
919
|
+
const changed = [
|
|
920
|
+
...resolvedPatches.map((pt) => ({
|
|
921
|
+
path: pt.path,
|
|
922
|
+
len: typeof pt.value === 'string' ? pt.value.length : JSON.stringify(pt.value)?.length || 0,
|
|
923
|
+
preview: previewOf(pt.value),
|
|
924
|
+
})),
|
|
925
|
+
...orderedDeletes.map((p) => ({ path: p, deleted: true })),
|
|
926
|
+
].slice(0, CHANGED_MAX)
|
|
811
927
|
// 工程根:走 card_write 管线(历史快照 + validate/render_status);其它根(测试/自定义)直接落盘。
|
|
812
928
|
// 仓库内运行由「root === 仓库根」判定;cardbuddy 发布态快照根在包内、永不等于工程根,
|
|
813
929
|
// 故由宿主显式 allowDispatch 开启(它已把 CARD_ROOT 指到同一个工程根,dispatch 落点一致)。
|
|
930
|
+
const summary = {
|
|
931
|
+
patches: patches.length,
|
|
932
|
+
deletes: deletes.length,
|
|
933
|
+
changed,
|
|
934
|
+
...(changed.length < patches.length + orderedDeletes.length
|
|
935
|
+
? { changedNote: `只回显前 ${CHANGED_MAX} 项,其余 ${patches.length + orderedDeletes.length - CHANGED_MAX} 项已写入` }
|
|
936
|
+
: {}),
|
|
937
|
+
...(syncedCompiled.length ? { compiledSynced: syncedCompiled.length } : {}),
|
|
938
|
+
// 不一致时不代劳,但**必须说出来**:模型原本要花 1~2 步 grep 才能发现「这张卡的 compiled
|
|
939
|
+
// 是转译版」,现在一次 patch 就知道。要同步就显式再写一条 .compiled。
|
|
940
|
+
...(compiledDrift.length
|
|
941
|
+
? {
|
|
942
|
+
compiledDrift: compiledDrift.length,
|
|
943
|
+
compiledDriftHint:
|
|
944
|
+
'这些 customAction 的 compiled 与 code 原本就不一致(转译版),未自动覆盖。运行时取 code||compiled 不受影响;若导出/编辑器需要 compiled 一致,请显式再 patch 一条 .compiled。',
|
|
945
|
+
}
|
|
946
|
+
: {}),
|
|
947
|
+
}
|
|
814
948
|
if (dispatch && (allowDispatch || path.resolve(root) === REPO_ROOT)) {
|
|
815
949
|
const res = await dispatch('card_write', { id, [target]: doc })
|
|
816
|
-
return { ok: true, via: 'card_write',
|
|
950
|
+
return { ok: true, via: 'card_write', ...summary, write: res }
|
|
817
951
|
}
|
|
818
952
|
fs.writeFileSync(file, JSON.stringify(doc, null, 2) + '\n', 'utf8')
|
|
819
|
-
return { ok: true, via: 'disk',
|
|
953
|
+
return { ok: true, via: 'disk', ...summary, file }
|
|
820
954
|
}
|
|
821
955
|
|
|
822
956
|
|
|
@@ -1967,6 +2101,10 @@ const ATTACH_BRIEF_MAX = 6000
|
|
|
1967
2101
|
*/
|
|
1968
2102
|
async function describeAttachments(root, attachments, dispatch, extraRoots = []) {
|
|
1969
2103
|
const blocks = []
|
|
2104
|
+
/** 目录浅层清单单独成一段:它是**每回合都可能变**的部分(建卡过程中产物增长即变),
|
|
2105
|
+
* 由调用方注入到消息尾部,不放进 system——否则 system 每回合都被它改掉一个字节,
|
|
2106
|
+
* 前缀缓存从该处起全部失效。路由判定结论则留在 system(稳定,且是行为指令)。 */
|
|
2107
|
+
const listingBlocks = []
|
|
1970
2108
|
/** 命中设计稿形态(MeaXure 导出 / 要素包 / 单张设计稿)⇒ 本轮是多阶段建卡管线,需要更多步数额度 */
|
|
1971
2109
|
let designFlow = false
|
|
1972
2110
|
for (const a of attachments || []) {
|
|
@@ -1975,12 +2113,14 @@ async function describeAttachments(root, attachments, dispatch, extraRoots = [])
|
|
|
1975
2113
|
const kind = a.isDir ? '文件夹' : a.isImage ? '图片' : '文件'
|
|
1976
2114
|
const lines = [`- ${kind}:${target}${a.note ? `(${a.note})` : ''}`]
|
|
1977
2115
|
if (a.isDir) {
|
|
2116
|
+
const listingLines = []
|
|
1978
2117
|
try {
|
|
1979
2118
|
const listing = fsList(root, { path: target, depth: 1, max: ATTACH_LIST_MAX }, extraRoots)
|
|
1980
|
-
listing.split('\n').forEach((l, i) =>
|
|
2119
|
+
listing.split('\n').forEach((l, i) => listingLines.push(i === 0 ? ` 清单:${l}` : ` ${l}`))
|
|
1981
2120
|
} catch (e) {
|
|
1982
|
-
|
|
2121
|
+
listingLines.push(` 清单读取失败:${String((e && e.message) || e)}`)
|
|
1983
2122
|
}
|
|
2123
|
+
listingBlocks.push([`- ${kind}:${target}${a.note ? `(${a.note})` : ''}`, ...listingLines].join('\n'))
|
|
1984
2124
|
}
|
|
1985
2125
|
if (a.isDir || a.isImage) {
|
|
1986
2126
|
try {
|
|
@@ -2002,10 +2142,12 @@ async function describeAttachments(root, attachments, dispatch, extraRoots = [])
|
|
|
2002
2142
|
}
|
|
2003
2143
|
blocks.push(lines.join('\n'))
|
|
2004
2144
|
}
|
|
2145
|
+
const clipBrief = (s) => (s.length <= ATTACH_BRIEF_MAX ? s : `${s.slice(0, ATTACH_BRIEF_MAX)}\n…(附件摘要过长已截断)`)
|
|
2005
2146
|
const text = blocks.join('\n')
|
|
2006
2147
|
return {
|
|
2007
2148
|
designFlow,
|
|
2008
|
-
text: text
|
|
2149
|
+
text: clipBrief(text),
|
|
2150
|
+
listing: clipBrief(listingBlocks.join('\n')),
|
|
2009
2151
|
}
|
|
2010
2152
|
}
|
|
2011
2153
|
|
|
@@ -3003,7 +3145,7 @@ function findSkillDocCandidates(root, wanted, limit = 6) {
|
|
|
3003
3145
|
|
|
3004
3146
|
// ---- 配置读取 / 写入 ----
|
|
3005
3147
|
|
|
3006
|
-
/** 极简 .env 解析(与 tools/
|
|
3148
|
+
/** 极简 .env 解析(与 tools/platform-env.mjs 同规则,避免 server-core 反向依赖 tools/) */
|
|
3007
3149
|
function parseEnvFile(file) {
|
|
3008
3150
|
const out = {}
|
|
3009
3151
|
try {
|
|
@@ -5114,6 +5256,85 @@ function normalizeUsage(u) {
|
|
|
5114
5256
|
}
|
|
5115
5257
|
}
|
|
5116
5258
|
|
|
5259
|
+
/**
|
|
5260
|
+
* 本轮上下文块:当前卡片 ID + 挂载目录的浅层清单。
|
|
5261
|
+
*
|
|
5262
|
+
* 为什么单独成一函数、而不是直接拼进 system:这两段是**每回合都可能变**的内容——
|
|
5263
|
+
* 卡片 ID 随选中卡变,目录清单是实时 fsList(建卡过程中产物增长即变)。
|
|
5264
|
+
* 拼在首条 system 消息里,隐式前缀缓存(DeepSeek / GLM 系按「完全相同前缀」计价)
|
|
5265
|
+
* 就会从该处起失效。本机 11 个真实回合的实测:step1 命中率 49.6%,其余步 88~98%
|
|
5266
|
+
* ——缺口正好在「system 尾部这段动态内容 + 本轮新 user 消息」上。
|
|
5267
|
+
*
|
|
5268
|
+
* 注意只搬**清单**:附件段落里的「建卡输入路由 / 上游命令」是行为指令,同一批附件下不变,
|
|
5269
|
+
* 留在 system 才既有指令作用、又不破坏命中。
|
|
5270
|
+
*
|
|
5271
|
+
* 只给卡片 ID、不给 schema 全文是刻意的:早先塞过 8KB schema 摘要,与模型自己
|
|
5272
|
+
* card_read 的全文重复,实测推理模型会在思考里反复背诵这份摘要而空转不收敛。
|
|
5273
|
+
*
|
|
5274
|
+
* @param {{cardId?:string, listingText?:string}} input
|
|
5275
|
+
* @returns {string} 空串表示本轮没有额外上下文
|
|
5276
|
+
*/
|
|
5277
|
+
function buildTurnContextBlock({ cardId = '', listingText = '', buildFlow = false, buildReason = '' } = {}) {
|
|
5278
|
+
const parts = []
|
|
5279
|
+
if (cardId) {
|
|
5280
|
+
parts.push(
|
|
5281
|
+
`## 当前卡片上下文\n\n用户正在编辑卡片 ID: ${cardId};现状以 card_read / card_node_models 的结果为准,不要凭记忆猜测。`,
|
|
5282
|
+
)
|
|
5283
|
+
}
|
|
5284
|
+
if (buildFlow) {
|
|
5285
|
+
// 「本轮是建卡流程」这条判定从 system 挪到了这里:它对缓存极不友好(写进 system 会让
|
|
5286
|
+
// 建卡轮与改卡轮的提示词整段不同),但模型必须知道 —— 放尾部既能保持 system 稳定,
|
|
5287
|
+
// 又能在动手前拦住它。配合 needBuildProtocol:嘴上说没用,服务端会真驳回。
|
|
5288
|
+
const why =
|
|
5289
|
+
buildReason === 'design'
|
|
5290
|
+
? '挂载了设计稿类输入'
|
|
5291
|
+
: buildReason === 'attachment'
|
|
5292
|
+
? '挂载了附件'
|
|
5293
|
+
: buildReason === 'empty-card'
|
|
5294
|
+
? '当前卡片是空的'
|
|
5295
|
+
: '判定为从零建卡'
|
|
5296
|
+
parts.push(
|
|
5297
|
+
`## 本轮判定:建卡流程(${why})\n` +
|
|
5298
|
+
'动手写任何卡片内容**之前**先 skill_read path=dsl-card-generator/references/build_protocol.md(S0~S4.5 全文),照它的阶段机推进;' +
|
|
5299
|
+
'准备跑 iterate 之前再读 converge_protocol.md(S4.9~S6)。' +
|
|
5300
|
+
'本回合未读就写 IR(fs_write *.ir.json)/ expand_ir / ir_merge / card_write 会被服务端直接驳回——这是硬门禁,不是建议。',
|
|
5301
|
+
)
|
|
5302
|
+
}
|
|
5303
|
+
if (listingText) {
|
|
5304
|
+
parts.push(
|
|
5305
|
+
`## 用户挂载目录的浅层清单(本机路径,未上传;内容以 fs_read / fs_list 的实时结果为准)\n\n${listingText}`,
|
|
5306
|
+
)
|
|
5307
|
+
}
|
|
5308
|
+
return parts.join('\n\n')
|
|
5309
|
+
}
|
|
5310
|
+
|
|
5311
|
+
/**
|
|
5312
|
+
* 把本轮上下文块插到**最后一条 user 消息之前**,而不是塞进 system。
|
|
5313
|
+
*
|
|
5314
|
+
* 缓存收益:system + tools + 历史 这段公共前缀从此与本轮动态内容无关,逐字节稳定;
|
|
5315
|
+
* 变化的只有末尾两条(上下文块 + 用户原话),而用户原话本来就每回合都是新的。
|
|
5316
|
+
* 语义不变:模型看到的内容一样,只是位置从「最前面」挪到「最后一句用户话之前」。
|
|
5317
|
+
*
|
|
5318
|
+
* 为什么单独成一条消息、而不是拼到用户原话尾部:拼接会把 markdown 尾巴塞进
|
|
5319
|
+
* 「以整条消息为输入」的解析里(实测:假网关按行提取路径时把 `---\n## …` 一起当成了文件名,
|
|
5320
|
+
* fs_read 直接报「路径不存在」)。真实模型虽按语义理解,但这类歧义没有收益,分开更干净。
|
|
5321
|
+
*
|
|
5322
|
+
* @param {Array<object>} messages OpenAI 形态的消息序列(原地插入,返回同一数组)
|
|
5323
|
+
* @param {string} block buildTurnContextBlock 的结果;空串时原样返回
|
|
5324
|
+
*/
|
|
5325
|
+
function injectTurnContext(messages, block) {
|
|
5326
|
+
const list = Array.isArray(messages) ? messages : []
|
|
5327
|
+
if (!block) return list
|
|
5328
|
+
for (let i = list.length - 1; i >= 0; i--) {
|
|
5329
|
+
if (list[i]?.role !== 'user') continue
|
|
5330
|
+
list.splice(i, 0, { role: 'user', content: block })
|
|
5331
|
+
return list
|
|
5332
|
+
}
|
|
5333
|
+
// 没有 user 消息(理论上不会发生):兜底追加一条,保证上下文不丢
|
|
5334
|
+
list.push({ role: 'user', content: block })
|
|
5335
|
+
return list
|
|
5336
|
+
}
|
|
5337
|
+
|
|
5117
5338
|
/**
|
|
5118
5339
|
* 定位前缀漂移:逐条比对上一次实际发给网关的消息与本次的,返回第一处不同的下标。
|
|
5119
5340
|
* - 下标落在尾部(= 只是追加了新消息)⇒ 正常,缓存继续命中;
|
|
@@ -5285,6 +5506,39 @@ function observeCtxScale(key, est, real) {
|
|
|
5285
5506
|
}
|
|
5286
5507
|
}
|
|
5287
5508
|
|
|
5509
|
+
/**
|
|
5510
|
+
* usage 回报是否可信到足以当「实测锚点」:real/est 落在 [0.55, 1.8] 之外一律不采信。
|
|
5511
|
+
* 典型异常源:网关只回缓存侧 token、把 completion 并进 prompt、对超长输入先截断再计数。
|
|
5512
|
+
* 采信错的锚点比不采信更糟 —— 它会把圆环读数和预算判定一起拽偏,且一路影响到回合结束。
|
|
5513
|
+
* 注意这**不是** ctxScale 的钳制:k 只是比例修正,锚点是绝对真值,错一点就是错一片。
|
|
5514
|
+
*/
|
|
5515
|
+
const CTX_ANCHOR_MIN = 0.55
|
|
5516
|
+
const CTX_ANCHOR_MAX = 1.8
|
|
5517
|
+
function ctxAnchorOk(est, real) {
|
|
5518
|
+
const e = Number(est)
|
|
5519
|
+
const r = Number(real)
|
|
5520
|
+
if (!(e > 500) || !(r > 500)) return false
|
|
5521
|
+
const k = r / e
|
|
5522
|
+
return k >= CTX_ANCHOR_MIN && k <= CTX_ANCHOR_MAX
|
|
5523
|
+
}
|
|
5524
|
+
|
|
5525
|
+
/**
|
|
5526
|
+
* 跨回合的实测锚点:`{ 卡id|网关|模型 → {real, est} }`。
|
|
5527
|
+
* 存在的理由:锚点若只活在单个回合里,**每一轮的第一步都退回估算**,而那正是用户按下发送后
|
|
5528
|
+
* 第一眼看的数字。同一张卡接着聊时,新一轮的消息序列 = 上轮收尾序列 + 一条问答,估算增量很小,
|
|
5529
|
+
* 因此用上轮收尾的实测值当起点是安全且明显更准的做法(错了也会被本步的 usage 立刻纠正)。
|
|
5530
|
+
*/
|
|
5531
|
+
const _ctxAnchors = new Map()
|
|
5532
|
+
const CTX_ANCHOR_KEEP = 50
|
|
5533
|
+
const ctxAnchorKey = (cardId, scaleKey) => `${cardId || ''}|${scaleKey}`
|
|
5534
|
+
|
|
5535
|
+
function rememberCtxAnchor(key, anchor) {
|
|
5536
|
+
// LRU:重新插入到尾部,超量时从最早的开始丢,避免长跑进程无限涨
|
|
5537
|
+
_ctxAnchors.delete(key)
|
|
5538
|
+
_ctxAnchors.set(key, anchor)
|
|
5539
|
+
while (_ctxAnchors.size > CTX_ANCHOR_KEEP) _ctxAnchors.delete(_ctxAnchors.keys().next().value)
|
|
5540
|
+
}
|
|
5541
|
+
|
|
5288
5542
|
/** 常驻 tools 定义的 token 估算(工具集在同一回合内不变 ⇒ 算一次缓存) */
|
|
5289
5543
|
let _toolsTokensCache = { sig: '', tokens: 0 }
|
|
5290
5544
|
function toolsTokens(tools) {
|
|
@@ -6184,6 +6438,15 @@ export function createAiChatMiddleware(opts = {}) {
|
|
|
6184
6438
|
// 后面组装 contextPrompt 时直接复用,不再重跑一次describeAttachments
|
|
6185
6439
|
let prebuiltAttachment = { designFlow: false, text: '' }
|
|
6186
6440
|
let attachedRoots = []
|
|
6441
|
+
/**
|
|
6442
|
+
* 本轮是否「建卡流程」——由 classifyBuildIntent 判定,**必须提到 try 外面**:
|
|
6443
|
+
* 工具循环里的建卡协议硬门禁(needBuildProtocol)要用它,而 buildIntent 原本是 try 块内的 const。
|
|
6444
|
+
*/
|
|
6445
|
+
let buildFlowThisTurn = false
|
|
6446
|
+
/** 建卡判定的理由(design / attachment / empty-card),尾部上下文块要把它说给模型听 */
|
|
6447
|
+
let buildReasonThisTurn = ''
|
|
6448
|
+
/** 本回合是否已读过建卡协议全文(读过才放行写 IR / expand_ir / card_write) */
|
|
6449
|
+
let buildProtocolRead = false
|
|
6187
6450
|
try {
|
|
6188
6451
|
;({ tools, dispatch } = await loadTools())
|
|
6189
6452
|
|
|
@@ -6200,6 +6463,8 @@ export function createAiChatMiddleware(opts = {}) {
|
|
|
6200
6463
|
const buildIntent = classifyBuildIntent(attachments, prebuiltAttachment.designFlow, {
|
|
6201
6464
|
empty: isCardEmptyForBuildIntent(root, cardContext?.id),
|
|
6202
6465
|
})
|
|
6466
|
+
buildFlowThisTurn = buildIntent.buildFlow
|
|
6467
|
+
buildReasonThisTurn = buildIntent.reason
|
|
6203
6468
|
systemPrompt = buildSystemPrompt({
|
|
6204
6469
|
shellAllowed: Boolean(defaults.shellAllowed),
|
|
6205
6470
|
vision: visionOn,
|
|
@@ -6242,21 +6507,42 @@ export function createAiChatMiddleware(opts = {}) {
|
|
|
6242
6507
|
* 冻结后 `used` 只随 messages 单调增长,k 的收敛体现在回合之间(与 `total` 同样按回合冻结的口径一致)。
|
|
6243
6508
|
*/
|
|
6244
6509
|
const turnScale = ctxScale(scaleKey)
|
|
6510
|
+
/**
|
|
6511
|
+
* 实测锚点:上一次网关 usage 回报的**本步真实输入 token**,以及与之对应的估算值(同一步、同一份 messages)。
|
|
6512
|
+
* 上下文占用改用增量口径:`真实锚点 + (当前估算 − 锚点估算)`。
|
|
6513
|
+
*
|
|
6514
|
+
* 为什么不再用「整段估算 × k」:est/real 不是常数比,同一回合内会随文本构成系统性漂移 ——
|
|
6515
|
+
* 早期 prompt 以中文系统提示为主(estTokens 按 CJK×1.0 估高),后期工具结果以 JSON / 代码为主
|
|
6516
|
+
* (按 ×0.3 估低)。实测 transfersjump 单回合 real/est 从 0.85 漂到 1.15(相对 35%),
|
|
6517
|
+
* 单标量 k 只能一头准一头偏:全量 12 回合 144 步的显示值落在真值的 0.77×~1.38×。
|
|
6518
|
+
* 增量口径只让「误差率 × 新增量」进账,误差不随总量放大(同一批日志回测收窄到 0.905~1.152)。
|
|
6519
|
+
*
|
|
6520
|
+
* 初值取上一回合的收尾测量(同一张卡 + 同一网关/模型),让新一轮**首步**也是真值口径;
|
|
6521
|
+
* 取不到(新卡 / 刚重启 / 换模型)就是空锚点,首步退回估算并标 source=estimated。
|
|
6522
|
+
*/
|
|
6523
|
+
let ctxAnchor = _ctxAnchors.get(ctxAnchorKey(cardContext?.id, scaleKey)) || { real: 0, est: 0 }
|
|
6245
6524
|
|
|
6246
|
-
let contextPrompt = systemPrompt
|
|
6247
|
-
// 只注入「当前卡片 ID」:此前额外塞 8KB schema 摘要,与模型自己 card_read 的全文重复,
|
|
6248
|
-
// 实测推理模型会在思考里反复背诵这份摘要而空转不收敛
|
|
6249
|
-
if (cardContext?.id) {
|
|
6250
|
-
contextPrompt += `\n\n## 当前卡片上下文\n\n用户正在编辑卡片 ID: ${cardContext.id};现状以 card_read / card_node_models 的结果为准,不要凭记忆猜测。`
|
|
6251
|
-
}
|
|
6252
6525
|
// 用户挂载的目录(或文件所在目录)纳入 fs_read / fs_list 白名单:挂载即授权只读。
|
|
6253
6526
|
// 附件摘要与建卡输入路由已在拼系统提示词之前跑过(分层要靠它的 designFlow),这里只做复用与注入。
|
|
6254
6527
|
const mountedRoots = attachedRoots
|
|
6255
6528
|
/** 本轮是否「设计稿建卡」流程(由附件路由判定得出,用于思考熔断催促时附加 IR 路线指引) */
|
|
6256
6529
|
const designFlow = prebuiltAttachment.designFlow
|
|
6257
|
-
|
|
6258
|
-
|
|
6259
|
-
|
|
6530
|
+
// 挂载附件的「稳定段」(路径 + 建卡输入路由 + 上游命令)留在 system:它是行为指令,
|
|
6531
|
+
// 同一批附件的判定结论不变,命中率高;只有目录浅层清单进尾部上下文块(见下)。
|
|
6532
|
+
const systemText =
|
|
6533
|
+
attachments.length && prebuiltAttachment.text
|
|
6534
|
+
? `${systemPrompt}\n\n## 用户挂载的本地文件(仅本机路径,未上传;需要内容时用工具自行读取)\n\n${prebuiltAttachment.text}`
|
|
6535
|
+
: systemPrompt
|
|
6536
|
+
/**
|
|
6537
|
+
* 本轮上下文块(卡片 ID、挂载目录的实时清单):**不进 system**,见 injectTurnContext 的注释。
|
|
6538
|
+
* 前者随选中卡变、后者随建卡产物增长而变,留在 system 里会让整段前缀从该处起失效。
|
|
6539
|
+
*/
|
|
6540
|
+
const turnContextBlock = buildTurnContextBlock({
|
|
6541
|
+
cardId: cardContext?.id,
|
|
6542
|
+
listingText: prebuiltAttachment.listing,
|
|
6543
|
+
buildFlow: buildFlowThisTurn,
|
|
6544
|
+
buildReason: buildReasonThisTurn,
|
|
6545
|
+
})
|
|
6260
6546
|
|
|
6261
6547
|
res.statusCode = 200
|
|
6262
6548
|
res.setHeader('Content-Type', 'text/event-stream; charset=utf-8')
|
|
@@ -6322,7 +6608,13 @@ export function createAiChatMiddleware(opts = {}) {
|
|
|
6322
6608
|
if (visionWanted && !visionOn) {
|
|
6323
6609
|
sseNotice(res, `已开启多模态视觉,但本轮用不了图像,降级为数值闭环(pixelDiff.match)。原因:${visionProbe.reason || '探测失败'}`, 'warn', 'vision-off')
|
|
6324
6610
|
}
|
|
6325
|
-
|
|
6611
|
+
// system 只放「跨回合稳定」的指令段;每回合可能变的部分(卡片 ID、挂载文件摘要)
|
|
6612
|
+
// 由 injectTurnContext 插到最后一条 user 消息之前,让 system + tools + 历史 这段公共前缀
|
|
6613
|
+
// 保持字节稳定——隐式前缀缓存按最长公共前缀计价,动态内容夹在 system 里会让 step1 全段失效。
|
|
6614
|
+
const fullMessages = injectTurnContext(
|
|
6615
|
+
[{ role: 'system', content: systemText }, ...toOpenAiMessages(compacted.messages)],
|
|
6616
|
+
turnContextBlock,
|
|
6617
|
+
)
|
|
6326
6618
|
// 视觉注入点①:用户挂载的图片随本轮 user 消息进上下文(落盘历史不含 base64,重开不重复注入)
|
|
6327
6619
|
if (visionOn && attachments.length) {
|
|
6328
6620
|
const parts = []
|
|
@@ -6519,22 +6811,31 @@ export function createAiChatMiddleware(opts = {}) {
|
|
|
6519
6811
|
// 每轮发请求前先按预算折叠早期工具结果 / 注入图像(最近 TRIM_KEEP_RECENT 条保持原文)。
|
|
6520
6812
|
// from = 上次折到哪:折叠只前进不回退,历史已折叠的部分保持原样 ⇒ 前缀缓存继续命中
|
|
6521
6813
|
//
|
|
6522
|
-
//
|
|
6523
|
-
//
|
|
6524
|
-
//
|
|
6525
|
-
//
|
|
6526
|
-
const
|
|
6814
|
+
// 预算换算:有实测锚点时用**偏移**(真实与估算等距平移),没有时退回比例(估算 × k)。
|
|
6815
|
+
// 偏移优于比例的原因见 ctxAnchor 定义处:est/real 的误差来自文本构成,不是固定倍数,
|
|
6816
|
+
// 用比例换算会把斜率误差再放大一次,同一条消息一会儿超预算一会儿不超。
|
|
6817
|
+
// 常驻 tools 数组每步都会发但不属于 messages,故两边都要单独扣掉 toolsTok。
|
|
6818
|
+
const estBefore = currentMessages.reduce((a, m) => a + openAiMsgTokens(m), 0) + toolsTok
|
|
6819
|
+
// 当步真实占用:锚点 + 增量;锚点缺失(回合首步 / 网关不报 usage)时退回整段估算 × k
|
|
6820
|
+
const realBefore = ctxAnchor.real > 0 ? ctxAnchor.real + (estBefore - ctxAnchor.est) : estBefore * turnScale
|
|
6821
|
+
const estBudgetTotal =
|
|
6822
|
+
ctxAnchor.real > 0 ? estBefore - Math.max(0, realBefore - turnContextBudget) : Math.floor(turnContextBudget / turnScale)
|
|
6823
|
+
const estBudget = Math.max(4000, Math.floor(estBudgetTotal) - toolsTok)
|
|
6527
6824
|
const turnTrim = trimTurnContext(currentMessages, { budgetTokens: estBudget, from: trimFrom })
|
|
6528
6825
|
trimFrom = turnTrim.from
|
|
6529
|
-
//
|
|
6530
|
-
|
|
6826
|
+
// 裁剪把估算压下去多少,真实占用就等量压下去多少(偏移换算的逆运算)
|
|
6827
|
+
const trimSaved = Math.max(0, estBefore - (turnTrim.tokens + toolsTok))
|
|
6828
|
+
// 输入框圆环的数据源:本轮**实际送进网关**的上下文量(裁剪后),按真实口径上报。
|
|
6829
|
+
// source=estimated 表示本回合还没有可用锚点(首步,或网关回报不可信被闸掉),此时是估算;
|
|
6830
|
+
// 网关一回话就补一条实测值(见 usage 分支),前端优先显示实测。
|
|
6531
6831
|
sseWrite(res, {
|
|
6532
6832
|
type: 'context-usage',
|
|
6533
|
-
used: Math.round(
|
|
6833
|
+
used: Math.max(0, Math.round(realBefore - trimSaved)),
|
|
6534
6834
|
total: turnContextBudget,
|
|
6535
6835
|
window: knownWindow,
|
|
6536
6836
|
est: turnTrim.tokens + toolsTok,
|
|
6537
6837
|
scale: Math.round(turnScale * 100) / 100,
|
|
6838
|
+
source: ctxAnchor.real > 0 ? 'anchored' : 'estimated',
|
|
6538
6839
|
})
|
|
6539
6840
|
if (clampNotice) sseNotice(res, clampNotice, 'warn', 'max-tokens-clamp')
|
|
6540
6841
|
if (turnTrim.trimmed) {
|
|
@@ -6680,7 +6981,22 @@ export function createAiChatMiddleware(opts = {}) {
|
|
|
6680
6981
|
// 正好等于「刚送进网关的输入」)。与同一轮的估算相除即得系数,免费、无额外请求。
|
|
6681
6982
|
if (Number(u.promptTokens) > 0) {
|
|
6682
6983
|
const estSent = currentMessages.reduce((a, m) => a + openAiMsgTokens(m), 0) + toolsTok
|
|
6683
|
-
|
|
6984
|
+
const realPrompt = Number(u.promptTokens)
|
|
6985
|
+
observeCtxScale(scaleKey, estSent, realPrompt)
|
|
6986
|
+
// 实测锚点:之后每步的占用 = 锚点 + 增量估算;同时把本步真值随 usage 推给前端,
|
|
6987
|
+
// 输入框圆环据此把预估值覆盖成实测值(source: measured)。
|
|
6988
|
+
// 只采信 ctxAnchorOk 通过的回报:网关偶尔会回「只算缓存侧」「把 completion 并进 prompt」
|
|
6989
|
+
// 之类的数,采信一次就会把整轮读数和折叠时机一起带偏。
|
|
6990
|
+
const trusted = ctxAnchorOk(estSent, realPrompt)
|
|
6991
|
+
if (trusted) {
|
|
6992
|
+
ctxAnchor = { real: realPrompt, est: estSent }
|
|
6993
|
+
// 存给下一回合的首步用(卡被 card_rename 改过名时按新 id 记,别记到作废的 id 上)
|
|
6994
|
+
rememberCtxAnchor(ctxAnchorKey(liveCardId, scaleKey), ctxAnchor)
|
|
6995
|
+
}
|
|
6996
|
+
// 不可信的回报连「显示」都不用:宁可让圆环保留估算态,也不能把错数当实测推给用户
|
|
6997
|
+
usageTotal.promptStep = trusted ? realPrompt : 0
|
|
6998
|
+
} else {
|
|
6999
|
+
usageTotal.promptStep = 0
|
|
6684
7000
|
}
|
|
6685
7001
|
usageTotal.steps++
|
|
6686
7002
|
usageTotal.promptTokens += Number(u.promptTokens) || 0
|
|
@@ -6690,7 +7006,9 @@ export function createAiChatMiddleware(opts = {}) {
|
|
|
6690
7006
|
if (u.reported) usageTotal.reported = true
|
|
6691
7007
|
usageTotal.hitRate =
|
|
6692
7008
|
usageTotal.promptTokens > 0 ? Math.round((usageTotal.cachedTokens / usageTotal.promptTokens) * 1000) / 10 : 0
|
|
6693
|
-
|
|
7009
|
+
// total / window 随 usage 一起下发:圆环拿实测值覆盖时不必依赖「之前一定推过 context-usage」
|
|
7010
|
+
// (续订回放、断线接回等场景下事件顺序不保证),分母永远有出处
|
|
7011
|
+
sseWrite(res, { type: 'usage', step, total: turnContextBudget, window: knownWindow, ...usageTotal })
|
|
6694
7012
|
break
|
|
6695
7013
|
}
|
|
6696
7014
|
case 'notice':
|
|
@@ -6730,8 +7048,16 @@ export function createAiChatMiddleware(opts = {}) {
|
|
|
6730
7048
|
// 网关报上下文溢出:callLLM 已按详情分类(减半救不了的才带 context-overflow 标记抛出),
|
|
6731
7049
|
// 说明是**输入**超窗。压缩后重试同一步(此刻尚未追加 assistant 消息,无悬空 tool_calls),任务不中断
|
|
6732
7050
|
contextRetries++
|
|
6733
|
-
//
|
|
6734
|
-
|
|
7051
|
+
// 预算换算走同一套口径:把「真实预算的 60%」换回估算尺度(有锚点用偏移,否则用比例)。
|
|
7052
|
+
// 网关已经报过超窗,说明真实值比估算大,再收紧 40% 给输出与思考留余量。
|
|
7053
|
+
const tightEstNow = currentMessages.reduce((a, m) => a + openAiMsgTokens(m), 0) + toolsTok
|
|
7054
|
+
const tightRealNow =
|
|
7055
|
+
ctxAnchor.real > 0 ? ctxAnchor.real + (tightEstNow - ctxAnchor.est) : tightEstNow * turnScale
|
|
7056
|
+
const tightBase =
|
|
7057
|
+
ctxAnchor.real > 0
|
|
7058
|
+
? tightEstNow - Math.max(0, tightRealNow - turnContextBudget * 0.6)
|
|
7059
|
+
: Math.floor((turnContextBudget / turnScale) * 0.6)
|
|
7060
|
+
const tight = Math.max(4000, Math.floor(tightBase) - toolsTok)
|
|
6735
7061
|
const deep = deepCompactTurn(currentMessages, tight, Math.max(4, DEEP_KEEP_RECENT - 2), true)
|
|
6736
7062
|
currentMessages = deep.droppedGroups
|
|
6737
7063
|
? deep.messages
|
|
@@ -6997,6 +7323,12 @@ export function createAiChatMiddleware(opts = {}) {
|
|
|
6997
7323
|
switch (toolName) {
|
|
6998
7324
|
case 'skill_read':
|
|
6999
7325
|
result = skillRead(root, parsedArgs)
|
|
7326
|
+
// 建卡协议全文被读过 ⇒ 解除本回合的写入硬门禁(见 needBuildProtocol)。
|
|
7327
|
+
// 只在「真的读到了正文」时置位:文档缺失 / 路径写错返回的是 Error 文本,
|
|
7328
|
+
// 那种情况放行会把「读不到协议」变成「可以硬写」,正好反向。
|
|
7329
|
+
if (/build_protocol\.md/i.test(String(parsedArgs?.path || '')) && !/^Error/.test(String(result))) {
|
|
7330
|
+
buildProtocolRead = true
|
|
7331
|
+
}
|
|
7000
7332
|
break
|
|
7001
7333
|
case 'card_grep':
|
|
7002
7334
|
result = cardGrep(root, parsedArgs)
|
|
@@ -7021,7 +7353,9 @@ export function createAiChatMiddleware(opts = {}) {
|
|
|
7021
7353
|
result = fsList(root, parsedArgs, mountedRoots)
|
|
7022
7354
|
break
|
|
7023
7355
|
case 'fs_write':
|
|
7024
|
-
result =
|
|
7356
|
+
result =
|
|
7357
|
+
needBuildProtocol({ buildFlow: buildFlowThisTurn, protocolRead: buildProtocolRead, toolName, args: parsedArgs }) ||
|
|
7358
|
+
fsWrite(root, parsedArgs, fsWriteLedger)
|
|
7025
7359
|
break
|
|
7026
7360
|
case 'ir_patch':
|
|
7027
7361
|
result = fidelityFused ? FIDELITY_FUSE_REJECT : irPatch(root, parsedArgs)
|
|
@@ -7030,6 +7364,17 @@ export function createAiChatMiddleware(opts = {}) {
|
|
|
7030
7364
|
result = fsCopy(root, parsedArgs, mountedRoots)
|
|
7031
7365
|
break
|
|
7032
7366
|
case 'skill_exec': {
|
|
7367
|
+
// 建卡协议硬门禁:expand_ir / ir_merge 属于「把内容写出去」,未读协议一律驳回
|
|
7368
|
+
const buildGuard = needBuildProtocol({
|
|
7369
|
+
buildFlow: buildFlowThisTurn,
|
|
7370
|
+
protocolRead: buildProtocolRead,
|
|
7371
|
+
toolName,
|
|
7372
|
+
args: parsedArgs,
|
|
7373
|
+
})
|
|
7374
|
+
if (buildGuard) {
|
|
7375
|
+
result = buildGuard
|
|
7376
|
+
break
|
|
7377
|
+
}
|
|
7033
7378
|
// 还原度熔断:iterate 不再真执行(合成拒绝结果让循环继续,模型还能说出收尾词)
|
|
7034
7379
|
if (fidelityFused && /iterate\.mjs$/.test(String(parsedArgs?.script || ''))) {
|
|
7035
7380
|
result = FIDELITY_FUSE_REJECT
|
|
@@ -7080,9 +7425,16 @@ export function createAiChatMiddleware(opts = {}) {
|
|
|
7080
7425
|
result = await runShellExec(root, parsedArgs, res, approvedTools)
|
|
7081
7426
|
break
|
|
7082
7427
|
default:
|
|
7083
|
-
|
|
7428
|
+
// card_write 也走这道门禁:整份硬写 schema 是「没读协议」时最常见的兜底动作,
|
|
7429
|
+
// 也是损失最大的一种(生成上限截断 / 要素整批丢失)
|
|
7430
|
+
result =
|
|
7431
|
+
needBuildProtocol({ buildFlow: buildFlowThisTurn, protocolRead: buildProtocolRead, toolName, args: parsedArgs }) ||
|
|
7432
|
+
(await dispatch(toolName, parsedArgs))
|
|
7084
7433
|
}
|
|
7085
|
-
const
|
|
7434
|
+
const split = splitToolResult(result)
|
|
7435
|
+
// 大卡折叠必须在 clipToolResult 之前:clip 只能「掐尾巴」,而这里换掉的是「取哪一段」
|
|
7436
|
+
const text = foldOversizeCardRead(toolName, split.text, parsedArgs)
|
|
7437
|
+
const image = split.image
|
|
7086
7438
|
// 写到了别的卡上(新建 / 另存)必须让用户当场看见:本轮目标卡与「当前卡」不一致
|
|
7087
7439
|
// 时推一条 warn(一次一轮一条)。card_rename 已在上面更新 liveCardId,不会误报。
|
|
7088
7440
|
const targetCardId = cardIdFromToolArgs(toolName, parsedArgs)
|
|
@@ -7318,16 +7670,20 @@ export {
|
|
|
7318
7670
|
classifyErrorCode,
|
|
7319
7671
|
normalizeUsage,
|
|
7320
7672
|
prefixDrift,
|
|
7673
|
+
buildTurnContextBlock,
|
|
7674
|
+
injectTurnContext,
|
|
7321
7675
|
locateDropped,
|
|
7322
7676
|
collectLocateRoots,
|
|
7323
7677
|
toolResultLimit,
|
|
7324
7678
|
clipToolResult,
|
|
7679
|
+
foldOversizeCardRead,
|
|
7325
7680
|
TOOL_RESULT_MAX_BY_TOOL,
|
|
7326
7681
|
TOOL_RESULT_MAX_DEFAULT,
|
|
7327
7682
|
classifyBuildIntent,
|
|
7328
7683
|
isCardEmptyForBuildIntent,
|
|
7329
7684
|
buildSystemPrompt,
|
|
7330
|
-
|
|
7685
|
+
BUILD_DIGEST_SECTION,
|
|
7686
|
+
needBuildProtocol,
|
|
7331
7687
|
docSections,
|
|
7332
7688
|
docToc,
|
|
7333
7689
|
extractSection,
|
|
@@ -7341,6 +7697,7 @@ export {
|
|
|
7341
7697
|
toolsTokens,
|
|
7342
7698
|
ctxScale,
|
|
7343
7699
|
observeCtxScale,
|
|
7700
|
+
ctxAnchorOk,
|
|
7344
7701
|
parseSkillJsonReport,
|
|
7345
7702
|
briefSkillReport,
|
|
7346
7703
|
foldJsonValue,
|