@migor/cli 0.10.5 → 0.10.7

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/CHANGELOG.md CHANGED
@@ -5,6 +5,178 @@
5
5
  (0.x 阶段:minor 可含破坏性变更,每个破坏性变更都在对应版本的「迁移」小节里写明)。
6
6
  决策的完整证据链在 `docs/spec.md` §10(带时间线的决策日志)。
7
7
 
8
+ ## [0.10.7] - 2026-11-12
9
+
10
+ ### 变更
11
+
12
+ 本版只改**调试面板的展开呈现**,无破坏性变更、无迁移动作。两包同步 0.10.6 → 0.10.7;
13
+ `@migor/agentia` 此次只有版本常量变化(`src/` 一行未动),实际改动全在调试面板与
14
+ `agentia view` 的产物面。
15
+
16
+ - **展开态不再是一大坨 JSON**:`tool.input` / `tool.output` 是对象或数组时,展开行下方追加
17
+ 一个**结构化正文块** —— 缩进、四类值着色、头部写类型与规模(`object · 6 项`)。
18
+ 此前唯一的展开方式是把整串 JSON 折行,20 个键在 400px 宽的右栏里就是十几行连续的
19
+ `{"a":"b","c":{"d":`:页面不报错、无溢出、构建与 e2e 全绿,只是人读不了 ——
20
+ 而「展开看工具到底收到/返回了什么」正是这块面板的主用途。
21
+ - **逐层折叠**:容器默认只铺两层,更深的折成 `▸ watch: { object · 2 项 }`,点一下铺开。
22
+ 折叠状态由调用方持有,所以**实时 run 里刚折起的容器不会被下一条事件弹开**。
23
+ - **同构数组成表**:元素键集完全相等、且各列都是标量时铺成表(`targets[*].file` 这种表头),
24
+ 3 行 `{name, score}` 从 9 行重复键名压到 1 行表头 + 3 行数据。判据刻意从严 ——
25
+ 表格里没法逐层展开,判错一次就是**静默丢列**,所以任一条件不满足就回落树形。
26
+ - **键路径可复制**:悬停键名出 `⧉`,点一下写入 `results[0].name`。没有剪贴板实现时
27
+ 点击不生效、也不显示「已复制」—— 不把没做到的事显示成做到了。
28
+ - **三处宿主同时生效**:CLI 的 `agentia dev` 面板、`agentia view` 生成的单文件 HTML、
29
+ 官网 Playground 共用这份渲染器。
30
+ - 字符串形态的展开(`tool.output` 的文本正文、官网模拟回放)**照旧**走原来的纯文本换行 ——
31
+ 那一档有两个 CSS 不变量守着,刻意保留两档并存,不做统一。
32
+
33
+ ### 新增:展开态结构化渲染(2026-11-11 ref-trace-full-view)
34
+
35
+ - **新件 `packages/trace-view/src/full-view.js`**:入参任意值、出参一个元素,与树的构建与
36
+ 状态机无关(零依赖、纯 DOM 构造、无内部状态)。默认铺两层(`OPEN_DEPTH = 2`)。
37
+ - **数据侧递原值**:`fromTrace.eventFull` 对 `tool.input` / `tool.output` 返回**原值**而非
38
+ `JSON.stringify` 结果 —— 此前在数据层就序列化了,渲染器根本看不到对象,结构化无从发生。
39
+ `rawArg` 保留(`.tr-io` 的正文与既有展开断言都靠它)。
40
+ - **原文仍然在场**:结构化时给行打 `tr-structured`,CSS 只是隐藏 `.tr-io`,它依旧持有
41
+ `JSON.stringify` 原文 ⇒ 既有 9 条展开断言一条都不用改,用户选中复制也照旧。
42
+ 改的是**画法**,不是数据。
43
+ - **截断必须说出来**:单值超 240 字符写「还有 N 字符」,一层超 40 项写「还有 N 项未展开」,
44
+ 嵌套超 6 层压平但仍标出「它是个容器」。静默截断比糊成一坨更坏 —— 它会让人以为工具
45
+ 就返回了这么点东西。循环引用不抛(调试面板不该因一条坏数据白屏)。
46
+ - **CLI 侧**:`inspector-routes.ts` 的 STATIC 白名单加 `full-view.js`(漏登记的后果是浏览器
47
+ 404、面板整块求值失败),`packages/cli/test/structure.test.mjs` 的 W5 守着这条。
48
+ - **验证**:trace-view 测试 +19(渲染件 15、接线 4),跑真 `createTraceView` + `playTrace`;
49
+ 反向验证 5 条(不传 `fold` ⇒ 折叠跨重建用例红;摘掉「键数相等」判定 ⇒ 成表边界红;
50
+ 摘掉挂块那一行 ⇒ 两处接线红;`eventFull` 退回 `JSON.stringify` ⇒ 结构化用例红;
51
+ `copyInto` 写 `title` 而非路径 ⇒ 复制用例红)。决策与判据见 `docs/spec.md` §10
52
+ 2026-11-11 ref-trace-full-view,守卫登记见 `docs/guards.md` §1。
53
+
54
+ ## [0.10.6] - 2026-11-09
55
+
56
+ ### 变更
57
+
58
+ 本版是一次**正确性收口**,不新增能力面:修掉八类「静默失效」,其中三处会改变调用方能观察到的
59
+ 行为,需要在升级前确认。两包同步 0.10.5 → 0.10.6。
60
+
61
+ - ⚠️ **MCP 反向桥默认不再暴露 `approval: 'required'` 的工具**(安全修)。MCP 没有审批挂起通道,
62
+ 暴露即等于绕过审批。需要这些人批工具的人显式设 `exposeApprovalRequiredTools: true`;
63
+ 被隐藏时构造期会打一行 warn **点名数量**,不静默少工具。
64
+ - ⚠️ **所有出站请求不再跟随重定向**(安全修)。307/308 会把鉴权头与请求体原样转到另一个
65
+ origin。依赖 3xx 跳转的外部端点现在会**报错**而不是静默跟过去,请把最终地址配进去。
66
+ - ⚠️ **坏值从「静默忽略」改成「响亮拒绝」**:`maxEventChars: 0` 现在真的截到空串
67
+ (此前被当成「不限」);`toolTimeoutMs` / `maxEventChars` / `maxToolResultChars` /
68
+ `awaitTask` 的 `timeoutMs`·`intervalMs` / OTLP·metrics 的 `timeoutMs` 在入口校验,
69
+ NaN·负数·小数直接抛。`metricsSink.intervalMs` 的负数与 `0` 同义(立即导出),已写进
70
+ `LIMIT_SEMANTICS` 表。
71
+ - 四条唤醒路径(`approve × signalTask` / `wakeDue × signalTask`)共用一把 per-task 互斥闸,
72
+ 同一任务不再被派发两遍;`cancel` 等同一张表的 tail。
73
+ - 审批决定带作用域:只认 `requestedAt` 与本次挂起匹配的决定,同一 `tool_use_id` 每段只消费一次
74
+ —— 修掉 OpenAI 兼容端点复用 `call_1` 导致的审批绕过。
75
+ - 失败尝试的部分 usage 落账:流中断前已收到的计量挂到错误上并写 `usage.partial`,
76
+ `maxCostUsd` / `maxTotalTokens` 不再系统性少算。
77
+ - 跨进程 SSE 兜底从「每连接一个 5s 定时器」提到 runner 级单例:一拍里每个任务只读一次 store,
78
+ 定时器有登记才存在;`streamTask` 的 `pollMs` 从此是开关(`<= 0` = 该流不参与兜底)。
79
+ - 落库剥离 `client` / `tools` 时留下可读痕迹,恢复段按 taskId 告警一次 —— 此前 per-task 自定义
80
+ client 的任务重启续跑会**静默**打到 runner 的端点。
81
+ - JSON / SQLite / Redis 三条 store 的恢复路径加固(读失败出声、半行写入回滚、迁移事务化、
82
+ SCAN 去重、水合失败跳过回写),详见 `docs/spec.md` §10。
83
+ - 构建面:四个 `package-lock.json` 的 `resolved` 从第三方镜像站改回官方 registry(共 463 处),
84
+ 两个示例 `Dockerfile` 加拉包重试。此前 2026-11-09 镜像站 5xx 让「构建 deploy 示例镜像」连挂
85
+ 两轮(`ER_NO_SUCH_TABLE` / `504`);改后该 job 从 174–368s 降到 20s。⚠️ 它与 `src/` 无关,
86
+ 但影响**从零安装**:`npm ci` 的 tarball 来源现在是官方源(镜像站用户仍可由自己的 npm 配置改写)。
87
+
88
+ ### ⚠️ 行为变更(安全修):MCP 反向桥默认隐藏 HITL 工具(2026-11-07 ref-deep-review-2026-11-07)
89
+
90
+ - **修的是什么**:`createMcpServer` 此前把 `app.tools` 整个交给 MCP 客户端。而
91
+ `@Tool({ approval: 'required' })` 的语义是「调用前必须有人批」,MCP 这条通道**没有**挂起与
92
+ 回调的通道 —— 工具被列出去就等于「审批被绕过」,且没有任何一处会报错。
93
+ - **现在**:默认过滤掉 `approval: 'required'` 的工具;确有需要(例如客户端自己实现了外部审批)
94
+ 显式设 `exposeApprovalRequiredTools: true`。过滤发生时打一行 warn 说明数量与原因 ——
95
+ 「明明写了 `@Tool`,MCP 客户端却看不到」不该靠猜。
96
+ - **迁移**:升级后如果 MCP 客户端少了一批工具,先看启动日志里那行 warn。要恢复旧行为就开那个开关,
97
+ 但请自行确保这些工具的人批在你的客户端侧成立。
98
+
99
+ ### ⚠️ 行为变更(安全修):出站 `fetch` 不跟随重定向
100
+
101
+ - **修的是什么**:默认的 `fetch` 跟 3xx,而 307/308 **保留方法与请求体** —— 一次被中间人控制或
102
+ 被错误配置的重定向,会把 `Authorization` 头与 prompt 原文发到另一个 origin。这条链上没有一个
103
+ 环节会报错,日志里只有一次成功的 `POST`。
104
+ - **现在**:所有出站调用(模型适配器、OTLP/metrics 导出、MCP HTTP)一律 `redirect: 'error'`,
105
+ 收到 3xx 直接失败。
106
+ - **迁移**:如果你的模型网关 / OTLP collector 依赖 301/302/307 跳转,请把最终地址直接配进去。
107
+ 报错信息里带原始状态码与 `Location`,照它改即可。
108
+
109
+ ### 唤醒互斥与取消优先(2026-11-07 ref-deep-review-2026-11-07)
110
+
111
+ - **修的是什么**:`approve` 与 `signalTask`、`wakeDue` 与 `signalTask` 各自凭**同一份** suspended
112
+ 快照判断「该我唤醒」,于是同一任务被派发两遍;两次执行都落库,后写的覆盖前写的,
113
+ 审批决定可能凭空消失,而 usage 记了两份。
114
+ - **现在**:四条唤醒路径共用 `transport/task-wake-gate.ts` 的 per-task 串行闸,同步段一次性登记
115
+ (无 await,无 TOCTOU 窗口);`cancel` 排在同一个 key 的 tail 上,不会插进正在进行的唤醒。
116
+ - **取消优先**:引擎的续跑段与主循环的 approval / deferred 出口统一补 abort 守卫;宿主在
117
+ suspended 分支再判一次取消意图 —— 修掉「cancel 等不到终态、超时定时器到点又跑一遍」。
118
+ - **验证**:`tests/transport/wake-gate.test.ts`、`tests/transport/drain-gate.test.ts`;反向变异
119
+ 确认「摘掉 gates」能复现双派发。
120
+
121
+ ### 审批决定的作用域(2026-11-07 ref-deep-review-2026-11-07)
122
+
123
+ - **修的是什么**:审批决定此前只按 `tool_use_id` 匹配。OpenAI 兼容端点每次都从 `call_1` 开始编号,
124
+ 于是**上一段**挂起时批过的 `call_1`,会被下一段的 `call_1` 当成「已批准」直接放行 ——
125
+ 一次静默的审批绕过。
126
+ - **现在**:`RunAgentOptions.approvalEpoch` 标出本次挂起的身份(异步宿主自动注入
127
+ `TaskRecord.suspendedSince`),只认 `requestedAt` 匹配的决定;同一段内每个 `tool_use_id` 也只
128
+ 消费一次(`consumedApprovals`)。三处判据(写入时的 `requestedAt`、`fillTimeoutDenials` 的
129
+ stale 覆盖、`approvalsComplete(rec, since)`)用的是同一个 epoch。
130
+ - **验证**:`tests/engine/approval-epoch.test.ts`(含「旧决定不得跨段复用」与「同段只消费一次」
131
+ 两条正面用例)。
132
+
133
+ ### 失败尝试的计量落账(2026-11-07 ref-deep-review-2026-11-07)
134
+
135
+ - **修的是什么**:适配器在流中断(网络断、上游 5xx、超时)时把整个 turn 判为失败,而**中断前已
136
+ 收到的** `input_tokens` / `output_tokens` 随之丢掉。重试三次的实际花费是三次之和,
137
+ `maxCostUsd` 只看到最后一次 —— 预算闸系统性偏松。
138
+ - **现在**:适配器把已收到的计量挂到错误对象上,引擎记进失败 turn 的 span 并写 `usage.partial`
139
+ 事件;`usageTotal` 因此包含失败尝试。
140
+ - **验证**:`tests/engine/review-fixes.test.ts` 覆盖「首两次失败 + 第三次成功」的总账。
141
+
142
+ ### 流生命周期与跨进程兜底(2026-11-07 / 2026-11-09 ref-stream-janitor-singleton)
143
+
144
+ - **修的是什么(跨进程)**:任务被另一个副本接管或已落终态时,本进程那条 SSE 不会自己结束 ——
145
+ 客户端永远等不到 `end` / `closed`。此前由 `streamTask` 给**每个订阅**各建一个 5s 定时器去
146
+ `store.get` 判断:N 个并发连接 = N 个定时器 + N/5 QPS,SQLite 下就是 N/5 次读锁;`unref()`
147
+ 只保证不挂住进程,不减读放大。
148
+ - **现在**:`transport/stream-janitor.ts` 改成「组」的形状 —— 一拍里每个**任务**只读一次 store,
149
+ 再把结论分发到该任务的所有订阅;定时器**有登记才存在**,最后一条注销即停。一拍里的意外
150
+ (判据抛错)被 catch 住,不会变成 `unhandledRejection` 打死宿主。
151
+ - **其余收口**:`streamTask` 的 `onDone` 补 rejection 处理(store 抖动不再打死进程);
152
+ SSE 单行复用 8M 码元上限并 flush 尾字节;上限判据改成**切出来的那一帧**(此前判整个 `buf`,
153
+ 一次 read 交付多行大 chunk 会被误报成「单行超限」);MCP HTTP 的 SSE 改增量读
154
+ (server 不关流不再让 `tools/call` 永挂)。
155
+ - **验证**:`tests/transport/stream-janitor.test.ts`(六条故障路径 + 两条结构不变量),
156
+ 行覆盖 100%、分支 89.58%;`tests/core/sse-text-stats.test.ts` 含「单帧 9M 仍要拒」的对照。
157
+
158
+ ### 持久化与恢复(2026-11-07 / 2026-11-09 ref-resume-dropped-refs)
159
+
160
+ - **修的是什么(落库)**:`JSON.stringify` 把 `AbortSignal` 变成 `{}`、把 `ModelClient` 变成
161
+ `{messages:{}}`、把 `tools` 里的 run 函数丢掉。读回后这些空壳会在恢复段被当成真对象使用
162
+ (`combineSignals({})` 直接 TypeError,工具菜单半残)。现在三条 JSON store 统一在落库前剥离。
163
+ - **修的是什么(剥离的副作用)**:剥离之后,「提交时本来没传 `client`」与「传了但被剥掉」在
164
+ 读回时**完全同形**,于是 per-task 自定义 client(典型是 OpenAI 兼容端点)的任务重启续跑时会
165
+ **静默**退回 runner 的端点。现在剥离时记下清单(`__agentiaDroppedRefs`),装配处按 taskId
166
+ 去重告警一次,写明「本段已退回 runner client / 装配菜单」与出路;键在装配前剔除,不进引擎。
167
+ - **其余加固**:FileTaskStore 读失败出声且禁止 compact、半行写入回滚;SQLite 迁移事务化、
168
+ `healRow` 用旧 json 做 CAS;Redis `list()` 对 SCAN 结果去重、单 key 错不再整表 reject;
169
+ Memory 水合失败跳过回写;runtime 的手工 HITL 续跑不再重复 load/append。
170
+ - **验证**:`tests/store/serialize-options.test.ts`(剥离标记三态 + 告警只响一次);
171
+ `tests/transport/resume-scanner.test.ts`。
172
+
173
+ ### 依赖
174
+
175
+ - 官网构建链:`astro` 7.3.8 → 7.4.5、`gsap` 3.15.0 → 3.15.1、`lenis` 1.3.26 → 1.3.27。
176
+ - 开发链:dev-dependencies 组 5 项升级;`@biomejs/biome` 适配到 2.6.4
177
+ (排除文档/YAML 格式化、`useMathMinMax`)。
178
+ - 框架本身**运行时依赖仍为 0**。
179
+
8
180
  ## [0.10.5] - 2026-11-05
9
181
 
10
182
  ### 变更
@@ -4300,7 +4472,9 @@ toolkit 142 / runtime 55 / engine 403 / container 10 / store 55 —— 全绿。
4300
4472
  首个公开发布:`@migor/agentia` + `@migor/cli`(scope `@migor/*`),两包版本同步。
4301
4473
  框架本体单包;CLI 独立成包(workspaces)。
4302
4474
 
4303
- [Unreleased]: https://github.com/migoric/agentia/compare/v0.10.5...HEAD
4475
+ [Unreleased]: https://github.com/migoric/agentia/compare/v0.10.7...HEAD
4476
+ [0.10.7]: https://github.com/migoric/agentia/releases/tag/v0.10.7
4477
+ [0.10.6]: https://github.com/migoric/agentia/releases/tag/v0.10.6
4304
4478
  [0.10.5]: https://github.com/migoric/agentia/releases/tag/v0.10.5
4305
4479
  [0.10.4]: https://github.com/migoric/agentia/releases/tag/v0.10.4
4306
4480
  [0.10.3]: https://github.com/migoric/agentia/releases/tag/v0.10.3
package/dist/AGENTS.md CHANGED
@@ -447,6 +447,7 @@ run 在**文件末尾** —— 取最早那种写法在长跑 dev 环里等于
447
447
  | `memory` | 跨 run 记忆 `{ store, keys }`:run 前水合进 blackboard(用户种子优先)、收尾写回;与 `session` 正交(见 `MemoryStore`) |
448
448
  | `beforeFlush` | `(trace, result) => void \| Promise<void>`:**sinks 冲刷之前**的最后一笔(run 正常收尾后调一次,抛错被吞)。给「**拿到结果才判得出**的结论」用的缝 —— 典型是 `defineEval` 的 score:等 `app.run` 返回再 `attachScore`,`metricsSink` 早在导出那一刻聚完账,分数就永远进不了指标。读 trace 就够的判断不必用它,写进 sinks 里即可(见 §6 判官配方) |
449
449
  | `approvals` | HITL 审批决定(`Record<tool_use_id, ApprovalDecision>`):恢复 `suspended` 的 run 时传入(异步宿主会自动带,见 §6.6「人工审批」);手工续跑「assistant 结尾带 tool_use」的消息历史时也可直接给 |
450
+ | `approvalEpoch` | 续跑段的挂起时刻(= `TaskRecord.suspendedSince`,异步宿主自动注入)。引擎只认 `requestedAt` 与它相等的决定 —— 防止旧挂起留下的同名 `tool_use_id`(OpenAI 兼容端点会复用 `call_1`)自动批准新一次调用;手工续跑省略 = 不做 epoch 过滤(同 run 内仍只消费一次) |
450
451
  | `events` | run 事件(`TaskEvent[]`,`{ eventId?, type, payload }` 全是字符串):挂起期间投递、随 `TaskRecord` 落库的事件,续跑段由引擎在**未决 tool_use 解决之后**渲染成 user 文本消息注入消息流(异步宿主经 `signalTask` 自动带,见 §6.6「事件投递」);手工续跑时也可直接给 |
451
452
 
452
453
  返回 `AgentRunOutput`:`{ run, result }`。`result` 含 `trace` / `stopReason` / `finalText` / `iterations` / `error` / `typed`;
@@ -1018,7 +1019,7 @@ trace 出去之后能干什么:指标、调用树面板、调优报告、生
1018
1019
  | `TraceRecorder` | 内存 recorder(一次 run 一个);`addLink(spanId, { traceId, spanId? })` 记一条跨 trace 链路(见 §6「跨进程关联」) |
1019
1020
  | `parseTraceparent` | 解析 W3C `traceparent` 头 → `{ traceId, spanId? }`;**非法 / 缺头一律返回 `undefined`**(不抛、不打 400)—— 结果直接交给 `traceContext` 选项,见 §6「跨进程关联」 |
1020
1021
  | `createOtlpExporter` | OTLP/JSON 导出,零依赖;选项见下面「`OtlpExporterOptions`」表 |
1021
- | `jsonlTraceSink` | JSONL 文件 sink:`jsonlTraceSink({ path })` → `TraceSink`,一行一条裸 Trace 追加落盘(父目录自动创建、追加不覆盖)—— **`agentia report` / `diff` / `harvest` / `export` / `view` 的输入格式**,见 §6「调优报告」。⚠️ **同一路径进程内幂等**(两次调用返回同一个 sink):两条 sink 对口一个文件会让一次 run 落两行同 traceId,而下面的命令按「一行 = 一次 run」计数 ⇒ 一次 run 被数两次(不报错,比不落盘更难发现)。所以「应用级 `sinks` 与 `registerDefaultTraceSink` 都往 `.agentia/traces.jsonl` 写」是安全的 |
1022
+ | `jsonlTraceSink` | JSONL 文件 sink:`jsonlTraceSink({ path, fsync? })` → `TraceSink`(`fsync: true` 时每次追加后同步落盘;缺省 false 保持旧性能),一行一条裸 Trace 追加落盘(父目录自动创建、追加不覆盖)—— **`agentia report` / `diff` / `harvest` / `export` / `view` 的输入格式**,见 §6「调优报告」。⚠️ **同一路径进程内幂等**(两次调用返回同一个 sink):两条 sink 对口一个文件会让一次 run 落两行同 traceId,而下面的命令按「一行 = 一次 run」计数 ⇒ 一次 run 被数两次(不报错,比不落盘更难发现)。所以「应用级 `sinks` 与 `registerDefaultTraceSink` 都往 `.agentia/traces.jsonl` 写」是安全的 |
1022
1023
  | `TraceRecordEvent` | **增量记账事件**(`onTraceEvent` 的回调参数):`span.begin` / `span.end` / `span.event` / `span.attribute` / `span.link`,每条带单调 `seq`。载荷是**增量 + 此刻的拷贝**(`span.begin` 只给初始形状,属性/事件/链路各走自己的类型);按 `seq` 升序折回必须**逐字等于**收尾时的 trace |
1023
1024
  | `metricsSink` | 指标累加器(Prometheus 文本 / OTLP metrics),满足 `TraceSink` 即接入 —— 见 §6「指标」 |
1024
1025
  | `buildRunReport` | 从一条 trace 生成**调优报告**(能力/模型的耗时、token、成本、错误率排行)—— 见 §6「调优报告」 |
@@ -1520,6 +1521,7 @@ server —— Claude Code / Cursor / 任何 MCP 宿主都能直接调你的 `@To
1520
1521
  - **结果映射与正向桥方向对称**:工具返回 string → `content: [{ type: 'text', text }]`,其他 JSON 化进 text;抛错 → 协议层成功 + `isError: true`(MCP 惯例;正向桥的连接器正好把 `isError` 转回抛错)。
1521
1522
  - **取消是协作式**:客户端断连 / `close()` 会中止该次调用的 `ToolRunContext.signal`;`toolTimeoutMs` 与引擎同款语义(非正数 / 不设 = 不限;超时 = **放弃等待** + abort `abandoned`,该次调用回 `isError`,server 不挂)。
1522
1523
  - **默认 client 是惰性构造的**:`client` 不给时第一次 `tools/call` 才走 `createAnthropicClient()`(与引擎默认同款)—— 纯工具 server 不需要 API key 在场。
1524
+ - **`approval: 'required'` 的工具默认不暴露**(2026-11-07):MCP 没有 HITL 挂起通道,反向桥直接 `tool.run` —— 暴露出去等于绕过人工审批。要暴露请显式设 `exposeApprovalRequiredTools: true`,并理解「MCP 客户端即审批人」;预算类旋钮(`maxTotalTokens` / `maxCostUsd` / `priceOverrides` / `maxToolResultChars` / `traceContent`)会透传给 `ToolRunContext`,否则子 agent 类工具在桥内不受预算约束。
1523
1525
  - **想看一个真用它干活的例子**:`examples/observability-mcp/` 把 `buildRunReport` / `diffTraces` 包成两个只读工具(`report_trace` / `diff_traces`)暴露给任意 MCP 宿主,让 agent 自己查「我上一轮慢在哪」;还带一条**实边界**(工具参数由模型给 ⇒ 只读 trace 根内的文件,realpath 比前缀),以及一条端到端的验收脚本(起真 server ⇐ 出厂连接器 + `mcpTools`)。
1524
1526
 
1525
1527
  ```ts
@@ -1555,6 +1557,12 @@ console.log(mcp.url); // http://127.0.0.1:8787/mcp
1555
1557
  | `traceLimits` | 每次 `tools/call` 那棵 trace 的事件数闸(与引擎 `traceLimits` 同款:超限停记 + run 根留 `trace.truncated` 簿记;缺省 = 不限;坏值构造期抛 TypeError) |
1556
1558
  | `maxEventChars` | 事件正文截断上限(与引擎 `maxEventChars` 同款;`false` = 不截断;缺省 = 引擎缺省) |
1557
1559
  | `toolTimeoutMs` | 单次 `tools/call` 的工具执行超时(毫秒),语义与引擎 `toolTimeoutMs` 同款;非正数 / 不设 = 不限 |
1560
+ | `exposeApprovalRequiredTools` | 是否把 `approval: 'required'` 的工具也暴露出去;**缺省 false**(MCP 没有 HITL 挂起通道,暴露 = 绕过人工审批)。显式打开即表示「MCP 客户端就是审批人」 |
1561
+ | `maxTotalTokens` | 单次 `tools/call` 的累计 token 上限(透传 `ToolRunContext.maxTotalTokens`) |
1562
+ | `maxCostUsd` | 单次 `tools/call` 的累计成本上限(透传 `ToolRunContext.maxCostUsd`) |
1563
+ | `priceOverrides` | 价格覆盖表(透传 `ToolRunContext.priceOverrides`,否则子循环里的自定义模型算不出成本) |
1564
+ | `maxToolResultChars` | 单条工具结果回模型的字符上限(透传 `ToolRunContext.maxToolResultChars`;`false` = 不截断) |
1565
+ | `traceContent` | assistant 文本记录口径(透传 `ToolRunContext.traceContent`;`'full'` = 记 `output.text`) |
1558
1566
  | `auth` | http 侧鉴权钩子:在读 body 之前调用,抛错即 401(原文只进服务端日志);stdio 侧信任父进程、本项不生效 |
1559
1567
  | `name` | `serverInfo.name`(握手回给客户端),缺省 `'agentia'` |
1560
1568
 
@@ -14,7 +14,7 @@
14
14
  * 3. usage 只由 llm.turn 累计(capability span 的 usage 是子 span 聚合,计入会双算)。
15
15
  */
16
16
 
17
- import { fmtArg, rawArg, capabilityTypeOf, CAP_ICO } from './view.js';
17
+ import { fmtArg, capabilityTypeOf, CAP_ICO } from './view.js';
18
18
 
19
19
  /** 从 span.attributes 认能力类型(框架用 `setAttribute(capabilityId, 'subagent', name)` 记类型) */
20
20
  function spanType(s) {
@@ -68,15 +68,24 @@ function eventText(ev) {
68
68
  }
69
69
 
70
70
  /**
71
- * 事件全文(展开态用)。
71
+ * 事件全文(展开态用)—— **给原值,不预先序列化**。
72
72
  *
73
- * 只有 `tool.input` 需要单独取:`fmtArg` 把入参摘要**砍到 4 个键 / 每值 21 字符 / 整串 62 字符**,
74
- * 直接拿摘要当全文就是「假展开」—— 点开看到的还是那 62 个字符。出参与其他事件本身就是原文
75
- * (`eventText` 原样返回 content),无需二次处理。
76
- * 原文口径统一在 view.js 的 `rawArg`:官网 playground 的模拟回放也要用它,两处一处定义。
73
+ * 2026-11-11 起 `full` 的类型本身携带语义:字符串 ⇒ 渲染器走 `.tr-io` 的纯文本换行;
74
+ * 对象/数组 ⇒ 交给 `full-view.js` 铺成带缩进的结构化块。所以这里必须把**原值**递下去 ——
75
+ * 若仍在这里 `rawArg` 成字符串,「一大坨 JSON」那条路就永远回不来了(渲染器只能看到字符串)。
76
+ *
77
+ * 口径:
78
+ * - `tool.input` → 入参原值(`fmtArg` 的摘要砍到 4 键 / 每值 21 字符 / 整串 62 字符,
79
+ * 拿摘要当全文就是「假展开」);
80
+ * - `tool.output` → `content` 原值:字符串就是纯文本(含图片块时会是非字符串,正好结构化);
81
+ * - 其余事件 → `eventText` 的字符串(它们的体本来就是给人看的一小段说明)。
77
82
  */
78
83
  function eventFull(ev) {
79
- if (ev.name === 'tool.input') return rawArg(ev.body?.input);
84
+ if (ev.name === 'tool.input') return ev.body?.input ?? '';
85
+ if (ev.name === 'tool.output') {
86
+ const c = ev.body?.content;
87
+ return c == null ? '' : c;
88
+ }
80
89
  return eventText(ev);
81
90
  }
82
91
 
@@ -0,0 +1,369 @@
1
+ /* 展开态的**结构化正文** —— 把「一大坨 JSON」拆成可折叠、可成表、可复制路径的块。
2
+ *
3
+ * ## 为什么需要它
4
+ *
5
+ * 展开此前只有一条路:把 `rawArg(v)`(= `JSON.stringify(v)`,**无缩进**)整串塞进
6
+ * `.tr-io` 再靠 CSS 换行。一个 20 键的工具入参在 400px 宽的右栏里会折成十几行连续的
7
+ * `{"a":"b","c":{"d":` —— 页面不报错、无溢出、构建全绿,只是**人读不了**。工具出参更糟:
8
+ * 它是 `content` 的原始序列化,长文本与图片块混在一起,一行到底。
9
+ *
10
+ * 缩进只是第一档。第二档(2026-11-11 晚,本文件的现状)解决缩进解决不了的三件事:
11
+ *
12
+ * 1. **静态文本没有"收起"**:深度 6 全铺开,一个出参就能把整棵树顶到屏幕外。现在容器
13
+ * 默认只铺两层(`OPEN_DEPTH`),其余折成 `{ object · 4 项 }`,点一下展开。折叠状态由
14
+ * 调用方传入(`opts.fold`)—— 渲染是全量重建,状态放本文件里活不过下一条事件。
15
+ * 2. **同构对象数组在浪费行**:`[{name,score},{...},{...}]` 每个元素重复一遍键名,3 行数据
16
+ * 吃掉 9 行。同构且各列都是标量时改成表格,表头一行、数据一行。
17
+ * 3. **路径拿不到**:想引用 `results[0].name` 只能用手数层级。每个键悬停出一个 `⧉`,
18
+ * 点一下把路径写进剪贴板(表头写的是 `results[*].name`,因为它是一整列)。
19
+ *
20
+ * ## 边界(都是「不这么做就会坏」的地方,不是口味)
21
+ *
22
+ * - **只在 `full` 不是字符串时接手**:字符串正文(工具返回的纯文本、官网模拟回放)
23
+ * 继续走 `.tr-io` + `.tr-open` 的老路 —— 那条路有 `test/style.test.js` 的两个不变量守着
24
+ * (最小宽度、caret 常显),换成本件就会让那两个守卫失去落点。两档并存是刻意的。
25
+ * - **不改 `.tr-io` 的文本**:本件只是**追加**一个兄弟块,`.tr-io` 仍持有原文(展开时由
26
+ * CSS 隐藏)。既有断言(`textContent === JSON.stringify(...)`)因此逐字保留 ——
27
+ * 展开的正文依旧是"原文",只是多了一层可读的呈现。
28
+ * - **截断必须说出来**:长字符串与超大集合会截断,但截断处标明「还有 N 字符 / N 项」。
29
+ * 静默截断会让人以为工具就返回了这么点东西 —— 比糊成一坨更坏。表格单元格同样走
30
+ * `primitive()`,所以这条在表里也成立(不做「表格例外」)。
31
+ * - **循环引用不抛**:手写的 trace 可能有环(`rawArg` 对它会回落 `[object Object]`),
32
+ * 这里同样只回落、不抛错:调试面板不该因为一条坏数据整棵树白屏。
33
+ * - **折叠状态必须外部持有**:`opts.fold` 是调用方的 Map(`view.js` 按事件键存一份)。
34
+ * 本文件只读写它、不 new 一份长期状态 —— 否则「点开一个容器,下一条事件到达时它自己合上」
35
+ * 会原样复现(这正是 `view.js` 的 `expanded` 集合存在的原因)。
36
+ * - **表格化只在"真的同构"时发生**:元素必须全是普通对象、键集完全一致、列数 1..6、
37
+ * 每列都是标量。任一不满足就回落树形 —— 表格里没法逐层展开,判错一次就是**丢数据**。
38
+ *
39
+ * 零依赖、纯 DOM 构造(只用 createElement / appendChild / textContent / className /
40
+ * dataset / innerHTML=''),与 view.js 共用同一套 DOM stub 跑测试。
41
+ */
42
+
43
+ /** 单个字符串值最多显示多少字符(超过就说出来,不静默截断) */
44
+ const MAX_STR = 240;
45
+ /** 一层里最多铺开多少项(再多就没法看了,留着类型与计数) */
46
+ const MAX_ITEMS = 40;
47
+ /** 嵌套深度上限:再深就整体序列化成一行,避免缩进把宽度吃光 */
48
+ const MAX_DEPTH = 6;
49
+ /** 默认展开到第几层(容器自身深度 < 它 ⇒ 默认铺开)。两层 = 根 + 一层子节点 */
50
+ const OPEN_DEPTH = 2;
51
+ /** 表格最多几列:再多横向就没法看了,回落树形 */
52
+ const MAX_COLS = 6;
53
+
54
+ function fvEl(tag, cls, text) {
55
+ const node = document.createElement(tag);
56
+ if (cls) node.className = cls;
57
+ if (text !== undefined) node.textContent = text;
58
+ return node;
59
+ }
60
+
61
+ /** 安全序列化:循环引用 / BigInt / 自定义 toJSON 抛错都回落成一句人话,绝不冒泡 */
62
+ function safeStringify(v) {
63
+ try {
64
+ const s = JSON.stringify(v, null, 2);
65
+ return s === undefined ? String(v) : s;
66
+ } catch {
67
+ return '[无法序列化 —— 可能是循环引用]';
68
+ }
69
+ }
70
+
71
+ /** 该值是否值得走结构化视图(字符串交给 .tr-io 的老路) */
72
+ function isStructured(v) {
73
+ return v !== null && typeof v === 'object';
74
+ }
75
+
76
+ /** 普通对象(排除数组与 null)—— 表格化与"容器"判定都只要这一种 */
77
+ function isPlainObject(v) {
78
+ return isStructured(v) && !Array.isArray(v);
79
+ }
80
+
81
+ /** 容器规模:数组算元素数,对象算键数 */
82
+ function sizeOf(v) {
83
+ return Array.isArray(v) ? v.length : Object.keys(v).length;
84
+ }
85
+
86
+ /** 头部那句类型说明:`object · 6 项` / `array · 12 项` / `string · 4,096 字符` */
87
+ function typeLabelOf(v) {
88
+ if (Array.isArray(v)) return `array · ${v.length} 项`;
89
+ if (isStructured(v)) return `object · ${Object.keys(v).length} 项`;
90
+ if (typeof v === 'string') return `string · ${v.length} 字符`;
91
+ return typeof v;
92
+ }
93
+
94
+ /** 基本类型的着色类与展示文本 */
95
+ function primitive(v) {
96
+ if (v === null) return { cls: 'tr-full-null', text: 'null' };
97
+ if (v === undefined) return { cls: 'tr-full-null', text: 'undefined' };
98
+ const t = typeof v;
99
+ if (t === 'string') {
100
+ const clipped = v.length > MAX_STR ? v.slice(0, MAX_STR) : v;
101
+ const rest = v.length > MAX_STR ? `… 还有 ${v.length - MAX_STR} 字符` : '';
102
+ return { cls: 'tr-full-str', text: JSON.stringify(clipped) + rest };
103
+ }
104
+ if (t === 'number') return { cls: 'tr-full-num', text: String(v) };
105
+ if (t === 'boolean') return { cls: 'tr-full-bool', text: String(v) };
106
+ return { cls: 'tr-full-null', text: String(v) };
107
+ }
108
+
109
+ /** 深层容器压成单行时的表示:保留"它是个容器"这个事实,不伪装成标量 */
110
+ function oneLine(v) {
111
+ const kind = Array.isArray(v) ? 'array' : 'object';
112
+ const s = safeStringify(v).replace(/\s+/g, ' ');
113
+ const clipped = s.length > MAX_STR ? s.slice(0, MAX_STR) + '…' : s;
114
+ return `<${kind} · ${sizeOf(v)} 项> ${clipped}`;
115
+ }
116
+
117
+ /** 子键的 JSON 路径:对象用 `.key`,数组用 `[i]`。根是空串,所以 `[0]` 不会变成 `.[0]` */
118
+ function pathOf(parent, key, isArr) {
119
+ return isArr ? `${parent}[${key}]` : parent ? `${parent}.${key}` : key;
120
+ }
121
+
122
+ /** 剪贴板:没有实现时**什么都不做**,绝不把没做到的事显示成「已复制」 */
123
+ function copyInto(btn, text, done) {
124
+ const clip = globalThis.navigator?.clipboard;
125
+ if (!clip || typeof clip.writeText !== 'function') return;
126
+ Promise.resolve(clip.writeText(text)).then(
127
+ () => {
128
+ btn.textContent = done;
129
+ },
130
+ () => {
131
+ btn.textContent = '复制失败';
132
+ },
133
+ );
134
+ }
135
+
136
+ /** 正在选文本时不要收起/展开 —— 鼠标拖选到行外松手会补一次 click,否则选完就自己合上了 */
137
+ function hasSelection() {
138
+ try {
139
+ return String(globalThis.getSelection?.() ?? '') !== '';
140
+ } catch {
141
+ return false;
142
+ }
143
+ }
144
+
145
+ /** 悬停才露出的 `⧉`:点它复制路径,**不**触发所在容器的折叠 */
146
+ function pathBtn(path) {
147
+ const btn = fvEl('span', 'tr-full-path', '⧉');
148
+ btn.title = '复制路径 ' + path;
149
+ btn.addEventListener('click', (e) => {
150
+ // 真实 DOM 里会冒泡到容器行的折叠处理;stub 的 click() 传的是空对象,故双保险
151
+ if (e && typeof e.stopPropagation === 'function') e.stopPropagation();
152
+ copyInto(btn, path, '已复制');
153
+ });
154
+ return btn;
155
+ }
156
+
157
+ /**
158
+ * 这一层要不要铺开。显式折叠/展开优先(`fold` 里有记录),否则按深度给默认值。
159
+ * @param {Map<string, boolean>} fold 路径 → 是否展开
160
+ */
161
+ function isOpen(fold, path, depth) {
162
+ return fold.has(path) ? fold.get(path) === true : depth < OPEN_DEPTH;
163
+ }
164
+
165
+ /**
166
+ * 同构对象数组 → 列名;不是"真同构"就返回 null(调用方回落树形)。
167
+ *
168
+ * 判据全部是「表格会丢东西」的地方,松一条就丢一类数据:元素必须是普通对象、
169
+ * 键集**完全一致**(缺键/多键都回落)、列数 1..6、每列都是标量(表格里没法逐层展开)。
170
+ */
171
+ function tableColsOf(arr) {
172
+ if (!Array.isArray(arr) || arr.length < 2 || arr.length > MAX_ITEMS) return null;
173
+ if (!isPlainObject(arr[0])) return null;
174
+ const cols = Object.keys(arr[0]);
175
+ if (cols.length === 0 || cols.length > MAX_COLS) return null;
176
+ for (const item of arr) {
177
+ if (!isPlainObject(item)) return null;
178
+ if (Object.keys(item).length !== cols.length) return null;
179
+ for (const c of cols) {
180
+ if (!Object.hasOwn(item, c)) return null;
181
+ if (isStructured(item[c])) return null;
182
+ }
183
+ }
184
+ return cols;
185
+ }
186
+
187
+ /**
188
+ * 同构数组铺成表格。缩进用 `data-depth`(值为 0 时不写)—— CSS 靠属性选择器补左外边距,
189
+ * 因为列数是动态的、行数也是动态的,没有一个能写死的 inline style 可靠活在 stub 里。
190
+ */
191
+ function renderTable(ctx, arr, cols, depth, path) {
192
+ const table = fvEl('table', 'tr-full-table');
193
+ if (depth > 0) table.dataset.depth = String(depth);
194
+
195
+ const thead = fvEl('thead');
196
+ const headRow = fvEl('tr');
197
+ for (const c of cols) {
198
+ const th = fvEl('th');
199
+ th.appendChild(fvEl('span', 'tr-full-col', c));
200
+ // 列的路径带 `[*]`:复制出去能直接用于过滤/取值,而不是某一行的那一份
201
+ th.appendChild(pathBtn(pathOf(path + '[*]', c, false)));
202
+ headRow.appendChild(th);
203
+ }
204
+ thead.appendChild(headRow);
205
+ table.appendChild(thead);
206
+
207
+ const tbody = fvEl('tbody');
208
+ for (const item of arr) {
209
+ const tr = fvEl('tr');
210
+ for (const c of cols) {
211
+ const td = fvEl('td');
212
+ const p = primitive(item[c]); // 表里同样截断并说出剩余(不做「表格例外」)
213
+ td.appendChild(fvEl('span', p.cls, p.text));
214
+ tr.appendChild(td);
215
+ }
216
+ tbody.appendChild(tr);
217
+ }
218
+ table.appendChild(tbody);
219
+ ctx.body.appendChild(table);
220
+ }
221
+
222
+ /** 缩进空格的落点:0 层不占位(否则每条顶级键都白带两个空格) */
223
+ function indentOf(depth) {
224
+ return depth > 0 ? fvEl('span', 'tr-full-indent', ' '.repeat(depth)) : null;
225
+ }
226
+
227
+ /** 折叠箭头的槽位。叶子行也占一格(传空串),否则同层键名会在「有箭头」与「没箭头」之间
228
+ * 左右跳 10px —— 一列键名对齐是这类面板最基本的可读性,不能靠行首参差来区分有没有子项。 */
229
+ function caretSlot(glyph) {
230
+ return fvEl('span', 'tr-full-caret', glyph);
231
+ }
232
+
233
+ /** 一行 = 缩进 + 箭头槽位(+ 稍后追加的键名/值)。所有行都从同一个入口起头,
234
+ * 这样才能保证「同一层的键名在同一条竖线上」—— 逐处手写就迟早漏一处。 */
235
+ function startRow(depth, caret) {
236
+ const row = fvEl('div', 'tr-full-line');
237
+ const indent = indentOf(depth);
238
+ if (indent) row.appendChild(indent);
239
+ row.appendChild(caretSlot(caret));
240
+ return row;
241
+ }
242
+
243
+ /**
244
+ * 把一个值铺进 `ctx.body`。
245
+ * @param {{body: Element, fold: Map<string, boolean>, repaint: () => void}} ctx
246
+ * @param {any} v 值
247
+ * @param {number} depth 当前缩进层级(根为 0)
248
+ * @param {string} path 当前值的 JSON 路径(根为空串)
249
+ */
250
+ function renderInto(ctx, v, depth, path) {
251
+ const box = ctx.body;
252
+
253
+ if (!isStructured(v)) {
254
+ const p = primitive(v);
255
+ const row = startRow(depth, '');
256
+ row.appendChild(fvEl('span', p.cls, p.text));
257
+ box.appendChild(row);
258
+ return;
259
+ }
260
+
261
+ const isArr = Array.isArray(v);
262
+ const cols = isArr ? tableColsOf(v) : null;
263
+ if (cols) {
264
+ renderTable(ctx, v, cols, depth, path);
265
+ return;
266
+ }
267
+
268
+ const keys = isArr ? v.map((_, i) => String(i)) : Object.keys(v);
269
+ const show = keys.slice(0, MAX_ITEMS);
270
+
271
+ for (const k of show) {
272
+ const child = isArr ? v[Number(k)] : v[k];
273
+ const keyText = isArr ? `[${k}]` : `${k}:`;
274
+ const childPath = pathOf(path, k, isArr);
275
+ // 「能否折叠」只看三件事:是容器、非空、没到深度上限 —— 与下面几个分支同一套判据。
276
+ // 先算出来是为了让箭头槽位在起行时就有正确字形(叶子行留空格位,键名才会对齐)。
277
+ const foldable = isStructured(child) && sizeOf(child) > 0 && depth < MAX_DEPTH;
278
+ const open = foldable && isOpen(ctx.fold, childPath, depth);
279
+ const row = startRow(depth, foldable ? (open ? '▾' : '▸') : '');
280
+
281
+ // 叶子:同一行里写完(缩进 + 键名 + 路径 + 值)
282
+ if (!isStructured(child)) {
283
+ const p = primitive(child);
284
+ row.appendChild(fvEl('span', 'tr-full-key', keyText));
285
+ row.appendChild(pathBtn(childPath));
286
+ row.appendChild(fvEl('span', p.cls, ' ' + p.text));
287
+ box.appendChild(row);
288
+ continue;
289
+ }
290
+
291
+ // 深度用尽 ⇒ 整体压成一行(并标明它是个容器),避免缩进吃掉全部宽度
292
+ if (depth >= MAX_DEPTH) {
293
+ row.appendChild(fvEl('span', 'tr-full-key', keyText));
294
+ row.appendChild(fvEl('span', 'tr-full-null', ' ' + oneLine(child)));
295
+ box.appendChild(row);
296
+ continue;
297
+ }
298
+
299
+ // 空容器:留一对括号,别让它看起来像"展开失败"
300
+ if (sizeOf(child) === 0) {
301
+ row.appendChild(fvEl('span', 'tr-full-key', keyText));
302
+ row.appendChild(fvEl('span', 'tr-full-null', Array.isArray(child) ? ' []' : ' {}'));
303
+ box.appendChild(row);
304
+ continue;
305
+ }
306
+
307
+ row.appendChild(fvEl('span', 'tr-full-key', keyText));
308
+ row.appendChild(pathBtn(childPath));
309
+ if (!open) {
310
+ const kind = Array.isArray(child) ? 'array' : 'object';
311
+ row.appendChild(fvEl('span', 'tr-full-hint', ` { ${kind} · ${sizeOf(child)} 项 }`));
312
+ } else if (Array.isArray(child) && tableColsOf(child)) {
313
+ // 成表时行内不铺子项,只在键名后标一句规模 —— 否则 `▾ targets:` 后面直接跟着表格,
314
+ // 看不出这是个数组、也看不出表格是不是全部内容(是不是被截断了)
315
+ row.appendChild(fvEl('span', 'tr-full-hint', ` array · ${child.length} 项`));
316
+ }
317
+ row.dataset.fold = childPath;
318
+ row.addEventListener('click', (e) => {
319
+ if (e && typeof e.stopPropagation === 'function') e.stopPropagation();
320
+ if (hasSelection()) return;
321
+ ctx.fold.set(childPath, !open);
322
+ ctx.repaint();
323
+ });
324
+ box.appendChild(row);
325
+
326
+ if (open) renderInto(ctx, child, depth + 1, childPath);
327
+ }
328
+
329
+ if (keys.length > show.length) {
330
+ const row = startRow(depth, '');
331
+ row.appendChild(fvEl('span', 'tr-full-null', `… 还有 ${keys.length - show.length} 项未展开`));
332
+ box.appendChild(row);
333
+ }
334
+ }
335
+
336
+ /**
337
+ * 建一个结构化正文块。
338
+ * @param {any} value `ev.full` 的**原值**(不是序列化后的字符串)
339
+ * @param {{fold?: Map<string, boolean>}} [opts]
340
+ * `fold` 是**调用方持有**的折叠状态(路径 → 是否展开)。省略时本块自建一份,
341
+ * 此时折叠状态活不过下一次「全量重建」—— 只对一次性渲染(测试、静态快照)够用。
342
+ * @returns {Element} `.tr-full` 块;调用方负责 append 到展开行之后
343
+ */
344
+ export function createFullView(value, opts = {}) {
345
+ const fold = opts.fold instanceof Map ? opts.fold : new Map();
346
+
347
+ const box = fvEl('div', 'tr-full');
348
+ box.dataset.kind = Array.isArray(value) ? 'array' : 'object';
349
+
350
+ const head = fvEl('div', 'tr-full-head');
351
+ head.appendChild(fvEl('span', 'tr-full-type', typeLabelOf(value)));
352
+ const copy = fvEl('span', 'tr-full-copy', '复制');
353
+ copy.dataset.copy = '1';
354
+ copy.addEventListener('click', () => copyInto(copy, safeStringify(value), '已复制'));
355
+ head.appendChild(copy);
356
+ box.appendChild(head);
357
+
358
+ const body = fvEl('div', 'tr-full-body');
359
+ const ctx = { body, fold, repaint: null };
360
+ // 只重画正文(不动头部):折叠不需要重建整棵树,滚动位置与头部反馈都留着
361
+ ctx.repaint = () => {
362
+ body.innerHTML = '';
363
+ renderInto(ctx, value, 0, '');
364
+ };
365
+ ctx.repaint();
366
+
367
+ box.appendChild(body);
368
+ return box;
369
+ }
@@ -12,3 +12,8 @@ export {
12
12
  } from './view.js';
13
13
  export { playTrace } from './fromTrace.js';
14
14
  export { summarizeTrace, renderSummary } from './summary.js';
15
+ /* 结构化正文块(2026-11-11):`full` 是对象/数组时,渲染器会给展开行挂上这个块。
16
+ * 单独导出是因为它是**纯函数式的 DOM 构造**(入参 → 元素),宿主可以拿它渲染任意值 ——
17
+ * 而 bundle 的导出面必须与这里逐字一致(`test/bundle.test.js`),所以内部件不进公共面、
18
+ * 这一件必须进。 */
19
+ export { createFullView } from './full-view.js';