@migor/cli 0.2.2 → 0.4.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
@@ -70,9 +70,9 @@ console.log(result.finalText, result.stopReason, result.trace.totalUsage);
70
70
  ## 2. 项目结构(CLI 约定)
71
71
 
72
72
  ```bash
73
- npx @migor/cli create my-app # 脚手架
73
+ npx @migor/cli create my-app # 脚手架(含 .env / .env.example)
74
74
  cd my-app && npm install
75
- export ANTHROPIC_API_KEY=sk-ant-...
75
+ $EDITOR .env # 填 ANTHROPIC_API_KEY(脚手架已生成,且已被 .gitignore 挡住)
76
76
  npx @migor/cli dev # tsx watch + 本地 inspector 面板
77
77
  npx @migor/cli g tool fetch-weather # 生成到 src/tools/fetch-weather/(skill/prompt/subagent 同理)
78
78
  npx @migor/cli doctor # 静态体检(未登记/悬空/命名/重复)
@@ -162,6 +162,7 @@ npx @migor/cli doctor # 静态体检(未登记/悬空/命名/重复
162
162
  | `onUnpricedModel` | 遇到价格表外的模型时回调(`{ model, spanId }`,每个循环作用域内每模型一次);抛错被吞,**不改变 run 结局**(定价缺失是宿主配置问题)。用它接告警 |
163
163
  | `toolTimeoutMs` | 缺省单工具超时(毫秒);超时该条 tool_result 记 is_error,不杀 run |
164
164
  | `maxToolConcurrency` | 缺省同回合并行工具上限;不设 = 不限(全并行) |
165
+ | `maxEventChars` | 缺省 trace 事件正文截断上限(可被单次 run 覆盖):数字 = 入参/出参统一用该上限,`false` = **不截断**(完整正文进 trace,面板里能展开看全文);不设 = 框架缺省 |
165
166
 
166
167
  ### `app.run(messages, opts?: RunAppOptions)`
167
168
 
@@ -185,6 +186,7 @@ npx @migor/cli doctor # 静态体检(未登记/悬空/命名/重复
185
186
  | `onUnpricedModel` | 单次覆盖未定价回调(**是函数,因此不在 transport 的 `RunInvocationOptions` 里** —— 异步宿主不会替你传) |
186
187
  | `toolTimeoutMs` | 单个工具执行超时(毫秒);超时该条 tool_result 记 is_error,run 继续 |
187
188
  | `maxToolConcurrency` | 同回合并行工具上限;缺省不限 |
189
+ | `maxEventChars` | trace 事件正文截断上限(字符):数字 = 入参/出参统一用该上限,`false` = **不截断**;缺省按类型收敛(入参/成功出参 2000、失败出参 1000)。**透传给子 agent/skill 的子循环** —— 同一棵调用树上口径一致。只影响**记账**,回给模型的 tool_result 永远完整 |
188
190
  | `session` | 会话持久化 `{ store, id }`:run 前拼历史、成功收尾追加本轮(见 `SessionStore`) |
189
191
 
190
192
  返回 `AgentRunOutput`:`{ run, result }`。`result` 含 `trace` / `stopReason` / `finalText` / `iterations` / `error` / `typed`。
@@ -283,6 +285,31 @@ result.typed; // { answer: string } | undefined
283
285
  | `isSuccessStopReason` | `end_turn` / `stop_sequence` 都算正常收尾 |
284
286
  | `resolveDefaultModel` | 显式 > `AGENTIA_MODEL` > `claude-opus-5` |
285
287
 
288
+ ### 环境变量与 `.env`(`loadEnvFile`)
289
+
290
+ | API | 说明 |
291
+ |---|---|
292
+ | `loadEnvFile` | 读一份 `.env` 进 `process.env`,返回**本次真正生效**的键;选项 `LoadEnvOptions`:`{ path?, override? }` |
293
+
294
+ ```ts
295
+ import { createApp, loadEnvFile } from '@migor/agentia';
296
+
297
+ loadEnvFile(); // 缺省 cwd/.env;文件不存在 = 静默返回 {}(首次 clone、CI 的正常路径)
298
+ const app = await createApp({ ... });
299
+ ```
300
+
301
+ 三条语义(都刻意,别当成实现细节):
302
+
303
+ - **框架不自动读 `.env`** —— 读哪个文件、什么时候读是宿主的启动决策。塞进 `createApp` 里自动做,会让「同一份代码换个目录跑结果不同」变成要花时间排查的悬案;而放 CLI 里只有 `agentia dev` 生效。写在**你的** `main.ts` 里,`node dist/main.js`、docker、别的宿主都一样读得到。
304
+ - **真实环境变量优先**(缺省不覆盖):`process.env` 里已定义(哪怕空串)的键保持不动 —— CI / docker / `FOO=bar npm start` 永远赢过文件。要让文件里的值压过环境变量就 `loadEnvFile({ override: true })`。
305
+ - **想知道「生效没」看返回值**,别去看文件:被挡下的键不在返回对象里。
306
+
307
+ `agentia create` 生成的脚手架把 `.env`、`.env.example` 与 `main.ts` 首行的 `loadEnvFile();` 都备好了,并在 `.gitignore` 里挡住 `.env` —— 生成 `.env` 却不 ignore,等于把 key 送进用户的第一个 commit。
308
+
309
+ > ⚠️ **本机 export 过 `ANTHROPIC_API_KEY` 的人**(比如同时用 Claude Code):按上面的优先级,脚手架 `.env` 里的 key 会被**静默压住**。改了 `.env` 却「没生效」时,先 `echo $ANTHROPIC_API_KEY` 看看环境里是不是已经有一份。
310
+
311
+ 解析规则(刻意窄,够用就好):`KEY=VALUE`,允许 `export ` 前缀与 `=` 两侧空白;`#` 整行注释;单引号内原样、双引号内认 `\n \r \t \" \\`;未加引号的值里 ` #` 起为行内注释。**不做变量展开、不合并续行**(需要就上专门的库);既不像 `KEY=VALUE` 又不是注释的行**直接报错并指出行号** —— 静默跳过等于让你以为「配上了其实没配上」。
312
+
286
313
  ### 宿主(换宿主不换语义)
287
314
 
288
315
  | API | 说明 |
@@ -320,14 +347,16 @@ const callable = { name: app.name, run: (msgs, opts) => app.run(msgs, { ...opts,
320
347
  const handler = createHttpHandler(callable, { runner });
321
348
  ```
322
349
 
323
- 异步侧更直接:`AsyncRunner` 的构造选项就有 `client`。完整可跑写法见仓库 `examples/complete/`。
350
+ 异步侧更直接:`AsyncRunner` 的构造选项就有 `client`。完整可跑写法见仓库 `examples/complete/`
351
+ (**不随 npm 包发布** —— 包里只有 `dist/`、README、LICENSE 与本说明):
352
+ <https://github.com/retrychx/agentia/tree/main/examples/complete>
324
353
 
325
354
  ### `createHttpHandler(app, opts?: HttpHandlerOptions)`
326
355
 
327
356
  | 选项 | 说明 |
328
357
  |---|---|
329
358
  | `authenticate` | 入口鉴权钩子:**除 `/healthz` 与 `/metrics` 外所有路径**都过它,且在**读 body 之前**(未通过就不收 body)。正常返回即通过;抛 `HttpException` 按其 `status`/`body` 回;抛别的错误回 401,原文只进服务端日志。框架**不实现策略**(不读 env、不碰凭据) |
330
- | `metrics` | 指标出口:给 `metricsSink()`(或任意 `{ render() }` / 返回字符串的闭包)后,`GET /metrics` 回它的 Prometheus 文本。**不鉴权**(拉取端在集群内网);要保护请放反代后面。不给则该路径 404 |
359
+ | `metrics` | 指标出口:给 `metricsSink()`(或任意 `{ render() }` / 返回字符串的闭包)后,`GET /metrics` 回它的 Prometheus 文本。**不鉴权**(拉取端在集群内网);要保护请放反代后面。不给则该路径 404。⚠️ 这只管**渲染** —— 数字要真的累计,必须把**同一个** sink 注册进 `createApp({ sinks: [metrics] })`(它靠 run 收尾投递,不自己埋点),否则 `/metrics` 恒为 0 **且不报错** |
331
360
  | `maxBodyBytes` | 请求 body 上限(字节),超限回 413;缺省 1 MiB |
332
361
  | `maxConcurrentRuns` | 同时在跑的 `POST /run` 上限,超限回 503 + `Retry-After`;缺省 32(传 `Infinity` 恢复无上限) |
333
362
  | `exposeErrors` | 是否把内部异常原文回给调用方;缺省 `false`(细节只进服务端日志) |
@@ -586,6 +615,18 @@ if (!report.ok) console.error(report.cases.filter((c) => !c.ok));
586
615
  - **内存上限** ≈ `(1 + 能力数 + 模型数) × windowSize` —— 能力数由 `maxCapabilities` 封顶,长跑宿主不会被拖住。
587
616
  - `costUsd` 依赖模型在价格表内(不在表里时不计、并计入 `unpricedTurns` 与 `usage.unpriced` 事件);根 span 未收尾(如失败路径的半截 trace)的 run 不进延迟样本。
588
617
 
618
+ ### 调用树面板(`agentia dev` 的本地面板 / 官网 Playground)
619
+
620
+ 同一份 `@migor/trace-view` 渲染器,**面板 / Playground / `report` 的能力排行三处共用**,不各写一套。
621
+
622
+ - **折叠态是「一行一件事」**:事件行(`tool.input` / `tool.output`)只显示省略号收敛的摘要。
623
+ - **点事件行展开看完整正文**(正文可选中复制),再点收起。展开的正文**默认仍与标签同一行**,
624
+ 只有在放不下时才整段换到下一行 —— 官网 Playground 的 trace 列(约 320px)就属于放不下。
625
+ - **可展开的行右端常显一个 caret**(`▸` / `▾`):这一行能不能展开随时看得见,不必先悬停才发现。
626
+ - **展开只能展开 trace 里存着的正文** —— 想看到被截断掉的部分,得在**记账时**就别截:`maxEventChars: false`(见 §4)。缺省截到 2000 字符,展开了也只有那 2000 字符。
627
+ - 入参折叠态的摘要被砍到 62 字符(4 个键 / 每值 21 字符);**展开拿到的是原文**,不是那份摘要 ——
628
+ 本地面板与官网 Playground 两个宿主都是如此(两个宿主都不该只喂摘要,否则点开什么都没多出来)。
629
+
589
630
  ### 调优报告(**哪个能力慢 / 贵 / 爱失败**)
590
631
 
591
632
  指标回答「整体怎么样」,报告回答「**该拧哪个旋钮**」:
@@ -613,12 +654,32 @@ CLI 侧有薄壳:`agentia report <trace.jsonl>` —— 每行一个 JSON(裸
613
654
  > 要看分位请用 `mergeRunReports` 汇总多条,或用 `metricsSink` 的直方图。
614
655
  > CLI 报告的聚合口径与 `agentia dev` 面板的能力排行同源(同一份 `@migor/trace-view` 实现)。
615
656
 
657
+ 那个 jsonl 从哪来 —— 框架不替你落盘(观测出口是缝),自己接一个 sink 就行,零依赖:
658
+
659
+ ```ts
660
+ import { appendFileSync } from 'node:fs';
661
+ import { createApp, type TraceSink } from '@migor/agentia';
662
+
663
+ const jsonl: TraceSink = {
664
+ export: (trace) => appendFileSync('trace.jsonl', `${JSON.stringify(trace)}\n`, 'utf8'),
665
+ };
666
+ const app = await createApp({ /* … */ sinks: [jsonl] });
667
+ // 之后:agentia report trace.jsonl
668
+ ```
669
+
670
+ 异步宿主更省事:把 `FileTaskStore` 的落盘文件直接喂给它 —— `TaskRecord` 里带 `result.trace`,
671
+ `report` 认这种形态,不用另写 sink。
672
+
616
673
  ### 生效配置快照(「这条 run 用了哪套旋钮」)
617
674
 
618
675
  每个 run 的**根 span** 都带一组 `config.*` attributes(`config.maxTokens` / `config.maxCostUsd` /
619
- `config.retry.maxAttempts` / `config.contextPolicy.budgetTokens` / `config.priceOverrides` …),
620
- 缺省值也记 —— 这样「没配」「配了缺省值」「配了别的值」三者可区分。换参数前后对比、复现线上行为都有据可查。
676
+ `config.retry.maxAttempts` / `config.contextPolicy.budgetTokens` / `config.priceOverrides` /
677
+ `config.maxEventChars` …)。
678
+ 带缺省值的那几项(`maxTokens` / `maxIterations` / `contextPolicy` / `retry`)**缺省值也记**——
679
+ 「没配」与「配了缺省值」因此可区分;可选项(`toolTimeoutMs` / `maxToolConcurrency` /
680
+ `maxEventChars`)只在设了才记。换参数前后对比、复现线上行为都有据可查。
621
681
  函数型选项(`summarize` / `confirm` 之类)只记「配没配」,不记函数体。
682
+ 截断关掉时记的是 `'off'` 而不是 `false` —— 后者在日志/看板里会被读成「上限为 0」。
622
683
 
623
684
  ### 提示词版本化
624
685
 
@@ -739,10 +800,10 @@ const callable = {
739
800
  | 能力引用是 provider 粒度 | 子 agent / skill 的 `tools` 写的是 **provider token**,不是单个工具名 |
740
801
  | 取消要传进客户端才有效 | 传 `signal` 后框架会 abort 在飞请求(内置 Anthropic / OpenAI 适配器都转发);不转发 `signal` 的自定义 `ModelClient` 只能「放弃等待」(请求在后台跑完、产物丢弃) |
741
802
  | 观测失败被吞 | sink 抛错不影响 run(观测是辅助动作);同理记忆水合/回写失败也不击穿 run |
742
- | 框架不读 env | 除 `AGENTIA_MODEL`(缺省模型覆盖)与 `OPENAI_API_KEY`(OpenAI 适配器)外不读环境变量;不含 dev 逻辑 |
803
+ | 框架不自动读 .env | 除 `AGENTIA_MODEL`(缺省模型覆盖)与 `OPENAI_API_KEY`(OpenAI 适配器)外,框架自己不去翻环境变量,也不读 `.env`;要读就在启动代码里调 `loadEnvFile()`(脚手架已内置那行),**真实环境变量优先**于文件 |
743
804
  | 鉴权只是缝 | 框架**不实现** token / JWT / 签名策略,也不碰凭据 env —— `authenticate` 只承诺「拦在入口、读 body 之前」;策略是宿主或反代的事 |
744
805
  | 运行时是 Node | 按 Node ≥ 18 设计与测试(`engines` 写明,CI 在 18/20/22 上守);**未对 Deno / edge 做验证**。`SqliteTaskStore` 需 Node ≥ 22.5(`node:sqlite`),未提供时构造期抛可读报错 |
745
- | 停机不由框架触发 | 框架给 `drain()` 但**不订阅** `SIGTERM`/`SIGINT`(不读 env、不做进程级决策);信号处理是宿主的 |
806
+ | 停机不由框架触发 | 框架给 `drain()` 但**不订阅** `SIGTERM`/`SIGINT`(不做进程级决策);信号处理是宿主的 |
746
807
  | 停机可能切断 SSE | `drain()` 超时后会强制关闭仍开着的 SSE 流,其 run 以 `stopReason='aborted'` 收尾 —— 客户端应把断流当作可重试 |
747
808
  | 鉴权失败即断连 | 未通过鉴权时在读到 body 之前就回响应,连接**不可复用**(显式 `connection: close`);这是「不收body省资源」的代价 |
748
809
  | 预算护栏不是硬实时 | 一回合记账完才判,实际用量可能超上限一个回合的量;模型自然收尾的那回合超限**不算失败**(只留 `budget.exceeded` 事件) |
package/dist/cli.js CHANGED
File without changes
package/dist/create.js CHANGED
@@ -2,7 +2,7 @@
2
2
  import { existsSync, mkdirSync, readdirSync, readFileSync, 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, projectGitignore, projectPackageJson, projectReadme, projectTsconfig, REGISTRY_PATH, toolIndexTs, } from './templates.js';
5
+ import { CAPABILITY_DIR_LIST, 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);
@@ -51,11 +51,16 @@ export function createProject(name, parent) {
51
51
  write(dir, REGISTRY_PATH, emptyRegistryTemplate());
52
52
  write(dir, 'README.md', projectReadme(name));
53
53
  write(dir, '.gitignore', projectGitignore());
54
- // 把四个分类目录都建出来:目录名自解释,用户一眼知道「新能力往哪放」
54
+ // 四个分类目录都建出来:目录名自解释,用户一看就知道新能力往哪放
55
55
  // (.gitkeep 让空目录能进版本库;discover 只认目录,会忽略它)
56
56
  for (const relDir of CAPABILITY_DIR_LIST) {
57
57
  write(dir, `${relDir}/.gitkeep`, '');
58
58
  }
59
+ // .env 是「填上就能跑」的入口(main.ts 首行 loadEnvFile() 读它);
60
+ // .env.example 进版本库当变量清单。**两者必须与 gitignore 的 .env 同时存在** ——
61
+ // 生成 .env 却不忽略它,等于把 key 直接送进用户的第一个 commit。
62
+ write(dir, '.env', projectDotEnv());
63
+ write(dir, '.env.example', projectDotEnvExample());
59
64
  // AI 使用说明:让 Claude Code / Cursor / Copilot 等一进项目就拿到权威 API 速查
60
65
  write(dir, 'AGENTS.md', guide);
61
66
  registerCapability(dir, 'hello', 'tool');
@@ -64,7 +69,7 @@ export function createProject(name, parent) {
64
69
  后续步骤:
65
70
  cd ${dir}
66
71
  npm install
67
- export ANTHROPIC_API_KEY=sk-ant-...
72
+ 把 API key 填进 .env(已生成,且已被 .gitignore 忽略)
68
73
  npm run dev
69
74
 
70
75
  目录约定:src/tools/ · src/skills/ · src/prompts/ · src/subagents/(一能力一文件夹)
@@ -14,7 +14,7 @@
14
14
  * 3. usage 只由 llm.turn 累计(capability span 的 usage 是子 span 聚合,计入会双算)。
15
15
  */
16
16
 
17
- import { fmtArg, capabilityTypeOf, CAP_ICO } from './view.js';
17
+ import { fmtArg, rawArg, capabilityTypeOf, CAP_ICO } from './view.js';
18
18
 
19
19
  /** 从 span.attributes 认能力类型(框架用 `setAttribute(capabilityId, 'subagent', name)` 记类型) */
20
20
  function spanType(s) {
@@ -67,6 +67,19 @@ function eventText(ev) {
67
67
  return body == null ? '' : typeof body === 'string' ? body : JSON.stringify(body);
68
68
  }
69
69
 
70
+ /**
71
+ * 事件全文(展开态用)。
72
+ *
73
+ * 只有 `tool.input` 需要单独取:`fmtArg` 把入参摘要**砍到 4 个键 / 每值 21 字符 / 整串 62 字符**,
74
+ * 直接拿摘要当全文就是「假展开」—— 点开看到的还是那 62 个字符。出参与其他事件本身就是原文
75
+ * (`eventText` 原样返回 content),无需二次处理。
76
+ * 原文口径统一在 view.js 的 `rawArg`:官网 playground 的模拟回放也要用它,两处一处定义。
77
+ */
78
+ function eventFull(ev) {
79
+ if (ev.name === 'tool.input') return rawArg(ev.body && ev.body.input);
80
+ return eventText(ev);
81
+ }
82
+
70
83
  /**
71
84
  * 把真实 Trace 线性回放给视图(同步、一次性;用于 run 结束后的静态展示)。
72
85
  * 返回 false 表示 trace 空、未做任何渲染。
@@ -126,6 +139,7 @@ export function playTrace(view, trace) {
126
139
  eventToolName(it.ev.name, it.ev.body && it.ev.body.tool, typeMap),
127
140
  eventText(it.ev),
128
141
  !it.ev.body || it.ev.body.ok !== false,
142
+ eventFull(it.ev),
129
143
  );
130
144
  } else {
131
145
  view.end({
@@ -1,6 +1,14 @@
1
1
  /* @migor/trace-view —— Agentia trace 调用树渲染器(框架无关、零依赖)。
2
2
  * 官网 playground 与 CLI inspector 共用同一份,避免两处渲染漂移。 */
3
3
 
4
- export { createTraceView, capabilityTypeOf, CAP_ICO, fmtArg, fmtNum, fmtMs } from './view.js';
4
+ export {
5
+ createTraceView,
6
+ capabilityTypeOf,
7
+ CAP_ICO,
8
+ fmtArg,
9
+ rawArg,
10
+ fmtNum,
11
+ fmtMs,
12
+ } from './view.js';
5
13
  export { playTrace } from './fromTrace.js';
6
14
  export { summarizeTrace, renderSummary } from './summary.js';
@@ -104,6 +104,57 @@
104
104
  color: #f7768e;
105
105
  }
106
106
 
107
+ /* —— 行展开:折叠时「一行一个语义单元」,点开的那一行改为显示**完整正文** ——
108
+ caret 绝对定位在行右端,不占 flex 宽度,所以行内其它元素的排版不受它影响。
109
+ **常显,并且是满不透明度的 `--faint`**:它是个**交互指示器**,不是装饰 ——
110
+ caret 用 0.75 的透明度试过,量出来是缺陷而非口味:WCAG 1.4.11 对非文本 UI 组件
111
+ 要求 ≥3:1,而 0.75 的 `--faint`(#71717a) 在官网 #0c0c0e 面板上实际绘制成 #58585f,
112
+ 只有 **2.77:1**(满不透明度 4.04:1)。
113
+ 所以没有「hover 才变亮」这一档:静止时它已经是最亮的那一档了。 */
114
+ .tr-row[data-expandable] {
115
+ position: relative;
116
+ cursor: pointer;
117
+ }
118
+ .tr-caret {
119
+ position: absolute;
120
+ right: 0;
121
+ top: 3px; /* 对齐 .tr-row 的 padding: 3px 0 */
122
+ color: var(--faint);
123
+ font-size: 10px;
124
+ }
125
+ /* caret 常显 ⇒ 右端要给它留一条窄槽。不留的话,被截断的正文省略号「…」正好落在 caret 底下,
126
+ ▸ 与 … 糊成一个字形。给正文加右内边距 = 内容盒右缘左移,省略号自行退到槽的左边。 */
127
+ .tr-row[data-expandable] .tr-io {
128
+ padding-right: 12px;
129
+ }
130
+ /* 只有点开的那一行换行;树的其余部分保持 nowrap 的列对齐 */
131
+ .tr-row.tr-open {
132
+ white-space: normal;
133
+ align-items: flex-start;
134
+ /* 允许正文换到下一行 —— 但**只在它真的放不下时**(判据是下面那条 min-width,不是无条件换行) */
135
+ flex-wrap: wrap;
136
+ }
137
+ /* 展开的正文**有高度上限**:工具出参动辄上千行(实测一条 60 行的出参就有 17878px 的
138
+ scrollWidth),不限高会把整个调用树推到屏幕之外,「看下一行」比「看全文」更常用。
139
+ 超出部分在行内滚动 —— 正文仍在 DOM 里,选中复制不受影响。 */
140
+ .tr-row.tr-open .tr-io {
141
+ /* 正文与标签**同一行**(既定的阅读节奏:一行一个语义单元)。换行只发生在**放不下时**,
142
+ 而这条线由 min-width 划 —— 所以 flex-basis 必须是 0:
143
+ · basis: auto ⇒ 换行判据是 max-content(正文动辄上百字符 ⇒ 几乎必然换行,等于无条件换行);
144
+ · basis: 0 ⇒ 判据回到 min-width,放得下就同排、放不下才整段换行。
145
+ 为什么 min-width 不能省:正文用 overflow-wrap: anywhere,min-content 只有 1 个字符宽 ——
146
+ 没有它时 flex 会心安理得地把正文压成一条 16px 的窄缝(59 个字符挤成 34 行、行高顶到上限),
147
+ 而页面 scrollWidth === clientWidth 一切「干净」,只是读不了。
148
+ 窄面板(官网 Playground 296px)里那行固定项本身就要 ~246px,所以那里它会如实地换行。 */
149
+ flex-basis: 0;
150
+ min-width: 22ch;
151
+ white-space: pre-wrap;
152
+ overflow-wrap: anywhere;
153
+ max-height: 60vh;
154
+ overflow: auto;
155
+ text-overflow: clip;
156
+ }
157
+
107
158
  @keyframes tr-blink {
108
159
  0%,
109
160
  100% {
@@ -9,6 +9,11 @@
9
9
  * 这个先后本身就是语义,不能拍平成一类;
10
10
  * - 四类能力标识符(⚙ tool / ◆ skill / ¶ prompt / ⊕ subagent)只靠字形区分、不上类型色。
11
11
  *
12
+ * 展开:折叠态**一字不变**(省略号收敛,事件行比 span 行更轻),点事件行可展开看**完整正文**。
13
+ * 展开状态存在渲染之外(每次事件都会全量重建 DOM),否则实时 run 里刚点开的行会在下一条事件
14
+ * 到来时自己合上。数据侧「行内摘要」与「展开全文」是两个字段:`text` 是摘要(入参经 fmtArg
15
+ * 砍到 62 字符),`full` 才是原文 —— 只有全文能撑起「调试时看工具结果」这件事。
16
+ *
12
17
  * 宿主需提供的 CSS 变量:--ice --faint --text(见 trace-view.css)。
13
18
  */
14
19
 
@@ -46,6 +51,19 @@ export function fmtArg(v) {
46
51
  return s.length > 62 ? s.slice(0, 61) + '…' : s;
47
52
  }
48
53
 
54
+ /* span 入参**原文** —— fmtArg 的对偶:摘要给折叠态,原文给展开态。
55
+ * 两者必须定义在同一处:哪个宿主自己搓一个「原文」,「展开」就会在那里退回摘要
56
+ * (点开什么都没多出来),而两个宿主共用这一份渲染器,正是为了不让这种漂移发生。 */
57
+ export function rawArg(v) {
58
+ if (v == null) return '';
59
+ if (typeof v === 'string') return v;
60
+ try {
61
+ return JSON.stringify(v) ?? String(v);
62
+ } catch {
63
+ return String(v);
64
+ }
65
+ }
66
+
49
67
  function el(tag, cls, text) {
50
68
  const node = document.createElement(tag);
51
69
  if (cls) node.className = cls;
@@ -67,6 +85,25 @@ export function createTraceView(rootEl, opts = {}) {
67
85
  let traceRoot = null;
68
86
  const spanMap = new Map();
69
87
  const usageAcc = { input: 0, output: 0, cacheRead: 0, cacheCreation: 0 };
88
+ /* 展开态的行 key 集合 + 事件 key 发号器。两者都必须活在 renderTrace 之外:
89
+ 渲染是【全量重建】(rootEl.innerHTML = ''),状态放进 DOM 或渲染过程里就活不过下一次事件。 */
90
+ const expanded = new Set();
91
+ let evSeq = 0;
92
+
93
+ /** 正在选文本时不要收起/展开 —— 鼠标拖选到行外松手会补一次 click,否则选完就自己合上了 */
94
+ function hasSelection() {
95
+ try {
96
+ return String(globalThis.getSelection?.() ?? '') !== '';
97
+ } catch {
98
+ return false;
99
+ }
100
+ }
101
+
102
+ function toggleExpand(key) {
103
+ if (expanded.has(key)) expanded.delete(key);
104
+ else expanded.add(key);
105
+ renderTrace();
106
+ }
70
107
 
71
108
  function renderTrace() {
72
109
  rootEl.innerHTML = '';
@@ -89,6 +126,7 @@ export function createTraceView(rootEl, opts = {}) {
89
126
 
90
127
  /* 事件行:没有 status 圈、没有耗时、不参与 usage 计数,只带入参/出参摘要 */
91
128
  if (ev) {
129
+ const open = expanded.has(ev.key);
92
130
  const row = el('div', 'tr-row tr-ev' + (ev.ok === false ? ' error' : ''));
93
131
  row.dataset.ev = ev.type;
94
132
  row.dataset.capability = capabilityTypeOf(ev.tool);
@@ -98,9 +136,19 @@ export function createTraceView(rootEl, opts = {}) {
98
136
  // 非 tool.* 事件(usage.unpriced / llm.retry / budget.* / context.budget)没有工具名 ——
99
137
  // 这一格整个不渲染,别把「无工具」显示成假工具名(.tr-name 是 flex:none,.tr-io 自然占满)
100
138
  if (ev.tool) row.appendChild(el('span', 'tr-name', ev.tool));
101
- const io = el('span', 'tr-io', ev.text || '');
102
- io.title = ev.text || '';
139
+ // 展开态用全文(ev.full),折叠态用摘要(ev.text)—— 折叠态因此与加展开之前逐字一致
140
+ const io = el('span', 'tr-io', (open ? ev.full : ev.text) || '');
141
+ // 展开后正文已在行里,再挂一个占满整行的原生 tooltip 只会挡视线
142
+ io.title = open ? '' : ev.text || '';
103
143
  row.appendChild(io);
144
+ if (ev.full) {
145
+ row.dataset.expandable = '1'; // 可展开标记:CSS 靠它出 caret / cursor,测试靠它找行
146
+ row.appendChild(el('span', 'tr-caret', open ? '▾' : '▸'));
147
+ if (open) row.className += ' tr-open';
148
+ row.addEventListener('click', () => {
149
+ if (!hasSelection()) toggleExpand(ev.key);
150
+ });
151
+ }
104
152
  rootEl.appendChild(row);
105
153
  return;
106
154
  }
@@ -175,6 +223,7 @@ export function createTraceView(rootEl, opts = {}) {
175
223
  };
176
224
  spanMap.clear();
177
225
  spanMap.set('root', traceRoot);
226
+ expanded.clear(); // 展开态属于上一棵树:reset 开新树时一并清掉
178
227
  usageAcc.input = 0;
179
228
  usageAcc.output = 0;
180
229
  usageAcc.cacheRead = 0;
@@ -206,11 +255,22 @@ export function createTraceView(rootEl, opts = {}) {
206
255
 
207
256
  /* span 事件:框架把普通工具 / @Prompt 调用记成【turn 上的事件】,不给它们建 capability span
208
257
  (只有 skill / subagent 会 recorder.begin('capability', …),见 engine/loop.ts)。
209
- events 与 order 并存:events 是数据、order 负责与子 span 的先后顺序。 */
210
- function event(id, type, tool, text, ok) {
258
+ events 与 order 并存:events 是数据、order 负责与子 span 的先后顺序。
259
+
260
+ `text` = 行内摘要,`full` = 展开看的原文(省略时同 text —— 出参与多数事件本来就是原文,
261
+ 只有 tool.input 的摘要被 fmtArg 砍过,调用方需要显式给 full)。
262
+ `key` 是展开状态的稳定标识:事件对象跨全量重建存活,所以键也稳定。 */
263
+ function event(id, type, tool, text, ok, full) {
211
264
  const node = spanMap.get(id);
212
265
  if (!node) return;
213
- const e = { type, tool, text, ok: ok !== false };
266
+ const e = {
267
+ type,
268
+ tool,
269
+ text,
270
+ ok: ok !== false,
271
+ full: full === undefined ? text : full,
272
+ key: 'ev' + ++evSeq,
273
+ };
214
274
  node.events.push(e);
215
275
  node.order.push({ t: 'ev', e });
216
276
  renderTrace();
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.2.2' },
63
+ dependencies: { '@migor/agentia': '^0.4.0' },
64
64
  devDependencies: {
65
65
  tsx: '^4.19.0',
66
66
  typescript: '^7.0.2',
@@ -83,7 +83,12 @@ export function projectTsconfig() {
83
83
  }, null, 2)}\n`;
84
84
  }
85
85
  export function mainTs(name) {
86
- return `import { createApp, SystemPrompt } from '@migor/agentia';
86
+ return `import { createApp, loadEnvFile, SystemPrompt } from '@migor/agentia';
87
+
88
+ // 读同目录的 .env(key 写文件里即可,不必每次 export)。框架**不自动**读 .env ——
89
+ // 读哪个文件、什么时候读由这里决定;已存在的真实环境变量优先,不会被文件覆盖。
90
+ // 想换路径/顺序:loadEnvFile({ path: '.env.local' }) 或直接删掉这一行改用自己的加载器。
91
+ loadEnvFile();
87
92
 
88
93
  const app = await createApp({
89
94
  name: '${name}',
@@ -99,7 +104,8 @@ const { result } = await app.run(
99
104
  // 「打印一行空白 + 退出 0」,让首次运行(比如忘了配 ANTHROPIC_API_KEY)看起来像成功。
100
105
  if (result.error) {
101
106
  console.error(\`run 失败(stopReason=\${result.stopReason}):\${result.error.message}\`);
102
- console.error('提示:模型调用默认读 ANTHROPIC_API_KEY;换端点或注入 client 见项目内 AGENTS.md。');
107
+ console.error('提示:模型调用读 ANTHROPIC_API_KEY —— 填进 .env(首行 loadEnvFile() 会读)或 export 均可;');
108
+ console.error(' 换端点 / 注入自定义 client 见项目内 AGENTS.md。');
103
109
  process.exitCode = 1;
104
110
  }
105
111
  if (result.finalText) console.log(result.finalText);
@@ -141,17 +147,52 @@ agentia g subagent my-agent # → src/subagents/my-agent/(含 system.md)
141
147
 
142
148
  ## 运行
143
149
 
144
- 需要 Anthropic API key:
150
+ 需要 Anthropic API key —— 填进脚手架已生成的 \`.env\` 即可(本文件在 \`.gitignore\` 里):
151
+
152
+ \`\`\`bash
153
+ # .env
154
+ ANTHROPIC_API_KEY=sk-ant-...
155
+ \`\`\`
156
+
157
+ \`\`\`bash
158
+ npm run dev -- "你的问题"
159
+ \`\`\`
160
+
161
+ 也可以用环境变量(适合 CI / 容器)——**真实环境变量优先,不会被 \`.env\` 覆盖**:
145
162
 
146
163
  \`\`\`bash
147
164
  export ANTHROPIC_API_KEY=sk-ant-...
148
165
  npm run dev -- "你的问题"
149
166
  \`\`\`
167
+
168
+ \`.env\` 由 \`src/main.ts\` 首行的 \`loadEnvFile()\` 读取。框架**不会自动读**它 ——
169
+ 读哪个文件、什么时候读由你的启动代码决定(这样「换目录跑」不会悄悄改变行为)。
150
170
  `;
151
171
  }
152
172
  export function projectGitignore() {
153
173
  return `node_modules
154
174
  dist
175
+
176
+ # 本地环境变量(可能含 key)—— 绝不提交
177
+ .env
178
+ .env.local
179
+ `;
180
+ }
181
+ /** 脚手架生成的 .env:填上就能跑,已进 .gitignore */
182
+ export function projectDotEnv() {
183
+ return `# 模型 API key —— 填上后 npm run dev / npm start 直接可用
184
+ # 本文件已被 .gitignore 忽略:不要提交,也不要把 key 写进 README / AGENTS.md
185
+ ANTHROPIC_API_KEY=
186
+ `;
187
+ }
188
+ /** 随脚手架提交的 .env.example:进版本库的变量清单,值一律留空 */
189
+ export function projectDotEnvExample() {
190
+ return `# 复制成 .env 再填值(.env 已 gitignore,不要提交)
191
+ ANTHROPIC_API_KEY=
192
+
193
+ # 可选
194
+ # ANTHROPIC_BASE_URL=https://api.anthropic.com
195
+ # AGENTIA_MODEL=claude-opus-5
155
196
  `;
156
197
  }
157
198
  // ---------- g 命令能力模板 ----------
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@migor/cli",
3
- "version": "0.2.2",
3
+ "version": "0.4.0",
4
4
  "description": "Agentia 框架命令行工具:create 脚手架 + g 能力生成 + dev 本地可观测面板(trace inspector)",
5
5
  "type": "module",
6
6
  "bin": {