@migor/cli 0.5.0 → 0.6.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.
package/dist/AGENTS.md CHANGED
@@ -105,7 +105,7 @@ npx @migor/cli doctor # 静态体检(未登记/悬空/命名/重复
105
105
  | `model` | `ctx.llm()` 的缺省模型 |
106
106
  | `maxTokens` | 同上 |
107
107
  | `maxIterations` | 同上 |
108
- | `tools` | `ctx.llm()` 可调工具:**provider token 列表**(复用该 provider 的 `@Tool` 菜单) |
108
+ | `tools` | `ctx.llm()` 可调工具:**provider token 列表**(复用该 provider 的能力菜单),或 `'<token>/<能力名>'` 能力级路径(只引菜单里的单个能力) |
109
109
 
110
110
  技能方法体拿到的第二参是 `SkillContext`:`ctx.llm({ prompt })` 才会真正调模型(脚本式,调几次由你写死)。
111
111
 
@@ -117,7 +117,7 @@ npx @migor/cli doctor # 静态体检(未登记/悬空/命名/重复
117
117
  | `schema` | 主 agent 填给子 agent 的任务入参 schema |
118
118
  | `name` | 缺省取方法名 |
119
119
  | `system` | 子 agent 的角色提示:`string` / `SystemPrompt` / `(task) => SystemParam` |
120
- | `tools` | 子 agent 可调工具:**provider token 列表** |
120
+ | `tools` | 子 agent 可调工具:**provider token 列表**,或 `'<token>/<能力名>'` 能力级路径 |
121
121
  | `model` | 子 agent 自己的模型 |
122
122
  | `maxTokens` | 同上 |
123
123
  | `maxIterations` | 同上 |
@@ -251,6 +251,25 @@ result.typed; // { answer: string } | undefined
251
251
 
252
252
  模型没提交就是 `undefined`(不是失败)。`stopReason` 仍以 `end_turn` 正常收尾。
253
253
 
254
+ ### 5.4 消息类型族(自有公共类型,与厂商 SDK 结构兼容)
255
+
256
+ `messages` 与模型响应的类型是框架**自有定义**(不从 `@anthropic-ai/sdk` 引类型),字段口径与
257
+ Anthropic Messages API 逐字对齐(snake_case),并与 SDK 的对应类型**结构兼容**:
258
+ 手里的 `Anthropic.MessageParam[]` 可以直接喂给 `app.run` / `executeRun`;装框架**不会**连带安装厂商 SDK。
259
+
260
+ | 类型 | 说明 |
261
+ |---|---|
262
+ | `MessageParam` | 请求消息 `{ role, content: string \| ContentBlockParam[] }`(`Role` 含 `'system'`,与 SDK 逐字对齐;发给端点仍是 user/assistant 语义) |
263
+ | `ContentBlockParam` | 请求块联合:text / image / tool_use / tool_result + `{ type: string }` 兜底成员(厂商新块型原样携带,读字段先按 `type` 收窄) |
264
+ | `Message` | 模型响应(`finalMessage()` 的产物):`{ id, type, role, content, model, stop_reason, stop_sequence, usage }` |
265
+ | `ContentBlock` | 响应块联合:text / tool_use / thinking + `{ type: string }` 兜底成员 |
266
+ | `ToolParam` | 发给模型的工具定义(**避让 @Tool 装饰器**,故不叫 Tool) |
267
+ | `MessageUsage` | 响应的 token 计量(**避让 trace 的 `Usage`**,故不叫 Usage) |
268
+ | `TextBlockParam` / `ImageBlockParam` / `ToolUseBlockParam` / `ToolResultBlockParam` | 请求侧具体块(与 SDK 同名类型逐字对齐) |
269
+ | `TextBlock` / `ToolUseBlock` / `ThinkingBlock` / `CacheControl` / `Role` | 响应侧具体块 / cache 断点标记 / 角色联合 |
270
+
271
+ 注意命名避让:`Tool` 是装饰器、`Usage` 是 trace 的聚合用量(camelCase)—— 消息侧对应物分别是 `ToolParam` 与 `MessageUsage`(snake_case)。
272
+
254
273
  ---
255
274
 
256
275
  ## 6. 运行时 API
@@ -410,7 +429,7 @@ process.on('SIGTERM', async () => {
410
429
  | `mapWithConcurrency` | 有界并发 map(结果保序);`maxToolConcurrency` 的底座,也可自用 |
411
430
 
412
431
  - **取消**:`app.run(messages, { signal })` 传 `AbortSignal` —— 框架会 abort 在飞请求(内置 Anthropic / OpenAI 适配器都转发 `signal`),run 以 `stopReason='aborted'` 收尾(算失败)。`createHttpHandler` 已内置「客户端断开即中止」;`AsyncRunner.runTimeoutMs` 到点同样是**真中止**(构造期校验:必须 ≥ 0 的**有限**数 —— NaN/Infinity 会被 `setTimeout` 钳到 1ms,等于每个任务立即超时,故直接抛错;要「不限」传 0 或不设)。
413
- - **重试**:缺省自动重试可重试失败(429 / 5xx / 连接失败),指数退避 + 抖动。`retry: false` 关闭,或 `retry: { maxAttempts, baseDelayMs, maxDelayMs, jitter, onRetry }` 调参。**只在本次尝试尚未产出任何文本时重试**(已吐出的字无法撤回)。⚠️ 与 SDK 内置重试叠加 —— 建议二选一调(这里 `maxAttempts: 1` 或把 SDK 的 `maxRetries` 调小)。
432
+ - **重试**:缺省自动重试可重试失败(429 / 5xx / 连接失败),指数退避 + 抖动。`retry: false` 关闭,或 `retry: { maxAttempts, baseDelayMs, maxDelayMs, jitter, onRetry }` 调参。**只在本次尝试尚未产出任何文本时重试**(已吐出的字无法撤回)。⚠️ 与底层 client 的内置重试叠加(默认 client 的 `maxRetries` 缺省 2)—— 建议二选一调(这里 `maxAttempts: 1`,或 `createAnthropicClient({ maxRetries: 0 })`)。
414
433
  - **流式**:`POST /run` 带 `Accept: text/event-stream` → SSE 逐帧下发(`text.delta` / `run.end` / `error`);不带该头仍回一元 JSON。
415
434
  - **工具超时 / 并发闸门**:`toolTimeoutMs` 超时**不杀 run**(该条 tool_result 记 `is_error`,模型可换路);`maxToolConcurrency` 给同回合的并行工具设上限(默认全并行)。⚠️ 超时 = **放弃等待**,`AgentTool.run` 没有 signal 参数,**副作用可能已发生** —— 想真停的工具请自行读 `ToolRunContext.signal`。
416
435
 
@@ -516,11 +535,13 @@ if (result.stopReason === 'budget_exceeded') console.warn('这次 run 被预算
516
535
 
517
536
  | API | 说明 |
518
537
  |---|---|
519
- | `createAnthropicClient` | 默认 ModelClient(Anthropic):自定义只传 `apiKey` / `baseURL`,不必直接依赖厂商 SDK |
538
+ | `createAnthropicClient` | 默认 ModelClient(Anthropic):自定义只传 `apiKey` / `baseURL`;框架**零运行时依赖**,不装厂商 SDK |
520
539
  | `createOpenAIClient` | OpenAI 兼容端点适配(DeepSeek 等;**真流式**、图片块转 `image_url`、cache token 恒 0) |
521
540
  | `InMemoryMemoryStore` | 跨 run 的**键值黑板**记忆(`{ store, keys }` 配 `executeRun`) |
522
541
  | `InMemorySessionStore` | 跨 run 的**对话历史**(`{ store, id }` 配 `executeRun` / `app.run`);与前者正交,可同时用 |
523
542
  | `traceToMessages` | 把 trace 还原成 messages(重放基底) |
543
+ | `forkMessages` | 分叉重放:在主循环第 `atTurn` 回合之前截断重放历史、拼上 `append` 新消息喂回 `app.run`(「从第 N 回合换个问法重跑」的基底,**不是续跑**) |
544
+ | `diffTraces` | 两条 trace 的 A/B 比对(prompt / 模型实验):run 级 summary + 逐 span 字段差;纯函数,llm.turn 配对**忽略模型名**,缺省忽略墙钟 |
524
545
  | `applyMiddleware` | 手动包裹配置菜单(装配层已自动做) |
525
546
 
526
547
  ### MCP 桥(MCP 是「工具来源」,不是新机制)
@@ -602,6 +623,48 @@ agentia harvest trace.jsonl --failed --limit 5 --out evals/harvested.ts
602
623
  - 预填的 `expect` 是从原 trace **抄录的实际轨迹** —— 发生过 ≠ 应该发生;
603
624
  - `EvalCase` 没有 `expect` 字段,粘贴时把断言搬进 `defineEval({ expect })`(脚手架注释会教)。
604
625
 
626
+ ### prompt / 模型 A/B(trace diff 与分叉重放)
627
+
628
+ 同一份输入跑两条 run(换模型、换 `SystemPrompt` 版本、换 prompt 都行),用 `diffTraces` 比出**结构与成本差**;
629
+ 想「从第 3 回合换个问法重跑」,用 `forkMessages` 在分叉点截断、拼上新消息喂回 `app.run`:
630
+
631
+ ```ts
632
+ import { diffTraces, forkMessages } from '@migor/agentia';
633
+
634
+ // A/B:同输入,只换模型(或换 system 版本),各跑一条
635
+ const a = await app.run(input, { model: 'claude-sonnet-5' });
636
+ const b = await app.run(input, { model: 'claude-opus-5' });
637
+
638
+ const diff = diffTraces(a.result.trace, b.result.trace);
639
+ // diff.summary:status / totalUsage.* / 根 attributes 差 —— A/B 模型第一眼就看 attributes.model
640
+ // diff.spans:逐 span 字段级差异,path 形如 run:main/llm.turn#0/capability:search
641
+ if (!diff.equal) console.log(diff.summary, diff.spans);
642
+
643
+ // 分叉重放:在主循环第 3 回合(0-based)之前截断,该回合及其后丢弃,换个问法继续
644
+ const messages = forkMessages(a.result.trace, {
645
+ atTurn: 3, // 合法范围 0..主循环回合数-1,越界抛可读错误
646
+ append: [{ role: 'user', content: '换个思路:先给结论,再补证据。' }],
647
+ });
648
+ const c = await app.run(messages); // 起一条新 run,沿着分叉点前的真实 tool 历史继续
649
+ ```
650
+
651
+ 不落代码也可以直比两份 trace 导出:`agentia diff a.jsonl b.jsonl`(输入形态同 `agentia report`),
652
+ 打印 run 级 summary + 逐 span 差异,**有差异时退出码 1**(diff(1) 语义)——可直接进 CI 挡
653
+ 「换 prompt / 模型后轨迹漂移」。
654
+
655
+ - **配对语义**(结构性配对,字段差异不影响配对):llm.turn 按回合序配对、**忽略 span name** ——
656
+ name 是模型 id,而「换模型重跑」正是 A/B 主用例,按 name 配对会把两侧所有 turn 报成缺失;
657
+ 模型差异降格为配对 turn 的 `name` 字段差。capability span 按 `kind:name` 配对
658
+ (`skill:foo` vs `skill:bar` 是不同能力,不该配上)。一侧多出的调用树记**一条缺侧记录**
659
+ (`SpanDiff.fields` 为空、`path` 照给),整支子树不再下钻。
660
+ - **墙钟缺省不比**:`ignoreTiming` 缺省 true(A/B 不关心时序);传 `false` 改比 span 时长
661
+ (`duration`),绝对时间戳永不比;`traceId` 是身份不是行为,同样永不比。
662
+ - ⚠️ **有损边界(与 `traceToMessages` / harvest 同源)**:trace **不记 assistant 文本与 run 的
663
+ 原始输入** —— 重放里 assistant 是标注占位(非逐字原文)、首尾说明性 user 是合成。
664
+ 因此 `forkMessages` **不是「续跑」**:它产出的是一份喂回 `app.run` / `runAgent` 的 messages,
665
+ 跑的是一条**新 run**,不是接着原 run 的循环位置。分叉的 blackboard 种子由调用方自带
666
+ (`app.run(messages, { blackboard: {…} })` —— trace 不记 blackboard)。
667
+
605
668
  ### 指标(从 trace 派生)
606
669
 
607
670
  | API | 说明 |
@@ -878,11 +941,13 @@ const callable = {
878
941
  | schema 校验是**子集** | 只覆盖 `type/properties/required/additionalProperties/enum/items`;`format`/`minimum`/`oneOf` 一律放行 |
879
942
  | 历史畸形就放弃裁剪 | `trimToolPairs` 遇到非严格交替历史会整体放弃(宁可少裁,也不切出孤立 tool_use 让请求 400) |
880
943
  | 缺省内存 store 不淘汰 | 长跑宿主请设 `InMemoryTaskStore({ maxRecords })` 或换 `FileTaskStore` / `SqliteTaskStore` |
881
- | 能力引用是 provider 粒度 | 子 agent / skill 的 `tools` 写的是 **provider token**,不是单个工具名 |
944
+ | 能力引用两种粒度 | `tools` 写 **provider token** = 整片能力菜单;写 `'<token>/<能力名>'` = 只引单个能力(@Tool/@Skill/@SubAgent/@Prompt 都可点名,装配期校验,名字不存在即抛错并列出可用名单) |
882
945
  | 能力名有格式校验 | 装饰器能力名(`name` 或缺省的方法名)必须匹配 `^[A-Za-z0-9_-]{1,64}$`(与 MCP 桥同口径),非法名在 `createApp` **装配期即抛错** —— 含空格/点/中文的名字会让模型 API 400,宁可在启动期拦住 |
883
946
  | `discover` 入口会回落 | 能力目录里源码与编译产物并存(`index.ts` + `index.js`)时,首选 `.ts` 加载失败会**回落 `.js` 并 warn** —— 命中的可能是**陈旧编译产物**(刚改过源码时注意);全部候选都失败才抛错并列出各自原因 |
884
947
  | `asset()` 的 rel 必须是相对路径 | 带 scheme(`file:` / `https:` …)的 rel 会让 `new URL(rel, base)` 整个忽略 base(「以为读了能力目录、实际读了别处」),显式抛错;`../` 越出能力目录是**有意放行**(共享资产如 `../../shared/x.md` 是合法用法) |
885
948
  | 取消要传进客户端才有效 | 传 `signal` 后框架会 abort 在飞请求(内置 Anthropic / OpenAI 适配器都转发);不转发 `signal` 的自定义 `ModelClient` 只能「放弃等待」(请求在后台跑完、产物丢弃) |
949
+ | 默认 client 的真端点验证范围 | `e2e:live` 跑在 DeepSeek 的 Anthropic **兼容**端点上;官方 Anthropic 端点的行为差异(thinking 细节、cache TTL 语义、新块型)目前只有本地假端点测试在守 —— 「mock 全绿发现不了厂商真实行为」是本框架自己记过的教训,接入官方端点前自己跑一遍 `npm run e2e:live` |
950
+ | thinking 块「能收、不主动请求」 | 框架**从不**在请求里开 extended thinking;默认 client 能收拼 thinking 块(`signature_delta` 会累积),`redacted_thinking` 与未知块型**原样透传**不丢 —— 但官方 API 的 thinking 回灌要求带合法 `signature`,自定义 client 开 thinking 时自己验证这条链 |
886
951
  | 工具阶段的 abort 有盲区 | abort 只在三处被观察:**回合边界 / 在飞模型请求 / 重试退避 sleep**。没设 `toolTimeoutMs` 且工具挂死时,abort 之后 run 也不会返回(工具的 Promise 永不 settle)—— 挂死的工具要么设超时,要么自己读 `ToolRunContext.signal` |
887
952
  | 观测失败被吞 | sink 抛错不影响 run(观测是辅助动作);同理记忆水合/回写失败也不击穿 run |
888
953
  | 框架不自动读 .env | 除 `AGENTIA_MODEL`(缺省模型覆盖)与 `OPENAI_API_KEY`(OpenAI 适配器)外,框架自己不去翻环境变量,也不读 `.env`;要读就在启动代码里调 `loadEnvFile()`(脚手架已内置那行),**真实环境变量优先**于文件 |
@@ -902,7 +967,7 @@ const callable = {
902
967
  | MCP 的协议层错误框架看不见 | `isError: true` 只有连接器能看见 —— 它必须转成抛错,否则模型收到的是一条「成功」的结果 |
903
968
  | MCP 超时同样是「不等了」 | 桥自带的 `timeoutMs` 取消不了 server 侧执行(拿不到取消句柄);它与 engine 的 `toolTimeoutMs` **双重计时**,谁短谁生效 |
904
969
  | MCP 名字可能被归一化 | 原名含 `-` / `.` / 空格 → 进菜单时变成 `_`;回调 server 用的仍是原名(`mcp.tool` attribute 里查得到) |
905
- | MCP 工具不能进 DI 容器 | 它没有 provider token,也不能被别的能力的 `tools` 引用 —— 引用是 provider 粒度 |
970
+ | MCP 工具不能进 DI 容器 | 它没有 provider token,也不能被别的能力的 `tools` 引用(两种引用粒度都要先有 token) |
906
971
  | 指标分位是窗口内精确值 | `*_last{quantile=...}` 只反映最近 `windowSize`(缺省 1024)条样本;要跨实例聚合请用直方图(`*_bucket` / `_sum` / `_count`,累积语义) |
907
972
  | 指标是**进程内**累加 | 不做分布式聚合与持久化:多实例各算各的(直方图可相加),重启即清零。要长期保留请把 `render()` 抓走或用 `export:'otlp'` 推给采集端 |
908
973
  | OTLP metrics 只推当前累计 | 按 `intervalMs` 周期导出**累积值**(CUMULATIVE),不做增量/背压;导出失败按 `onExportError` 处理(缺省吞掉,不重试、不阻塞 run) |
@@ -912,6 +977,7 @@ const callable = {
912
977
  | 能力标签有基数上限 | `labelMode:'capability'`(缺省)+ `maxCapabilities`(缺省 200),超出的能力归入 `capability="__other__"`;`snapshot().droppedCapabilities` 给出被归并的能力个数。要完整明细请用 `buildRunReport`(不设上限) |
913
978
  | 提示词版本只是标记 | 框架不存版本库、不回滚:`version` 只落 run 根 attribute;`system` 传已拼好的 `SystemParam` 时无版本可记 |
914
979
  | `agentia harvest` 的产物是轨迹骨架 | trace **不记 assistant 文本**(llm.turn 只记 usage/事件),故 harvest 用例脚本里的 text 块是占位、预填 `expect` 是从原 trace 抄录的实际轨迹 —— 脚手架不是成品,人工核对后再进 CI(见 §6「线上 trace 回流」) |
980
+ | 分叉重放不是续跑 | `forkMessages` 与 `traceToMessages` / harvest **同源有损**:trace 不记 assistant 文本与 run 原始输入(重放里 assistant 是标注占位、首尾 user 是合成),也不记 blackboard(分叉种子经 `RunInvocationOptions.blackboard` 自带);它产出喂回 `app.run` 的 messages、起的是**新 run**,不是接着原 run 的循环位置跑 |
915
981
  | 评分来自 run 之外 | `Score` 走 run 根 `score` **事件**而非 span 字段(评分通常在 run 跑完后才产生);`attachScore` 找不到根 span 时静默忽略,多次调用即多条事件(不同维度各记各的) |
916
982
  | 配额不是框架子系统 | 只给缝(middleware + TraceSink + BudgetGuard),计数放哪(内存 / Redis / DB)与超限怎么办都是你的策略 |
917
983
  | 人工介入只到「闸门」 | `middleware` 能 `await` 审批决策再放行;**跨进程挂起/续跑框架不做** —— `RunStatus` 无「待批准」态、循环位置不落库,`traceToMessages` 重放有损,不能拿它假装续跑(要跨重启审批请上工作流引擎)|
@@ -931,7 +997,7 @@ const callable = {
931
997
  | `ctx.get('k')` 没有类型 | 没做 `Blackboard` 声明合并(见 5.1) |
932
998
  | `result.typed` 是 `unknown` | `resultSchema` 用的是裸 JsonSchema;改 `fromZod<T>`(见 5.3) |
933
999
  | TS 里想 `app.my_tool(...)` | 不要这样写:能力由模型选择,不是你的方法。要确定性调用就**直接调类方法** |
934
- | 子 agent 调不到工具 | `tools` 是 **provider token**(文件夹名)列表,不是工具名 |
1000
+ | 子 agent 调不到工具 | `tools` 的元素是 **provider token** 或 `'<token>/<能力名>'` 路径,不是裸工具名(裸名字会按「未注册 provider」在装配期抛错) |
935
1001
  | 长跑内存涨 | 缺省内存 store 不淘汰;设 `InMemoryTaskStore({ maxRecords })` 或换耐久 store |
936
1002
  | 鉴权钩子抛错,客户端只看到「未通过鉴权」 | 这是设计:非 `HttpException` 的错误原文只进服务端日志(要回给调用方就抛 `HttpException(status, body)`) |
937
1003
  | 停机后 `POST /tasks` 回 503 | `drain()` 已被调用(或注入的 runner 已 drain)—— 这是「拒新单」的正常行为,任务没丢 |
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
@@ -9,6 +9,7 @@ import { doctor } from './doctor.js';
9
9
  import { addPackage } from './add.js';
10
10
  import { reportCommand } from './report.js';
11
11
  import { harvestCommand } from './harvest.js';
12
+ import { diffCommand } from './diff.js';
12
13
  const USAGE = `agentia —— Agentia 框架命令行工具
13
14
 
14
15
  用法:
@@ -20,6 +21,7 @@ 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
 
@@ -109,6 +111,14 @@ function main(argv) {
109
111
  });
110
112
  return 0;
111
113
  }
114
+ if (command === 'diff') {
115
+ // 异步命令(读文件):同 report 的受理模式
116
+ void diffCommand(rest).catch((e) => {
117
+ console.error(`错误:${e.message}`);
118
+ process.exitCode = 1;
119
+ });
120
+ return 0;
121
+ }
112
122
  if (command === 'add') {
113
123
  const [pkg, ...extra] = rest;
114
124
  if (pkg === undefined || extra.length > 0) {
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,300 @@
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
+ const USAGE = '用法:agentia diff <a.jsonl> <b.jsonl>';
220
+ /** 逐行 JSON.parse + extractTrace(同 harvest),取第一条可提取的 trace;坏行跳过计数 */
221
+ async function readFirstTrace(file) {
222
+ let raw;
223
+ try {
224
+ raw = await readFile(file, 'utf8');
225
+ }
226
+ catch (e) {
227
+ throw new Error(`读不到文件 ${file}(${e.message})`);
228
+ }
229
+ let badLines = 0;
230
+ for (const line of raw.split('\n')) {
231
+ const s = line.trim();
232
+ if (!s)
233
+ continue;
234
+ try {
235
+ const t = extractTrace(JSON.parse(s));
236
+ if (t)
237
+ return { trace: t, badLines };
238
+ badLines += 1;
239
+ }
240
+ catch {
241
+ badLines += 1;
242
+ }
243
+ }
244
+ throw new Error(`${file} 里没有可识别的 trace(支持裸 Trace 或含 result.trace / trace 的记录)` +
245
+ (badLines > 0 ? `;另有 ${badLines} 行无法解析` : ''));
246
+ }
247
+ /** 差异值 → 展示串:stringifySafe 序列化(短串直接放),长串截断 200 字符(同 DIFF_VALUE_CHARS) */
248
+ function fmtValue(v) {
249
+ return truncateWithMark(stringifySafe(v), DIFF_VALUE_CHARS);
250
+ }
251
+ function fmtEntry(e) {
252
+ return ` ${e.field}: ${fmtValue(e.a)} → ${fmtValue(e.b)}`;
253
+ }
254
+ /** 失败一律**抛错**(同 report/harvest 的受理模式);「有差异」不是错误,就地设 exitCode = 1 */
255
+ export async function diffCommand(args) {
256
+ const files = [];
257
+ for (const a of args) {
258
+ if (a.startsWith('--'))
259
+ throw new Error(`未知参数:${a}\n${USAGE}`);
260
+ files.push(a);
261
+ }
262
+ if (files.length !== 2)
263
+ throw new Error(USAGE);
264
+ const [fileA, fileB] = files;
265
+ const ra = await readFirstTrace(fileA);
266
+ const rb = await readFirstTrace(fileB);
267
+ const d = diffTraces(ra.trace, rb.trace);
268
+ const badNote = (n) => (n > 0 ? `(跳过无法解析 ${n} 行)` : '');
269
+ const lines = [
270
+ `trace a ${fileA}(${ra.trace.traceId ?? '(无 traceId)'})${badNote(ra.badLines)}`,
271
+ `trace b ${fileB}(${rb.trace.traceId ?? '(无 traceId)'})${badNote(rb.badLines)}`,
272
+ ];
273
+ if (d.equal) {
274
+ lines.push('两条 trace 等价(无结构与字段差异)');
275
+ process.stdout.write(`${lines.join('\n')}\n`);
276
+ return 0;
277
+ }
278
+ lines.push('');
279
+ if (d.summary.length > 0) {
280
+ lines.push('run 级差异:');
281
+ for (const e of d.summary)
282
+ lines.push(fmtEntry(e));
283
+ }
284
+ if (d.spans.length > 0) {
285
+ if (d.summary.length > 0)
286
+ lines.push('');
287
+ lines.push('span 差异:');
288
+ for (const s of d.spans) {
289
+ const side = s.a === undefined ? ' (仅存在于 b)' : s.b === undefined ? ' (仅存在于 a)' : '';
290
+ lines.push(`${s.path}${side}`);
291
+ for (const e of s.fields)
292
+ lines.push(fmtEntry(e));
293
+ }
294
+ }
295
+ process.stdout.write(`${lines.join('\n')}\n`);
296
+ // diff(1) 语义:不同即 1。此刻 cli.ts 末尾的 `process.exitCode = main(...)` 已同步执行完,
297
+ // 这里的赋值在其后(await 之后),不会被覆盖。
298
+ process.exitCode = 1;
299
+ return 0;
300
+ }
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/npm-bin.js CHANGED
@@ -1,7 +1,44 @@
1
1
  /**
2
- * Windows 上 npm / npx 是 .cmd shim:不带 shell 的 spawn / spawnSync 无法直接执行
3
- * (ENOENT)。统一在这里按平台点名 —— 保持 CLI 零依赖(不引 cross-spawn)。
2
+ * Windows 上 npm / npx 是 .cmd shim:Node 修补 CVE-2024-27980(18.20.2 / 20.12.2 / 21.7.3+)后,
3
+ * 不带 shell 直接 spawn .cmd 会 EINVAL。统一在这里产出 spawn 描述(cross-spawn 同思路,
4
+ * 保持零依赖):win32 走 cmd.exe 包装 + 逐参数脱敏,其余平台原样直 spawn。
5
+ *
6
+ * 为什么不用 options.shell:true —— shell:true 时 Node 把「命令 + 参数」用空格 join 后
7
+ * 整体塞给 cmd.exe,参数**不做任何转义**;而 add 的参数是用户输入的包名/本地路径,
8
+ * 含 `&` / `|` / `%` 即成命令注入。所以这里走 cmd.exe 包装并逐参数脱敏
9
+ * (算法取自 cross-spawn lib/util/escape.js,https://qntm.org/cmd)。
4
10
  */
5
- export function npmBin(bin, platform = process.platform) {
6
- return platform === 'win32' ? `${bin}.cmd` : bin;
11
+ // cmd.exe 元字符(见 http://www.robvanderwoude.com/escapechars.php)
12
+ const CMD_META_CHARS = /([()\][%!^"`<>&|;, *?])/g;
13
+ /** 命令名脱敏(npm.cmd 本身):不加引号,只 ^ 脱敏元字符 */
14
+ function escapeCmdCommand(command) {
15
+ return command.replace(CMD_META_CHARS, '^$1');
16
+ }
17
+ /**
18
+ * 参数脱敏:反斜杠+引号序列按 CreateProcess 规则翻倍转义,整体加双引号,
19
+ * 再对 cmd 元字符加 ^(`^` 落在引号外才生效,所以先加引号后脱敏)。
20
+ */
21
+ function escapeCmdArg(arg) {
22
+ let out = arg
23
+ // 反斜杠序列 + 引号:反斜杠翻倍,引号加反斜杠
24
+ .replace(/(?=(\\+?)?)\1"/g, '$1$1\\"')
25
+ // 末尾反斜杠序列翻倍(随后会拼上收尾引号)
26
+ .replace(/(?=(\\+?)?)\1$/, '$1$1');
27
+ out = `"${out}"`;
28
+ return out.replace(CMD_META_CHARS, '^$1');
29
+ }
30
+ /**
31
+ * npm / npx 的 spawn 描述:win32 返回 cmd.exe 包装(参数已脱敏,
32
+ * 全局安装的 npm.cmd 非 node_modules/.bin 代理 shim,单层 ^ 脱敏即可);
33
+ * 其余平台原样返回(直接 spawn 可执行文件)。
34
+ */
35
+ export function npmSpawn(bin, args, platform = process.platform, comspec = process.env.comspec) {
36
+ if (platform !== 'win32')
37
+ return { command: bin, args, options: {} };
38
+ const shellCommand = [escapeCmdCommand(`${bin}.cmd`), ...args.map(escapeCmdArg)].join(' ');
39
+ return {
40
+ command: comspec ?? 'cmd.exe',
41
+ args: ['/d', '/s', '/c', `"${shellCommand}"`],
42
+ options: { windowsVerbatimArguments: true },
43
+ };
7
44
  }
package/dist/templates.js CHANGED
@@ -60,7 +60,7 @@ export function projectPackageJson(name) {
60
60
  dev: 'tsx src/main.ts',
61
61
  typecheck: 'tsc --noEmit -p tsconfig.json',
62
62
  },
63
- dependencies: { '@migor/agentia': '^0.5.0' },
63
+ dependencies: { '@migor/agentia': '^0.6.0' },
64
64
  devDependencies: {
65
65
  tsx: '^4.19.0',
66
66
  typescript: '^7.0.2',
@@ -76,6 +76,9 @@ export function projectTsconfig() {
76
76
  moduleResolution: 'NodeNext',
77
77
  lib: ['ES2022'],
78
78
  strict: true,
79
+ // 框架零运行时依赖(不再有厂商 SDK 经传递链把 @types/node 带进编译程序),
80
+ // node 全局类型必须显式声明
81
+ types: ['node'],
79
82
  esModuleInterop: true,
80
83
  skipLibCheck: true,
81
84
  },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@migor/cli",
3
- "version": "0.5.0",
3
+ "version": "0.6.0",
4
4
  "description": "Agentia 框架命令行工具:create 脚手架 + g 能力生成 + dev 本地可观测面板(trace inspector)",
5
5
  "type": "module",
6
6
  "bin": {
@@ -33,7 +33,7 @@
33
33
  "prepublishOnly": "node ../../scripts/check-release.mjs && npm run build"
34
34
  },
35
35
  "devDependencies": {
36
- "@types/node": "^22.0.2",
36
+ "@types/node": "^26.5.1",
37
37
  "typescript": "^7.0.2"
38
38
  }
39
39
  }