@migor/cli 0.10.3 → 0.10.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/view.js ADDED
@@ -0,0 +1,361 @@
1
+ /**
2
+ * `agentia view <trace.jsonl>` —— 落盘 trace → **单文件自包含 HTML**(无外链、离线可开、可转发)。
3
+ *
4
+ * 补的是「事后看一棵树」这一整段:`agentia dev` 的面板是**在飞**的(run 列表是内存 ring,
5
+ * 重启即空),而落盘 / 生产的 trace 此前只有文本侧(`report` 排行 / `diff` 差异 /
6
+ * `harvest` / `export`)—— 没有一处能把它**画出来**。业界 2026 已收敛到同一形态
7
+ * (agentskit / telltrace / claude-tap / AgentCanvas 全是「文件 → 单文件 HTML」)。
8
+ *
9
+ * 形态上的三条硬约束(缺一条就不是这个形态):
10
+ * 1. **零外链**:bundle 与 CSS **内联**,没有 server、没有 CDN ⇒ 双击就能开、转发即完整。
11
+ * 2. **安全**:trace 含模型输出 = 不可信输入 ⇒ 内联 JSON 必须转义 `<`(防 `</script>` 提前闭合
12
+ * 注入脚本),所有用户可控文本(文件名等)进 HTML 前必须 esc。
13
+ * 3. **默认脱敏**:产物的默认命运是「被转发」⇒ 默认 `redactTrace`,且产物里**可见地**标明
14
+ * 「已脱敏 · best-effort」。`--raw` 关闭时 stderr 出声 + 产物打水印(对齐 LangSmith
15
+ * 分享页那条「确认没有敏感信息」的纪律)。
16
+ *
17
+ * ⚠️ **不许静默截断**:`--limit` 超了必须明说(stdout + 产物 banner 各一处)——
18
+ * 「少给了还不说」会让看报告的人以为文件里就这些,是本仓最忌的形状。
19
+ *
20
+ * ⚠️ **截断取哪一端由落盘形态决定,不是审美**:`.agentia/traces.jsonl` 是**只增不减**
21
+ * (dev-runner 明写「不轮转、不清空」),最新的一次 run 在**文件末尾**。所以 `--limit`
22
+ * 缺省取**最近** N 条 —— 取最早那种写法在长跑 dev 环里等于「想复盘刚才那次,给你今早第一批」。
23
+ * 要从头看(如排查首次出现的问题)用 `--head`。同一条纪律:去重时**位置也要跟着最后写入**
24
+ * (`Map` 原地 `set` 不移动位置,顺序会停在「首次出现」,与「最近」相矛盾)。
25
+ */
26
+ import { spawn } from 'node:child_process';
27
+ import { existsSync } from 'node:fs';
28
+ import { readFile, writeFile } from 'node:fs/promises';
29
+ import { basename, dirname, extname, join, resolve } from 'node:path';
30
+ import { extractTrace } from './report.js';
31
+ import { activePresetNames, redactTrace } from './redact.js';
32
+ export const USAGE = '用法:agentia view <trace.jsonl> [-o <out.html>] [--open] [--raw] [--limit N] [--head] [--trace-id <id>] [--redact-key <字段名>]';
33
+ /**
34
+ * 默认一次最多内联多少条 trace(超了明说)。
35
+ * ⚠️ 方向是**最近 N 条**:落盘文件只增不减,最新在末尾(见文件头注)。
36
+ */
37
+ export const DEFAULT_LIMIT = 50;
38
+ /** 默认不带 `--limit` 时的兜底上限(字节)—— 长对话的 trace 可能很大,防止产出几百 MB 的 HTML */
39
+ const MAX_INLINE_BYTES = 32 * 1024 * 1024;
40
+ function esc(s) {
41
+ return s.replace(/[&<>"']/g, (c) => `&#${c.charCodeAt(0)};`);
42
+ }
43
+ /**
44
+ * 内联进 `<script>` 的 JSON:把 `<` 转成 `\u003c`。
45
+ * 没有这一步,trace 里出现 `</script>` 就会**提前闭合脚本块**,其后内容被当 HTML 解析
46
+ * ⇒ 任意脚本注入。JSON 里 `\u003c` 解析回来仍是 `<`,对数据零影响。
47
+ */
48
+ function inlineJson(value) {
49
+ return JSON.stringify(value).replace(/</g, '\\u003c');
50
+ }
51
+ /** 打开浏览器(失败要出声 —— 「你说 --open 了我没打开」不能是静默的) */
52
+ function openInBrowser(file) {
53
+ // ⚠️ Windows 的 `start` 是 cmd 内建命令(不是 exe)⇒ 直接 spawn('start') 必 ENOENT。
54
+ // 必须走 `cmd /c start "" <file>`:那个空串是**窗口标题占位**,缺了它 `start` 会把
55
+ // 带空格的路径当成标题、什么也不打开(经典的静默失败)。
56
+ const [cmd, argv] = process.platform === 'darwin'
57
+ ? ['open', [file]]
58
+ : process.platform === 'win32'
59
+ ? ['cmd', ['/c', 'start', '', file]]
60
+ : ['xdg-open', [file]];
61
+ const child = spawn(cmd, argv, { stdio: 'ignore', detached: true });
62
+ child.on('error', () => {
63
+ console.error(`[agentia] --open 失败:找不到 "${cmd}",请手动打开 ${file}`);
64
+ });
65
+ child.unref();
66
+ }
67
+ /** 读构建期拷进 dist/inspector/ 的 trace-view 单文件 bundle(同源派生,不是第二份实现) */
68
+ async function readAsset(rel) {
69
+ try {
70
+ return await readFile(new URL(`./inspector/${rel}`, import.meta.url), 'utf8');
71
+ }
72
+ catch (e) {
73
+ throw new Error(`缺少 CLI 资源(inspector/${rel}):${e.message};请先 \`npm run build:cli\``);
74
+ }
75
+ }
76
+ /**
77
+ * 把 bundle 尾部那句 `export { ... };` 剥掉 —— 内联进**普通 `<script>`**(不是 module):
78
+ * `file://` 下 module script 走 CORS 那条路,双击打开有失败风险,普通 script 没有。
79
+ * ⚠️ 剥不干净要**响亮报错**:残留 `export` / `import` 在普通 script 里是 SyntaxError
80
+ * ⇒ 整页白屏,而 CLI 已经打印了「已生成」。构建期还有一道更宽的闸
81
+ * (`bundle.mjs` 末尾按 classic script 编译整段 body),这里是不让旧产物溜过去的第二道。
82
+ */
83
+ function stripExports(bundle) {
84
+ const out = bundle.replace(/\nexport\s*\{[^}]*\};\s*$/, '\n');
85
+ if (/\bexport\s*\{/.test(out) || /^\s*(?:export|import)\s/m.test(out)) {
86
+ throw new Error('trace-view bundle 里仍有 export / import 语句 —— 内联进普通 <script> 会是 SyntaxError' +
87
+ '(整页白屏);请检查 packages/trace-view/scripts/bundle.mjs 的产物形状');
88
+ }
89
+ return out;
90
+ }
91
+ function buildHtml(opts) {
92
+ const { traces, css, bundle, source, redacted, presetNames, extraKeys, truncated, rangeWord } = opts;
93
+ /* ⚠️ 「已脱敏」必须**说清覆盖什么**:只写「覆盖 6 类」读者无从判断自己的敏感字段在不在内
94
+ * —— 实测里 `password=hunter2` 就不在任何预设里(预设只管凭证/PII 的固定形态)。
95
+ * 与其偷偷加规则(会与 examples/observability 的 6 类漂移),不如把类名与边界都摆出来。 */
96
+ /* ⚠️ 两档脱敏是**两种机制**,别写成一档:`presets` 是「值内凭证形态」的正则替换,
97
+ * `--redact-key` 是「按字段名」整字段抹平(不看值)。实测里 `password=hunter2` 落在
98
+ * 字符串值内部 ⇒ `--redact-key password` 拦不住它(除非字段名恰好叫 password)。
99
+ * 与其让 banner 暗示「加了 key 就都解决了」,不如把两档各自能干什么摆清楚。 */
100
+ const banner = redacted
101
+ ? `已脱敏 · best-effort · 覆盖 ${presetNames.length} 类值内凭证形态:<code>${esc(presetNames.join(' / '))}</code>` +
102
+ (extraKeys.length > 0
103
+ ? `;另按字段名整字段抹平:<code>${esc(extraKeys.join(' / '))}</code>`
104
+ : '') +
105
+ ' —— <b><code>password=</code> 这类值内形态不在预设内(要它请用 <code>--redact-key</code> 按字段名处理),不能替代人工检查</b>'
106
+ : '<b>⚠️ 未脱敏(--raw)</b> —— 含完整 prompt 与工具出入参,<b>转发前请自行检查</b>';
107
+ const bannerCls = redacted ? 'ok' : 'warn';
108
+ const truncNote = truncated > 0
109
+ ? `<div class="note">⚠️ 源文件共 ${truncated + traces.length} 条,本页只含${rangeWord} ${traces.length} 条(<code>--limit</code> 调整${rangeWord === '最近' ? ',<code>--head</code> 换从头看' : ''})</div>`
110
+ : '';
111
+ return `<!doctype html>
112
+ <html lang="zh">
113
+ <head>
114
+ <meta charset="utf-8" />
115
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
116
+ <title>agentia trace — ${esc(source)}</title>
117
+ <style>
118
+ ${css}
119
+ </style>
120
+ <style>
121
+ body { margin: 0; font: 13px/1.6 -apple-system, "PingFang SC", "Microsoft YaHei", sans-serif; }
122
+ header { padding: 10px 14px; border-bottom: 1px solid #ddd; display: flex; gap: 10px; align-items: center; flex-wrap: wrap; }
123
+ header .title { font-weight: 600; }
124
+ header .spacer { flex: 1; }
125
+ #banner { padding: 6px 14px; font-size: 12px; }
126
+ #banner.ok { background: #eef7ee; color: #264; }
127
+ #banner.warn { background: #fdf3e2; color: #742; }
128
+ .note { padding: 6px 14px; font-size: 12px; background: #fff8e1; color: #742; }
129
+ #pick { max-width: 30em; }
130
+ main { padding: 12px 14px; }
131
+ code { background: #f2f2f2; padding: 0 3px; }
132
+ </style>
133
+ </head>
134
+ <body>
135
+ <header>
136
+ <span class="title">agentia trace</span>
137
+ <span>${esc(source)}</span>
138
+ <span class="spacer"></span>
139
+ <select id="pick" aria-label="选择 trace"></select>
140
+ </header>
141
+ <div id="banner" class="${bannerCls}">${banner}</div>
142
+ ${truncNote}
143
+ <main><div id="tree"></div></main>
144
+ <script type="application/json" id="agentia-traces">${inlineJson(traces)}</script>
145
+ <script>
146
+ ${stripExports(bundle)}
147
+ </script>
148
+ <script>
149
+ (function () {
150
+ var traces = JSON.parse(document.getElementById('agentia-traces').textContent);
151
+ var pick = document.getElementById('pick');
152
+ var view = createTraceView(document.getElementById('tree'), {});
153
+ traces.forEach(function (t, i) {
154
+ var o = document.createElement('option');
155
+ o.value = String(i);
156
+ o.textContent = (t.traceId ? String(t.traceId).slice(0, 8) : '#') + (i + 1) +
157
+ ' · ' + (t.status || '?') + ' · ' + (t.spans ? t.spans.length : 0) + ' spans';
158
+ pick.appendChild(o);
159
+ });
160
+ function show(i) {
161
+ var t = traces[i];
162
+ if (!t) return;
163
+ playTrace(view, t);
164
+ try { location.hash = String(i); } catch (e) {}
165
+ }
166
+ pick.addEventListener('change', function () { show(Number(pick.value)); });
167
+ var start = 0;
168
+ var h = parseInt(String(location.hash || '').replace('#', ''), 10);
169
+ if (Number.isFinite(h) && traces[h]) start = h;
170
+ pick.value = String(start);
171
+ show(start);
172
+ })();
173
+ </script>
174
+ </body>
175
+ </html>
176
+ `;
177
+ }
178
+ export async function viewCommand(args) {
179
+ let file;
180
+ let out;
181
+ let open = false;
182
+ let raw = false;
183
+ let limit;
184
+ /** 截断取最早 N 条(缺省取最近 N 条,见文件头注) */
185
+ let head = false;
186
+ let traceId;
187
+ /** 业务自有敏感字段(可重复)—— 预设管不到 `password=` 这类,这里是使用者的兜底口子 */
188
+ const extraKeys = [];
189
+ for (let i = 0; i < args.length; i += 1) {
190
+ const a = args[i];
191
+ if (a === '--open') {
192
+ open = true;
193
+ }
194
+ else if (a === '--raw') {
195
+ raw = true;
196
+ }
197
+ else if (a === '--head') {
198
+ head = true;
199
+ }
200
+ else if (a === '-o' || a === '--out') {
201
+ out = args[i + 1];
202
+ if (out === undefined)
203
+ throw new Error(`-o 需要一个文件参数\n${USAGE}`);
204
+ i += 1;
205
+ }
206
+ else if (a === '--limit') {
207
+ const n = Number(args[i + 1]);
208
+ if (args[i + 1] === undefined || !Number.isInteger(n) || n <= 0) {
209
+ throw new Error(`--limit 需要一个正整数\n${USAGE}`);
210
+ }
211
+ limit = n;
212
+ i += 1;
213
+ }
214
+ else if (a === '--trace-id') {
215
+ traceId = args[i + 1];
216
+ if (traceId === undefined)
217
+ throw new Error(`--trace-id 需要一个 traceId\n${USAGE}`);
218
+ i += 1;
219
+ }
220
+ else if (a === '--redact-key') {
221
+ const k = args[i + 1];
222
+ if (k === undefined)
223
+ throw new Error(`--redact-key 需要一个字段名\n${USAGE}`);
224
+ extraKeys.push(k);
225
+ i += 1;
226
+ }
227
+ else if (a.startsWith('--')) {
228
+ throw new Error(`未知参数:${a}\n${USAGE}`);
229
+ }
230
+ else if (file === undefined) {
231
+ file = a;
232
+ }
233
+ else {
234
+ throw new Error(USAGE);
235
+ }
236
+ }
237
+ if (file === undefined)
238
+ throw new Error(USAGE);
239
+ let text;
240
+ try {
241
+ text = await readFile(file, 'utf8');
242
+ }
243
+ catch (e) {
244
+ throw new Error(`读不到文件 ${file}(${e.message})`);
245
+ }
246
+ // 解析 + 按 traceId 去重(append-only 落盘里同一棵树会写多次,同 `report` 的口径:留最后一行)
247
+ // ⚠️ 位置也要跟着「最后一行」:`Map` 对已存在的键原地 `set` **不移动位置** ⇒ 顺序会停在
248
+ // 「首次出现」,与「取最近 N 条」直接矛盾(重跑补对的树会被挤到很前面而落选)。先 delete 再 set。
249
+ const byId = new Map();
250
+ let badLines = 0;
251
+ let total = 0;
252
+ let anonSeq = 0;
253
+ for (const line of text.split('\n')) {
254
+ const s = line.trim();
255
+ if (!s)
256
+ continue;
257
+ let parsed;
258
+ try {
259
+ parsed = extractTrace(JSON.parse(s));
260
+ }
261
+ catch {
262
+ parsed = null;
263
+ }
264
+ if (!parsed) {
265
+ badLines += 1;
266
+ continue;
267
+ }
268
+ total += 1;
269
+ anonSeq += 1;
270
+ const key = parsed.traceId ?? `__anon_${anonSeq}`;
271
+ if (byId.has(key))
272
+ byId.delete(key);
273
+ byId.set(key, parsed);
274
+ }
275
+ if (byId.size === 0) {
276
+ throw new Error(`${file} 里没有可识别的 trace(支持裸 Trace 或含 result.trace / trace 的记录)` +
277
+ (badLines > 0 ? `;另有 ${badLines} 行无法解析` : ''));
278
+ }
279
+ let traces = [...byId.values()];
280
+ if (traceId !== undefined) {
281
+ const hit = traces.filter((t) => t.traceId === traceId);
282
+ if (hit.length === 0) {
283
+ throw new Error(`${file} 里没有 traceId 为 ${traceId} 的 trace(共 ${traces.length} 条)`);
284
+ }
285
+ traces = hit;
286
+ }
287
+ const cap = limit ?? DEFAULT_LIMIT;
288
+ /** 文案里说清取的是哪一端(不许一律写「前 N 条」—— 那正是本仓最忌的「少给了还说不准」) */
289
+ const rangeWord = head ? '最早' : '最近';
290
+ let truncated = 0;
291
+ if (traces.length > cap) {
292
+ truncated = traces.length - cap;
293
+ traces = head ? traces.slice(0, cap) : traces.slice(-cap);
294
+ }
295
+ // 字节兜底:长对话的 trace 可能极大,静默产出几百 MB 的 HTML 不如响亮报错。
296
+ // 保留方向与 --limit 一致:默认保最近的(丢最早),--head 保最早的。
297
+ let bytes = traces.reduce((s, t) => s + JSON.stringify(t).length, 0);
298
+ if (bytes > MAX_INLINE_BYTES) {
299
+ const ordered = head ? traces : [...traces].reverse();
300
+ const keep = [];
301
+ bytes = 0;
302
+ for (const t of ordered) {
303
+ const size = JSON.stringify(t).length;
304
+ if (bytes + size > MAX_INLINE_BYTES)
305
+ break;
306
+ keep.push(t);
307
+ bytes += size;
308
+ }
309
+ truncated += traces.length - keep.length;
310
+ traces = head ? keep : keep.reverse();
311
+ console.error(`[agentia] 内联体积超过 ${Math.floor(MAX_INLINE_BYTES / 1024 / 1024)} MiB,只保留${rangeWord} ${traces.length} 条(--limit 调整)`);
312
+ }
313
+ if (traces.length === 0)
314
+ throw new Error('没有可内联的 trace(单条就超过体积上限,请用 --limit 1 或拆分文件)');
315
+ if (!raw)
316
+ traces = traces.map((t) => redactTrace(t, { keys: extraKeys }));
317
+ const presetNames = raw ? [] : activePresetNames(undefined);
318
+ const [css, bundle] = await Promise.all([
319
+ readAsset('trace-view.css'),
320
+ readAsset('trace-view.bundle.js'),
321
+ ]);
322
+ const html = buildHtml({
323
+ traces,
324
+ css,
325
+ bundle,
326
+ source: file,
327
+ redacted: !raw,
328
+ presetNames,
329
+ extraKeys: raw ? [] : extraKeys,
330
+ truncated,
331
+ rangeWord,
332
+ });
333
+ // 缺省产物放「人看得见的地方」:输入在点目录里(`agentia dev` 的缺省落盘就是
334
+ // `.agentia/traces.jsonl`)时**上跳到最近的非点目录**再写 —— 别把 HTML 藏进隐藏目录:
335
+ // Finder 里看不见,也容易被 `.gitignore` 的 `.agentia/` 一并吞掉。
336
+ // 其余情况写在输入旁边(同 report 的直觉)。
337
+ const absIn = resolve(file);
338
+ let outDir = dirname(absIn);
339
+ while (basename(outDir).startsWith('.') && dirname(outDir) !== outDir)
340
+ outDir = dirname(outDir);
341
+ const baseName = `${basename(absIn, extname(absIn))}.html`;
342
+ const target = out ?? join(outDir, baseName);
343
+ if (resolve(target) === absIn) {
344
+ throw new Error(`产物路径不能是输入文件本身(${target})—— 那会把 trace 数据覆盖成 HTML`);
345
+ }
346
+ const overwriting = existsSync(target);
347
+ await writeFile(target, html, 'utf8');
348
+ console.log(`已生成 ${target}(${traces.length} 条 trace,${(html.length / 1024).toFixed(1)} KiB,单文件自包含)` +
349
+ (overwriting ? ' —— 已覆盖同名文件' : ''));
350
+ if (truncated > 0) {
351
+ console.log(`⚠️ 源文件共 ${total} 行记录 / ${truncated + traces.length} 条 trace,本页只含${rangeWord} ${traces.length} 条 —— 用 --limit 调整`);
352
+ }
353
+ if (badLines > 0)
354
+ console.log(`⚠️ 另有 ${badLines} 行无法解析(已跳过)`);
355
+ if (raw) {
356
+ console.error('⚠️ --raw:产物未脱敏,含完整 prompt 与工具出入参 —— 转发前请自行检查');
357
+ }
358
+ if (open)
359
+ openInBrowser(target);
360
+ return 0;
361
+ }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@migor/cli",
3
- "version": "0.10.3",
4
- "description": "Agentia 框架命令行工具:create 脚手架 + g 能力生成 + dev 本地可观测面板(trace inspector)",
3
+ "version": "0.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": {
7
7
  "agentia": "./dist/cli.js"
@@ -13,17 +13,25 @@
13
13
  "dist",
14
14
  "CHANGELOG.md"
15
15
  ],
16
- "repository": "retrychx/agentia",
16
+ "repository": "migoric/agentia",
17
17
  "homepage": "https://agentia-web.pages.dev",
18
18
  "bugs": {
19
- "url": "https://github.com/retrychx/agentia/issues"
19
+ "url": "https://github.com/migoric/agentia/issues"
20
20
  },
21
21
  "license": "MIT",
22
22
  "keywords": [
23
23
  "agentia",
24
24
  "agent",
25
+ "ai-agent",
26
+ "ai-agent-framework",
25
27
  "cli",
26
- "scaffold"
28
+ "cli-tool",
29
+ "scaffold",
30
+ "generator",
31
+ "trace-viewer",
32
+ "observability",
33
+ "devtools",
34
+ "typescript"
27
35
  ],
28
36
  "engines": {
29
37
  "node": ">=18"
@@ -37,7 +45,7 @@
37
45
  "prepublishOnly": "node ../../scripts/check-release.mjs && npm run build"
38
46
  },
39
47
  "devDependencies": {
40
- "@types/node": "^26.5.1",
48
+ "@types/node": "^26.6.5",
41
49
  "typescript": "^7.0.2"
42
50
  }
43
51
  }