@migor/cli 0.2.1 → 0.2.2

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.
@@ -0,0 +1,157 @@
1
+ <!doctype html>
2
+ <html lang="zh-CN">
3
+ <head>
4
+ <meta charset="UTF-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6
+ <title>Agentia Inspector</title>
7
+ <link rel="stylesheet" href="/trace-view.css" />
8
+ <style>
9
+ :root {
10
+ --ice: #34cbe0;
11
+ --faint: #71717a;
12
+ --text: #e4e4e7;
13
+ --muted: #a1a1aa;
14
+ --border: #27272a;
15
+ --bg: #0b0b0d;
16
+ --card: #111113;
17
+ --mono: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
18
+ }
19
+ * { box-sizing: border-box; }
20
+ body {
21
+ margin: 0;
22
+ background: var(--bg);
23
+ color: var(--text);
24
+ font: 13px/1.5 -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", sans-serif;
25
+ }
26
+ header {
27
+ display: flex; align-items: baseline; gap: 12px;
28
+ padding: 14px 20px; border-bottom: 1px solid var(--border);
29
+ }
30
+ header h1 { margin: 0; font-size: 14px; font-weight: 600; letter-spacing: .2px; }
31
+ header .sub { color: var(--faint); font-size: 12px; }
32
+ header .live { margin-left: auto; color: var(--faint); font-size: 12px; }
33
+ header .live b { color: var(--ice); font-weight: 500; }
34
+ main { display: grid; grid-template-columns: 300px minmax(0, 1fr); gap: 14px; padding: 14px 20px; }
35
+ aside, section {
36
+ background: var(--card); border: 1px solid var(--border); border-radius: 6px;
37
+ }
38
+ aside { overflow: hidden auto; max-height: calc(100vh - 90px); }
39
+ section { padding: 14px; min-height: 240px; font-family: var(--mono); font-size: 12px; overflow: auto; }
40
+ .run {
41
+ display: block; width: 100%; text-align: left; cursor: pointer;
42
+ background: none; border: 0; border-bottom: 1px solid var(--border);
43
+ color: var(--text); padding: 10px 12px; font: inherit;
44
+ }
45
+ .run:hover { background: #17171a; }
46
+ .run.active { background: #17171a; box-shadow: inset 2px 0 0 var(--ice); }
47
+ .run .r1 { display: flex; align-items: center; gap: 6px; }
48
+ .run .dot { width: 6px; height: 6px; border-radius: 50%; background: var(--ice); flex: none; }
49
+ .run.err .dot { background: #f7768e; }
50
+ .run .name { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
51
+ .run .r2 { margin-top: 3px; color: var(--faint); font-size: 11px; font-family: var(--mono); }
52
+ .empty { color: var(--faint); padding: 16px; }
53
+ #ttl { color: var(--faint); margin-bottom: 10px; font-family: var(--mono); font-size: 11.5px; }
54
+ .usage { display: flex; gap: 16px; margin-top: 12px; color: var(--muted); font-family: var(--mono); font-size: 12px; }
55
+ .usage b { color: var(--text); font-weight: 500; }
56
+ </style>
57
+ </head>
58
+ <body>
59
+ <header>
60
+ <h1>Agentia Inspector</h1>
61
+ <span class="sub">每次 run 的调用树与能力执行</span>
62
+ <span class="live" id="live">等待 run…</span>
63
+ </header>
64
+ <main>
65
+ <aside id="runs"><div class="empty">还没有 run。触发一次 agent 调用即可。</div></aside>
66
+ <section>
67
+ <div id="ttl"></div>
68
+ <div id="trace"><div class="tr-empty">// 选一个 run 查看调用树</div></div>
69
+ <!-- 能力排行(G2):哪个能力慢 / 贵 / 爱失败 —— 调优从这里下手 -->
70
+ <div id="summary"></div>
71
+ <div class="usage" id="usage" hidden>
72
+ <span>in <b id="u-in">0</b></span>
73
+ <span>out <b id="u-out">0</b></span>
74
+ </div>
75
+ </section>
76
+ </main>
77
+
78
+ <script type="module">
79
+ import { createTraceView, playTrace, summarizeTrace, renderSummary } from './index.js';
80
+
81
+ const runsEl = document.getElementById('runs');
82
+ const traceEl = document.getElementById('trace');
83
+ const summaryEl = document.getElementById('summary');
84
+ const ttlEl = document.getElementById('ttl');
85
+ const liveEl = document.getElementById('live');
86
+ const usageEl = document.getElementById('usage');
87
+ const view = createTraceView(traceEl, {
88
+ // 无公开单价 → 不估算成本;只展示 token
89
+ usage: { in: document.getElementById('u-in'), out: document.getElementById('u-out') },
90
+ });
91
+
92
+ let current = null;
93
+ let list = [];
94
+
95
+ const fmtMs = (ms) => (ms >= 1000 ? (ms / 1000).toFixed(2) + 's' : ms + 'ms');
96
+ const fmtNum = (n) => Number(n || 0).toLocaleString('en-US');
97
+ const ago = (t) => {
98
+ const s = Math.max(0, Math.round((Date.now() - t) / 1000));
99
+ return s < 60 ? s + 's 前' : Math.round(s / 60) + 'm 前';
100
+ };
101
+
102
+ function renderList() {
103
+ if (!list.length) {
104
+ runsEl.innerHTML = '<div class="empty">还没有 run。触发一次 agent 调用即可。</div>';
105
+ return;
106
+ }
107
+ runsEl.innerHTML = '';
108
+ for (const r of list) {
109
+ const b = document.createElement('button');
110
+ b.className = 'run' + (r.ok ? '' : ' err') + (r.traceId === current ? ' active' : '');
111
+ b.innerHTML =
112
+ `<span class="r1"><span class="dot"></span><span class="name"></span></span>` +
113
+ `<span class="r2">${fmtMs(r.ms)} · ${fmtNum(r.tokens)} tok · ${ago(r.startedAt)}</span>`;
114
+ b.querySelector('.name').textContent = r.name;
115
+ b.addEventListener('click', () => open(r.traceId));
116
+ runsEl.appendChild(b);
117
+ }
118
+ }
119
+
120
+ async function refresh() {
121
+ const res = await fetch('/api/runs');
122
+ list = await res.json();
123
+ renderList();
124
+ }
125
+
126
+ async function open(id) {
127
+ const res = await fetch('/api/runs/' + encodeURIComponent(id));
128
+ if (!res.ok) return;
129
+ const trace = await res.json();
130
+ current = id;
131
+ ttlEl.textContent = trace.traceId;
132
+ usageEl.hidden = false;
133
+ playTrace(view, trace);
134
+ summaryEl.innerHTML = renderSummary(summarizeTrace(trace));
135
+ renderList();
136
+ }
137
+
138
+ // 打开最新一条(首屏/新 run 到达时)
139
+ async function openLatest() {
140
+ await refresh();
141
+ if (list.length && !current) await open(list[0].traceId);
142
+ }
143
+
144
+ // SSE:新 run 到达即刷新列表并切到最新
145
+ const es = new EventSource('/stream');
146
+ es.onmessage = async (m) => {
147
+ const s = JSON.parse(m.data);
148
+ liveEl.innerHTML = `最新 run:<b>${s.name}</b> · ${fmtMs(s.ms)}`;
149
+ await refresh();
150
+ await open(s.traceId);
151
+ };
152
+ es.onerror = () => { liveEl.textContent = '连接断开(dev 进程还在吗?)'; };
153
+
154
+ openLatest();
155
+ </script>
156
+ </body>
157
+ </html>
@@ -0,0 +1,31 @@
1
+ /**
2
+ * inspector preload —— 由 dev.ts 用 `NODE_OPTIONS=--import <本文件>` 注入到【子进程】。
3
+ *
4
+ * 为什么需要它:`agentia dev` 用 tsx 起的是子进程,而 trace sink 必须在子进程里注册
5
+ * (框架的 registerDefaultTraceSink 是进程内全局注册表)。CLI 无法在子进程外挂上,
6
+ * 于是用 preload 在应用代码 import 之前抢先注册。
7
+ *
8
+ * 关键:必须从【用户项目】(process.cwd())解析 @migor/agentia,而不是从 CLI 的
9
+ * 安装位置 —— 否则拿到的是另一个模块实例,注册表不共享,sink 静默失效。
10
+ *
11
+ * 失败一律降级:打印一行告警即可,绝不阻断 dev。
12
+ */
13
+ import { createRequire } from 'node:module';
14
+ import { join } from 'node:path';
15
+ import { pathToFileURL } from 'node:url';
16
+ import { createInspectSink } from './inspector-sink.js';
17
+ const port = Number(process.env.AGENTIA_INSPECT_PORT || 0);
18
+ if (port > 0) {
19
+ try {
20
+ const req = createRequire(join(process.cwd(), 'package.json'));
21
+ const entry = req.resolve('@migor/agentia');
22
+ const mod = (await import(pathToFileURL(entry).href));
23
+ if (typeof mod.registerDefaultTraceSink !== 'function') {
24
+ throw new Error('@migor/agentia 未导出 registerDefaultTraceSink(版本过旧?)');
25
+ }
26
+ mod.registerDefaultTraceSink(createInspectSink({ port }));
27
+ }
28
+ catch (e) {
29
+ console.warn(`[agentia] inspector 未挂载:${e.message}`);
30
+ }
31
+ }
@@ -0,0 +1,18 @@
1
+ export function createInspectSink(opts) {
2
+ const url = `http://${opts.host ?? '127.0.0.1'}:${opts.port}/ingest`;
3
+ return {
4
+ async export(trace) {
5
+ try {
6
+ // Node 18+ 全局 fetch;这里不做降级 —— dev 场景 Node 版本由 dev.ts 统一把关
7
+ await fetch(url, {
8
+ method: 'POST',
9
+ headers: { 'content-type': 'application/json' },
10
+ body: JSON.stringify(trace),
11
+ });
12
+ }
13
+ catch {
14
+ /* 面板未开 / 已关:静默 */
15
+ }
16
+ },
17
+ };
18
+ }
@@ -0,0 +1,166 @@
1
+ /**
2
+ * agentia dev 的本地 inspector 服务(node:http,零依赖)。
3
+ *
4
+ * 面板展示每次 run 的调用树:左边 run 列表、右边该 run 的能力执行
5
+ * (tool / skill / prompt / subagent 的入参、出参、耗时、token、cache、错误)。
6
+ *
7
+ * trace 由框架侧的 TraceSink 经 `POST /ingest` 投递 —— 注入方式见 dev.ts
8
+ * (NODE_OPTIONS=--import 拉起 inspector-preload,preload 从用户项目解析框架并注册 sink)。
9
+ * 本服务不认识框架类型:入参按结构面(TraceLike)校验,CLI 因此保持零运行时依赖。
10
+ */
11
+ import { createServer } from 'node:http';
12
+ import { readFile } from 'node:fs/promises';
13
+ import { dirname, join } from 'node:path';
14
+ import { fileURLToPath } from 'node:url';
15
+ /** 环形缓冲上限:面板只保证「回看最近 N 条」,不做历史归档 */
16
+ const MAX_RUNS = 50;
17
+ /** 静态资源目录(构建期由 scripts/copy-assets.mjs 就位) */
18
+ const ASSETS = join(dirname(fileURLToPath(import.meta.url)), 'inspector');
19
+ const PAGE = join(dirname(fileURLToPath(import.meta.url)), 'inspector-page.html');
20
+ const STATIC = new Set(['index.js', 'view.js', 'fromTrace.js', 'summary.js', 'trace-view.css']);
21
+ function summarize(t) {
22
+ const spans = t.spans || [];
23
+ const root = spans.find((s) => s.kind === 'run') || spans[0];
24
+ const startedAt = root ? root.startedAt : 0;
25
+ const endedAt = root && root.endedAt != null ? root.endedAt : startedAt;
26
+ const u = t.totalUsage || {};
27
+ const status = t.status || (root && root.status) || 'ok';
28
+ return {
29
+ traceId: t.traceId,
30
+ status,
31
+ name: root ? root.name : 'run',
32
+ startedAt,
33
+ ms: endedAt - startedAt,
34
+ tokens: (u.inputTokens || 0) + (u.outputTokens || 0),
35
+ ok: status !== 'error',
36
+ };
37
+ }
38
+ /**
39
+ * 起 inspector 服务。缺省监听 127.0.0.1 的随机空闲端口(port 0),
40
+ * 便于 dev 命令并发多开互不打架;实际端口经 resolve 的 port 返回。
41
+ */
42
+ export function startInspector(opts = {}) {
43
+ const host = opts.host ?? '127.0.0.1';
44
+ const runs = new Map();
45
+ const order = []; // 到达顺序,用于淘汰与列表排序
46
+ const clients = new Set(); // SSE 订阅者
47
+ const broadcast = (s) => {
48
+ const chunk = `data: ${JSON.stringify(s)}\n\n`;
49
+ for (const c of clients)
50
+ c.write(chunk);
51
+ };
52
+ const json = (res, code, body) => {
53
+ const text = JSON.stringify(body);
54
+ res.writeHead(code, {
55
+ 'content-type': 'application/json; charset=utf-8',
56
+ 'content-length': Buffer.byteLength(text),
57
+ });
58
+ res.end(text);
59
+ };
60
+ const text = (res, code, type, body) => {
61
+ res.writeHead(code, { 'content-type': type, 'content-length': Buffer.byteLength(body) });
62
+ res.end(body);
63
+ };
64
+ const readBody = (req, maxBytes = 8 * 1024 * 1024) => new Promise((resolve, reject) => {
65
+ let size = 0;
66
+ const chunks = [];
67
+ req.on('data', (c) => {
68
+ size += c.length;
69
+ if (size > maxBytes) {
70
+ const err = new Error(`请求 body 过大(上限 ${maxBytes} 字节)`);
71
+ err.statusCode = 413; // 让 handler 的 catch 回 413 而非 500
72
+ reject(err);
73
+ req.destroy();
74
+ return;
75
+ }
76
+ chunks.push(c);
77
+ });
78
+ req.on('end', () => resolve(Buffer.concat(chunks).toString('utf8')));
79
+ req.on('error', reject);
80
+ });
81
+ const handler = async (req, res) => {
82
+ const path = new URL(req.url || '/', `http://${host}`).pathname;
83
+ try {
84
+ if (req.method === 'POST' && path === '/ingest') {
85
+ const trace = JSON.parse(await readBody(req));
86
+ if (!trace || typeof trace.traceId !== 'string') {
87
+ json(res, 400, { error: 'traceId 缺失' });
88
+ return;
89
+ }
90
+ if (!runs.has(trace.traceId)) {
91
+ order.push(trace.traceId);
92
+ if (order.length > MAX_RUNS)
93
+ runs.delete(order.shift());
94
+ }
95
+ runs.set(trace.traceId, trace);
96
+ broadcast(summarize(trace));
97
+ json(res, 200, { ok: true });
98
+ return;
99
+ }
100
+ if (req.method === 'GET' && path === '/api/runs') {
101
+ json(res, 200, order
102
+ .slice()
103
+ .reverse()
104
+ .map((id) => summarize(runs.get(id))));
105
+ return;
106
+ }
107
+ if (req.method === 'GET' && path.startsWith('/api/runs/')) {
108
+ const id = decodeURIComponent(path.slice('/api/runs/'.length));
109
+ const t = runs.get(id);
110
+ if (!t) {
111
+ json(res, 404, { error: 'run 不存在(可能已被淘汰)' });
112
+ return;
113
+ }
114
+ json(res, 200, t);
115
+ return;
116
+ }
117
+ if (req.method === 'GET' && path === '/stream') {
118
+ res.writeHead(200, {
119
+ 'content-type': 'text/event-stream',
120
+ 'cache-control': 'no-cache',
121
+ connection: 'keep-alive',
122
+ });
123
+ res.write(': connected\n\n');
124
+ clients.add(res);
125
+ req.on('close', () => clients.delete(res));
126
+ return;
127
+ }
128
+ if (req.method === 'GET' && (path === '/' || path === '/index.html')) {
129
+ text(res, 200, 'text/html; charset=utf-8', await readFile(PAGE, 'utf8'));
130
+ return;
131
+ }
132
+ if (req.method === 'GET' && STATIC.has(path.slice(1))) {
133
+ const name = path.slice(1);
134
+ const type = name.endsWith('.css')
135
+ ? 'text/css; charset=utf-8'
136
+ : 'text/javascript; charset=utf-8';
137
+ text(res, 200, type, await readFile(join(ASSETS, name), 'utf8'));
138
+ return;
139
+ }
140
+ json(res, 404, { error: 'not found' });
141
+ }
142
+ catch (e) {
143
+ const status = e.statusCode ?? 500;
144
+ json(res, status, { error: e.message });
145
+ }
146
+ };
147
+ const server = createServer((req, res) => {
148
+ void handler(req, res);
149
+ });
150
+ return new Promise((resolve, reject) => {
151
+ server.once('error', reject);
152
+ server.listen(opts.port ?? 0, host, () => {
153
+ const addr = server.address();
154
+ const port = addr && typeof addr === 'object' ? addr.port : (opts.port ?? 0);
155
+ resolve({
156
+ port,
157
+ close: () => new Promise((done) => {
158
+ for (const c of clients)
159
+ c.end();
160
+ clients.clear();
161
+ server.close(() => done());
162
+ }),
163
+ });
164
+ });
165
+ });
166
+ }
package/dist/layout.js ADDED
@@ -0,0 +1,44 @@
1
+ /**
2
+ * 老布局识别与迁移提示。
3
+ *
4
+ * 目录约定改名(2026-09-13)前是项目根的 `units/` + `units.ts`;新约定是四分类目录
5
+ * `src/tools` · `src/skills` · `src/prompts` · `src/subagents` + 注册表 `src/registry.ts`。
6
+ *
7
+ * **运行时零破坏**:`discover` 收的是路径,老项目 `discover: 'units'` 照跑。变的只是
8
+ * `create` / `g` / `doctor` 的**约定** —— 所以这三个命令必须在老项目里**明确提示迁移**,
9
+ * 而不是悄悄在旁边新建一棵 `src/tools/`,让项目里长出两套能力目录。
10
+ */
11
+ import { existsSync } from 'node:fs';
12
+ import { join } from 'node:path';
13
+ import { REGISTRY_PATH } from './templates.js';
14
+ const LEGACY_REGISTRY = 'units.ts';
15
+ const LEGACY_DIR = 'units';
16
+ /**
17
+ * 检测老布局:项目根还有 `units.ts` 或 `units/`,且新布局的 `src/registry.ts` 不在。
18
+ * 两者都没有、或已经迁移过(新注册表在)→ 返回 null。
19
+ */
20
+ export function legacyLayout(dir) {
21
+ const registry = existsSync(join(dir, LEGACY_REGISTRY));
22
+ const hasLegacyDir = existsSync(join(dir, LEGACY_DIR));
23
+ if (!registry && !hasLegacyDir)
24
+ return null;
25
+ if (existsSync(join(dir, REGISTRY_PATH)))
26
+ return null; // 已迁移(可能还留着老目录,不算阻塞)
27
+ return { registry, dir: hasLegacyDir };
28
+ }
29
+ /** 迁移提示(多行,直接打印) */
30
+ export function legacyMigrationHint(found) {
31
+ const what = [found.dir ? 'units/' : null, found.registry ? 'units.ts' : null]
32
+ .filter(Boolean)
33
+ .join(' + ');
34
+ return `检测到老布局(${what})—— 目录约定已改为四分类目录,新能力不会再写进 units/:
35
+
36
+ 1) 把 units/<name>/ 按类型挪进 src/tools/ · src/skills/ · src/prompts/ · src/subagents/
37
+ (目录名就是类型:@Tool→tools、@Skill→skills、@Prompt→prompts、@SubAgent→subagents)
38
+ 2) 把 units.ts 挪成 ${REGISTRY_PATH},import 改成按分类的相对路径,
39
+ 如 import Echo from './tools/echo/index.js';
40
+ 3) src/main.ts 的 discover 从 'units' 改成四分类目录数组(或仍指向你自定义的目录)
41
+
42
+ 运行时本身不受影响:discover 收的是路径,discover: 'units' 照跑 —— 但 CLI 不会再往老目录追加。
43
+ 迁移完这行提示自然消失。`;
44
+ }
package/dist/registry.js CHANGED
@@ -1,13 +1,14 @@
1
- /** units.ts 注册表 codemod:基于 @agentia 标记行插入/跳过条目,幂等 */
2
- import { existsSync, readFileSync, writeFileSync } from 'node:fs';
3
- import { join } from 'node:path';
4
- import { IMPORTS_END_MARKER, ENTRIES_END_MARKER, emptyRegistryTemplate, kebabToPascal, } from './templates.js';
1
+ /** src/registry.ts 注册表 codemod:基于 @agentia 标记行插入/跳过条目,幂等 */
2
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
3
+ import { dirname, join } from 'node:path';
4
+ import { CAPABILITY_DIRS, IMPORTS_END_MARKER, ENTRIES_END_MARKER, REGISTRY_PATH, emptyRegistryTemplate, kebabToPascal, } from './templates.js';
5
5
  export class RegistryError extends Error {
6
6
  }
7
- /** 注册表文件不存在时按空模板创建 */
7
+ /** 注册表文件不存在时按空模板创建(含所需的父目录) */
8
8
  export function ensureRegistry(dir) {
9
- const file = join(dir, 'units.ts');
9
+ const file = join(dir, REGISTRY_PATH);
10
10
  if (!existsSync(file)) {
11
+ mkdirSync(dirname(file), { recursive: true });
11
12
  writeFileSync(file, emptyRegistryTemplate(), 'utf8');
12
13
  }
13
14
  return file;
@@ -18,25 +19,31 @@ function insertAtMarkers(content, importLine, entryLine) {
18
19
  const importsEnd = lines.findIndex((l) => l.trim() === IMPORTS_END_MARKER);
19
20
  const entriesEnd = lines.findIndex((l) => l.trim() === ENTRIES_END_MARKER);
20
21
  if (importsEnd === -1 || entriesEnd === -1) {
21
- throw new RegistryError(`units.ts 缺少 ${IMPORTS_END_MARKER} / ${ENTRIES_END_MARKER} 标记行,无法自动登记;请手工维护该文件`);
22
+ throw new RegistryError(`${REGISTRY_PATH} 缺少 ${IMPORTS_END_MARKER} / ${ENTRIES_END_MARKER} 标记行,无法自动登记;请手工维护该文件`);
22
23
  }
23
24
  lines.splice(entriesEnd, 0, entryLine);
24
25
  lines.splice(importsEnd, 0, importLine);
25
26
  return lines.join('\n');
26
27
  }
27
- /** 把单元登记进 dir/units.ts;已存在同名条目则跳过。标记行缺失时报错。 */
28
- export function registerUnit(dir, name) {
28
+ /** 分类目录 → 相对注册表(src/registry.ts)的 import 前缀,如 src/tools → ./tools */
29
+ function importPrefix(type) {
30
+ return './' + CAPABILITY_DIRS[type].replace(/^src\//, '');
31
+ }
32
+ /** 把能力登记进 src/registry.ts;已存在同名条目则跳过。标记行缺失时报错。 */
33
+ export function registerCapability(dir, name, type) {
29
34
  const file = ensureRegistry(dir);
30
35
  const content = readFileSync(file, 'utf8');
31
- const importLine = `import ${kebabToPascal(name)} from './units/${name}/index.js';`;
36
+ const prefix = importPrefix(type);
37
+ const source = `${prefix}/${name}/index.js`;
38
+ const importLine = `import ${kebabToPascal(name)} from '${source}';`;
32
39
  const entryLine = ` { provide: '${name}', useClass: ${kebabToPascal(name)} },`;
33
- if (content.includes(`'./units/${name}/index.js'`) || content.includes(`provide: '${name}'`)) {
40
+ if (content.includes(`'${source}'`) || content.includes(`provide: '${name}'`)) {
34
41
  return 'already';
35
42
  }
36
43
  writeFileSync(file, insertAtMarkers(content, importLine, entryLine), 'utf8');
37
44
  return 'registered';
38
45
  }
39
- /** 把第三方包(agentia add)登记进 dir/units.ts;token/import 来源已存在则跳过。 */
46
+ /** 把第三方包(agentia add)登记进 src/registry.ts;token/import 来源已存在则跳过。 */
40
47
  export function registerPackage(dir, token, importSource) {
41
48
  const file = ensureRegistry(dir);
42
49
  const content = readFileSync(file, 'utf8');
package/dist/report.js ADDED
@@ -0,0 +1,125 @@
1
+ /**
2
+ * `agentia report <file.jsonl>` —— 从 trace 落盘文件生成**调优报告**(G1 的 CLI 薄壳)。
3
+ *
4
+ * 回答的是调优第一步的问题:「**哪个能力慢 / 贵 / 爱失败**」—— 有了它才知道该拧哪个旋钮
5
+ * (budgetTokens / keepToolPairs / maxCostUsd / toolTimeoutMs …)。
6
+ *
7
+ * 输入格式(每行一个 JSON,两种都收):
8
+ * - 裸 Trace(含 `spans`)—— 如 `createOtlpExporter` 之外的本地落盘、或自己 writeFile 的记录;
9
+ * - TaskRecord(含 `result.trace` 或 `trace`)—— 如 `FileTaskStore` / `SqliteTaskStore` 的导出。
10
+ *
11
+ * 聚合实现**不在这里** —— 复用 `@migor/trace-view` 的 `summarizeTrace`
12
+ * (构建期由 scripts/copy-assets.mjs 拷进 dist/inspector/)。CLI 因此保持零 npm 运行时依赖,
13
+ * 且与 inspector 面板/官网 playground 共用同一份聚合口径(避免两处漂移)。
14
+ */
15
+ import { readFile } from 'node:fs/promises';
16
+ /** 从一行 JSON 里把 trace 抠出来(裸 Trace 或包在记录里的 Trace) */
17
+ function extractTrace(v) {
18
+ if (!v || typeof v !== 'object')
19
+ return null;
20
+ const o = v;
21
+ if (Array.isArray(o.spans))
22
+ return o;
23
+ const result = o.result;
24
+ const rt = result?.trace;
25
+ if (rt && Array.isArray(rt.spans))
26
+ return rt;
27
+ const trace = o.trace;
28
+ if (trace && Array.isArray(trace.spans))
29
+ return trace;
30
+ return null;
31
+ }
32
+ /** 打印一张按总耗时降序的能力排行(跨多行记录时按能力合并) */
33
+ export async function reportCommand(args) {
34
+ const file = args[0];
35
+ // 失败一律**抛错**(而不是就地设 process.exitCode)—— 本命令是异步的,
36
+ // 就地设的 exitCode 会被 cli.ts 末尾那句 `process.exitCode = main(...)` 覆盖成 0。
37
+ if (file === undefined || args.length > 1) {
38
+ throw new Error('用法:agentia report <trace.jsonl>');
39
+ }
40
+ let raw;
41
+ try {
42
+ raw = await readFile(file, 'utf8');
43
+ }
44
+ catch (e) {
45
+ throw new Error(`读不到文件 ${file}(${e.message})`);
46
+ }
47
+ const traces = [];
48
+ let badLines = 0;
49
+ for (const line of raw.split('\n')) {
50
+ const s = line.trim();
51
+ if (!s)
52
+ continue;
53
+ try {
54
+ const t = extractTrace(JSON.parse(s));
55
+ if (t)
56
+ traces.push(t);
57
+ else
58
+ badLines += 1;
59
+ }
60
+ catch {
61
+ badLines += 1;
62
+ }
63
+ }
64
+ if (traces.length === 0) {
65
+ throw new Error(`${file} 里没有可识别的 trace(支持裸 Trace 或含 result.trace / trace 的记录)` +
66
+ (badLines > 0 ? `;另有 ${badLines} 行无法解析` : ''));
67
+ }
68
+ // 聚合口径与 inspector 面板 / 官网 playground 同源(构建期拷进来的 trace-view 产物)
69
+ const { summarizeTrace } = (await import(new URL('./inspector/summary.js', import.meta.url).href));
70
+ const merged = new Map();
71
+ let okRuns = 0;
72
+ for (const t of traces) {
73
+ if (t.status !== 'error')
74
+ okRuns += 1;
75
+ for (const row of summarizeTrace(t)) {
76
+ const cur = merged.get(row.capability);
77
+ if (!cur)
78
+ merged.set(row.capability, { ...row });
79
+ else {
80
+ cur.calls += row.calls;
81
+ cur.errors += row.errors;
82
+ cur.totalMs += row.totalMs;
83
+ cur.maxMs = Math.max(cur.maxMs, row.maxMs);
84
+ if (row.tokens != null)
85
+ cur.tokens = (cur.tokens ?? 0) + row.tokens;
86
+ if (row.costUsd != null)
87
+ cur.costUsd = (cur.costUsd ?? 0) + row.costUsd;
88
+ }
89
+ }
90
+ }
91
+ const rows = [...merged.values()].sort((a, b) => b.totalMs - a.totalMs || b.calls - a.calls);
92
+ const pad = (s, n) => (s.length >= n ? s : s + ' '.repeat(n - s.length));
93
+ const fmtMs = (ms) => (ms >= 1000 ? `${(ms / 1000).toFixed(2)}s` : `${ms}ms`);
94
+ console.log(`trace 文件 ${file}`);
95
+ console.log(`runs ${traces.length}(失败 ${traces.length - okRuns})${badLines > 0 ? ` · 跳过无法解析 ${badLines} 行` : ''}`);
96
+ // 失败 run 要说清原因:只报「失败 N」而不给理由,对调优没用。而且首次运行最常见的失败
97
+ // (没配 API key)恰恰不产出任何 capability 行 —— 会走到下面「没有可归因的能力」提前返回。
98
+ const failed = traces.filter((t) => t.status === 'error');
99
+ for (const t of failed.slice(0, 3)) {
100
+ const spans = (t.spans ?? []);
101
+ const msg = spans.find((s) => s?.error?.message)?.error?.message;
102
+ console.log(` ✗ ${t.traceId ?? '(无 traceId)'}${msg ? ` ${msg}` : ''}`);
103
+ }
104
+ if (failed.length > 3)
105
+ console.log(` …另有 ${failed.length - 3} 条失败 run`);
106
+ console.log('');
107
+ if (rows.length === 0) {
108
+ console.log('(没有可归因的能力:这些 trace 里既没有 capability span,也没有 tool.output 事件)');
109
+ return 0;
110
+ }
111
+ console.log(`${pad('capability', 34)} ${pad('calls', 6)} ${pad('err', 5)} ${pad('total', 9)} ${pad('max', 9)} ${pad('tokens', 9)} ${pad('cost', 12)}`);
112
+ let total = 0;
113
+ let errs = 0;
114
+ for (const r of rows) {
115
+ total += r.totalMs;
116
+ errs += r.errors;
117
+ console.log(`${pad(r.capability, 34)} ${pad(String(r.calls), 6)} ${pad(String(r.errors), 5)} ` +
118
+ `${pad(fmtMs(r.totalMs), 9)} ${pad(fmtMs(r.maxMs), 9)} ` +
119
+ `${pad(r.tokens != null ? String(r.tokens) : '-', 9)} ` +
120
+ `${pad(r.costUsd != null ? r.costUsd.toFixed(6) : '-', 12)}`);
121
+ }
122
+ console.log('');
123
+ console.log(`合计耗时 ${fmtMs(total)} · 失败 ${errs} 次 · 能力 ${rows.length} 个`);
124
+ return 0;
125
+ }