@v587d/capital-generation 2.9.0 → 2.9.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (38) hide show
  1. package/CHANGELOG.md +57 -0
  2. package/README.md +4 -3
  3. package/chart-ui/README.md +4 -3
  4. package/chart-ui/package.json +1 -1
  5. package/config/package.json +1 -1
  6. package/lib/agents/bash-guard.d.ts +10 -6
  7. package/lib/agents/bash-guard.js +15 -12
  8. package/lib/agents/root-tool-policy.js +2 -0
  9. package/lib/chart/artifact-ref.d.ts +11 -1
  10. package/lib/chart/artifact-ref.js +25 -3
  11. package/lib/chart/delivery.d.ts +66 -0
  12. package/lib/chart/delivery.js +148 -0
  13. package/lib/chart/events.d.ts +2 -0
  14. package/lib/chart/events.js +55 -1
  15. package/lib/chart/present-tool.d.ts +36 -0
  16. package/lib/chart/present-tool.js +149 -0
  17. package/lib/chart/tool.d.ts +1 -0
  18. package/lib/chart/tool.js +9 -5
  19. package/lib/chart-render-entry.d.ts +2 -1
  20. package/lib/chart-render-entry.js +0 -3
  21. package/lib/data-collector/dataset-tools.js +1 -1
  22. package/lib/data-collector/store.d.ts +1 -0
  23. package/lib/present-entry.d.ts +19 -0
  24. package/lib/present-entry.js +114 -0
  25. package/lib/web-retriever/local-fetch.d.ts +1 -1
  26. package/lib/web-retriever/local-fetch.js +1 -1
  27. package/lib/web-retriever/wind-client.d.ts +1 -1
  28. package/lib/web-retriever/wind-client.js +1 -1
  29. package/package.json +8 -2
  30. package/preset/capital-generation/README.md +31 -13
  31. package/preset/capital-generation/agent.patch.yml +31 -34
  32. package/preset/capital-generation/skills/capital-chart-protocol/SKILL.md +6 -4
  33. package/preset/capital-generation/skills/capital-data-profile/SKILL.md +9 -4
  34. package/preset/capital-generation/skills/capital-data-query/SKILL.md +34 -19
  35. package/preset/capital-generation/skills/capital-equity-research/SKILL.md +6 -1
  36. package/preset/capital-generation/skills/capital-orchestration/SKILL.md +15 -14
  37. package/preset/capital-generation/skills/capital-visualization-protocol/SKILL.md +10 -5
  38. package/watchlist/package.json +1 -1
package/CHANGELOG.md CHANGED
@@ -7,6 +7,63 @@
7
7
  - **第三位(patch)**:不破坏既有工具 / 配置 / 会话的新增与修复(如 2.1.1、2.1.2)。
8
8
  - **第二位(minor)**:有需要用户知晓的行为变更,且段内附迁移说明(如 2.1.0 的图表呈现通道重做、2.2.0 的数据源与检索来源扩容、2.4.0 的**必须换宿主版本**、2.9.0 的选择器收敛与质检前置——本插件自己的工具与数据契约没变,所以只抬第二位)。
9
9
 
10
+ ## [2.9.2] - 2026-10-23
11
+
12
+ `data_junior` 接入官方 PTC(`run_code`):内层工具调用压进一段程序,多 Dataset 并发、`too_large`
13
+ 收窄与 skill 读取不再逐次往返模型。实机同类任务由 15 分钟级降到 2–3 分钟级;工具、数据契约、
14
+ 用户配置与宿主基线都不变。图表交付的收尾闸门同时修复结算通知竞争与同轮重复写入。
15
+
16
+ ### Added
17
+
18
+ - **分析师的 PTC 执行面**:`data_junior` 只保留 `run_code` 直调,Dataset / skill / 出图委派 /
19
+ 回传都通过 SDK `tools.*` 在程序内完成;不再挂 bash / pwsh,同时保住了“不读原始行”的边界。
20
+ - **多 Dataset 并发与 `too_large` 自愈**:同一程序里可以 `Promise.all` 多份 `describe_dataset`;
21
+ 结果过大时按 hint 收窄后继续,不再多开模型轮次。
22
+ - **PTC 会话报告**:`npm run ptc:report` 汇总 `run_code` 段数、`Promise.all`、skill 打印、
23
+ `too_large` 与并发窗口,供实机验收。
24
+
25
+ ### Changed
26
+
27
+ - **分析师提示词与三册协议同步 PTC 写法**:第一次加载协议就明确 skill 也要在 `run_code` 内用
28
+ `tools.skill`,不要直调原生工具;查询、profile 与可视化协议同步程序内调度。
29
+ - **图表交付继续由收尾流写入**:`present_charts` 只记录清单,宿主在最终答复前统一落
30
+ `deliverables/presented`;结算通知不再阻塞收尾,且同一 exchange 不会重复写卡。
31
+
32
+ ### Fixed
33
+
34
+ - **图表卡在结算通知竞争时丢失**:子 Agent 的 `subagent-settled` 通知在收尾流中途到达时,
35
+ 不再被当成“还有待处理消息”;通知同时清掉已结算的直属子 Agent。
36
+ - **同一轮重复交付**:后续 `finish` 帧缺少 `turn` 时不再写第二张卡。
37
+ - **直调 skill 误调用**:软约束已把 `tools.skill` 提到第一次加载协议处;模型仍可能试错一次,
38
+ 宿主拒绝后会自愈,不影响结果。
39
+
40
+ ## [2.9.1] - 2026-10-21
41
+
42
+ 修复实机 `dsh web` 反馈的两处收尾体验:图表交付不再散落在等待轮,最终答复不再只剩一句
43
+ “分析已完成…见上”。不需要用户改配置,也没有宿主版本变化。
44
+
45
+ ### Added
46
+
47
+ - **`present_charts` 收尾交付步骤**(仅主 Agent):`render_chart` 出图后只回 `chart_ref`,主 Agent
48
+ 在写最终答复前一次性登记本轮全部图表,卡片因此落在**最后一轮**答复末尾的「本轮文件改动 /
49
+ 交付」行;refs 因上下文压缩丢失时可用 `task_id` 兜底。
50
+ - **图表登记支持按 task 回查**:registry 记录标题,并可按 root session / 工作区 / task 列出;
51
+ fork / branch 出来的顶层会话仍能解析并登记自己子 Agent 出的图。
52
+
53
+ ### Changed
54
+
55
+ - **自动模式不再在出图时自动登记交付**(单体载体保留):旧行为会把卡片写进主 Agent 等待子 Agent
56
+ 时所在的中间轮,DSH 折叠后用户容易错过。
57
+ - **最终答复必须是自足总结**:禁止“分析已完成 / 结论见上 / 如上”一类指针式收尾;被结算通知
58
+ 唤醒且无新信息时,要么不输出可见正文,要么重发一份自足总结。
59
+ - 收口顺序写进人设与编排协议:委派全部结算 → 对账通过 → `present_charts` → 最终答复。
60
+
61
+ ### Fixed
62
+
63
+ - fork / branch 顶层会话的 `parentSession` 是 seed 谱系,不再被当成委派链往上走(否则图表会
64
+ 登记到被 fork 的旧会话,且 root 自己解析不到 `chart_ref`)。
65
+ - 交付登记不再把内部记录字段 `owner_cwd` 混进模型可见回执。
66
+
10
67
  ## [2.9.0] - 2026-10-20
11
68
 
12
69
  新会话选择器收敛到三个用户模式:**自动模式 / 行情专家 / 资讯专员**。分析师与绘图专员继续注册,
package/README.md CHANGED
@@ -18,8 +18,9 @@
18
18
  <p align="center">
19
19
  <a href="https://awesome-dsh-plugin.com"><img src="https://awesome-dsh-plugin.com/badge.svg" alt="awesome · DSH plugin"></a>
20
20
  <a href="LICENSE"><img src="https://img.shields.io/badge/License-MIT-green" alt="License"></a>
21
- <a href="https://github.com/v587d/capital-generation/releases"><img src="https://img.shields.io/badge/version-2.9.0-9cf" alt="Version"></a>
21
+ <a href="https://github.com/v587d/capital-generation/releases"><img src="https://img.shields.io/badge/version-2.9.2-9cf" alt="Version"></a>
22
22
  <a href="https://www.npmjs.com/package/@v587d/capital-generation"><img src="https://img.shields.io/npm/v/%40v587d%2Fcapital-generation" alt="npm version"></a>
23
+ <a href="https://github.com/deepseek-ai/deepseek-harness"><img src="https://img.shields.io/badge/powered_by-dsh-4D6BFE?style=flat-square&logo=deepseek&logoColor=white" alt="powered-by-dsh"></a>
23
24
  </p>
24
25
 
25
26
  # Capital Generation
@@ -154,7 +155,7 @@ https://github.com/v587d/capital-generation
154
155
 
155
156
  - **查数据和资料:** 覆盖 A 股、港股、美股与期货等品种,由专用角色按需获取行情、财务、指数、基金、宏观行业与期货数据,检索公告、新闻和研报。共计 **128** 个数据收集能力和 **14** 个网页检索能力,部分公开数据源无需额外密钥;具体覆盖范围见 [数据收集能力表](docs/data-collector-capabilities.md) 和 [网页检索能力表](docs/web-retriever-capabilities.md)。
156
157
  - **数据质检:** 结构化数据不会直接进入模型上下文。每份数据先由分析师检查结构、时间轴、缺失与基础统计,再按受控方式汇总;原始数据行始终留在工具层。
157
- - **可视化:** 分析师判断数据适合图表,或你明确要图时,绘图专员会生成可交互图表;明确不要图时只完成质检。图表作为本轮交付物出现在会话里,可在侧栏查看或离线打开。了解处理流程可看 [Agent 角色说明](docs/agent-roles.md)。
158
+ - **可视化:** 分析师判断数据适合图表,或你明确要图时,绘图专员会生成可交互图表;明确不要图时只完成质检。图表会登记为**最后一轮答复**的交付物,在答复末尾集中出现,可在侧栏查看或离线打开。了解处理流程可看 [Agent 角色说明](docs/agent-roles.md)。
158
159
 
159
160
  | 走势折线 | K 线与量价 |
160
161
  | :---: | :---: |
@@ -190,7 +191,7 @@ https://github.com/v587d/capital-generation
190
191
  - **安装后找不到模式?** 确认插件出现在「插件」→ 已安装列表;Web / CLI profile 安装后要重启该 profile,并在新会话选择模式。选择器里应看到自动模式、行情专家、资讯专员;分析师和绘图专员是自动模式的内部角色,不会出现在这里。
191
192
  - **配置在哪?** 打开「插件」→ 已安装的 **Capital Generation**,页面顶部就是「数据源 / 精选 Skills / 包含的组件」三格,五把密钥都在第一格;不在 DSH 的普通「设置」页面,也不用点进任何一行组件。保存密钥后新建会话再试。
192
193
  - **升级到 2.9.0 后,分析师和绘图专员去哪了?** 这一版把它们收进自动模式内部,不再出现在选择器与预设管理里,避免误开一个没有取数入口的空会话。旧会话仍能按原身份恢复;新任务直接用自动模式提出即可。
193
- - **桌面端升级后仍是旧版?** 桌面端安装时会固定所解析的版本,不会自动升级。卸载后重新安装,再到插件详情查看版本号;如需指定版本,可在地址末尾加 `#v2.9.0` 或 `@2.9.0`。
194
+ - **桌面端升级后仍是旧版?** 桌面端安装时会固定所解析的版本,不会自动升级。卸载后重新安装,再到插件详情查看版本号;如需指定版本,可在地址末尾加 `#v2.9.2` 或 `@2.9.2`。
194
195
  - **刚发布的新版本装不到?** 桌面端取包时会跳过**刚发布不久**的版本(宿主侧包管理器的发布冷却策略,实测会静默装成上一个版本),国内镜像的同步也可能滞后一段时间。急着用最新代码,改用仓库地址安装最直接。
195
196
  - **网页抓取失败?** 配置卡片中的「允许启动本地提取网页内容」默认开启:AnySearch 提取失败时可尝试本机直连。关闭后,本机直连的具名来源查询也不可用;请检查该开关和网络连接。
196
197
  - **Wind 那部分会消耗积分?** 会。公告与新闻检索、宏观 / 行业 / 汇率指标、按日期区间取的历史 K 线都按次消耗你在 Wind 侧的额度;额度与条款以 Wind 为准,插件不做用量封顶。没配 `WIND_API_KEY` 时这几类不可用,其余功能照常。
@@ -90,9 +90,10 @@ route owner 自己负责,平台自己的 `/api` 与首页分别走 `connection
90
90
 
91
91
  ## 本轮呈现(官方交付通道)
92
92
 
93
- 出图成功后,**宿主**(`@v587d/capital-generation` 的 `render_chart`)会往"用户正在看的根会话"
94
- 追加一条**官方** `deliverables/presented` 事件,把 `capital-analysis/charts/<chart_id>/chart.html`
95
- 登记为**本轮交付物**:
93
+ auto 模式下 `render_chart` 不再自动登记:`present_charts` 只记录本轮图表,宿主在**收尾流结束、
94
+ 收尾消息落盘之前**写一条**官方** `deliverables/presented` 事件到"用户正在看的根会话",把
95
+ `capital-analysis/charts/<chart_id>/chart.html` 登记为**收尾轮交付物**(单体载体仍在出图时
96
+ 自动登记):
96
97
 
97
98
  - 载荷只有官方契约字段:`turn` / `callId`(`capital-chart:<chart_id>` 句柄)/ `files[]`
98
99
  (工作区相对 `html_path` + 标题)——绝不含 rows、series、HTML 或绝对路径;
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@v587d/capital-charts",
3
- "version": "2.9.0",
3
+ "version": "2.9.2",
4
4
  "description": "自动模式的图表前端组件(浏览器半边 + 序列旁路路由):由 @v587d/capital-generation 随包携带并作为 host 平面行挂载。",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@v587d/config",
3
- "version": "2.9.0",
3
+ "version": "2.9.2",
4
4
  "description": "自动模式的取数凭据与偏好配置行:卡片坐在 @v587d/capital-generation 插件主页的「配置」段,点这一行本身没有表单。",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -1,13 +1,17 @@
1
- /** Shell guard for bash/pwsh: blocks only doomed sandbox escalation; command content is never
2
- * inspected (the sandbox blocks writes, not reads/network). Delegated approvals are fixed to
3
- * `never`, so escalation auto-rejects with a misleading "user rejected"; block early instead.
4
- * Root escalation still uses ask; do not pin delegated sessions read-only (`store.writeContext`
5
- * needs workspace-write). `tools.guard` is monotonic; pre-execute is a rewritable waterfall. */
1
+ /** Execution guard for bash/pwsh/run_code: blocks only doomed sandbox escalation; command/program
2
+ * content is never inspected (the sandbox blocks writes, not reads/network). Delegated approvals
3
+ * are fixed to `never`, so escalation auto-rejects with a misleading "user rejected"; block early
4
+ * instead. Root escalation still uses ask; do not pin delegated sessions read-only
5
+ * (`store.writeContext` needs workspace-write). `tools.guard` is monotonic; pre-execute is a
6
+ * rewritable waterfall. */
6
7
  import { type GuardedExecution, type PolicyContext } from './root-tool-policy.js';
8
+ /** Both shell names on every platform plus the PTC transport: the preset mounts one shell pair for
9
+ * the root and `run_code` for data_junior, so matching only the local platform kills the Windows gate. */
10
+ export declare const EXECUTION_ESCALATION_TOOL_NAMES: readonly ["bash", "pwsh", "run_code"];
7
11
  export type BashGuardExecution = GuardedExecution;
8
12
  /** States that the rejection is the system's, not the user's, and gives the recovery path. */
9
13
  export declare const SHELL_ESCALATION_UNREACHABLE: string;
10
- /** Reads identity plus `sandbox_permissions`/`justification`, never `command`; unknown identity fails closed. */
14
+ /** Reads identity plus `sandbox_permissions`/`justification`, never `command`/`code`; unknown identity fails closed. */
11
15
  export declare function bashGuardReason(exec: BashGuardExecution | undefined): string | undefined;
12
16
  /** Installs on every Capital preset agent. Must listen on `ctx.root`: agent/created routes by
13
17
  * the agent object, so tagged standing-scope listeners never fire. Failures only warn. */
@@ -1,23 +1,26 @@
1
- /** Shell guard for bash/pwsh: blocks only doomed sandbox escalation; command content is never
2
- * inspected (the sandbox blocks writes, not reads/network). Delegated approvals are fixed to
3
- * `never`, so escalation auto-rejects with a misleading "user rejected"; block early instead.
4
- * Root escalation still uses ask; do not pin delegated sessions read-only (`store.writeContext`
5
- * needs workspace-write). `tools.guard` is monotonic; pre-execute is a rewritable waterfall. */
1
+ /** Execution guard for bash/pwsh/run_code: blocks only doomed sandbox escalation; command/program
2
+ * content is never inspected (the sandbox blocks writes, not reads/network). Delegated approvals
3
+ * are fixed to `never`, so escalation auto-rejects with a misleading "user rejected"; block early
4
+ * instead. Root escalation still uses ask; do not pin delegated sessions read-only
5
+ * (`store.writeContext` needs workspace-write). `tools.guard` is monotonic; pre-execute is a
6
+ * rewritable waterfall. */
6
7
  import { agentSessionId, isCapitalPresetAgent, SHELL_TOOL_NAMES } from './root-tool-policy.js';
7
- /** Both names on every platform: the preset mounts a pair, so matching only the local platform kills the Windows gate. */
8
- const shellToolNames = new Set(SHELL_TOOL_NAMES);
8
+ /** Both shell names on every platform plus the PTC transport: the preset mounts one shell pair for
9
+ * the root and `run_code` for data_junior, so matching only the local platform kills the Windows gate. */
10
+ export const EXECUTION_ESCALATION_TOOL_NAMES = [...SHELL_TOOL_NAMES, 'run_code'];
11
+ const executionToolNames = new Set(EXECUTION_ESCALATION_TOOL_NAMES);
9
12
  /** One listener set per root context: host policy and legacy call sites install once. */
10
13
  const installedRoots = new WeakSet();
11
14
  /** States that the rejection is the system's, not the user's, and gives the recovery path. */
12
- export const SHELL_ESCALATION_UNREACHABLE = 'shell 工具(bash / pwsh)的沙箱升级只有主 Agent 的会话能拿到用户审批:被委派的子会话拿不到'
15
+ export const SHELL_ESCALATION_UNREACHABLE = '执行工具(bash / pwsh / run_code)的沙箱升级只有主 Agent 的会话能拿到用户审批:被委派的子会话拿不到'
13
16
  + '(框架把委派会话的审批策略固定为 never,不会去问用户),当前调用方也不是可确认的主 Agent 会话。'
14
17
  + '不要重试升级:缺数值用 describe_dataset / query_dataset,缺数据用 send_message 发 data_gap 回主 Agent。';
15
18
  function isRecord(value) {
16
19
  return typeof value === 'object' && value !== null && !Array.isArray(value);
17
20
  }
18
- /** Reads identity plus `sandbox_permissions`/`justification`, never `command`; unknown identity fails closed. */
21
+ /** Reads identity plus `sandbox_permissions`/`justification`, never `command`/`code`; unknown identity fails closed. */
19
22
  export function bashGuardReason(exec) {
20
- if (exec === undefined || typeof exec.name !== 'string' || !shellToolNames.has(exec.name))
23
+ if (exec === undefined || typeof exec.name !== 'string' || !executionToolNames.has(exec.name))
21
24
  return undefined;
22
25
  const args = isRecord(exec.arguments) ? exec.arguments : undefined;
23
26
  const escalated = args !== undefined && (args.sandbox_permissions !== undefined || args.justification !== undefined);
@@ -64,7 +67,7 @@ export function registerBashGuard(ctx) {
64
67
  return;
65
68
  const dispose = installAgentGuard(agent);
66
69
  if (dispose === undefined) {
67
- ctx.logger?.warn('capital-generation: shell 闸门未生效(agent scope 上没有 tools.guard):沙箱升级拦截只剩 persona 软约束');
70
+ ctx.logger?.warn('capital-generation: 执行工具闸门未生效(agent scope 上没有 tools.guard):沙箱升级拦截只剩 persona 软约束');
68
71
  return;
69
72
  }
70
73
  const id = agentSessionId(agent);
@@ -72,7 +75,7 @@ export function registerBashGuard(ctx) {
72
75
  guards.set(id, dispose);
73
76
  }
74
77
  catch (error) {
75
- ctx.logger?.warn(`capital-generation: bash 闸门安装异常:${error instanceof Error ? error.message : String(error)}`);
78
+ ctx.logger?.warn(`capital-generation: 执行工具闸门安装异常:${error instanceof Error ? error.message : String(error)}`);
76
79
  }
77
80
  });
78
81
  const stopDisposed = listenCtx.on?.('agent/disposed', (payload) => {
@@ -47,6 +47,8 @@ export const ROOT_AGENT_DENIED_TOOLS = [
47
47
  'write',
48
48
  'edit',
49
49
  ];
50
+ // ⛔ `run_code` belongs nowhere in this list: it is a reserved PTC transport name, so `restrict()`
51
+ // throws on it. The root stays native; deployment-level `dsh-tools.mode` must not be set to ptc.
50
52
  export const CAPITAL_PRESET_ID = ROOT_PRESET_ID;
51
53
  export function isRootAgent(agent) {
52
54
  if (agent?.session === undefined)
@@ -1,5 +1,8 @@
1
1
  import type { SessionLike } from '../data-collector/store.js';
2
- /** One-hop scope: a token lets a nested child reuse its owner's scope. */
2
+ /**
3
+ * Subagent children carry `origin: 'subagent'`; a user fork/branch root also has `parentSession`
4
+ * (seed lineage) and must stay its own scope. One hop: the token lets a nested child reuse its owner.
5
+ */
3
6
  export declare function chartSessionScopeId(session: SessionLike): string;
4
7
  export interface ChartArtifactRef {
5
8
  chart_ref: string;
@@ -10,6 +13,7 @@ export interface ChartArtifactRef {
10
13
  captured_at: number | null;
11
14
  kind: string;
12
15
  axis: 'time' | 'index';
16
+ title: string;
13
17
  chart_url: string | null;
14
18
  spec_path: string;
15
19
  series_path: string;
@@ -42,6 +46,7 @@ export declare class ChartArtifactRegistry {
42
46
  captured_at?: number | null;
43
47
  kind: string;
44
48
  axis: 'time' | 'index';
49
+ title: string;
45
50
  chart_url: string | null;
46
51
  spec_path: string;
47
52
  series_path: string;
@@ -55,6 +60,11 @@ export declare class ChartArtifactRegistry {
55
60
  task_id?: string | null;
56
61
  }): ChartArtifactRef;
57
62
  revoke(chartRef: string): boolean;
63
+ /** Charts of one session scope, for the model's final-turn delivery step; newest last. */
64
+ list(input: {
65
+ session: SessionLike;
66
+ task_id?: string | null;
67
+ }): ChartArtifactRef[];
58
68
  size(): number;
59
69
  private publicRecord;
60
70
  }
@@ -8,9 +8,12 @@ function defaultChartRef() {
8
8
  : `${Date.now().toString(36)}${Math.random().toString(36).slice(2, 10)}`;
9
9
  return `chart_${suffix}`;
10
10
  }
11
- /** One-hop scope: a token lets a nested child reuse its owner's scope. */
11
+ /**
12
+ * Subagent children carry `origin: 'subagent'`; a user fork/branch root also has `parentSession`
13
+ * (seed lineage) and must stay its own scope. One hop: the token lets a nested child reuse its owner.
14
+ */
12
15
  export function chartSessionScopeId(session) {
13
- const parent = session.header?.parentSession;
16
+ const parent = session.header?.origin === 'subagent' ? session.header.parentSession : undefined;
14
17
  return typeof parent === 'string' && parent.length > 0 ? parent : session.id;
15
18
  }
16
19
  /**
@@ -56,6 +59,7 @@ export class ChartArtifactRegistry {
56
59
  captured_at: input.captured_at ?? null,
57
60
  kind: input.kind,
58
61
  axis: input.axis,
62
+ title: input.title,
59
63
  chart_url: input.chart_url,
60
64
  spec_path: input.spec_path,
61
65
  series_path: input.series_path,
@@ -91,12 +95,30 @@ export class ChartArtifactRegistry {
91
95
  revoke(chartRef) {
92
96
  return this.records.delete(chartRef);
93
97
  }
98
+ /** Charts of one session scope, for the model's final-turn delivery step; newest last. */
99
+ list(input) {
100
+ const now = this.now();
101
+ this.purgeExpired(now);
102
+ const scope = chartSessionScopeId(input.session);
103
+ const cwd = input.session.header?.cwd ?? null;
104
+ const records = [];
105
+ for (const record of this.records.values()) {
106
+ if (record.owner_scope_id !== scope || record.owner_cwd !== cwd)
107
+ continue;
108
+ if (input.task_id !== undefined && input.task_id !== null && record.task_id !== input.task_id)
109
+ continue;
110
+ records.push(record);
111
+ }
112
+ return records
113
+ .sort((left, right) => left.created_at - right.created_at)
114
+ .map((record) => this.publicRecord(record));
115
+ }
94
116
  size() {
95
117
  this.purgeExpired(this.now());
96
118
  return this.records.size;
97
119
  }
98
120
  publicRecord(record) {
99
- const { owner_scope_id: _ownerScope, ...publicRecord } = record;
121
+ const { owner_scope_id: _ownerScope, owner_cwd: _ownerCwd, ...publicRecord } = record;
100
122
  return publicRecord;
101
123
  }
102
124
  }
@@ -0,0 +1,66 @@
1
+ /**
2
+ * Chart delivery at the closing answer.
3
+ *
4
+ * DSH's deliverable card only replays `deliverables/presented` events whose seq is before the
5
+ * turn's closing assistant message, and a turn is final only after the fact. The one place that is
6
+ * provably before the closing message is the assistant stream's terminal `finish` chunk: the loop
7
+ * appends the message after the stream ends. So delivery is deferred to that chunk, gated on the
8
+ * root session being idle (no actionable inbox input, no running direct children), which is exactly
9
+ * the shape of a closing turn. Waiting/aborted/tool-call responses never flush.
10
+ *
11
+ * `subagent-settled` notices are not actionable input: they can arrive while the closing answer is
12
+ * already streaming. Counting them as pending would strand a card that the model has just selected,
13
+ * because the follow-up step often has nothing new to say.
14
+ */
15
+ import type { ChartArtifactRegistry } from './artifact-ref.js';
16
+ import type { ChartTurnEventInput } from './events.js';
17
+ import type { SessionLike } from '../data-collector/store.js';
18
+ /** Structural subset of `@deepseek-ai/dsh-llm` AssistantStreamFrame. */
19
+ export interface AssistantStreamFrameLike {
20
+ readonly type?: string;
21
+ readonly attemptId?: string;
22
+ readonly turn?: number;
23
+ readonly chunk?: {
24
+ readonly type?: string;
25
+ readonly reason?: {
26
+ readonly kind?: string;
27
+ };
28
+ readonly blockType?: string;
29
+ readonly block?: {
30
+ readonly type?: string;
31
+ };
32
+ };
33
+ }
34
+ interface InboxStateLike {
35
+ 'next-turn'?: readonly unknown[];
36
+ 'next-step'?: readonly unknown[];
37
+ }
38
+ export interface ChartDeliveryOptions {
39
+ artifacts: ChartArtifactRegistry;
40
+ /** Writes one first-party deliverables/presented batch; must be synchronous (pre-message seq). */
41
+ publish: (sessionId: string, inputs: ChartTurnEventInput[]) => boolean;
42
+ /** Durable pending input; synchronous projection read. Absent means "cannot tell" → proceed. */
43
+ inboxOf?: (session: SessionLike) => InboxStateLike | undefined;
44
+ /** Bounded batch; the card collapses past four files and expands on demand. */
45
+ maxCharts?: number;
46
+ }
47
+ export interface ChartDelivery {
48
+ /** `present_charts` selection; merged across calls because models often retry in batches. */
49
+ request(session: SessionLike, inputs: ChartTurnEventInput[]): void;
50
+ acceptStream(session: SessionLike | undefined, frame: AssistantStreamFrameLike): void;
51
+ /** A new human message starts a new exchange: old charts must not ride into it. */
52
+ acceptUserMessage(sessionId: string, time: number): void;
53
+ acceptSubagentStart(info: {
54
+ id?: string;
55
+ }): void;
56
+ acceptSubagentEnd(info: {
57
+ id?: string;
58
+ }): void;
59
+ /** False until any stream frame was observed: callers fall back to immediate delivery. */
60
+ streaming(): boolean;
61
+ /** Diagnostics/tests: explicitly selected inputs for the current exchange. */
62
+ pending(sessionId?: string): number;
63
+ }
64
+ export declare const DEFAULT_MAX_DELIVERED_CHARTS = 32;
65
+ export declare function createChartDelivery(options: ChartDeliveryOptions): ChartDelivery;
66
+ export {};
@@ -0,0 +1,148 @@
1
+ /** Settlement notices race the closing answer; they do not require another model step before the card. */
2
+ function isSettledNotice(value) {
3
+ return value !== null && typeof value === 'object'
4
+ && value.source?.kind === 'subagent-settled';
5
+ }
6
+ function inboxMessages(inbox) {
7
+ return [...(inbox?.['next-turn'] ?? []), ...(inbox?.['next-step'] ?? [])];
8
+ }
9
+ export const DEFAULT_MAX_DELIVERED_CHARTS = 32;
10
+ export function createChartDelivery(options) {
11
+ const maxCharts = options.maxCharts ?? DEFAULT_MAX_DELIVERED_CHARTS;
12
+ /** Plugin instances are per root agent, so one session id guards against child streams leaking in. */
13
+ let sessionId;
14
+ let explicit = [];
15
+ const exchangeStarts = new Map();
16
+ const fallbackStart = Date.now();
17
+ let lastFlushedTurn;
18
+ let sawFrame = false;
19
+ const attempts = new Map();
20
+ const running = new Map();
21
+ const owns = (session) => {
22
+ if (session?.id === undefined)
23
+ return false;
24
+ // A leaked child frame must not bind the manager: the card belongs to the user-facing root.
25
+ if (session.header?.origin === 'subagent')
26
+ return false;
27
+ sessionId ??= session.id;
28
+ return session.id === sessionId;
29
+ };
30
+ const autoInputs = (session) => options.artifacts
31
+ .list({ session })
32
+ .filter((artifact) => artifact.created_at >= (exchangeStarts.get(session.id) ?? fallbackStart))
33
+ .map((artifact) => ({
34
+ ownerSessionId: session.id,
35
+ chart_id: artifact.chart_id,
36
+ title: artifact.title,
37
+ html_path: artifact.html_path,
38
+ }));
39
+ /** A settled notice proves the child is done even if the lifecycle edge was missed or the parent
40
+ * observed it outside its scope. Reconciliating here keeps the stream-time gate synchronous. */
41
+ const reconcileSettled = (inbox) => {
42
+ for (const message of inboxMessages(inbox)) {
43
+ if (!isSettledNotice(message))
44
+ continue;
45
+ const childId = message.source?.senderSessionId;
46
+ if (typeof childId === 'string')
47
+ running.delete(childId);
48
+ }
49
+ };
50
+ const idle = (inbox) => {
51
+ if (running.size > 0)
52
+ return false;
53
+ if (inbox === undefined)
54
+ return true;
55
+ return inboxMessages(inbox).every(isSettledNotice);
56
+ };
57
+ const flush = (session, turn) => {
58
+ // A later step in the same exchange may omit `turn`; it still must not write a second card.
59
+ if (lastFlushedTurn !== undefined && (turn === undefined || lastFlushedTurn === turn))
60
+ return;
61
+ const inbox = options.inboxOf?.(session);
62
+ reconcileSettled(inbox);
63
+ if (!idle(inbox))
64
+ return;
65
+ const inputs = (explicit.length > 0 ? explicit : autoInputs(session)).slice(0, maxCharts);
66
+ if (inputs.length === 0)
67
+ return;
68
+ let written = false;
69
+ try {
70
+ written = options.publish(session.id, inputs);
71
+ }
72
+ catch {
73
+ written = false;
74
+ }
75
+ // Sentinel keeps a second turn-less finish in the same exchange from duplicating the card.
76
+ if (written)
77
+ lastFlushedTurn = turn ?? -1;
78
+ };
79
+ return {
80
+ request(session, inputs) {
81
+ if (!owns(session) || inputs.length === 0)
82
+ return;
83
+ const seen = new Set(explicit.map((item) => item.chart_id));
84
+ for (const input of inputs) {
85
+ if (seen.has(input.chart_id))
86
+ continue;
87
+ seen.add(input.chart_id);
88
+ explicit.push(input);
89
+ }
90
+ if (explicit.length > maxCharts)
91
+ explicit = explicit.slice(0, maxCharts);
92
+ },
93
+ acceptStream(session, frame) {
94
+ if (!owns(session) || frame === undefined)
95
+ return;
96
+ sawFrame = true;
97
+ const attemptId = frame.attemptId ?? '__default__';
98
+ if (frame.type === 'start') {
99
+ attempts.set(attemptId, { toolCall: false });
100
+ return;
101
+ }
102
+ if (frame.type === 'end') {
103
+ attempts.delete(attemptId);
104
+ return;
105
+ }
106
+ const chunk = frame.chunk;
107
+ if (frame.type !== 'chunk' || chunk === undefined)
108
+ return;
109
+ const attempt = attempts.get(attemptId) ?? { toolCall: false };
110
+ attempts.set(attemptId, attempt);
111
+ if (chunk.type === 'tool-call-delta'
112
+ || (chunk.type === 'block-start' && chunk.blockType === 'tool-call')
113
+ || (chunk.type === 'block-end' && chunk.block?.type === 'tool-call')) {
114
+ attempt.toolCall = true;
115
+ return;
116
+ }
117
+ if (chunk.type !== 'finish' || chunk.reason?.kind !== 'stop' || attempt.toolCall)
118
+ return;
119
+ flush(session, frame.turn);
120
+ },
121
+ acceptUserMessage(id, time) {
122
+ exchangeStarts.set(id, time);
123
+ if (id !== sessionId)
124
+ return;
125
+ explicit = [];
126
+ lastFlushedTurn = undefined;
127
+ },
128
+ acceptSubagentStart(info) {
129
+ if (typeof info?.id !== 'string' || info.id.length === 0)
130
+ return;
131
+ running.set(info.id, 1);
132
+ },
133
+ acceptSubagentEnd(info) {
134
+ if (typeof info?.id !== 'string')
135
+ return;
136
+ // One child session runs one activation at a time; deleting also clears duplicate starts.
137
+ running.delete(info.id);
138
+ },
139
+ streaming() {
140
+ return sawFrame;
141
+ },
142
+ pending(id) {
143
+ if (id !== undefined && id !== sessionId)
144
+ return 0;
145
+ return explicit.length;
146
+ },
147
+ };
148
+ }
@@ -38,6 +38,8 @@ export interface ChartTurnEventInput {
38
38
  }
39
39
  export interface ChartEventPublisher {
40
40
  publish(input: ChartTurnEventInput): boolean;
41
+ /** One first-party event carrying every file; the turn cap counts events, not files. */
42
+ publishMany(ownerSessionId: string, inputs: ChartTurnEventInput[]): boolean;
41
43
  /** Diagnostics: queued charts not yet written. */
42
44
  pendingCount?(ownerSessionId?: string): number;
43
45
  /** Diagnostics: last publish trace, surfaced in receipts / disk so failures are visible without a terminal. */
@@ -33,7 +33,10 @@ function isAppendableSession(value) {
33
33
  && typeof value.append === 'function'
34
34
  && typeof value.id === 'string';
35
35
  }
36
+ /** `parentSession` alone is seed lineage (a user fork); only `origin: 'subagent'` is delegation. */
36
37
  function parentSessionOf(session) {
38
+ if (session.header?.origin !== 'subagent')
39
+ return undefined;
37
40
  const parent = session.header?.parentSession;
38
41
  return typeof parent === 'string' && parent.length > 0 ? parent : undefined;
39
42
  }
@@ -157,7 +160,7 @@ function buildChartEventPublisher(ctx) {
157
160
  const key = `${owner.id}:${turn}`;
158
161
  const used = perTurn.get(key) ?? 0;
159
162
  if (used >= MAX_CHARTS_PER_TURN) {
160
- warn(`图表对话流事件跳过:turn ${turn} 已达到 ${MAX_CHARTS_PER_TURN} 张上限`);
163
+ warn(`图表对话流事件跳过:turn ${turn} 已达到 ${MAX_CHARTS_PER_TURN} 次上限`);
161
164
  return false;
162
165
  }
163
166
  try {
@@ -171,6 +174,31 @@ function buildChartEventPublisher(ctx) {
171
174
  note(`图表已登记为本轮交付(owner=${owner.id},turn=${turn},${input.chart_id})`);
172
175
  return true;
173
176
  };
177
+ const appendMany = (owner, turn, inputs) => {
178
+ if (inputs.length === 0)
179
+ return false;
180
+ const key = `${owner.id}:${turn}`;
181
+ const used = perTurn.get(key) ?? 0;
182
+ if (used >= MAX_CHARTS_PER_TURN) {
183
+ warn(`图表对话流事件跳过:turn ${turn} 已达到 ${MAX_CHARTS_PER_TURN} 次上限`);
184
+ return false;
185
+ }
186
+ const first = inputs[0];
187
+ if (first === undefined)
188
+ return false;
189
+ const payload = buildChartEventPayload(first, turn);
190
+ payload.files = inputs.map((item) => ({ path: item.html_path, description: item.title }));
191
+ try {
192
+ owner.append(CHART_DELIVERABLE_EVENT, payload);
193
+ }
194
+ catch (error) {
195
+ warn(`图表对话流事件写入失败(图表已落盘):${error instanceof Error ? error.message : String(error)}`);
196
+ return false;
197
+ }
198
+ perTurn.set(key, used + 1);
199
+ note(`图表已登记为本轮交付(owner=${owner.id},turn=${turn},${inputs.length} 张)`);
200
+ return true;
201
+ };
174
202
  /** Flush queued charts into a turn; the preferred hook is turn-stopping. */
175
203
  const flush = (ownerSessionId, turn) => {
176
204
  const queued = pending.get(ownerSessionId);
@@ -305,6 +333,32 @@ function buildChartEventPublisher(ctx) {
305
333
  lastPublishTrace = `${trace.join(' ')} 回合打开(turn=${lastTurn}) → ${written ? '已直接登记' : '写入失败'}`;
306
334
  return written;
307
335
  },
336
+ publishMany(ownerSessionId, inputs) {
337
+ const owner = resolveOwnerSession(sessions, ownerSessionId);
338
+ if (owner === undefined) {
339
+ lastPublishTrace = `owner 未解析:批量交付缺少可用 owner 会话`;
340
+ warn('图表对话流事件跳过:找不到批量交付的 owner 会话(图表已落盘,走 html_path 降级)');
341
+ return false;
342
+ }
343
+ const boundary = boundaryOf(owner);
344
+ const turn = boundary?.lastTurn;
345
+ if (typeof boundary?.openTurnStartSeq !== 'number' || typeof turn !== 'number' || !Number.isSafeInteger(turn) || turn < 1) {
346
+ // Between turns: queue each input so the existing flush path still delivers them later.
347
+ const queued = pending.get(owner.id) ?? [];
348
+ if (queued.length + inputs.length > MAX_PENDING_CHARTS) {
349
+ lastPublishTrace = `owner=${owner.id} 批量寄存超限(${queued.length}+${inputs.length} > ${MAX_PENDING_CHARTS})→ 未写入`;
350
+ warn('图表对话流事件跳过:进程内寄存已超出上限(冲刷链路异常)');
351
+ return false;
352
+ }
353
+ queued.push(...inputs);
354
+ pending.set(owner.id, queued);
355
+ lastPublishTrace = `owner=${owner.id} 回合未打开 → 已寄存 ${inputs.length} 张,等 turn-stopping/turn-start 冲刷`;
356
+ return true;
357
+ }
358
+ const written = appendMany(owner, turn, inputs);
359
+ lastPublishTrace = `owner=${owner.id} 回合打开(turn=${turn}) → ${written ? `已批量登记 ${inputs.length} 张` : '写入失败'}`;
360
+ return written;
361
+ },
308
362
  /** Diagnostics/tests: queued charts not yet flushed. */
309
363
  pendingCount(ownerSessionId) {
310
364
  if (ownerSessionId !== undefined)