@migor/cli 0.10.8 → 0.10.10

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.
@@ -349,7 +349,10 @@ function handleRunDetail(ctx) {
349
349
  ctx.json(ctx.res, 404, { error: 'run 不存在(可能已被淘汰)' });
350
350
  return;
351
351
  }
352
- ctx.json(ctx.res, 200, t);
352
+ // 明细**带上 CLI 侧记账**(顶层 `note`):`finalText` 只活在那里 —— trace 上没有它。
353
+ // 不带的话,面板一离开这一轮就再也拿不回回复(见 `RunNote.finalText`)。
354
+ // 形状是 `Trace + { note }`,`playTrace` 只读 `spans` / `traceId`,多一个键无影响。
355
+ ctx.json(ctx.res, 200, { ...t, note: ctx.state.notes.get(id) ?? null });
353
356
  }
354
357
  /**
355
358
  * 并排 A/B:`GET /api/runs/compare?a=<id>&b=<id>` → `{ diff, comparability }`。
package/dist/markdown.js CHANGED
@@ -15,15 +15,22 @@
15
15
  *
16
16
  * 支持的子集(刻意不做的都在「不做」里):
17
17
  * - 块:`#` 标题(1–6 级)、围栏码块(``` / ~~~,带语言标注)、无序/有序列表(**一层**)、
18
- * 引用(合并成一段正文)、`---` 分隔线、段落
18
+ * 引用(合并成一段正文)、`---` 分隔线、**GFM 表格**(表头 / 分隔行 / 对齐 / `\|` 转义)、段落
19
19
  * - 行内:行内码、`**粗**`、`*斜*`、`[文字](链接)`、`<https://…>` 自动链接
20
+ * - **HTML 注释被丢弃**(`<!-- … -->`,可跨行):注释不是正文。真实素材里最常见的就是
21
+ * 「迁移自 …」「TODO: 这条规则已废弃」这类作者旁注,让它们以字面文本占掉开头三四行,
22
+ * 比不渲染更糟。原文一个字节都不改(调用方手里仍是原串)⇒ 「隐藏」只发生在**渲染视图**里。
20
23
  *
21
24
  * 不做(都是刻意的,不是漏):
22
- * - **不做 HTML 透传**(安全前提,见上)
23
- * - 不做表格 / 任务列表 / 嵌套列表 / 脚注 / 数学 —— 面板是调试视图,不是文档站
25
+ * - **不做 HTML 透传**(安全前提,见上)—— 注释是唯一的例外,且是**丢掉**而不是渲染
26
+ * - **不做任务列表 / 嵌套列表 / 脚注 / 数学** —— 面板是调试视图,不是文档站
24
27
  * - 不做 setext 标题(`===` 下划线式)—— 与分隔线歧义大、收益小
25
28
  * - 不做图片(`![]()`)—— 面板**不去外网取图**:一次 run 的正文不该触发网络请求
26
29
  * - 不做**词内** `_` 强调(`read_file_tool` 原样显示)—— 与 CommonMark 同口径,见 `parseInline`
30
+ *
31
+ * ⚠️ 表格是 2026-11-12 补的(原先明写「不做表格」)。**那个判断是错的**:agent 的产出里
32
+ * 「错误汇总 / 逐项统计 / 参数对照」天然就是表,砍掉表格等于把最有信息量的几种结果打回
33
+ * 一行行 `| a | b |` 加上 `|---|---|` 的源文 —— 面板的读者被迫在脑子里做列对齐。
27
34
  */
28
35
  /**
29
36
  * 链接协议白名单。**这是安全边界,不是风格选择**:
@@ -43,6 +50,14 @@ const QUOTE = /^\s{0,3}>\s?(.*)$/;
43
50
  const UL = /^(\s*)([-*+])\s+(.*)$/;
44
51
  const OL = /^(\s*)(\d{1,9})[.)]\s+(.*)$/;
45
52
  const BLANK = /^\s*$/;
53
+ /**
54
+ * 表格的分隔行:`| --- | :--: |` / `--- | ---` 两种写法都收。
55
+ * 至少**两列**(`(\|…)+` 强制的)—— 单列「表格」在面板里就是一段带竖线的文本,
56
+ * 成表只会白占一行表头,还容易被 `a | b` 这种正文误判。
57
+ */
58
+ const TABLE_SEP = /^\s*\|?\s*:?-+:?\s*(\|\s*:?-+:?\s*)+\|?\s*$/;
59
+ /** 「这个字符串值不值得渲染」的长度上限(见 `looksLikeMarkdown`)—— 几百 KB 逐行建 DOM 只会卡住面板 */
60
+ const MAX_RENDER_CHARS = 100_000;
46
61
  /** 「单词字符」:`_` 的词内排除用它判边界(与 CommonMark 的 intraword 规则同口径) */
47
62
  const WORD_CHAR = /[A-Za-z0-9_]/;
48
63
  /** 下标可能越界(`text[-1]` / `text[len]` 都是 `undefined`)⇒ 越界一律当「不是单词字符」 */
@@ -52,15 +67,18 @@ function isWordChar(ch) {
52
67
  /**
53
68
  * 把一段 Markdown 文本解析成块序列。
54
69
  *
55
- * 三条与「真 Markdown」不同但**刻意**的口径:
70
+ * 四条与「真 Markdown」不同但**刻意**的口径:
56
71
  * - 缩进 ≥2 空格的续行归上一条列表项(列表只解析**一层**:更深一层当正文,不递归建树);
57
72
  * - 未闭合的围栏码块吃掉到文末(**不静默丢内容** —— 模型被截断时最常见的就是这个形态);
58
- * - 引用(`>`)合并成**一段正文**(面板里引用几乎总是模型在引日志,逐行建块只会更碎)。
73
+ * - 引用(`>`)合并成**一段正文**(面板里引用几乎总是模型在引日志,逐行建块只会更碎);
74
+ * - 表格按**表头**宽度对齐:缺的列补空、多出来的列丢掉(与 GFM 同口径)。表格之后紧接的
75
+ * 非 `|` 行立刻收尾 —— 表格没有围栏,边界只能靠「下一行还像不像表格」来判断。
59
76
  */
60
77
  export function parseMarkdown(text) {
61
78
  const src = String(text ?? '').replace(/\r\n?/g, '\n');
62
79
  const lines = src.split('\n');
63
80
  const blocks = [];
81
+ const comment = { open: false };
64
82
  let i = 0;
65
83
  let para = [];
66
84
  const flushPara = () => {
@@ -72,7 +90,9 @@ export function parseMarkdown(text) {
72
90
  blocks.push({ type: 'p', inline: parseInline(joined, 0) });
73
91
  };
74
92
  while (i < lines.length) {
75
- const line = lines[i];
93
+ // 注释先剥掉再看这一行是什么(围栏码块的内容由下面的内层循环整段吞掉,不会走到这里 ⇒
94
+ // 码块里的 `<!-- … -->` 原样保留,这正是「代码是字面量」的意思)
95
+ const line = stripComments(lines[i], comment);
76
96
  // —— 围栏码块 ——
77
97
  const fence = FENCE.exec(line);
78
98
  if (fence) {
@@ -159,6 +179,34 @@ export function parseMarkdown(text) {
159
179
  blocks.push({ type: 'list', ordered, items });
160
180
  continue;
161
181
  }
182
+ // —— 表格(GFM):表头行 + 分隔行**同时**成立才算 ——
183
+ // 两条都要,是这条判据的全部要害:只要「行里有 `|`」就成表,`a | b` 这种正文会被
184
+ // 吞成一行表头;只要「下一行像分隔行」就成表,`---` 分隔线下面那段会被吃掉。
185
+ if (isTableStart(line, lines[i + 1])) {
186
+ flushPara();
187
+ const header = splitRow(line).map((c) => parseInline(c, 0));
188
+ const align = splitRow(stripComments(lines[i + 1], { open: comment.open })).map(alignOf);
189
+ const rows = [];
190
+ i += 2;
191
+ while (i < lines.length) {
192
+ // 表格没有围栏:边界只能靠「下一行还像不像表格」。空白、另一个块的起始、
193
+ // 不含 `|` 的行,三者任一条都收尾(否则后面的正文会被当成格子吃掉)。
194
+ const raw = lines[i];
195
+ if (!raw.includes('|') || BLANK.test(raw))
196
+ break;
197
+ if (FENCE.test(raw) || HR.test(raw) || HEADING.test(raw) || QUOTE.test(raw))
198
+ break;
199
+ if (UL.test(raw) || olAt(raw))
200
+ break;
201
+ const cells = splitRow(raw).slice(0, header.length);
202
+ while (cells.length < header.length)
203
+ cells.push('');
204
+ rows.push(cells.map((c) => parseInline(c, 0)));
205
+ i++;
206
+ }
207
+ blocks.push({ type: 'table', align, header, rows });
208
+ continue;
209
+ }
162
210
  // —— 其余:段落(连续非空行合并,行内换行保留) ——
163
211
  para.push(line.trim());
164
212
  i++;
@@ -166,6 +214,122 @@ export function parseMarkdown(text) {
166
214
  flushPara();
167
215
  return blocks;
168
216
  }
217
+ /**
218
+ * 剥掉 HTML 注释(可跨行)。`state.open` 跨行携带 —— 块级解析是逐行扫的,
219
+ * 没有这个开关,一段被空行隔开的三行注释会被当成三段正文(每段都带着半截 `<!--`)。
220
+ *
221
+ * 只在**非码块**的行上调用。码块内容由围栏那段内层循环整段吞掉,走不到这里 ——
222
+ * 所以「代码里的 `<!-- -->` 原样保留」不是靠额外判断,是靠调用点。
223
+ */
224
+ function stripComments(line, state) {
225
+ let s = line;
226
+ for (;;) {
227
+ if (state.open) {
228
+ const end = s.indexOf('-->');
229
+ if (end < 0)
230
+ return '';
231
+ s = s.slice(end + 3);
232
+ state.open = false;
233
+ continue;
234
+ }
235
+ const start = s.indexOf('<!--');
236
+ if (start < 0)
237
+ return s;
238
+ const end = s.indexOf('-->', start + 4);
239
+ if (end < 0) {
240
+ state.open = true;
241
+ return s.slice(0, start);
242
+ }
243
+ s = s.slice(0, start) + s.slice(end + 3);
244
+ }
245
+ }
246
+ /**
247
+ * 一行表格 → 格子文本(已 trim,`\|` 解成 `|`)。
248
+ * 首尾的定界竖线去掉;**但**以 `\|` 结尾时不能去 —— 那是最后一个格子里的字面竖线。
249
+ */
250
+ function splitRow(line) {
251
+ let s = line.trim();
252
+ if (s.startsWith('|'))
253
+ s = s.slice(1);
254
+ if (s.endsWith('|') && !s.endsWith('\\|'))
255
+ s = s.slice(0, -1);
256
+ const cells = [];
257
+ let buf = '';
258
+ for (let i = 0; i < s.length; i++) {
259
+ const c = s[i];
260
+ if (c === '\\' && s[i + 1] === '|') {
261
+ buf += '|';
262
+ i++;
263
+ continue;
264
+ }
265
+ if (c === '|') {
266
+ cells.push(buf.trim());
267
+ buf = '';
268
+ continue;
269
+ }
270
+ buf += c;
271
+ }
272
+ cells.push(buf.trim());
273
+ return cells;
274
+ }
275
+ /** 分隔行的单个格子 → 对齐方式 */
276
+ function alignOf(cell) {
277
+ const t = cell.trim();
278
+ const left = t.startsWith(':');
279
+ const right = t.endsWith(':');
280
+ if (left && right)
281
+ return 'center';
282
+ if (right)
283
+ return 'right';
284
+ if (left)
285
+ return 'left';
286
+ return null;
287
+ }
288
+ /** 这一行是不是表格的开头:表头含 `|`、下一行是分隔行、且两行列数**相等** */
289
+ function isTableStart(head, sep) {
290
+ if (sep === undefined || !head.includes('|') || !TABLE_SEP.test(sep))
291
+ return false;
292
+ return splitRow(head).length === splitRow(sep).length;
293
+ }
294
+ /**
295
+ * 这段正文「值不值得按 Markdown 渲染」—— 给**宿主**用(trace 树里展开的工具出参)。
296
+ *
297
+ * 判据只认**块级**记号,且要求**至少两分**(围栏码块与表格各算两分,其余各一分):
298
+ * 标题 + 列表 = 2 分 ✅;只有一堆 `#` 注释 ❌;只有一处围栏码块 ✅。
299
+ *
300
+ * 为什么这么严:判错的代价不是「少渲染一点」,而是**把一段本来好读的文本毁掉** ——
301
+ * 日志行被并成段落、原有缩进被吃掉、行首的 `#` 与 `|` 凭空消失。所以宁可漏(保持原文),
302
+ * 不可错。
303
+ *
304
+ * 刻意**不**看行内记号(`**粗**` / `[x](y)`):`**kwargs` / `*args` 是 Python 源码里最常见
305
+ * 的东西,按行内记号判会把整份源码判成 Markdown —— 而那正是工具出参的另一种常见形态。
306
+ *
307
+ * 超过 `MAX_RENDER_CHARS` 一律不渲染:几百 KB 的正文逐行建 DOM 只会把面板卡住,
308
+ * 而这种长度的东西本来就该用「复制」拿走。
309
+ */
310
+ export function looksLikeMarkdown(text) {
311
+ if (typeof text !== 'string' || text.length > MAX_RENDER_CHARS)
312
+ return false;
313
+ const lines = text.replace(/\r\n?/g, '\n').split('\n');
314
+ const kinds = new Set();
315
+ for (let i = 0; i < lines.length; i++) {
316
+ const line = lines[i];
317
+ if (FENCE.test(line))
318
+ kinds.add('fence');
319
+ else if (isTableStart(line, lines[i + 1]))
320
+ kinds.add('table');
321
+ else if (HEADING.test(line))
322
+ kinds.add('h');
323
+ else if (QUOTE.test(line))
324
+ kinds.add('q');
325
+ else if (UL.test(line) || olAt(line))
326
+ kinds.add('list');
327
+ }
328
+ let score = 0;
329
+ for (const k of kinds)
330
+ score += k === 'fence' || k === 'table' ? 2 : 1;
331
+ return score >= 2;
332
+ }
169
333
  /** 有序列表标记(`1.` / `1)`),返回与 UL 同形的三组:缩进 / 标记 / 正文 */
170
334
  function olAt(line) {
171
335
  const m = OL.exec(line);
@@ -119,6 +119,45 @@ export function historyText(history, cursor, draft) {
119
119
  return draft;
120
120
  return history[history.length - 1 - cursor] ?? draft;
121
121
  }
122
+ export function stopReasonClass(r) {
123
+ const sr = r.stopReason ?? null;
124
+ if (sr === 'aborted')
125
+ return 'aborted';
126
+ // suspended 先于 ok 判:引擎的 `isSuccessStopReason` 不把它算成功,而 trace 状态记 ok
127
+ // (挂起段本身执行无误)⇒ 只看 ok 就漏了「这一轮其实还等着人」。
128
+ if (sr === 'suspended')
129
+ return 'suspended';
130
+ return r.ok ? 'ok' : 'failed';
131
+ }
132
+ /**
133
+ * 收尾原因的**中文短语**(面板显示用)。
134
+ *
135
+ * 未知串**原样返回**(不回落成「完成」、也不回落成空串):面板不认识的
136
+ * `stop_reason` 只能说明面板落后于框架,把原文亮出来才能一眼看出是哪个新原因。
137
+ * 要覆盖的成员由 `test/stop-reason.test.mjs` 对着 `src/engine/types.ts` 的
138
+ * `AgentStopReason` 逐个数 —— 框架加了成员而这里没表态,那条守卫就红。
139
+ */
140
+ const STOP_REASON_TEXT = {
141
+ end_turn: '模型自然收尾',
142
+ stop_sequence: '命中停止序列',
143
+ max_tokens: '撞到输出上限',
144
+ model_context_window_exceeded: '撞到上下文窗口',
145
+ refusal: '模型拒绝回答',
146
+ pause_turn: '模型请求暂停',
147
+ max_iterations: '撞到迭代上限',
148
+ result_missing: '没提交结构化结果',
149
+ budget_exceeded: '撞到预算护栏',
150
+ aborted: '已中止',
151
+ tool_use_no_blocks: '回合里没有可执行块',
152
+ suspended: '等人审批',
153
+ unknown_stop_reason: '未识别的收尾原因',
154
+ error: '出错',
155
+ };
156
+ export function stopReasonText(sr) {
157
+ if (!sr)
158
+ return '';
159
+ return STOP_REASON_TEXT[sr] ?? sr;
160
+ }
122
161
  /**
123
162
  * 这个 run 算不算**失败**?
124
163
  *
@@ -128,12 +167,12 @@ export function historyText(history, cursor, draft) {
128
167
  * - run 列表把它标成红点(用户会以为是自己把工程改坏了);
129
168
  * - 对话视图给这一轮打红边「(这一轮失败了…)」,而同一个 run 的通知条写着「已中止」。
130
169
  *
131
- * 判别顺序(**先 stopReason 后 ok**)与 `runDoneNotice` 是同一条语义,钉在单测里。
170
+ * 判别顺序归 `stopReasonClass`(**先 stopReason 后 ok**),与 `runDoneNotice` 同一条语义。
171
+ * 这里只是它的一元投影 —— 别在两处各写一遍:
172
+ * `suspended` 也不是失败(挂着等人,`ok` 同样可能是 false),旧实现漏掉它。
132
173
  */
133
174
  export function runIsFailure(r) {
134
- if (r.stopReason === 'aborted')
135
- return false;
136
- return !r.ok;
175
+ return stopReasonClass(r) === 'failed';
137
176
  }
138
177
  /** `MessageParam.content` 可能是 string,也可能是 content block 数组 —— 只取文本块 */
139
178
  export function textOfContent(content) {
@@ -194,7 +233,7 @@ export function mergeConversation(messages, runs, sessionId) {
194
233
  continue;
195
234
  if ((r.sessionId ?? null) !== sessionId)
196
235
  continue;
197
- const prompt = (r.prompt ?? '').trim();
236
+ const prompt = (r.note?.prompt ?? '').trim();
198
237
  if (prompt.length === 0 || seen.has(prompt))
199
238
  continue;
200
239
  seen.add(prompt);
@@ -402,16 +441,192 @@ export function comparability(a, b) {
402
441
  * 但原因要可查)⇒ 中止时 `ok` 也是 **false**。所以必须**先**认 `stopReason`:
403
442
  * 反过来先看 `ok`,用户按的「中止」就会被显示成一句吓人的「run 失败」。
404
443
  * 这个顺序错一次很难在 code review 里看出来,所以钉在单测里。
444
+ *
445
+ * 四类模板(`stopReasonClass`):原文一起给 —— 「撞到预算护栏」是给人读的,
446
+ * `budget_exceeded` 是给人 grep 的,两者都要。
405
447
  */
406
448
  export function runDoneNotice(ev) {
407
- if (ev.stopReason === 'aborted') {
449
+ const cls = stopReasonClass(ev);
450
+ const text = stopReasonText(ev.stopReason);
451
+ // 中文短语与标识符不同才拼(未识别的串 `text === sr`,只显示一次)
452
+ const why = text && text !== ev.stopReason ? `${text} · ${ev.stopReason}` : ev.stopReason;
453
+ if (cls === 'aborted') {
408
454
  return { text: 'run 已中止(trace 保住了)', isError: false };
409
455
  }
410
- if (!ev.ok) {
411
- return { text: `run 失败(${ev.stopReason}):${ev.error || '未知原因'}`, isError: true };
456
+ if (cls === 'suspended') {
457
+ // 挂起不是结束:run 带着完整消息历史等人裁决,之后从原处继续。
458
+ // 旧文案把它写成「run 完成(suspended)」—— 那是在说一件没发生的事。
459
+ return { text: `run 停在审批(${why}):裁决之后从挂起处继续`, isError: false };
460
+ }
461
+ if (cls === 'failed') {
462
+ return { text: `run 失败(${why}):${ev.error || '未知原因'}`, isError: true };
412
463
  }
413
464
  return { text: `run 完成(${ev.stopReason})`, isError: false };
414
465
  }
466
+ function ledNum(n) {
467
+ return typeof n === 'number' && Number.isFinite(n) && n > 0 ? n : 0;
468
+ }
469
+ /**
470
+ * 一条 run 的账:`3 轮 · 7 次工具 · 2 失败 · in 8.1k / out 1.5k · cache 6.2k · 77% · $0.014`。
471
+ *
472
+ * 为什么值得单独一条纯函数:这五个数此前**一个都没有**(页面上只有 footer 的 in / out),
473
+ * 而它们全都在 `Trace.totalUsage` 与 span 上。其中**cache 命中率**是这类面板最该给、又最常
474
+ * 缺的一个数 —— 它直接对应账单(缓存读的单价通常只有未命中的 1/10)。
475
+ *
476
+ * 三条口径(都是「不给假读数」):
477
+ * - 分母为 0 / 字段缺失的那一项**整项不出现**,不显示 `0%` / `$0.0000`。
478
+ * 「成本是 0」与「没有单价表」是两件事,折成同一个 `$0` 就是在说谎。
479
+ * - cache 命中率 = 读 /(读 + 写 + 未命中输入)。**分母含缓存写入**:只算读除以输入会让
480
+ * 「刚写了一大块缓存」的那次 run 显示成 100% 命中 —— 那正是最容易误读的时刻。
481
+ * - 轮次/工具数**从 span 与事件里数**,不读 `AgentRunResult.iterations`:后者不在 trace 上
482
+ * (`engine/loop.ts` 只把它写进返回值),而面板手上只有 trace。数出来的口径写在 title 里。
483
+ */
484
+ export function runLedger(trace, budget) {
485
+ const items = [];
486
+ if (!trace)
487
+ return items;
488
+ const spans = Array.isArray(trace.spans) ? trace.spans : [];
489
+ const turns = spans.filter((s) => s && s.kind === 'llm.turn').length;
490
+ if (turns > 0)
491
+ items.push({
492
+ key: '轮次',
493
+ value: `${turns} 轮`,
494
+ title: '按 llm.turn span 数(一次模型往返 = 一轮)',
495
+ });
496
+ let toolCalls = 0;
497
+ for (const s of spans) {
498
+ for (const ev of s?.events ?? []) {
499
+ if (ev && ev.name === 'tool.input')
500
+ toolCalls += 1;
501
+ }
502
+ }
503
+ if (toolCalls > 0) {
504
+ items.push({
505
+ key: '工具',
506
+ value: `${fmtCount(toolCalls)} 次`,
507
+ title: '按 tool.input 事件数(普通工具不建 span)',
508
+ });
509
+ }
510
+ const errors = spans.filter((s) => s && s.status === 'error').length;
511
+ if (errors > 0) {
512
+ items.push({ key: '失败', value: `${fmtCount(errors)} 个 span`, tone: 'err' });
513
+ }
514
+ const u = trace.totalUsage ?? null;
515
+ if (u) {
516
+ const inTok = ledNum(u.inputTokens);
517
+ const outTok = ledNum(u.outputTokens);
518
+ if (inTok + outTok > 0) {
519
+ items.push({ key: 'in / out', value: `${fmtCount(inTok)} / ${fmtCount(outTok)}` });
520
+ }
521
+ const cr = ledNum(u.cacheReadTokens);
522
+ const cc = ledNum(u.cacheCreationTokens);
523
+ const miss = inTok;
524
+ const base = cr + cc + miss;
525
+ if (cr + cc > 0 && base > 0) {
526
+ const rate = Math.round((cr / base) * 100);
527
+ items.push({
528
+ key: 'cache',
529
+ value: `${fmtCount(cr)} 读 · ${rate}% 命中`,
530
+ title: `缓存读 ${cr} / 缓存写 ${cc} / 未命中输入 ${miss};命中率 = 读 ÷(读 + 写 + 未命中)`,
531
+ });
532
+ }
533
+ // 成本:只有框架给得出估算时才显示(无公开单价的服务商给不出 —— 那是「不知道」,
534
+ // 不是「花了 0 元」)
535
+ if (typeof u.costEstimate === 'number' && Number.isFinite(u.costEstimate)) {
536
+ const cap = ledNum(budget?.maxCostUsd);
537
+ items.push({
538
+ key: '成本',
539
+ value: cap > 0 ? `$${u.costEstimate.toFixed(4)} / $${cap}` : `$${u.costEstimate.toFixed(4)}`,
540
+ title: '由 token × 单价估算(框架的总账,不是账单)',
541
+ });
542
+ }
543
+ }
544
+ const root = spans.find((s) => s && s.kind === 'run') ?? spans[0];
545
+ const model = root?.attributes?.model;
546
+ if (typeof model === 'string' && model.length > 0)
547
+ items.push({ key: '模型', value: model });
548
+ return items;
549
+ }
550
+ /**
551
+ * 一条 run 是否命中关键词。大小写不敏感、纯子串(不做正则:`(` 之类会让输入直接炸)。
552
+ *
553
+ * 搜的字段包含 `stopReasonText`(中文短语)—— 用户想找「撞预算那条」时会打「预算」,
554
+ * 而数据里存的是 `budget_exceeded`。只搜原文等于逼人记住标识符。
555
+ */
556
+ export function matchRunQuery(r, q) {
557
+ const needle = q.trim().toLowerCase();
558
+ if (needle.length === 0)
559
+ return true;
560
+ const fields = [
561
+ r.note?.prompt ?? '',
562
+ r.name ?? '',
563
+ r.note?.workdir ?? '',
564
+ r.stopReason ?? '',
565
+ stopReasonText(r.stopReason ?? null),
566
+ r.traceId,
567
+ ];
568
+ return fields.some((f) => String(f).toLowerCase().includes(needle));
569
+ }
570
+ /**
571
+ * 列表过滤:关键词 **且**(可选)「只看异常」。
572
+ *
573
+ * 「异常」= `stopReasonClass !== 'ok'`(失败 / 中止 / 等审批三种都算)。为什么这三种都算:
574
+ * 用户点「只看异常」时想找的是「没正常跑完的那些」,而中止与挂起恰恰是最需要回看的两类 ——
575
+ * 只过滤 `ok === false` 会把挂起的 run(trace 状态是 ok)漏掉。
576
+ */
577
+ export function filterRuns(runs, q, opts = {}) {
578
+ const onlyAbnormal = opts.onlyAbnormal === true;
579
+ return runs.filter((r) => {
580
+ if (onlyAbnormal && stopReasonClass({ ok: r.ok, stopReason: r.stopReason ?? null }) === 'ok') {
581
+ return false;
582
+ }
583
+ return matchRunQuery(r, q);
584
+ });
585
+ }
586
+ // ---------- ⑦ 深链 / 重跑 ----------
587
+ /**
588
+ * `location.search` → 要打开的 run id(`?run=<traceId>`)。
589
+ *
590
+ * 解析失败 / 没有这个参数 / 空串一律 `null`(= 照旧打开最新那条)。不抛:一个手改坏的
591
+ * URL 不该让面板白屏。
592
+ */
593
+ export function runIdFromSearch(search) {
594
+ const s = String(search ?? '').trim();
595
+ if (s.length === 0)
596
+ return null;
597
+ let params;
598
+ try {
599
+ params = new URLSearchParams(s.startsWith('?') ? s.slice(1) : s);
600
+ }
601
+ catch {
602
+ return null;
603
+ }
604
+ const id = (params.get('run') ?? '').trim();
605
+ return id.length > 0 ? id : null;
606
+ }
607
+ /**
608
+ * 「重跑」要回填的那四个输入(`RunNote` 里全都有 —— 这正是它值得存的原因)。
609
+ *
610
+ * 三条口径:
611
+ * - **不自动提交**:回填之后由用户按运行。一个自己跑起来的按钮会在误触时花钱。
612
+ * - 能力选择**过滤掉已失效的 token**(能力被删过),过滤后为空则回落全量 ——
613
+ * `toolSources: []` 在框架侧是「每个 provider 都成了孤儿」的纯陷阱值。
614
+ * - `note` 缺失(trace 不是 dev 环起的 / 已被淘汰)⇒ `null`,调用方负责说「这条 run 没有
615
+ * 开发期元信息,重跑不了」。不猜一个空的 prompt 出来。
616
+ */
617
+ export function rerunFromNote(note, all) {
618
+ if (!note)
619
+ return null;
620
+ const picked = note.toolSources === null || note.toolSources === undefined
621
+ ? [...all]
622
+ : filterSelected(note.toolSources, all);
623
+ return {
624
+ prompt: typeof note.prompt === 'string' ? note.prompt : '',
625
+ workdir: typeof note.workdir === 'string' ? note.workdir : '',
626
+ toolSources: picked.length > 0 ? picked : [...all],
627
+ multiTurn: note.multiTurn === true,
628
+ };
629
+ }
415
630
  // ---------- 路径显示 ----------
416
631
  /**
417
632
  * 把绝对路径压短给面板看(`~/proj/a`)。
@@ -597,3 +812,55 @@ export function collapseDecision(text, opts = {}) {
597
812
  : '';
598
813
  return { collapsed, lines, chars, hint };
599
814
  }
815
+ // ---------- ④ 右栏三块的标题栏读数:**收起不等于失明** ----------
816
+ /**
817
+ * 右栏三块(调用树 / 能力排行 / 回复)都能折叠(2026-11-12 ref-panel-panes)。
818
+ *
819
+ * 折叠后标题栏是这一块**唯一**还看得见的东西 ⇒ 它必须自己回答「值不值得展开」。
820
+ * 这不是装饰:把整块收起来却一个数都不留,用户就只能在「占屏」与「失明」之间二选一 ——
821
+ * 那恰好是这次改动之前的状态(三块挤在一列,谁都不够高)。
822
+ *
823
+ * 三条都是**纯格式化**(不量 DOM、不读时钟、不看窗口),所以它们进得了单测;
824
+ * 面板那边只负责把返回值写进 `textContent`。
825
+ */
826
+ function fmtCount(n) {
827
+ const v = Number.isFinite(n) && n > 0 ? n : 0;
828
+ if (v < 1000)
829
+ return String(v);
830
+ const k = v / 1000;
831
+ // 一万以上不保留小数:标题栏只有一行,`12.3k` 比 `12k` 多不出信息
832
+ return `${k < 10 ? k.toFixed(1) : String(Math.round(k))}`.replace(/\.0$/, '') + 'k';
833
+ }
834
+ /** 调用树:`58 span`。span 数是这一块「有没有东西看」的最小信号,也随在飞帧增长 */
835
+ export function traceMeta(spanCount) {
836
+ return `${fmtCount(spanCount)} span`;
837
+ }
838
+ /**
839
+ * 能力排行:`7 项 · 24 次 · 2 失败`。
840
+ *
841
+ * 失败数**只在真的有人失败时**出现 —— 恒挂一个「0 失败」等于给每一行加噪声,
842
+ * 与 `collapseDecision` 的 `hint` 同一条纪律(空的话就别占位置)。
843
+ * `errors` 的语义是**工具/护栏报错次数**(`summarizeTrace` 的账),不是「有几个能力出错」。
844
+ */
845
+ export function summaryMeta(rows) {
846
+ if (!Array.isArray(rows) || rows.length === 0)
847
+ return '0 项';
848
+ let calls = 0;
849
+ let errors = 0;
850
+ for (const r of rows) {
851
+ calls += Number.isFinite(r?.calls) ? r.calls : 0;
852
+ errors += Number.isFinite(r?.errors) ? r.errors : 0;
853
+ }
854
+ const head = `${rows.length} 项 · ${fmtCount(calls)} 次`;
855
+ return errors > 0 ? `${head} · ${fmtCount(errors)} 失败` : head;
856
+ }
857
+ /**
858
+ * 回复:`32 行 · 1.4k 字符`。
859
+ *
860
+ * 行/字符数的算法**复用 `collapseDecision`**(同一个 `split('\n')` 口径)——
861
+ * 标题栏说「32 行」而正文折叠控件说「共 31 行」是最难查的一类不一致。
862
+ */
863
+ export function replyMeta(text) {
864
+ const d = collapseDecision(text);
865
+ return d.lines > 1 ? `${d.lines} 行 · ${fmtCount(d.chars)} 字符` : `${fmtCount(d.chars)} 字符`;
866
+ }
@@ -11,10 +11,10 @@
11
11
  "typecheck": "tsc --noEmit -p tsconfig.json"
12
12
  },
13
13
  "dependencies": {
14
- "@migor/agentia": "^0.10.8"
14
+ "@migor/agentia": "^0.10.10"
15
15
  },
16
16
  "devDependencies": {
17
- "@migor/cli": "^0.10.8",
17
+ "@migor/cli": "^0.10.10",
18
18
  "tsx": "^4.19.0",
19
19
  "typescript": "^7.0.2",
20
20
  "@types/node": "^22.0.0"
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@migor/cli",
3
- "version": "0.10.8",
3
+ "version": "0.10.10",
4
4
  "description": "CLI for the Agentia framework: scaffold a project, generate a capability, and open a local trace inspector for runs.",
5
5
  "type": "module",
6
6
  "bin": {