@migor/cli 0.5.0 → 0.6.1

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/add.js CHANGED
@@ -3,7 +3,7 @@ import { spawnSync } from 'node:child_process';
3
3
  import { existsSync, readFileSync } from 'node:fs';
4
4
  import { join, resolve } from 'node:path';
5
5
  import { registerPackage } from './registry.js';
6
- import { npmBin } from './npm-bin.js';
6
+ import { npmSpawn } from './npm-bin.js';
7
7
  /** 从 add 参数解析真实包名:支持 name[@version]、@scope/name[@version]、本地路径(. / 前缀,或 file: 协议) */
8
8
  export function resolvePackageName(arg) {
9
9
  // file: 协议(npm install file:./pkg 的写法)先剥前缀,再按本地路径处理
@@ -34,7 +34,10 @@ export function packageNameToToken(pkgName) {
34
34
  }
35
35
  export function addPackage(arg) {
36
36
  const cwd = process.cwd();
37
- const r = spawnSync(npmBin('npm'), ['install', arg], { cwd, stdio: 'inherit' });
37
+ // arg 是用户输入(包名 / 本地路径,可能含空格与 cmd 元字符)——
38
+ // 必须走 npmSpawn 的逐参数脱敏,不能用 shell:true(Node 对 shell 命令行不做任何转义)
39
+ const spec = npmSpawn('npm', ['install', arg]);
40
+ const r = spawnSync(spec.command, spec.args, { cwd, stdio: 'inherit', ...spec.options });
38
41
  if (r.error || r.status !== 0) {
39
42
  console.error(`错误:npm install ${arg} 失败,src/registry.ts 未改动`);
40
43
  process.exitCode = 1;
package/dist/cli.js CHANGED
@@ -7,8 +7,9 @@ import { RegistryError } from './registry.js';
7
7
  import { devServer } from './dev.js';
8
8
  import { doctor } from './doctor.js';
9
9
  import { addPackage } from './add.js';
10
- import { reportCommand } from './report.js';
11
- import { harvestCommand } from './harvest.js';
10
+ import { reportCommand, USAGE as REPORT_USAGE } from './report.js';
11
+ import { harvestCommand, USAGE as HARVEST_USAGE } from './harvest.js';
12
+ import { diffCommand, USAGE as DIFF_USAGE } from './diff.js';
12
13
  const USAGE = `agentia —— Agentia 框架命令行工具
13
14
 
14
15
  用法:
@@ -20,11 +21,29 @@ const USAGE = `agentia —— Agentia 框架命令行工具
20
21
  agentia report <trace.jsonl> 从 trace 落盘文件生成调优报告(能力耗时/成本/错误率排行)
21
22
  agentia harvest <trace.jsonl> 把线上 trace 翻成 eval 用例骨架
22
23
  ([--out <file.ts>] [--failed] [--limit N])
24
+ agentia diff <a.jsonl> <b.jsonl> 两条 trace 的调用树 A/B 比对(有差异时退出码 1)
23
25
  agentia add <pkg> 安装第三方能力包并登记到 src/registry.ts
24
26
  agentia --help 显示本帮助
25
27
 
26
28
  name 规则:小写字母开头的小写 kebab-case(如 hello、doc-reviewer)
27
29
  `;
30
+ // 各子命令自己的用法串 —— 同时供 fail() 与「子命令级 --help」使用(单源,不写两遍)。
31
+ // report / harvest / diff 的用法串在各自模块里(那里本来就有),此处 import 复用。
32
+ const CREATE_USAGE = '用法:agentia create <name> [--dir <parent>]';
33
+ const G_USAGE = `用法:agentia g <type> <name>(type: ${CAPABILITY_TYPES.join(' | ')})`;
34
+ const ADD_USAGE = '用法:agentia add <pkg>(npm 包名或本地包路径)';
35
+ /** 子命令 → 自己的用法串。`--help` 落在子命令上时打它。 */
36
+ const SUB_USAGE = {
37
+ create: CREATE_USAGE,
38
+ g: G_USAGE,
39
+ generate: G_USAGE,
40
+ dev: '用法:agentia dev',
41
+ doctor: '用法:agentia doctor',
42
+ report: REPORT_USAGE,
43
+ harvest: HARVEST_USAGE,
44
+ diff: DIFF_USAGE,
45
+ add: ADD_USAGE,
46
+ };
28
47
  function fail(message) {
29
48
  console.error(`错误:${message}`);
30
49
  process.exitCode = 1;
@@ -41,6 +60,13 @@ function main(argv) {
41
60
  console.log(USAGE);
42
61
  return 0;
43
62
  }
63
+ // 子命令级 --help:此前 `agentia report --help` 会把 --help 当成「要读的文件名」,
64
+ // 用户拿到的是 ENOENT 而不是该命令的用法(report / harvest / diff 尤其需要)。
65
+ const subUsage = SUB_USAGE[command];
66
+ if (subUsage !== undefined && (rest[0] === '--help' || rest[0] === '-h')) {
67
+ console.log(subUsage);
68
+ return 0;
69
+ }
44
70
  if (command === 'create') {
45
71
  const name = rest[0];
46
72
  let parent;
@@ -56,7 +82,7 @@ function main(argv) {
56
82
  }
57
83
  }
58
84
  if (name === undefined)
59
- return fail('缺少项目名,用法:agentia create <name> [--dir <parent>]');
85
+ return fail(`缺少项目名,${CREATE_USAGE}`);
60
86
  if (!checkName(name)) {
61
87
  return fail(`非法项目名「${name}」:需匹配小写 kebab-case(如 my-app)`);
62
88
  }
@@ -65,7 +91,7 @@ function main(argv) {
65
91
  if (command === 'g' || command === 'generate') {
66
92
  const [type, name, ...extra] = rest;
67
93
  if (type === undefined || name === undefined || extra.length > 0) {
68
- return fail(`用法:agentia g <type> <name>(type: ${CAPABILITY_TYPES.join(' | ')})`);
94
+ return fail(G_USAGE);
69
95
  }
70
96
  if (!isCapabilityType(type)) {
71
97
  return fail(`未知能力类型「${type}」,可选:${CAPABILITY_TYPES.join(' | ')}`);
@@ -109,10 +135,18 @@ function main(argv) {
109
135
  });
110
136
  return 0;
111
137
  }
138
+ if (command === 'diff') {
139
+ // 异步命令(读文件):同 report 的受理模式
140
+ void diffCommand(rest).catch((e) => {
141
+ console.error(`错误:${e.message}`);
142
+ process.exitCode = 1;
143
+ });
144
+ return 0;
145
+ }
112
146
  if (command === 'add') {
113
147
  const [pkg, ...extra] = rest;
114
148
  if (pkg === undefined || extra.length > 0) {
115
- return fail('用法:agentia add <pkg>(npm 包名或本地包路径)');
149
+ return fail(ADD_USAGE);
116
150
  }
117
151
  try {
118
152
  return addPackage(pkg);
package/dist/create.js CHANGED
@@ -1,8 +1,8 @@
1
1
  /** create 命令:生成 Agentia 项目脚手架 */
2
- import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from 'node:fs';
2
+ import { existsSync, mkdirSync, readdirSync, readFileSync, statSync, writeFileSync } from 'node:fs';
3
3
  import { dirname, join, resolve } from 'node:path';
4
4
  import { fileURLToPath } from 'node:url';
5
- import { CAPABILITY_DIR_LIST, emptyRegistryTemplate, mainTs, projectDotEnv, projectDotEnvExample, projectGitignore, projectPackageJson, projectReadme, projectTsconfig, REGISTRY_PATH, toolIndexTs, } from './templates.js';
5
+ import { CAPABILITY_DIR_LIST, copyAssetsMjs, emptyRegistryTemplate, mainTs, projectDotEnv, projectDotEnvExample, projectGitignore, projectPackageJson, projectReadme, projectTsconfig, REGISTRY_PATH, toolIndexTs, } from './templates.js';
6
6
  import { registerCapability } from './registry.js';
7
7
  function write(dir, rel, content) {
8
8
  const file = join(dir, rel);
@@ -38,6 +38,14 @@ export function createProject(name, parent) {
38
38
  process.exitCode = 1;
39
39
  return 1;
40
40
  }
41
+ // 路径存在但不是目录时 readdirSync 会抛原始 ENOTDIR(栈里全是 node:fs 内部帧),
42
+ // 下面那句友好文案根本轮不到 —— 先判类型(`agentia create my-app` 而 my-app 是
43
+ // 个文件是常见手误)。
44
+ if (existsSync(dir) && !statSync(dir).isDirectory()) {
45
+ console.error(`错误:${dir} 已存在且不是目录`);
46
+ process.exitCode = 1;
47
+ return 1;
48
+ }
41
49
  if (existsSync(dir) && readdirSync(dir).length > 0) {
42
50
  console.error(`错误:目录 ${dir} 已存在且非空`);
43
51
  process.exitCode = 1;
@@ -48,6 +56,7 @@ export function createProject(name, parent) {
48
56
  write(dir, 'tsconfig.json', projectTsconfig());
49
57
  write(dir, 'src/main.ts', mainTs(name));
50
58
  write(dir, 'src/tools/hello/index.ts', toolIndexTs('hello'));
59
+ write(dir, 'scripts/copy-assets.mjs', copyAssetsMjs());
51
60
  write(dir, REGISTRY_PATH, emptyRegistryTemplate());
52
61
  write(dir, 'README.md', projectReadme(name));
53
62
  write(dir, '.gitignore', projectGitignore());
@@ -72,6 +81,8 @@ export function createProject(name, parent) {
72
81
  把 API key 填进 .env(已生成,且已被 .gitignore 忽略)
73
82
  npm run dev
74
83
 
84
+ 生产构建:npm run build && npm start(tsc → dist/,.md 资产由 scripts/copy-assets.mjs 跟随拷贝)
85
+
75
86
  目录约定:src/tools/ · src/skills/ · src/prompts/ · src/subagents/(一能力一文件夹)
76
87
 
77
88
  提示:项目内 AGENTS.md 是本框架的使用说明(API 速查 + 已知边界),
package/dist/dev.js CHANGED
@@ -14,7 +14,7 @@ import { existsSync } from 'node:fs';
14
14
  import { dirname, join } from 'node:path';
15
15
  import { fileURLToPath, pathToFileURL } from 'node:url';
16
16
  import { startInspector } from './inspector.js';
17
- import { npmBin } from './npm-bin.js';
17
+ import { npmSpawn } from './npm-bin.js';
18
18
  /** --import 可用性:Node ≥20.6(≥18.19 已回移植) */
19
19
  function supportsImport() {
20
20
  const [major, minor] = process.versions.node.split('.').map(Number);
@@ -44,7 +44,9 @@ export function devServer() {
44
44
  process.on('SIGINT', forward('SIGINT'));
45
45
  process.on('SIGTERM', forward('SIGTERM'));
46
46
  const startChild = (env) => {
47
- child = spawn(npmBin('npx'), ['tsx', 'watch', 'src/main.ts'], { cwd, stdio: 'inherit', env });
47
+ // 参数全是静态字面量(无用户输入);win32 下经 npmSpawn 走 cmd.exe 包装(见 npm-bin.ts)
48
+ const spec = npmSpawn('npx', ['tsx', 'watch', 'src/main.ts']);
49
+ child = spawn(spec.command, spec.args, { cwd, stdio: 'inherit', env, ...spec.options });
48
50
  child.on('error', (err) => {
49
51
  console.error(`错误:启动 dev 失败:${err.message}`);
50
52
  process.exitCode = 1;
package/dist/diff.js ADDED
@@ -0,0 +1,301 @@
1
+ /**
2
+ * `agentia diff <a.jsonl> <b.jsonl>` —— 两条 trace 的调用树 A/B 比对。
3
+ *
4
+ * 两个文件各取第一条可提取的 trace(裸 Trace 或含 result.trace / trace 的记录,
5
+ * 同 `agentia report` 的输入),跑 diffTraces 后人读输出:run 级 summary + 按 span
6
+ * 分组的字段级差异。diff(1) 语义:**有差异时退出码 1**(等价 0),便于脚本串接;
7
+ * 用法/文件错误照旧抛错走受理模式。
8
+ *
9
+ * ⚠️ 本文件是 src/engine/trace-diff.ts 的**去类型移植副本** —— CLI 零运行时依赖、
10
+ * 不能 import 框架,此处是逐行移植(本地保留同名 interface 使函数体逐字同形);
11
+ * 逐字对拍守护在 packages/cli/test/diff.test.mjs,**改算法必须两边同步**。
12
+ */
13
+ import { readFile } from 'node:fs/promises';
14
+ import { extractTrace } from './report.js';
15
+ /* ── 以下是 src/engine/trace-diff.ts 的去类型移植(保持逐字同形)────────────────── */
16
+ /* core/json.ts 两个小工具的移植(diff 依赖它们) */
17
+ /** 任意值 → 字符串:string 原样,其余 JSON.stringify,循环引用等异常回落 String()。 */
18
+ function stringifySafe(x) {
19
+ if (typeof x === 'string')
20
+ return x;
21
+ try {
22
+ return JSON.stringify(x) ?? String(x);
23
+ }
24
+ catch {
25
+ return String(x);
26
+ }
27
+ }
28
+ /** 截断到上限字符、超长加省略标记 `…(+N)`。trace 展示(engine/loop)与 replay 共用同一格式。 */
29
+ function truncateWithMark(s, n) {
30
+ return s.length > n ? `${s.slice(0, n)}…(+${s.length - n})` : s;
31
+ }
32
+ const USAGE_FIELDS = [
33
+ 'inputTokens',
34
+ 'outputTokens',
35
+ 'cacheReadTokens',
36
+ 'cacheCreationTokens',
37
+ 'costEstimate',
38
+ ];
39
+ /** 每个 span 的事件差异最多报 20 条,超出记一条 `events` 截断说明 */
40
+ const EVENT_DIFF_LIMIT = 20;
41
+ /** 事件 body 差异值截断上限(body 可能很大,防爆) */
42
+ const DIFF_VALUE_CHARS = 200;
43
+ export function diffTraces(a, b, opts = {}) {
44
+ const ignoreTiming = opts.ignoreTiming ?? true;
45
+ const summary = [];
46
+ if (a.status !== b.status)
47
+ summary.push({ field: 'status', a: a.status, b: b.status });
48
+ pushUsageDiffs(summary, 'totalUsage', a.totalUsage, b.totalUsage);
49
+ const aRoot = a.spans.find((s) => s.spanId === a.rootSpanId);
50
+ const bRoot = b.spans.find((s) => s.spanId === b.rootSpanId);
51
+ if (aRoot && bRoot)
52
+ pushAttrDiffs(summary, aRoot.attributes, bRoot.attributes);
53
+ const spans = [];
54
+ const ctx = {
55
+ aChildren: childrenMap(a),
56
+ bChildren: childrenMap(b),
57
+ ignoreTiming,
58
+ out: spans,
59
+ };
60
+ // 根与根无条件配对(一侧连根都没有时记缺侧)
61
+ if (aRoot || bRoot) {
62
+ walk(aRoot, bRoot, `run:${(aRoot ?? bRoot).name}`, ctx);
63
+ }
64
+ return { equal: summary.length === 0 && spans.length === 0, summary, spans };
65
+ }
66
+ /** parentSpanId → 直接子 span(保 spans 数组插入序,供稳定排序前的同刻次序) */
67
+ function childrenMap(trace) {
68
+ const map = new Map();
69
+ for (const s of trace.spans) {
70
+ if (s.parentSpanId === null)
71
+ continue;
72
+ const kids = map.get(s.parentSpanId);
73
+ if (kids)
74
+ kids.push(s);
75
+ else
76
+ map.set(s.parentSpanId, [s]);
77
+ }
78
+ return map;
79
+ }
80
+ function walk(aSpan, bSpan, path, ctx) {
81
+ const fields = aSpan && bSpan ? diffSpanFields(aSpan, bSpan, ctx.ignoreTiming) : [];
82
+ // 只收有差异的节点:字段差,或缺侧(整棵子树缺失,一条记录代表整支,不再下钻)
83
+ if (fields.length > 0 || !aSpan || !bSpan) {
84
+ ctx.out.push({ path, a: aSpan, b: bSpan, fields });
85
+ }
86
+ if (!aSpan || !bSpan)
87
+ return;
88
+ const aKids = ctx.aChildren.get(aSpan.spanId) ?? [];
89
+ const bKids = ctx.bChildren.get(bSpan.spanId) ?? [];
90
+ for (const pair of pairChildren(aKids, bKids)) {
91
+ walk(pair.a, pair.b, `${path}/${pair.seg}`, ctx);
92
+ }
93
+ }
94
+ /**
95
+ * 配对键:llm.turn 只有 kind(忽略 name —— name 是模型 id,「换模型重跑」是 A/B 主用例,
96
+ * 按 name 配对会把两侧 turn 全报成缺失;模型差异降格为配对 turn 的 name 字段差);
97
+ * 其余 span 按 `kind:name`(skill:foo vs skill:bar 是不同能力,不该配上)。
98
+ */
99
+ function pairKey(s) {
100
+ return s.kind === 'llm.turn' ? 'llm.turn' : `${s.kind}:${s.name}`;
101
+ }
102
+ function pairChildren(aKids, bKids) {
103
+ const keys = [];
104
+ const seen = new Set();
105
+ for (const s of [...aKids, ...bKids]) {
106
+ const k = pairKey(s);
107
+ if (!seen.has(k)) {
108
+ seen.add(k);
109
+ keys.push(k);
110
+ }
111
+ }
112
+ const pairs = [];
113
+ for (const key of keys) {
114
+ // 同键孩子按 startedAt 稳定排序(同刻保插入序)后按下标一一配对
115
+ const as = aKids.filter((s) => pairKey(s) === key).sort((x, y) => x.startedAt - y.startedAt);
116
+ const bs = bKids.filter((s) => pairKey(s) === key).sort((x, y) => x.startedAt - y.startedAt);
117
+ const n = Math.max(as.length, bs.length);
118
+ for (let i = 0; i < n; i++) {
119
+ const sample = as[i] ?? bs[i];
120
+ let seg;
121
+ if (sample.kind === 'llm.turn') {
122
+ seg = `llm.turn#${i}`;
123
+ }
124
+ else {
125
+ seg = `${sample.kind}:${sample.name}`;
126
+ if (n > 1)
127
+ seg += `#${i}`; // 同键重复时加下标区分
128
+ }
129
+ pairs.push({ a: as[i], b: bs[i], seg });
130
+ }
131
+ }
132
+ return pairs;
133
+ }
134
+ /** 两侧都在时的字段级差异(顺序:status / name / error / usage.* / attributes.* / duration / events) */
135
+ function diffSpanFields(a, b, ignoreTiming) {
136
+ const fields = [];
137
+ if (a.status !== b.status)
138
+ fields.push({ field: 'status', a: a.status, b: b.status });
139
+ if (a.name !== b.name)
140
+ fields.push({ field: 'name', a: a.name, b: b.name });
141
+ if (!errorEqual(a.error, b.error))
142
+ fields.push({ field: 'error', a: a.error, b: b.error });
143
+ pushUsageDiffs(fields, 'usage', a.usage, b.usage);
144
+ pushAttrDiffs(fields, a.attributes, b.attributes);
145
+ if (!ignoreTiming) {
146
+ // 比时长而非绝对时间戳(未结束为 undefined,与已结束也是差异)
147
+ const da = a.endedAt === undefined ? undefined : a.endedAt - a.startedAt;
148
+ const db = b.endedAt === undefined ? undefined : b.endedAt - b.startedAt;
149
+ if (da !== db)
150
+ fields.push({ field: 'duration', a: da, b: db });
151
+ }
152
+ pushEventDiffs(fields, a.events, b.events);
153
+ return fields;
154
+ }
155
+ /** error 比 type + message + retryable 三元组 */
156
+ function errorEqual(x, y) {
157
+ if (!x || !y)
158
+ return x === y;
159
+ return x.type === y.type && x.message === y.message && x.retryable === y.retryable;
160
+ }
161
+ /** usage 逐字段比;缺省(undefined)与 0 是不同语义 —— 一侧有一侧无也是差异 */
162
+ function pushUsageDiffs(out, prefix, x, y) {
163
+ for (const f of USAGE_FIELDS) {
164
+ const xv = x?.[f];
165
+ const yv = y?.[f];
166
+ if (xv !== yv)
167
+ out.push({ field: `${prefix}.${f}`, a: xv, b: yv });
168
+ }
169
+ }
170
+ /** attributes 并集逐键比(键序排序,输出确定) */
171
+ function pushAttrDiffs(out, x, y) {
172
+ const keys = [...new Set([...Object.keys(x), ...Object.keys(y)])].sort();
173
+ for (const k of keys) {
174
+ if (x[k] !== y[k])
175
+ out.push({ field: `attributes.${k}`, a: x[k], b: y[k] });
176
+ }
177
+ }
178
+ /**
179
+ * events 序列比:长度不等记一条 `events` 计数差;逐下标比 name 与 body
180
+ * (body 用 stringifySafe 序列化后比字符串,差异值截断到 200 字符防爆)。
181
+ * 每个 span 最多报 EVENT_DIFF_LIMIT 条,超出记一条 `events` 截断说明。
182
+ * event.time 是墙钟字段,永不比。
183
+ */
184
+ function pushEventDiffs(out, x, y) {
185
+ let budget = EVENT_DIFF_LIMIT;
186
+ let omitted = 0;
187
+ const push = (entry) => {
188
+ if (budget > 0) {
189
+ out.push(entry);
190
+ budget--;
191
+ }
192
+ else {
193
+ omitted++;
194
+ }
195
+ };
196
+ if (x.length !== y.length)
197
+ push({ field: 'events', a: x.length, b: y.length });
198
+ const n = Math.min(x.length, y.length);
199
+ for (let i = 0; i < n; i++) {
200
+ if (x[i].name !== y[i].name) {
201
+ push({ field: `events[${i}].name`, a: x[i].name, b: y[i].name });
202
+ }
203
+ const bx = stringifySafe(x[i].body);
204
+ const by = stringifySafe(y[i].body);
205
+ if (bx !== by) {
206
+ push({
207
+ field: `events[${i}].body`,
208
+ a: truncateWithMark(bx, DIFF_VALUE_CHARS),
209
+ b: truncateWithMark(by, DIFF_VALUE_CHARS),
210
+ });
211
+ }
212
+ }
213
+ if (omitted > 0) {
214
+ const note = `(事件差异超过 ${EVENT_DIFF_LIMIT} 条,另有 ${omitted} 条未列出)`;
215
+ out.push({ field: 'events', a: note, b: note });
216
+ }
217
+ }
218
+ /* ── 命令本体:两个 JSONL 各取第一条 trace → diff → 人读输出 ──────────────────── */
219
+ /** 用法串(cli.ts 的子命令 `--help` 也从这里取,避免两处各写一份) */
220
+ export const USAGE = '用法:agentia diff <a.jsonl> <b.jsonl>';
221
+ /** 逐行 JSON.parse + extractTrace(同 harvest),取第一条可提取的 trace;坏行跳过计数 */
222
+ async function readFirstTrace(file) {
223
+ let raw;
224
+ try {
225
+ raw = await readFile(file, 'utf8');
226
+ }
227
+ catch (e) {
228
+ throw new Error(`读不到文件 ${file}(${e.message})`);
229
+ }
230
+ let badLines = 0;
231
+ for (const line of raw.split('\n')) {
232
+ const s = line.trim();
233
+ if (!s)
234
+ continue;
235
+ try {
236
+ const t = extractTrace(JSON.parse(s));
237
+ if (t)
238
+ return { trace: t, badLines };
239
+ badLines += 1;
240
+ }
241
+ catch {
242
+ badLines += 1;
243
+ }
244
+ }
245
+ throw new Error(`${file} 里没有可识别的 trace(支持裸 Trace 或含 result.trace / trace 的记录)` +
246
+ (badLines > 0 ? `;另有 ${badLines} 行无法解析` : ''));
247
+ }
248
+ /** 差异值 → 展示串:stringifySafe 序列化(短串直接放),长串截断 200 字符(同 DIFF_VALUE_CHARS) */
249
+ function fmtValue(v) {
250
+ return truncateWithMark(stringifySafe(v), DIFF_VALUE_CHARS);
251
+ }
252
+ function fmtEntry(e) {
253
+ return ` ${e.field}: ${fmtValue(e.a)} → ${fmtValue(e.b)}`;
254
+ }
255
+ /** 失败一律**抛错**(同 report/harvest 的受理模式);「有差异」不是错误,就地设 exitCode = 1 */
256
+ export async function diffCommand(args) {
257
+ const files = [];
258
+ for (const a of args) {
259
+ if (a.startsWith('--'))
260
+ throw new Error(`未知参数:${a}\n${USAGE}`);
261
+ files.push(a);
262
+ }
263
+ if (files.length !== 2)
264
+ throw new Error(USAGE);
265
+ const [fileA, fileB] = files;
266
+ const ra = await readFirstTrace(fileA);
267
+ const rb = await readFirstTrace(fileB);
268
+ const d = diffTraces(ra.trace, rb.trace);
269
+ const badNote = (n) => (n > 0 ? `(跳过无法解析 ${n} 行)` : '');
270
+ const lines = [
271
+ `trace a ${fileA}(${ra.trace.traceId ?? '(无 traceId)'})${badNote(ra.badLines)}`,
272
+ `trace b ${fileB}(${rb.trace.traceId ?? '(无 traceId)'})${badNote(rb.badLines)}`,
273
+ ];
274
+ if (d.equal) {
275
+ lines.push('两条 trace 等价(无结构与字段差异)');
276
+ process.stdout.write(`${lines.join('\n')}\n`);
277
+ return 0;
278
+ }
279
+ lines.push('');
280
+ if (d.summary.length > 0) {
281
+ lines.push('run 级差异:');
282
+ for (const e of d.summary)
283
+ lines.push(fmtEntry(e));
284
+ }
285
+ if (d.spans.length > 0) {
286
+ if (d.summary.length > 0)
287
+ lines.push('');
288
+ lines.push('span 差异:');
289
+ for (const s of d.spans) {
290
+ const side = s.a === undefined ? ' (仅存在于 b)' : s.b === undefined ? ' (仅存在于 a)' : '';
291
+ lines.push(`${s.path}${side}`);
292
+ for (const e of s.fields)
293
+ lines.push(fmtEntry(e));
294
+ }
295
+ }
296
+ process.stdout.write(`${lines.join('\n')}\n`);
297
+ // diff(1) 语义:不同即 1。此刻 cli.ts 末尾的 `process.exitCode = main(...)` 已同步执行完,
298
+ // 这里的赋值在其后(await 之后),不会被覆盖。
299
+ process.exitCode = 1;
300
+ return 0;
301
+ }
package/dist/doctor.js CHANGED
@@ -5,11 +5,48 @@ import { CAPABILITY_DIRS, CAPABILITY_DIR_LIST, REGISTRY_PATH, isValidName } from
5
5
  import { legacyLayout, legacyMigrationHint } from './layout.js';
6
6
  /** 能力入口候选(须与框架 src/toolkit/discover.ts 的 ENTRY_CANDIDATES 一致) */
7
7
  const ENTRY_CANDIDATES = ['index.ts', 'index.mts', 'index.js', 'index.mjs'];
8
+ /**
9
+ * 解析 import 绑定子句,产出本文件内可见的标识符。
10
+ * 覆盖 default / named(含 `as` 别名与 `type` 修饰)/ namespace / 混合形态;
11
+ * 注册表由 codemod 与人工共同维护,named import 等形态此前被静默跳过(悬空条目漏检)。
12
+ */
13
+ function boundIdents(clause) {
14
+ const idents = [];
15
+ const brace = clause.match(/\{([\s\S]*)\}/);
16
+ const head = brace ? clause.replace(brace[0], '') : clause;
17
+ // 括号外部分:default 标识符和/或 `* as Ns`
18
+ for (const part of head.split(',')) {
19
+ const p = part.trim().replace(/^type\s+/, '');
20
+ if (!p)
21
+ continue;
22
+ const ns = p.match(/^\*\s+as\s+(\w+)$/);
23
+ if (ns)
24
+ idents.push(ns[1]);
25
+ else if (/^\w+$/.test(p))
26
+ idents.push(p);
27
+ }
28
+ // 括号内 named 列表:`A`、`A as B`、`type A`
29
+ if (brace) {
30
+ for (const item of brace[1].split(',')) {
31
+ const p = item.trim().replace(/^type\s+/, '');
32
+ if (!p)
33
+ continue;
34
+ const as = p.match(/^\w+\s+as\s+(\w+)$/);
35
+ if (as)
36
+ idents.push(as[1]);
37
+ else if (/^\w+$/.test(p))
38
+ idents.push(p);
39
+ }
40
+ }
41
+ return idents;
42
+ }
8
43
  /** 解析 src/registry.ts:imports(标识符 → 来源)+ entries(provide token → useClass 标识符) */
9
44
  function parseRegistry(content) {
10
45
  const imports = new Map();
11
- for (const m of content.matchAll(/^import\s+(\w+)\s+from\s+'([^']+)'/gm)) {
12
- imports.set(m[1], m[2]);
46
+ // 单/双引号、type 修饰、跨行 named 列表都认;副作用 import(无绑定子句)不产生标识符
47
+ for (const m of content.matchAll(/^import\s+(?:type\s+)?([\s\S]*?)\s+from\s+(['"])([^'"]+)\2/gm)) {
48
+ for (const ident of boundIdents(m[1]))
49
+ imports.set(ident, m[3]);
13
50
  }
14
51
  const entries = [];
15
52
  for (const m of content.matchAll(/\{\s*provide:\s*'([^']+)'\s*,\s*useClass:\s*(\w+)/g)) {
package/dist/harvest.js CHANGED
@@ -6,9 +6,11 @@
6
6
  * trace 的 llm.turn 重建),汇总成一个 eval TS 脚手架写到 --out(缺省 stdout)。
7
7
  *
8
8
  * ⚠️ 用例骨架生成器与框架侧 `src/eval/harvest.ts` 的 `harvestEvalCase` **同源同形** ——
9
- * CLI 零运行时依赖、不能 import 框架,此处是逐行移植(packages/cli/test/harvest.test.mjs
10
- * 里有一条「与框架产物逐字一致」的对拍,改生成格式必须两边同步)。
9
+ * CLI 零运行时依赖、不能 import 框架,此处是去类型移植(正文逐行相同,另有 3 处空值兜底:
10
+ * `trace.spans ?? []`、`startedAt ?? 0` ×2)。**产物**逐字一致由
11
+ * packages/cli/test/harvest.test.mjs 的对拍守着(改生成格式必须两边同步)。
11
12
  */
13
+ import { existsSync } from 'node:fs';
12
14
  import { readFile, writeFile } from 'node:fs/promises';
13
15
  import { extractTrace } from './report.js';
14
16
  /** trace 不记 assistant 文本 —— 脚本 text 块的统一占位(与框架侧同一个词) */
@@ -25,16 +27,26 @@ function stringifySafe(x) {
25
27
  }
26
28
  }
27
29
  function toolInputsOf(turn) {
28
- return (turn.events ?? [])
29
- .filter((e) => e.name === 'tool.input')
30
- .map((e) => {
30
+ const out = [];
31
+ for (const e of turn.events ?? []) {
32
+ if (e.name !== 'tool.input')
33
+ continue;
31
34
  const b = (e.body ?? {});
32
- return {
33
- tool: typeof b.tool === 'string' ? b.tool : 'unknown',
35
+ // 缺 `tool` 的事件**跳过**,不回填占位名(与框架侧 src/eval/harvest.ts 逐字同步):
36
+ // 伪造的 'unknown' 会在生成的脚本里变成一个真的(且断言必然通过的)工具调用。
37
+ if (typeof b.tool !== 'string')
38
+ continue;
39
+ out.push({
40
+ tool: b.tool,
34
41
  toolUseId: typeof b.tool_use_id === 'string' ? b.tool_use_id : undefined,
35
42
  input: b.input === undefined ? undefined : stringifySafe(b.input),
36
- };
37
- });
43
+ });
44
+ }
45
+ return out;
46
+ }
47
+ /** 注释里只放单行:外来 trace 的 name/source/traceId 含换行会击穿生成物的注释语法 */
48
+ function oneLine(s) {
49
+ return s.replace(/[\r\n]+/g, ' ');
38
50
  }
39
51
  function parseToolInput(raw) {
40
52
  const s = raw ?? '';
@@ -75,7 +87,7 @@ function lastUserText(messages) {
75
87
  /** 与框架侧 harvestEvalCase 逐字同形的生成器(见文件头注释的对拍测试) */
76
88
  export function harvestEvalCase(input) {
77
89
  const { trace } = input;
78
- const name = input.name ?? `harvest-${trace.traceId}`;
90
+ const name = oneLine(input.name ?? `harvest-${trace.traceId}`);
79
91
  const spans = trace.spans ?? [];
80
92
  const mainTurns = spans
81
93
  .filter((s) => s.kind === 'llm.turn' && s.parentSpanId === trace.rootSpanId)
@@ -106,7 +118,7 @@ export function harvestEvalCase(input) {
106
118
  const toolSeq = mainTurns.flatMap((t) => toolInputsOf(t).map((e) => e.tool));
107
119
  const userText = lastUserText(input.messages);
108
120
  const head = [
109
- `// ┄┄ harvest 用例骨架:${name}(trace ${trace.traceId}${input.source ? `,来源 ${input.source}` : ''})┄┄`,
121
+ `// ┄┄ harvest 用例骨架:${name}(trace ${oneLine(trace.traceId ?? '')}${input.source ? `,来源 ${oneLine(input.source)}` : ''})┄┄`,
110
122
  '// ⚠️ 脚手架,不是成品 —— 人工核对后再进 CI:',
111
123
  '// · trace 不记 assistant 文本(llm.turn 只记 usage/事件),脚本里的 text 块是占位;',
112
124
  ];
@@ -156,7 +168,8 @@ function toRow(v, index) {
156
168
  label: t.traceId ?? `row-${index + 1}`,
157
169
  };
158
170
  }
159
- const USAGE = '用法:agentia harvest <trace.jsonl> [--out <file.ts>] [--failed] [--limit N]';
171
+ /** 用法串(cli.ts 的子命令 `--help` 也从这里取,避免两处各写一份) */
172
+ export const USAGE = '用法:agentia harvest <trace.jsonl> [--out <file.ts>] [--force] [--failed] [--limit N]';
160
173
  function renderEvalFile(opts) {
161
174
  const note = `来源:${opts.file},${opts.cases.length} 条记录${opts.onlyFailed ? '(仅失败)' : ''}` +
162
175
  (opts.badLines > 0 ? `,跳过无法解析 ${opts.badLines} 行` : '');
@@ -196,12 +209,16 @@ export async function harvestCommand(args) {
196
209
  let file;
197
210
  let out;
198
211
  let onlyFailed = false;
212
+ let force = false;
199
213
  let limit;
200
214
  for (let i = 0; i < args.length; i += 1) {
201
215
  const a = args[i];
202
216
  if (a === '--failed') {
203
217
  onlyFailed = true;
204
218
  }
219
+ else if (a === '--force') {
220
+ force = true;
221
+ }
205
222
  else if (a === '--out') {
206
223
  out = args[i + 1];
207
224
  if (out === undefined)
@@ -270,6 +287,11 @@ export async function harvestCommand(args) {
270
287
  process.stdout.write(content);
271
288
  }
272
289
  else {
290
+ // 产物是「人工核对后再进 CI」的脚手架,它的价值恰恰在用户手改过的断言与 input 上:
291
+ // 重跑一次就静默抹掉等于毁掉那份人工成果,所以默认拒绝覆盖(要覆盖显式 --force)。
292
+ if (!force && existsSync(out)) {
293
+ throw new Error(`${out} 已存在(产物需人工核对,默认不覆盖);要覆盖请加 --force\n${USAGE}`);
294
+ }
273
295
  await writeFile(out, content, 'utf8');
274
296
  console.log(`已写出 ${out}:${picked.length} 个用例骨架` +
275
297
  (onlyFailed ? '(仅失败记录)' : '') +