cardbuddy 0.17.0 → 0.18.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.
@@ -58,6 +58,46 @@ export function mergeServerTurn(chatDir, cardId, msg) {
58
58
  return writeHistory(chatDir, cardId, kept)
59
59
  }
60
60
 
61
+ /**
62
+ * 浏览器 POST 整表时的「合并校对」:服务端已按 turnId 写过权威消息,这里只补浏览器独有的东西。
63
+ *
64
+ * 铁律:**已收尾的回合,服务端那份永远赢** —— 不是「客户端自认不是权威才丢」,而是
65
+ * 「只要盘上已有该 turnId 的权威消息,客户端同 turnId 的任何副本一律丢弃」。
66
+ *
67
+ * 为什么不能用 `!m.serverAuthored` 当条件(2026-10-09 实测修掉的缺陷):
68
+ * 客户端 `revive()` 会把 `serverAuthored: true` 还原到内存消息上,`serialize()` 又原样写回。
69
+ * 于是浏览器 POST 上来的副本**也带着 serverAuthored=true**,旧条件
70
+ * `srvTurns.has(turnId) && !m.serverAuthored` 永远为假 → 副本被 `put()` 按 id 覆盖权威消息。
71
+ * 而 `serialize()` 是**有损白名单**(刻意剥掉 base64 截图 / 流式态),于是这次覆盖必然丢字段。
72
+ * 现场:greetingRecommend 第一回合 usage(缓存命中率)与 17 条 toolCall 的 truncated 全部消失,
73
+ * 摘要条退化成「未提供」、工具行不再显示截断提示。**服务端日志里数据是全的** ——
74
+ * 说明不是采集问题,是落盘被覆盖。
75
+ *
76
+ * @param {any[]} stored 已落盘的消息(服务端权威侧)
77
+ * @param {any[]} incoming 浏览器 POST 上来的整表
78
+ * @returns {any[]} 合并结果,按 ts 稳定排序
79
+ */
80
+ export function mergeIncomingMessages(stored, incoming) {
81
+ const storedMsgs = Array.isArray(stored) ? stored : []
82
+ // 盘上已收尾的回合:这些 turnId 的任何浏览器副本都不许覆盖权威消息
83
+ const settled = new Set(storedMsgs.filter((m) => m && m.serverAuthored && m.turnId).map((m) => m.turnId))
84
+ const byId = new Map()
85
+ const anon = []
86
+ const put = (m) => {
87
+ if (!m || typeof m !== 'object') return
88
+ if (m.id) byId.set(m.id, m)
89
+ else anon.push(m)
90
+ }
91
+ for (const m of storedMsgs) put(m)
92
+ for (const m of Array.isArray(incoming) ? incoming : []) {
93
+ if (!m || typeof m !== 'object') continue
94
+ // 已收尾回合的客户端副本:无论它自称什么,一律丢掉(上面已说明为什么不能信它的标记)
95
+ if (m.role === 'assistant' && m.turnId && settled.has(m.turnId)) continue
96
+ put(m)
97
+ }
98
+ return [...byId.values(), ...anon].sort((x, y) => (Number(x.ts) || 0) - (Number(y.ts) || 0))
99
+ }
100
+
61
101
  export function writeHistory(chatDir, cardId, messages) {
62
102
  const file = historyFile(chatDir, cardId)
63
103
  if (!file) return { ok: false, error: 'missing-card-id' }
@@ -1164,6 +1164,140 @@ export function persistReport(result, { sp, dp, irPath, reset = false, design =
1164
1164
  }
1165
1165
  }
1166
1166
 
1167
+ /**
1168
+ * 报告摘要:给 stdout(= 模型上下文)用,全文已在 persistReport 里落盘。
1169
+ *
1170
+ * 为什么必须摘要(2026-10-09 实测):原版 `JSON.stringify(result, null, 2)` 把整份报告灌进上下文,
1171
+ * 单轮 6~15KB,而模型每轮的决策依据只有「gate 过了没有 / match 多少 / 差在哪个节点」。
1172
+ * 体积大头全在它不需要的东西上:
1173
+ * - `contributors.elements[]`:每个热区逐节点度量(rect/h/hSibling/designH/flags),
1174
+ * 而提示词自己写着「一轮只改 contributors[0].elements[0]」—— 后面的热区本轮根本不看;
1175
+ * - `elements[]`(--elements 的逐元素实测)、`fidelity.missing[]`/`invented[]` 的全文清单:
1176
+ * 门禁只判 `.length === 0`,逐条文案对「该不该改样式」没有增量信息;
1177
+ * - `geometry.collapsed[]`/`overWrapped[]` 全文、`lint.warningDetails`、`preflight.details`。
1178
+ * 15 轮迭代 = 9~22 万字符常驻,且每轮都重发(宿主把历史工具结果原样带回)⇒ 上下文斜率被它单独拉高。
1179
+ *
1180
+ * 摘要必须保留的字段(S5 全部决策都基于这些,删一条就会「照着不存在的结论调像素」):
1181
+ * ok / gate(含 checks.failed.verdict.stage)/ pixelDiff.match / pixelDiff.size.heightDelta
1182
+ * / best / stalled / rounds / conclusion / contributors[0] / structuralGaps
1183
+ * / contentOffset1x+hint / widthMismatch / geometry.counts / 各 errors 计数
1184
+ * / readiness / diffImage / renderImage / report(全文路径,供 --detail 或 fs_read 取全文)
1185
+ *
1186
+ * 削峰的边界(刻意保留的「第一条 + 计数」):
1187
+ * - contributors 只留 [0],其余热区只报个数;
1188
+ * - missing / invented / collapsed 等清单只留**前 3 条文本 + 总数**——
1189
+ * 前几条往往就是漏得最刺眼的(最长文案 / 最显眼的标题),模型据此能定位;要看全量有 report 路径。
1190
+ */
1191
+ export function summarizeReport(result) {
1192
+ if (!result || typeof result !== 'object') return result
1193
+ // 幂等:已封装的摘要原样放过。宿主侧会对旧版未封装的全量报告做兜底裁剪,
1194
+ // 二次裁剪会把 {total,sample} 当数组处理、把内容清空 —— 这是「封装过就别再动」的硬约束。
1195
+ if (result.summary === true) return result
1196
+ const r = result
1197
+ const pd = r.pixelDiff
1198
+ /**
1199
+ * 清单类字段:留前 3 条 + 总数(不是只留计数 —— 模型要靠具体文案判断漏了什么)。
1200
+ * 容忍两种输入:数组(全量)与 {total,sample}(已封装)—— 后者原样返回。
1201
+ */
1202
+ const list = (arr, map = (x) => x) => {
1203
+ if (arr && !Array.isArray(arr) && typeof arr === 'object') return arr.total != null || arr.sample ? arr : undefined
1204
+ const a = Array.isArray(arr) ? arr : []
1205
+ if (!a.length) return undefined
1206
+ return { total: a.length, sample: a.slice(0, 3).map(map), more: a.length > 3 ? `…另 ${a.length - 3} 条(见 report 全文)` : undefined }
1207
+ }
1208
+
1209
+ const out = {
1210
+ ok: r.ok,
1211
+ cardId: r.cardId,
1212
+ // 全文落盘路径放第一行:模型要看被削掉的内容时有确定入口,不必重跑 --detail
1213
+ report: r.report,
1214
+ conclusion: r.conclusion,
1215
+ rounds: r.rounds,
1216
+ best: r.best,
1217
+ stalled: r.stalled,
1218
+ gate: r.gate,
1219
+ iterationReset: r.iterationReset,
1220
+ appliedSets: r.appliedSets,
1221
+ validate: r.validate
1222
+ ? { errors: r.validate.errors, warnings: r.validate.warnings, envError: r.validate.envError, details: r.validate.details }
1223
+ : undefined,
1224
+ lint: r.lint
1225
+ ? {
1226
+ errors: r.lint.errors,
1227
+ warnings: r.lint.warnings,
1228
+ infos: r.lint.infos,
1229
+ details: r.lint.details,
1230
+ warningDetails: r.lint.warningDetails,
1231
+ }
1232
+ : undefined,
1233
+ preflight: r.preflight
1234
+ ? { errors: r.preflight.errors, details: list(r.preflight.details, (d) => (typeof d === 'string' ? d : d?.message || JSON.stringify(d))) }
1235
+ : undefined,
1236
+ readiness: r.readiness,
1237
+ widthMismatch: r.widthMismatch,
1238
+ contentOffset1x: r.contentOffset1x,
1239
+ contentOffsetHint: r.contentOffsetHint,
1240
+ // 像素主体:只留决策要用的三件(match / 尺寸差 / 热区),inkBox / diffPixels / totalPixels 是中间量
1241
+ pixelDiff: pd
1242
+ ? {
1243
+ match: pd.match,
1244
+ diffRatio: pd.diffRatio,
1245
+ size: pd.size,
1246
+ contentOffset: pd.contentOffset,
1247
+ structuralGaps: pd.structuralGaps,
1248
+ // 热区本身已在 contributors 里归因过,这里只留坐标供「还有哪些区没归因」的自查
1249
+ hotspots: Array.isArray(pd.hotspots) ? pd.hotspots.slice(0, 3) : pd.hotspots,
1250
+ hotspotsTotal: Array.isArray(pd.hotspots) ? pd.hotspots.length : pd.hotspotsTotal,
1251
+ }
1252
+ : undefined,
1253
+ structuralGaps: r.structuralGaps,
1254
+ // 分项归因:只留首条热区(提示词钦定「一轮只改 contributors[0].elements[0]」),其余只报个数
1255
+ contributors: Array.isArray(r.contributors) ? r.contributors.slice(0, 1) : r.contributors,
1256
+ contributorsTotal: Array.isArray(r.contributors) ? r.contributors.length : r.contributorsTotal,
1257
+ contributorsNote: r.contributorsNote,
1258
+ geometry: r.geometry
1259
+ ? {
1260
+ counts: r.geometry.counts,
1261
+ collapsed: list(r.geometry.collapsed),
1262
+ outOfCard: list(r.geometry.outOfCard),
1263
+ overWrapped: r.geometry.counts?.overWrapped != null ? undefined : list(r.geometry.overWrapped),
1264
+ }
1265
+ : undefined,
1266
+ geometryNote: r.geometryNote,
1267
+ fidelity: r.fidelity
1268
+ ? {
1269
+ total: r.fidelity.total,
1270
+ matched: r.fidelity.matched,
1271
+ missing: list(r.fidelity.missing),
1272
+ invented: list(r.fidelity.invented),
1273
+ slices: r.fidelity.slices,
1274
+ symbols: r.fidelity.symbols,
1275
+ shapes: r.fidelity.shapes,
1276
+ }
1277
+ : undefined,
1278
+ render: r.render,
1279
+ designImage: r.designImage,
1280
+ diffImage: r.diffImage,
1281
+ renderImage: r.renderImage,
1282
+ diffImageNote: r.diffImageNote,
1283
+ elapsedMs: r.elapsedMs,
1284
+ }
1285
+ if (r.error || r.message) {
1286
+ out.error = r.error
1287
+ out.message = r.message
1288
+ }
1289
+ // 被削掉的字段名写清楚:模型必须知道「摘要 ≠ 全量」,否则会把「没提到」误读成「不存在」
1290
+ // fs_read 现在支持 offset 续读(尾巴会给下一段起点),所以「读全文」的正解是照着 offset 读到底,
1291
+ // 而不是一遍 fs_read —— 一遍只拿得到一屏。
1292
+ out.summaryNote =
1293
+ '本报告为摘要(contributors 只留首条热区、要素/几何清单只留前 3 条 + 总数、逐元素度量已省略);' +
1294
+ '需要全量明细用 fs_read 读 report 路径(长报告会带下一段 offset,照抄续读),或重跑加 --detail。'
1295
+ // 幂等标记:宿主侧(ai-chat.js)会对未封装的旧版全量报告做一次兜底裁剪,
1296
+ // 见标记后它会原样放过 —— 否则二次裁剪会把已封装的 {total,sample} 当数组处理、把内容清空。
1297
+ out.summary = true
1298
+ return out
1299
+ }
1300
+
1167
1301
  /* ── 核心管线 ──────────────────────────────────────────────── */
1168
1302
  async function runPipeline({ ir: irPath, out: outDir, design: designPath, width, session, alwaysDiffImage, withElements, sets, specPath, skipPreflight, slicesDir: slicesDirArg, reset: resetFlag, noReadiness: noReadinessArg }) {
1169
1303
  const t0 = performance.now()
@@ -1476,7 +1610,7 @@ async function runPipeline({ ir: irPath, out: outDir, design: designPath, width,
1476
1610
 
1477
1611
  /* ── CLI ───────────────────────────────────────────────────── */
1478
1612
  function parseArgs(argv) {
1479
- const out = { ir: '', out: '', design: '', width: 0, keepAlive: false, diffImage: false, elements: false, sets: [], spec: '', gate: false, id: '', skipPreflight: false, slicesDir: '', reset: false, noReadiness: false }
1613
+ const out = { ir: '', out: '', design: '', width: 0, keepAlive: false, diffImage: false, elements: false, sets: [], spec: '', gate: false, id: '', skipPreflight: false, slicesDir: '', reset: false, noReadiness: false, detail: false }
1480
1614
  for (let i = 0; i < argv.length; i++) {
1481
1615
  const a = argv[i]
1482
1616
  if (a === '--ir') out.ir = argv[++i] || ''
@@ -1488,6 +1622,8 @@ function parseArgs(argv) {
1488
1622
  else if (a === '--slices-dir') out.slicesDir = argv[++i] || ''
1489
1623
  else if (a === '--id') out.id = argv[++i] || ''
1490
1624
  else if (a === '--gate') out.gate = true
1625
+ // stdout 默认只回摘要(省上下文);--detail 才回全量报告(模型真要看逐元素度量时才加)
1626
+ else if (a === '--detail') out.detail = true
1491
1627
  else if (a === '--no-preflight') out.skipPreflight = true
1492
1628
  // 逃生门:跳过 S4.9 内容就绪门禁(debug 用;正常流程**不该**用它)
1493
1629
  else if (a === '--no-readiness') out.noReadiness = true
@@ -1625,7 +1761,7 @@ async function main() {
1625
1761
  }
1626
1762
 
1627
1763
  if (!args.ir || !args.out) {
1628
- console.error('用法: node iterate.mjs --ir <IR.json> --out <cardDir> [--design <design.png>] [--width 343]')
1764
+ console.error('用法: node iterate.mjs --ir <IR.json> --out <cardDir> [--design <design.png>] [--width 343] [--detail]')
1629
1765
  process.exit(1)
1630
1766
  }
1631
1767
 
@@ -1644,7 +1780,8 @@ async function main() {
1644
1780
  noReadiness: args.noReadiness,
1645
1781
  })
1646
1782
 
1647
- console.log(JSON.stringify(result, null, 2))
1783
+ // 默认摘要:stdout = 模型上下文,全量报告已落盘在 report 字段指向的文件(见 summarizeReport 头注)
1784
+ console.log(JSON.stringify(args.detail ? result : summarizeReport(result)))
1648
1785
  if (!result.ok) process.exit(1)
1649
1786
  // 门禁未达标不算脚本失败(报告照常回喂),但退出码要让调用方一眼看出没收敛
1650
1787
  if (result.gate && result.gate.passed === false) process.exitCode = 2
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cardbuddy",
3
- "version": "0.17.0",
3
+ "version": "0.18.0",
4
4
  "description": "Local dev service for the CardBuddy card editor — decouples card CRUD (sync/save/validate/render-status/screenshot) from the static public editor, so the editor can be deployed to any static host while each developer runs a local service against their own card source.",
5
5
  "type": "module",
6
6
  "bin": {