diff --git a/FEATURES.md b/FEATURES.md index 22991dae..5dfccdba 100644 --- a/FEATURES.md +++ b/FEATURES.md @@ -379,7 +379,8 @@ records are listed separately and do not introduce another feature status. compatible connection without requiring a duplicate selection. Settings displays the actual administrator or fallback model source. Ask permits only real Host-registered `read` and `skill` tools plus Main-managed Web - Search/Fetch proxies, and rejects plugin impersonation of those names. + Search/Fetch and enabled Story Graph proxies, and rejects plugin impersonation + of those names. Execute allows all enabled built-in and plugin tools with the current user's permissions. Image input follows the selected model connection's declared capability: text models reject images before Host or model invocation, while @@ -404,8 +405,9 @@ records are listed separately and do not introduce another feature status. browser, using the current local project and model. These are independent sessions, without imported GoodBuddy conversation history. Closing a DS page does not stop its service; use GoodBuddy's stop action. Remote shortcuts are - not connected. Managed standard Node delivery, Continue/OpenCode Ask built-in - MCP mappings, and full cross-platform package acceptance remain incomplete; + not connected. Continue/OpenCode Ask now map Main-bound read tools through + native permissions. Managed standard Node delivery and full cross-platform + package acceptance remain incomplete; existing Windows real-model evidence does not establish full capability parity. See the [implementation and remaining work](./docs/features/assistant-workbar/progress.md#2026-09-27-local-native-client-validation). - **Runtime telemetry opt-out**: Local DS Web and remote Runtime/model-bridge @@ -598,8 +600,9 @@ records are listed separately and do not introduce another feature status. the applicable checks pass. - [x] **On-demand built-in MCP**: Knowledge, Magic Notes, GoodBuddy configuration, and built-in browser MCP can be enabled independently and assigned to direct - models, GoodBuddy-managed OpenCode, and Continue. Settings explicitly marks - DeepSeek Harness unsupported. Built-in MCP uses short-lived local authority + models, GoodBuddy-managed OpenCode, and Continue. DeepSeek Harness remains + unsupported for these servers; Story Graph supports it through the Main proxy. + Built-in MCP uses short-lived local authority for the current request, and user configuration cannot loosen Ask/Execute read/write boundaries. - **Obsidian integration (implemented in source, acceptance in progress)**: @@ -830,6 +833,13 @@ records are listed separately and do not introduce another feature status. - [ ] **Batch runs and comparison lab** (planned): Compares model, Prompt, role, and workflow configurations in batches and summarizes quality, duration, tokens, cost, failure rate, and artifact differences. +- **Story Graph read tools (unreleased source)**: Agents can search saved work, + read current claims or timelines, and page through source evidence. Supervisor + enablement and Runtime assignment control discovery and calls, including old + sessions. Model, local Harness, and local/remote OpenCode and Continue share + the desktop reader. Queries make no model calls and do not infer replacement + from newer reviews. Cross-scope fact reuse and `as_of` remain deferred; see the + [design and validation](./docs/features/conversation-supervision/story-graph-mcp-design.md). - [ ] **Temporal memory and fact-conflict detection** (planned): Adds validity periods, current/expired/conflicting fact detection, fact checking, and evidence tracing to memory and the knowledge graph. diff --git a/FEATURES.zh-CN.md b/FEATURES.zh-CN.md index 0d43af1d..b9dae4a7 100644 --- a/FEATURES.zh-CN.md +++ b/FEATURES.zh-CN.md @@ -57,10 +57,10 @@ - **SSH Host 环境准备验证记录**:当前源码已在隔离 Linux x64 环境通过包安装、Ask/Execute、原生子代理工作区外写入、重连及 stop/bootstrap。现有记录未覆盖 Host 卡片的完整 GitHub、北京镜像、Linux x64/arm64、取消和离线 GoodBuddy 传输矩阵,候选 CI/原生打包与系统休眠唤醒也尚未验证;开发记录不代表版本已经发布。 - [x] **远程工作区与长任务更新**:Agent `0.11.23` 新增远程文件/Git 管理和可选模型限额,随包 Runtime 不再施加固定十分钟 Prompt 时限;无限请求时长仍保留独立连接超时。请先升级 Desktop 至 `0.12.11`,再下载并更新 Host 上的 Agent。 - [x] **远程原生问答与取消修复**:Agent `0.11.24` 修复远程原生问答转交和取消待答后同一会话续发,需要先升级 Desktop 至 `0.13.0`。 -- [x] **DeepSeek Harness(预览)**:使用 GoodBuddy 固定 Host 和 OpenAI 兼容模型连接;优先使用管理员提供的连接,否则跟随兼容的默认模型或首个兼容连接,无需单独重复选择,设置页显示实际管理员或回退模型来源。Ask 只允许调用 Host 中真实注册的 `read`、`skill` 以及 Main 管理的 Web Search/Fetch 代理,拒绝插件同名冒充,Execute 放行全部已启用内置及插件工具,并以当前用户权限运行。图像输入跟随所选模型连接的能力声明,文本模型在 Host 或模型调用前拒绝图片,图片模型通过有界内联内容和临时 Attachment Store 接收 JPEG/PNG。Windows Host 启动和 ACP 会话共用规范工作区路径,同目录不同写法可复用正常 Runtime,不再因路径不一致而创建会话失败。 +- [x] **DeepSeek Harness(预览)**:使用 GoodBuddy 固定 Host 和 OpenAI 兼容模型连接;优先使用管理员提供的连接,否则跟随兼容的默认模型或首个兼容连接,无需单独重复选择,设置页显示实际管理员或回退模型来源。Ask 只允许调用 Host 中真实注册的 `read`、`skill` 以及 Main 管理的 Web Search/Fetch 和已启用的 Story Graph 代理,拒绝插件同名冒充,Execute 放行全部已启用内置及插件工具,并以当前用户权限运行。图像输入跟随所选模型连接的能力声明,文本模型在 Host 或模型调用前拒绝图片,图片模型通过有界内联内容和临时 Attachment Store 接收 JPEG/PNG。Windows Host 启动和 ACP 会话共用规范工作区路径,同目录不同写法可复用正常 Runtime,不再因路径不一致而创建会话失败。 - [x] **DSH npm 插件市场**:市场默认关闭,由用户显式开启后搜索公共 npm 的 `dsh-plugin` 包,使用捆绑 npm 执行精确版本安装和普通 lifecycle scripts,并支持启停、JSON 配置、移除、失败启动自动停用和离线管理已安装插件;关闭市场只隐藏目录与管理界面,不改变已有插件的启停状态,第三方代码不受 Ask 初始化隔离。 - [x] **Ask 与 Execute 工作模式**:Ask 保持只读;Execute 是用户对当前本机或 SSH 账号可用工具、进程、网络和可写路径的完整授权,包括工作区外路径及原生子代理工作。 -- **本地 Runtime 原生客户端(预览)**:输入区可按当前本地项目与模型,在工作栏终端打开 Continue/OpenCode,或在系统浏览器打开官方 DS Web。客户端使用独立会话,不导入 GoodBuddy 对话历史;关闭 DS 网页不停止服务,可在 GoodBuddy 中停止。远程快捷入口未接通,托管标准 Node 交付、Continue/OpenCode Ask 内置 MCP 映射和完整跨平台安装包验收仍有缺口;Windows 真实模型证据不代表全部会话能力等价,详见[实现与剩余工作](./docs/features/assistant-workbar/progress.md#2026-09-27-local-native-client-validation)。 +- **本地 Runtime 原生客户端(预览)**:输入区可按当前本地项目与模型,在工作栏终端打开 Continue/OpenCode,或在系统浏览器打开官方 DS Web。客户端使用独立会话,不导入 GoodBuddy 对话历史;关闭 DS 网页不停止服务,可在 GoodBuddy 中停止。Continue/OpenCode Ask 已通过原生权限映射 Main 绑定的只读工具。远程快捷入口未接通,托管标准 Node 交付和完整跨平台安装包验收仍有缺口;Windows 真实模型证据不代表全部会话能力等价,详见[实现与剩余工作](./docs/features/assistant-workbar/progress.md#2026-09-27-local-native-client-validation)。 - **Runtime 遥测关闭配置**:本地 DS Web、远程 Runtime 和模型桥启动共用遥测关闭环境,不限制模型、MCP 或用户主动联网。Continue 在自动更新关闭时跳过检查;远程改动随配套 Agent 新启动的 Runtime 生效,当前源码 Linux x64 Agent/model 验证见 [0.15.0 发布准备](./docs/development/release-preparation-0.15.0.md)。 - [x] **Runtime 原生交互转交**:OpenCode 与 Continue 的选择、yes/no、自由文本回答和跳过使用现有问答卡片,本机与托管 SSH OpenCode 同时转交属于当前请求的子会话提问。托管 SSH OpenCode 问答需要 Desktop `0.13.0`、Agent `0.11.24` 和新启动的托管 Runtime,不扩展到任意 ACP 服务。回答或跳过成功后在原位置保留问题与答案,支持多轮记录和本地会话重载;旧版本已丢弃的答案不能恢复。并行问题按顺序等待回答,重复事件保留草稿,提交失败可重试;取消远程待答后可在同一会话继续发送。Execute 权限确认自动处理,不等待第二次审批;具体支持范围见[交互边界](./docs/features/assistant-workbar/runtime-interactions.md)。 - [x] **原生执行清单**:OpenCode 和 Continue 在对话顶部更新只读执行清单,进度随会话保存。显式清空和远程重放保留请求归属,取消不把未完成条目标成完成;远程交付需要配套 Agent,详见[清单契约](./docs/features/assistant-workbar/runtime-checklist-technical-design.md)。 @@ -87,7 +87,7 @@ - [x] **Skills 按需接入**:可分配给直连模型、OpenCode、Continue 和 DeepSeek Harness,并使用有界资源和受控 Runtime 边界。 - [x] **本机工具执行环境源码链路**:在“能力与工具 > 工具执行环境”中为本机 Skills 与 stdio MCP 选择 GoodBuddy 托管 Node.js、按需安装的托管 Python,或经过真实验证的自定义解释器;提供独立的原生地址/OSS 镜像选择、诊断、安装进度、取消和删除。新的本机 Runtime 与 stdio MCP 获得不可变 PATH 快照,不修改普通终端、系统环境或远程 Host。Windows x64 托管 Node 与原生地址 Python 已通过真实安装验证;托管 Python 归档按目标文件系统验证,Linux 保留大小写不同的合法路径,所有目标仍拒绝完全重复路径和不安全条目。 - **本机工具执行环境验证记录**:六个平台/架构的 OSS 镜像对象已完成字节、大小和 SHA-256 公开回读验证。托管 Python 保持按需下载,不向 Desktop 发行包额外带入许可证文件。每个标准打包任务使用目标架构原生 Runner,并在打包前真实安装托管 Python,验证 SSL、pip 与 venv。不可变的 `v0.12.0` 尝试在发布前通过 Windows 与 macOS,但暴露了 Linux TAR 大小写处理问题;`v0.12.1` 在同步 Agent 发布时于原生打包前取消。已发布的 `v0.12.2` 随后通过全部六个原生打包任务及其托管 Python 安装探针。现有记录未覆盖真实 Skill/MCP、自定义解释器和运行中进程协调。 -- [x] **内置 MCP 按需接入**:知识库、魔法笔记、GoodBuddy 配置与内置浏览器 MCP 可分别启停,并可分配给直连模型、GoodBuddy 管理的 OpenCode 和 Continue;DeepSeek Harness 在设置中明确显示为暂不支持。内置 MCP 仅通过当前请求的短期本机权限提供,Ask / Execute 读写边界不受用户配置放宽。 +- [x] **内置 MCP 按需接入**:知识库、魔法笔记、GoodBuddy 配置与内置浏览器 MCP 可分别启停,并可分配给直连模型、GoodBuddy 管理的 OpenCode 和 Continue;这些 Server 在 DeepSeek Harness 中仍不支持。Story Graph 另通过 Main 代理支持 Harness。内置 MCP 仅通过当前请求的短期本机权限提供,Ask / Execute 读写边界不受用户配置放宽。 - **Obsidian 接入(源码已实现,验收中)**:默认关闭,默认发现全部本机注册仓库,也可指定文件夹并在保存或启用前测试。随包提供 MCPVault 0.16.0,19 个共享工具保留全部上游能力;生产组件自动化、Electron 托管 Node、Windows 设置与聊天 UI,以及受限网络下的 Windows x64 解包应用已验证。物理断网、安装器及其他系统验收待完成。DeepSeek Harness 与远程执行保持不可用,详见[功能文档与验证记录](./docs/features/obsidian/README.md);此项不表示已发布。 - [x] **MCP Tools**:显式启用的自定义 MCP 可按 Runtime 分配给直连模型、GoodBuddy 管理的 OpenCode、Continue Agent Execute 和 DeepSeek Harness,并仅在 Execute 加载;Agent 子进程只获得按请求签发的本机回环权限,MCP 地址、命令和凭据保留在 Main,动态工具仍经过发现、执行记录与权限边界。直连模型与 Harness 的健康调用复用当前请求已发现的工具清单,不在每次调用前重复发现。 - [x] **MCP Prompts 与 Resources 元数据**:MCP 测试仅在 Server 声明对应能力时发现有界的 Prompt、参数与 Resource 元数据,不读取 Resource 内容;Runtime 支持的 Prompt 可填入聊天草稿后继续编辑。OpenCode 可报告实验性 Resource 清单,Continue 当前版本明确不支持 Resources。 @@ -129,6 +129,7 @@ - [ ] **监督后续能力**(规划中):逐事件完整回放、Experiment 目标、人工修改时间序列审计、事件触发及更完整的执行观察仍未完成。当前自动回顾受输入预算和滚动窗口限制,删除报告不会重置处理进度或重建图谱。详见 [会话监督 PRD](./docs/features/conversation-supervision/prd.md)。 - [ ] **批量运行与对比实验室**(规划中):对模型、Prompt、角色和工作流配置执行批量对比,汇总质量、耗时、Token、费用、失败率和成果差异。 - [ ] **时态记忆与事实冲突检测**(规划中):为记忆和知识图谱增加有效期、当前事实、过期与矛盾检测、事实核验及证据回溯。 +- **Story Graph 只读工具(未发布源码)**:Agent 可搜索已保存的工作,读取当前主张或时间线,并分页核对来源。监督者应用开关及 Runtime 分配同时控制发现和调用,旧会话也受限制。Model、本地 Harness、本地与远程 OpenCode/Continue 共用桌面读取入口;查询不调用模型,不以较新回顾推断替代关系。跨 scope 事实复用及 `as_of` 仍待实现,详见[设计与验证](./docs/features/conversation-supervision/story-graph-mcp-design.md)。 - [ ] **可视化受控工作流**(规划中):提供版本化 DAG、条件分支、审批、取消和恢复,执行节点继续经过 Main Runtime 边界。 - [ ] **统一 Run Graph 与回放**(规划中):关联任务、Subagent、模型、知识、工具审批、用量和成果,支持失败定位、重试和脱敏导出。 diff --git a/docs/features/assistant-workbar/progress.md b/docs/features/assistant-workbar/progress.md index 6066552c..977f2ecc 100644 --- a/docs/features/assistant-workbar/progress.md +++ b/docs/features/assistant-workbar/progress.md @@ -172,11 +172,17 @@ the final DS/coordinator and UI/preload/IPC paths were rerun separately and pass This is not a claim of a subsequent all-green full-suite run. Full output is in `native-full-tests.log` beside the live evidence. -Remaining: remote coordinator wiring and real Host acceptance belong to the +Remaining at this checkpoint: remote coordinator wiring and real Host acceptance belong to the concurrent remote implementation; Continue/OpenCode Ask built-in MCP mappings, managed standard Node distribution, full Electron/packaged UI acceptance and other platforms are not verified here. `ipc.ts` was not edited in this work. +2026-09-28 update: native Continue/OpenCode Ask now pass Main-bound read-tool +names to native permission flags and execution hooks. The Story Graph work also +adds Supervisor enablement to native-client reuse keys and gateway checks. +The OpenCode generated plugin is executed by a focused test; packaged native UI +acceptance remains separate. See [Story Graph validation](../conversation-supervision/progress.md#2026-09-28-story-graph-只读工具). + ## 2026-09-26 结构升级与历史回收分离(未发布) - schema 45/46 的回归先复现普通结构升级执行 `VACUUM`,修复后不转换历史、 diff --git a/docs/features/assistant-workbar/runtime-native-client-proposal.md b/docs/features/assistant-workbar/runtime-native-client-proposal.md index 0468a158..56fdb223 100644 --- a/docs/features/assistant-workbar/runtime-native-client-proposal.md +++ b/docs/features/assistant-workbar/runtime-native-client-proposal.md @@ -390,8 +390,10 @@ tool names; the Web adapter maps these through the official MCP naming contract. Custom MCP is exposed in Execute, matching the existing Runtime behavior; custom `readOnlyHint` annotations do not introduce a separate Ask permission rule. Assigned skill directories containing `SKILL.md` load through the official skill -plugin. Continue/OpenCode native Ask currently omit MCP endpoints, including -built-ins; extending their existing native name mappings remains separate work. +plugin. Continue/OpenCode native Ask now retain Main-bound endpoints and map the +granted read-tool names through both native permissions and execution hooks. +OpenCode prefixes names with the bound MCP server name; Continue uses raw names. +Story Graph additionally rechecks Supervisor enablement on every call. The coordinator remains local-only at this checkpoint. The remote implementation is being developed separately and must be connected and verified on the shared diff --git a/docs/features/conversation-supervision/README.md b/docs/features/conversation-supervision/README.md index 0b962a91..9fb363c9 100644 --- a/docs/features/conversation-supervision/README.md +++ b/docs/features/conversation-supervision/README.md @@ -16,6 +16,7 @@ | [UI 设计](./ui-design.md) | 应用导航、工作回顾、侧栏反馈及页面状态 | | [User Stories](./user-stories.md) | 监督者与故事线图谱的用户操作和验收场景 | | [技术设计](./technical-design.md) | Main/Renderer 分层、数据模型、IPC、安全边界和实施顺序 | +| [时态 Story Graph MCP 设计](./story-graph-mcp-design.md) | 只读工具、时间与版本语义、Runtime 接入,以及后续跨范围事实复用和记忆过渡合同 | | [回顾分块调度设计](./review-scheduling-design.md) | 第 0 节定义生产分页、批次持久化、续跑、当前设置及剩余边界;其余章节保留改造前基线、目标设计和原型证据 | | [实施进度](./progress.md) | 已验证实现、验证证据和当前边界 | | [故事线图谱设计](../story-graph/prd.md) | 图谱对象、环绕时间轴与实体交互 | @@ -23,3 +24,5 @@ 生产入口已接通完整来源分页、叶子持久化、持续处理至完成、活动页主动暂停/继续及批次详情;设置可调页大小、批次正文/消息上限、单次响应容量、两个模型超时和共享并发。实际合同及限制见[生产接线](./review-scheduling-design.md#0-生产接线与剩余边界)。真实 DeepSeek 验证已覆盖一个完整的 27 条消息会话;较早的 129 条消息实验仍未完成。两次实验共 5 次付费请求,详情见进度。原始 AbortError 原因尚未由日志确定。 实施状态以代码和[实施进度](./progress.md)为准。逻辑待定项见[逻辑设计](./logic-design.md),架构边界见[技术设计](./technical-design.md)。现有心跳行为见[智能心跳](../smart-heartbeat/README.md),原型范围见[Story Graph 演示](../story-graph/README.md)。 + +2026-09-28:Agent 可按需通过三个只读 MCP 工具读取决定、演变和来源;监督者关闭后不暴露工具并拒绝旧会话调用。本地与远程接线及验证边界见[实施进度](./progress.md#2026-09-28-story-graph-只读工具)。当前 memory 读取、精确 scope 增量进度和候选身份保留;跨 scope 叶子复用、读取入口切换及 `as_of` 仍属后续能力。 diff --git a/docs/features/conversation-supervision/logic-design.md b/docs/features/conversation-supervision/logic-design.md index 8f12bf48..0b11936f 100644 --- a/docs/features/conversation-supervision/logic-design.md +++ b/docs/features/conversation-supervision/logic-design.md @@ -172,6 +172,8 @@ flowchart LR ## 完整性与待定项 +FR-S11、US-S28 至 US-S31 的规则由[时态 Story Graph MCP 设计](./story-graph-mcp-design.md)统一定义:时间/确认与有效性见第 3 节,读取范围与分页见第 4 节,应用关闭与 Runtime 接入见第 6 节,跨 scope 复用与记忆过渡见第 7、8 节。D1 读取已接入,后两项仍待实现;Global/项目独立 checkpoint 和同 scope 候选身份保留。MCP 不接受 `as_of`,较晚生成的结果也不自动使旧决定失效。 + 本设计覆盖触发、证据输入、静默检查、正常整理、取消与失败、跨视图共享结果、用户纠正、知识库写入及历史回放。它补充产品逻辑,不代表接口、持久化或迁移方案已经完成。 仍待产品决策:事件触发与阶段识别条件、反馈频率与静默判定、实体合并与拆分的交互、时间轴比例和集中度含义。应用启停按[应用启停与执行](#应用启停与执行)处理;既有入口偏好、计划与运行记录的保留要求见 FR-S1、S2 和 US-S14。 diff --git a/docs/features/conversation-supervision/progress.md b/docs/features/conversation-supervision/progress.md index e1d4aca3..f9c6ed42 100644 --- a/docs/features/conversation-supervision/progress.md +++ b/docs/features/conversation-supervision/progress.md @@ -1,9 +1,52 @@ # 监督者实施进度 -日期:2026-09-26。 +日期:2026-09-28。 当前记录以已验证生产行为为准。监督者尚未覆盖全部 user stories。 +## 2026-09-28 Story Graph 只读工具 + +对应 FR-S11、US-S28 至 US-S30 的 D1 读取实现。输入、分页与版本语义由[专项设计](./story-graph-mcp-design.md)负责。US-S31 的跨范围事实复用和 memory 读取切换仍待实现。 + +实现路径: + +- [共享工具合同](../../../src/shared/story-graph-tools.ts)定义 `story_graph_search`、`story_graph_get_context`、`story_graph_read_source`。内置目录与能力设置支持 Model、OpenCode、Continue、DeepSeek Harness 分配;应用关闭时 Main 不签发图谱读取权限。 +- [只读 SQLite 投影](../../../src/main/assistant/story-graph-reader.ts)读取精确 scope 下的历史结果、当前对象、事件、变化、关系、来源和成功叶子描述。保留旧决定与修订依据,确认状态和有效性分别返回,未知有效性不按时间推断。没有新增 schema、业务写入或模型调用。 +- [Main gateway](../../../src/main/agent/knowledge-mcp-gateway.ts)在发现、读取前和返回前复核监督者开关及 Runtime 分配;IPC 将项目和 Runtime 固定到请求。Model 和 Harness 代理、本地 MCP、原生客户端共用此入口。Harness 的目录、scope schema 转换及 Host Ask 执行钩子均已接通。 +- 远程沿用 `ProtocolRemoteRuntimeChannel`、`MainImageToolSession`、`AgentImageToolMcp` 和 `RuntimeAcpBackend` 的现有工具通道。Ask 可以只有图谱描述,没有图像生成描述。OpenCode 根/子会话仅放行当前端点;Continue Ask 保留 Main 会话 MCP,并由原生只读工具名和 Main 双重校验。 +- 对象页为 10/50 项,正文页为 4,000/8,000 码点(默认/上限)。Context 用带位置的 JSON 片段续读长对象;游标绑定过滤、scope 和读取修订,变更后返回 `stale_cursor`。来源默认读保存片段;显式 current 通过 locator 读当前记录,version 仅接受可验证的现存版本。知识引用片段不声称是整份知识原文,来源迁出项目、删除、未知历史和响应过大均有明确结果。 + +定向验证: + +```text +npx vitest run src/main/assistant/story-graph-reader.test.ts src/agent-daemon/story-graph-integration.test.ts src/main/assistant/supervision-production.test.ts src/main/capabilities/capability-service.test.ts src/main/ipc.test.ts src/main/remote-agent/protocol-remote-runtime-channel.test.ts src/agent-daemon/runtime-acp-backend.test.ts src/agent-daemon/continue-acp-helper.test.ts src/agent-daemon/opencode-subagent-plugin.test.ts src/main/agent/native-terminal-client.test.ts +``` + +10 文件、337 项通过,1 项既有条件测试跳过。覆盖真实 SQLite、HTTP MCP、Main/Agent 消息往返、协议准备和释放、四个 Runtime 的 IPC 项目绑定、Supervisor 关闭后不再读取、取消、来源删除/迁移、旧游标、Unicode 正文续页、超长响应及 A → B 修订后 C 仍为选项。冷启动审查发现 current 来源读取依赖运行回顾后才注册的 SQLite 函数,改为按现存 context 直接计算同一修订;新连接回归通过。最终读取相关 3 文件、28 项再次通过。 + +其他受影响路径也已定向验证: + +- `continue-runtime.test.ts`、`opencode-runtime.test.ts`、`deepseek-harness-runtime.test.ts`、`native-client-coordinator.test.ts`、`protocol-remote-runtime-channel.test.ts`、`knowledge-mcp-gateway.test.ts`:6 文件、243 项通过。 +- `goodbuddy-harness-control-plane.test.ts`、`continue-host-adapter.test.ts` 及相关桥接/读取测试:6 文件、98 项通过。`model-tool-provider.test.ts`、能力设置及 Continue helper 组合:3 文件、92 项通过。 +- `npx vitest run src/main/agent/deepseek-harness-acp-e2e.test.ts -t "reads Story Graph"`:1 项通过、13 项按名称跳过。真实受控 Harness Host 经 ACP 和 Main 代理执行 Ask/Execute 读取,随后关闭监督者并确认工具移除;推理使用测试实现,没有外部模型请求。 +- `npx tsc --noEmit -p tsconfig.node.json`、`tsconfig.agent.json`、`tsconfig.web.json` 均通过;最终 Main 类型检查再次通过。本次变更的 TypeScript 源码和测试定向 ESLint 通过,`git diff --check` 通过。 + +### Linux Host 验证 + +[SSH 探针](../../../scripts/story-graph-host-probe.ts)从当前源码临时打包,在共享 Linux x64 Host 运行 `AgentImageToolMcp`、`AgentOwnedAcpPrompt` 和 `ContinueHostAdapter`,使用 Host 安装的 OpenCode 和 Continue(1.5.47)。桌面启动隔离内存 SQLite、Main gateway 和 `MainImageToolSession`,通过 SSH 搬运工具通道消息;用户数据库、模型凭据和正文库没有复制到 Host。Host 临时运行目录在结束时清理。 + +探针通过:HTTP 发现三个只读工具、搜索/上下文/来源读取及分页、`as_of` 拒绝、OpenCode Ask/Execute、Continue Ask/Execute 的实际工具调用,以及关闭后的发现和旧会话调用拦截。成功探针有 10 次本机确定性推理请求,其中 2 次为 Runtime 标题生成,桌面读取 10 次,付费调用 0 次。RPC blob framing 与 prompt 生命周期另由上述 `runtime-acp-backend` 和 `protocol-remote-runtime-channel` 测试覆盖;该 SSH 探针使用标准输入输出承载工具消息,没有执行安装包升级或完整桌面 UI 操作。 + +复跑时在仓库使用 esbuild 将脚本输出到临时目录,再通过已有 SSH 身份传至 Host 的 GoodBuddy 测试目录。桌面命令参数为: + +```text +node +``` + +### 保留边界 + +没有执行全量测试、付费模型请求、提交、推送或发布。真实模型如何根据冲突证据形成最终建议、跨平台安装包和完整桌面 UI 仍未在本次验收;没有据此声明 US-S28 的建议质量已验证。查询目前同步扫描所选 scope 的已发布事实,大库延迟和运行中断粒度没有性能验收。`replaces`/`contradicts`、历史有效期、`as_of`、跨 scope 事实复用和 memory 消费切换均没有伪造实现;已有记忆与 checkpoint 保留。中文设计扫描无阻断项,复核项是时间、范围和证据限制,按技术含义保留。 + ## 已验证 ### 2026-09-26 单次执行与取消集成复核 @@ -423,10 +466,19 @@ npx eslint scripts/review-algorithm.mjs scripts/review-algorithm.test.mjs script - 结束时间收尾:手动心跳使用真实报告完成时间,下游失败单独记录结束时间。`npx vitest run src/main/assistant/supervision-activity.test.ts src/main/assistant/heartbeat-service.test.ts src/main/assistant/heartbeat-database.test.ts src/main/assistant/supervision-history.test.ts`:4 个文件、21 项通过,新增受控时钟用例区分 10:00 开始、10:02 报告完成、10:03 下游失败。随后 Node typecheck 和这 3 个改动源码/测试文件的 ESLint 通过。 - 已存在的手动失败无法补回;旧心跳没有冻结范围和关联 ID,保留缺失提示。心跳仍按原保留期限清理,监督结果独立保存。取消和人工修改时间序列审计未实现;`no_change` 的后续接入见下节。 +### 2026-09-28 继续讨论正文与布局 + +侧栏与图谱来源详情直接显示可编辑的发送正文,移除隐藏上下文拼接及附带提示。目标会话独立分隔,底部复用共享右对齐操作区。失败保留草稿,取消不发送,目标切换继续使旧预览失效。Main 的来源筛选与发送契约未修改。 + +- `npx vitest run src/renderer/src/SupervisionDiscussion.test.tsx src/renderer/src/SupervisionCard.test.tsx`:10 项通过,覆盖正文可见、编辑后精确提交、删除引用后不补回、失败重试、取消、重复提交和目标切换。 +- `npx vitest run src/main/ipc.test.ts -t 'scopes supervision continuation|dispatches supervision continuation'`:2 项通过,使用真实 SQLite 与队列解析器核验来源范围及空附件发送。 +- `npx vitest run tests/supervisor-layout.electron.test.ts` 分别设置 `GOODBUDDY_SUPERVISOR_DISCUSSION=1`、`GOODBUDDY_SUPERVISOR_SIDEBAR=1` 运行通过。图谱覆盖 1440/390px,侧栏覆盖 480/300/200px,均含浅深主题;检查 16px 区块间距、8px 字段间距、目标分隔、右对齐及无横向溢出,侧栏另检查滚动后按钮可命中。截图与测量保存在临时目录 `opencode/discussion-feedback-graph`、`opencode/discussion-feedback-sidebar`。 +- Renderer 类型检查 `npx tsc --noEmit -p tsconfig.web.json`、8 个改动 TS/TSX/MJS 文件的定向 ESLint 与改动文件 `git diff --check` 通过。Electron 使用生产组件和模拟数据,未调用模型;未跑全套测试,未提交。 + ### 其他边界 - 新批次来源读取按 messageId 返回已保存片段及版本;旧历史缺少消息 ID 时保留会话/时间定位。本地知识引用通过现有 `KnowledgeService` 解析文档/分块,失效时显示不可用;外部引用只使用已保存 locator,不调用远端全库。 -- 继续讨论和知识库写入使用现有本地会话队列与知识库实体接口,并要求先预览、再由用户确认提交。侧栏已支持结果直达图谱,尚无可编辑的上下文预览。 +- 继续讨论和知识库写入使用现有本地会话队列与知识库实体接口,并要求先预览、再由用户确认提交。侧栏支持结果直达图谱;继续讨论的侧栏与图谱入口均已提供可编辑的实际上下文正文,发送规则见 [UI 设计](./ui-design.md)。 - 关系移除保留撤销状态及原始来源;界面没有提供恢复入口。 - 候选集最多 100 个实体,复用依赖模型显式选择;不会自动合并历史重复实体或跨 scope 合并。结果级历史内容已保存,事件滑块仍不重建逐事件的实体演变。Experiment 和完整图谱回放尚未实现;批次暂停/继续已接通。 diff --git a/docs/features/conversation-supervision/story-graph-mcp-design.md b/docs/features/conversation-supervision/story-graph-mcp-design.md new file mode 100644 index 00000000..33fa0813 --- /dev/null +++ b/docs/features/conversation-supervision/story-graph-mcp-design.md @@ -0,0 +1,165 @@ +# 时态 Story Graph 内置 MCP 设计 + +日期:2026-09-28。状态:D1 只读工具及本地、远程接线已实现,验证记录见[实施进度](./progress.md#2026-09-28-story-graph-只读工具)。D2 跨范围复用、D3 记忆读取切换及历史重建仍未实现。 + +本文负责 FR-S11 的 MCP 工具契约、时间语义、范围复用和记忆过渡设计。产品要求见[监督者产品设计](./supervisor-prd.md),用户验收见 [US-S28 至 US-S31](./user-stories.md#us-s28-依据决定演变继续工作)。既有图谱对象与交互仍由[图谱 PRD](../story-graph/prd.md)定义。可执行输入以[共享工具合同](../../../src/shared/story-graph-tools.ts)为准,数据投影见 [Story Graph Reader](../../../src/main/assistant/story-graph-reader.ts);本文件同时保留后续阶段目标。 + +## 1. 目标与现状 + +监督者的主要产出是带时间、演变关系和来源的 Story Graph。工作回顾、侧栏和 Agent 上下文从同一组事实中组织内容。扁平记忆条目无法独立表达“当时决定了什么、后来为何修订、现在应采用什么”,因此不再作为目标架构的主要产物。 + +用户侧统一为监督者维护的工作记忆:包含长期偏好、决定、事件、未解决事项及其依据。“替代记忆”指统一概念与后续读取入口,保留已确认偏好、用户修订和已有数据。它不授权删除 `memory_items`、抹掉历史或给旧内容补造事件。 + +| 能力 | 当前代码事实 | 本设计目标 | +| --- | --- | --- | +| 图谱与来源 | 已有结果、事件、实体、关系、结果图谱快照及成功叶子;历史归属存在缺失边界 | 按对象、时间和相关性读取事实与演变,并返回来源版本和覆盖信息 | +| MCP | [内置目录](../../../src/shared/builtin-mcp-servers.ts)和[能力合同](../../../src/shared/capability-contracts.ts)已有 `story-graph`,包含三个只读工具 | 按需读取,沿用应用启停及 Runtime 分配 | +| 时间与关系 | [监督合同](../../../src/shared/supervision-contracts.ts)已有 `occurredAt`、确认状态和 `revised`;关系枚举没有明确替代/矛盾类型 | 分离发生、生成、记录时间;用有证据的关系表达替代和矛盾 | +| 范围与身份 | checkpoint 按阶段、精确 scope、来源区分;候选实体只取同 scope | 同一来源版本的有效叶子事实可跨 scope 复用,范围仍控制可见性与解释 | +| 记忆 | `reviewBackground` 仍读已确认记忆;最多四条、每条 500 字符,作为当前背景 | 逐步由统一查询承接,保留确认状态与适用范围 | +| 历史重建 | 有结果快照,不具备完整来源版本及人工修改时间序列 | 首期只提供 `current`、`timeline`;`as_of` 留待历史重建能力具备后设计交付 | + +现状依据还包括 [AssistantDatabase](../../../src/main/assistant/assistant-database.ts) 的 `saveReviewCheckpoints`、`listSupervisionCandidates`、`reviewBackground`,以及[生产分块调度](./review-scheduling-design.md#0-生产接线与剩余边界)。现有结果快照只证明某次回顾保存过哪些内容,不能据此声称能还原任意时刻的认识。 + +## 2. 对象与稳定身份 + +复用现有 SQLite、领域对象和来源定位。设计不要求图数据库、独立向量库、完整正文副本、新持久化日志或通用历史快照层。所需读取视图先从已有结果、叶子、关系和 locator 推导;无法推导的内容明确返回未知。 + +| 对象 | 概念合同 | 身份与关联规则 | +| --- | --- | --- | +| 故事线 `story_line` | `id`、主题、范围、事件与实体引用 | 沿用故事线 ID;不把项目名当 ID | +| 事件 `event` | `id`、类型、描述、发生时间、实体及来源引用 | 决定、讨论、变化、里程碑是事件类型;一次回顾运行不是工作事件 | +| 实体 `entity` | `id`、描述、确认状态、有效性、演变引用 | 对象 ID 与内容修订分离;同名或模型局部 ID 不证明同一实体 | +| 变化 `entity_change` | 实体、事件、变化类型、依据、适用时间 | 指向具体对象版本;无独立主键时以已有所属对象和稳定关联键定位,不以页内下标造身份 | +| 关系 `relation` | `id`、两端引用、类型、理由、来源、确认状态 | 替代/矛盾关系必须能定位两端主张及支持该关系的证据 | +| 阶段总结 `summary` | 结果 ID、范围、区间、正文、事实引用 | 用于导航;检索与上下文仍能访问完整叶子事实 | +| 来源引用 `source_reference` | 引用 ID、来源类型与原 ID、版本、locator、可用性 | 引用 ID 与原消息/任务 ID 分开;跨结果的引用 UUID 不等于新的原始来源 | + +偏好、方案、决定涉及的概念沿用实体及事件表达,不为每个业务名词另建基础设施。业务上的“已决定”(`decided`)与“已完成”(`completed`)是不同状态,这里不声明新增任务状态枚举;运行 `completed` 仅表示整理发布成功。决定采用方案、计划执行、模型写出“已决定”均不能推出任务完成,完成须有任务记录或明确执行证据。 + +返回对象携带 `object_ref`(类型和稳定 ID)、`object_revision`、`result_id` 及适用 scope。版本可使用已有结果身份与可用修订信息表达;没有可靠对象版本时返回 `unknown`,不把 `updated_at` 伪装成完整版本链。旧文本主键及 Main 精确别名映射继续保留,`candidateRef` 只是一次模型输入内的候选引用,不作为 MCP 持久 ID。跨 scope 身份合并须有明确映射依据,不能因复用叶子事实而自动合并同名实体。 + +## 3. 时间、确认与有效性 + +| 维度 | 含义 | 禁止的推断 | +| --- | --- | --- | +| `event_time` | 原记录描述的事件发生时刻或区间;带精度、来源依据,未知可空 | 不以回顾时间填补未知发生时间;消息发送时间只有在确实表示该事件时才可采用 | +| `review_generated_at` | 产生该归纳的回顾时间 | 较晚生成不表示事件较晚发生或旧决定已被替代 | +| `recorded_at` | 对象或版本实际写入系统的时间 | 迟录、补录不改变事件先后,也不证明当时已知 | +| `validity` | 该主张适用的区间及当前有效、被替代、存在争议或未知状态 | 不用确认时间、记录时间直接推导有效起止 | +| `confirmation_state` | 自动归纳、已确认、用户修订、已撤销等内容审核状态 | 已确认不等于永久有效;未确认不等于已失效 | + +`replaces` 表示有明确证据的新主张替代旧主张,记录方向、两端版本、适用范围、理由和来源。`contradicts` 表示两项主张存在证据支持的冲突,不自动决定谁正确。它们是目标关系语义,不能将当前 `contrasts` 或单纯 `revised` 无条件映射为这两类关系。新增选项、日期较新、措辞更肯定都不足以建立替代关系;没有证据时保留并列主张或待核对差异。 + +时间区间输入使用带时区的 ISO 8601 时间,查询归一化为 UTC。概念 `time_range` 使用闭区间 `[from, to]`,与现有监督范围保持一致;区间事件按相交判定。检索支持明确的 `time_basis`:默认 `event_time`,也可指定 `review_generated_at` 或 `recorded_at`。响应回显所用维度。未知时间不冒充命中区间,返回可计算的排除数量或 `unknown`,允许通过不限定时间的查询另行查找。 + +`timeline` 按发生时间和稳定 ID 排序,未知时间单列,展示目前保存的演变及缺口。它可以包含后来补录的旧事件,因而不等于“当时已知”。`current` 给出截至读取时、在已覆盖证据内可支持的当前理解;有冲突或缺口时保留并说明,不能仅挑最新摘要作为答案。 + +`as_of` 是未来能力,首期输入 schema 不接受该模式或参数,调用必须明确返回不支持,不能悄悄降级为 `current`。只有具备所需对象版本、人工修订记录以及可用来源版本/历史重建,并能验证不会泄漏当时未知的信息后,才另行定义其语义和验收。本文不批准为它新建持久化日志或快照体系。 + +## 4. 公共读取合同 + +三个工具都只读已有数据,不调用模型、不自动发起回顾、不更新 checkpoint、不确认或修订对象、不创建任务,也不写入记忆或知识库。检索采用已有数据的确定性过滤与相关性排序,不能在调用中借模型补齐缺失事实。生成面向用户的建议由调用方 Agent 完成,必须引用返回证据并遵守其范围与限制。 + +默认 scope 为调用会话的当前项目,由 Main 解析并冻结;没有当前项目时要求显式选择 scope,不默认为 Global。调用方可显式请求 Global 或指定项目集合,沿用已有范围与能力授权校验。分页继续使用首请求范围,切换 UI 当前项目不改变旧游标。对象与来源读取也要重新校验归属,稳定 ID 不能绕过 scope。 + +首期沿用现有精确 scope 结果归属,不把 Global 摘要冒充单项目结论,也不隐式把 Global 结果投影为项目上下文。项目查询缺少该 scope 数据时明确返回空结果及覆盖状态。未来跨 scope 复用只接纳能按来源项目和事实依据正确筛选的叶子,跨项目归纳仍保留真实范围。 + +| 响应部分 | 必须表达的信息 | +| --- | --- | +| 范围 | `requested_scope`、`resolved_scope`、每项原始 scope、所选时间区间和时间维度 | +| 修订 | 对象/结果修订引用、来源版本、读取时间;区分已知版本与 `unknown` | +| 覆盖 `coverage` | 已知处理范围、来源版本和片段区间、缺失或未处理部分、未纳入来源类型;缺少数据时为 `unknown`,不能用零代替未知 | +| 分页 `page` | `returned_count`、`has_more`、`next_cursor`、本页是否完整及限制原因;集合数量不能计算时为 `unknown` | +| 证据 | 来源引用与 locator、关系依据、确认与有效性、已知冲突和历史缺口 | + +覆盖与分页独立:最后一页只证明匹配结果读完,不能证明所有原始消息已被整理;已处理全部入选正文也不证明模型识别了全部事实。旧 `coverage_json` 只有条数时,不能推导完整的时间、版本或语义覆盖。 + +列表及长字段都需有界分页,超过响应容量时提供可续读的位置,无法分页的响应明确失败。不得静默裁剪后标完整。游标绑定过滤条件、排序和可用结果修订;分页期间内容变化、无法维持同一读取版本时返回 `stale_cursor` 并要求重新查询,不混入新旧页。该规则可基于现有修订检查完成,不要求持久化查询快照。取消、读取失败与真实空页分别返回。 + +## 5. 工具定义 + +### `story_graph_search` + +输入:`query`、可选 `scope`、`object_types`、`time_range`、`time_basis`、`page_size` 和 `cursor`。`object_types` 对应第 2 节的对象类型;省略时检索全部支持类型。默认 scope 及时间规则按第 3、4 节执行。对象页默认 10 项,上限 50 项。匹配为不区分大小写的连续文本匹配,标题、实体标签和摘要字段权重高于其他字段;同分按类型、稳定 ID 和结果 ID 排序。 + +输出相关对象页,包含匹配原因或命中字段、明确标为摘录的预览、稳定引用、修订、相关演变和来源入口,以及公共响应信息。过滤须先于分页,按相关性排序并以稳定 ID 消除同分歧义。不能只在最近若干摘要中筛选后声称查完历史,也不能将摘要中的命中替代叶子事实。 + +### `story_graph_get_context` + +输入:`object_ref`、可选 `scope`、`mode: current | timeline`(默认 `current`)、可选时间范围与维度、页大小及游标。`current` 表达当前理解;时间过滤只限定证据集合,响应需标明“在所选证据范围内”,不能称作该区间终点时的历史状态。 + +输出对象当前描述、明确的决定/工作状态、偏好与适用范围、未解决事项、相关实体及关系、演变和证据引用。`timeline` 分页列出提出、补充、验证、修订、替代和冲突证据,保留旧决定及新修订;多组集合或长正文须各自提供续读位置,不能只对顶层对象分页。返回的是存储事实及其组织视图,不在工具内生成新建议。 + +D1 返回各结果快照、当前投影、事件、变化、关系、来源及成功叶子的独立描述。既有存储没有可靠的有效期和替代关系枚举,因此 `validity` 返回 `unknown`,保留 `revised`、`contrasts` 及原始理由供调用方核对。当前实体投影不会遮掉旧结果或叶子描述。`content` 是可拼接的 JSON 文本片段,每项附零基码点位置;按对象引用和结果 ID 拼接后解析。`content_page_size` 默认 4,000、上限 8,000 码点,游标可停在对象内部。完整响应超过 100,000 字节时明确失败,要求缩小页大小。 + +对象不存在、scope 不匹配、版本缺失或只有部分历史时分别说明。已存在的某个结果快照可作为具体版本读取依据,但不能作为任意 `as_of` 查询的替代品。 + +### `story_graph_read_source` + +输入:`source_reference_id`、可选 `scope`、明确的读取种类 `snapshot | version | current`、所需 `source_version`(读取指定版本时必填)及片段页位置。默认 `snapshot`,读取该引用实际保存的证据片段;不默认换成当前原文。 + +输出正文页、`content_kind`、请求版本与实际返回版本、`source_location`、原来源 ID、已保存片段与整份来源的范围和长度、可用时间元数据,以及公共覆盖和分页信息。位置用零基、左闭右开的 Unicode 码点 `[start, end)`;消息 locator 尽量保留项目、会话、消息、顺序和角色,本地资料保留文档/分块,外部片段保留已有 locator。缺少位置时明确标记,不伪造链接。 + +`snapshot` 只声称返回当次实际保存的片段;`version` 仅在该来源支持并保留指定版本时成功;`current` 显式读取当前内容并报告与引用版本是否不同。历史版本不可用时返回 `historical_version_unavailable` 及可用读取选项,由调用方决定是否另读当前版本。来源删除或不可访问时返回对应可用性;已保存片段仍可读时准确标注其范围。读取完一个片段不能称整份原文已读完,超长内容必须可续页,无静默截断。 + +D1 来源页默认 4,000、上限 8,000 码点。`current` 通过既有 locator 精确读取消息、任务状态正文或保存的知识引用片段;不拉取外部知识库原文。`version` 仅在当前可读来源的修订恰好等于请求版本时返回该版正文。旧版正文没有保留时明确不可用;无 locator 的旧引用仍可读 snapshot。当前来源迁出所选项目时拒绝读取。已保存引用的范围与当前位置分别校验。 + +## 6. 内置 MCP 与 Runtime 接入 + +内置 MCP 列表提供 `Story Graph`,能力 ID 为 `story-graph`,默认分配给 Model、OpenCode、Continue 和 DeepSeek Harness;监督者应用默认关闭,开启应用后才可发现工具。工具描述说明适用于“继续上次工作”“此前如何决定”“方案为何改变”等请求。普通聊天不强制查询,也不把整份图谱自动注入每轮上下文;Agent 先搜索、再取相关对象上下文,在需要核对原话、处理冲突或详细引用时读取来源。 + +| 配置或目录 | 当前状态 | 目标映射 | +| --- | --- | --- | +| `heartbeatEnabled` | 监督者应用开关,新设置默认关闭 | 为 Story Graph 暴露和每次调用的必要条件;关闭后不列出三个工具,旧会话直接调用也拒绝 | +| 内置能力 `enabled` 与 `assignments` | [CapabilityService](../../../src/main/capabilities/capability-service.ts)管理现有 MCP 开关与 Runtime 分配 | `story-graph` 沿用同一管理方式;应用关闭时不得由内置能力开关绕过,保留用户原分配 | +| 内置工具目录 | 已有 Story Graph 条目 | 映射 `story_graph_search`、`story_graph_get_context`、`story_graph_read_source`,全部 `access: read`、`readOnlyHint: true` | +| 监督整理参数 | 超时、并发、来源页与批次设置已存在 | 控制后台整理;不让一次 MCP 查询启动整理,也不以正文批次预算截断检索结果 | +| 计划 `enabled` | 决定自动回顾是否到期执行 | 不决定已有图谱能否读取;应用开启且能力可用时,无计划也能查已有结果 | + +Main 在工具发现和调用分发时共同检查应用启用、能力启用、Runtime 分配和 scope;只隐藏菜单不足以阻止旧会话调用。关闭后拒绝新的读取请求,已在途响应交付前复核状态,避免旧绑定继续返回数据。启用工具本身不产生回顾或数据写入。Ask 与 Execute 均可读取,同样没有写工具,也不借此扩大 Execute 权限。 + +本地 OpenCode、Continue 和原生客户端复用 [KnowledgeMcpGateway](../../../src/main/agent/knowledge-mcp-gateway.ts) 的 MCP 注册和分发;直连 Model 与 DeepSeek Harness Main 代理调用同一服务。远程 OpenCode/Continue 复用现有图像工具的受管 HTTP MCP 和二进制通道,`storyGraph` 描述独立于图像生成描述,Ask 可只携带图谱读取。请求仍由桌面 Main 解析范围和读取 SQLite,不把数据库、正文库或凭据复制到 Host。 + +远程发现每次向 Main 复核启用状态;调用复用同一个范围、分页及版本读取入口。OpenCode 根会话和子会话精确放行当前受管端点的图谱工具;Continue Ask 保留 Main 绑定的会话端点并只允许共享只读工具名。DeepSeek Harness 的 Main 目录、参数转换和 Host 执行钩子均接入这三个工具;其他普通内置 MCP 的 Harness 限制保留。当前远程 Runtime 支持 OpenCode/Continue,不新增远程 Harness。桥接不可用时明确失败。 + +远程使用须配套部署含此接线的 Desktop 与 Agent;旧 Agent 不识别新增的工具描述字段。本次没有发布或更新受管包,源码验证不代表已发布客户端具备该能力。 + +## 7. Global 与项目复用 + +当前 Global、单项目和不同项目集合分别维护 checkpoint 和故事线候选身份,项目集合排序归一化后才比较。项目 A 已处理某消息,不会使 Global 的相同消息自动成为已处理;Global 当前会按自身范围重新收集原始来源,不能描述为“只聚合已有项目图谱”。报告与监督也是独立阶段,不能互相推进位置。当前手动重看还会重新整理所选历史。 + +目标是同一来源版本的事实提取成功一次后,在 Global 和项目查询/整理中复用可用叶子事实,包括事件、实体变化、关系证据及来源位置,不只复用项目摘要。Global 可在已提取事实之上组织跨项目关系和摘要;范围变化本身不应造成相同正文再次提取。范围控制查询归属和综合判断,不再充当重复提取的唯一分界。 + +复用须核对原来源身份、版本、已覆盖片段及提取合同是否仍适用。仅部分成功时继续缺失部分;来源改版或用户明确要求重新分析时可产生新的提取结果,不能覆盖已确认内容。任务的当前状态、偏好背景和既有摘要仍须标明时间角色,不能作为跨 scope 复用时新发生的事件。共享事实的可见范围不能被 Global 摘要扩大。 + +这一目标尚未实现。本轮不批准具体数据迁移、跨 scope 主键重写、历史实体合并、checkpoint 转换或缓存表方案。实施前应从已有成功叶子和来源版本定位验证最小复用方案,列出旧数据的可复用程度与缺口;保留现有精确 scope 行为直到替代方案完成验证,不提前修改现有 checkpoint 含义。 + +## 8. 记忆过渡与分阶段交付 + +已确认的长期偏好必须继续可用,并保留 Global/项目适用范围。旧记忆只有更新时间时,只能显示为当前偏好背景和实际更新时间,不能补造“首次决定”“当时生效”或替代关系。新的图谱归纳与已确认偏好冲突时返回待核对差异,不以较新生成时间覆盖用户选择。 + +| 阶段 | 交付内容与退出条件 | 当前状态 | +| --- | --- | --- | +| D0 设计 | 记录工具、时间、范围、过渡与验收合同 | 已记录 | +| D1 只读查询 | 基于现有数据实现三个工具及本地/远程接线、配置目录、关闭拦截和分页;缺失版本与覆盖返回未知,对应 US-S28 至 US-S30 | 已实现;验证范围见实施进度,当前 memory 读取继续保留 | +| D2 事实复用 | 验证同一来源版本跨 scope 复用完整叶子;明确身份、版本变化与已有数据处理方案,达到 US-S31 的范围验收 | 待设计具体存储调整并实施;无已批准迁移方案 | +| D3 概念与读取统一 | 让统一入口承接已确认偏好及图谱上下文;核对引用、范围、冲突和旧数据可读性后,才切换现有 memory 消费路径 | 待实现;不以删除旧记录作为退出条件 | +| 未来历史重建 | 单独定义并验证 `as_of` 所需版本与当时可知性 | 不属于首期,不承诺交付时间 | + +D1 可将现有已确认偏好作为明确标记的背景投影,原读取路径在过渡期仍工作。重复背景按原记忆身份识别,图谱未知历史不能由记忆更新时间补齐。具体迁移与旧入口退出时点须根据实现证据确定,本文没有要求立即删除旧 memory API、设置或 UI。 + +## 9. 验收映射 + +核心场景使用可核对的旧决定 A、后来明确修订为 B 的证据,以及“现在继续做应采用什么”的请求。工具返回 A、修订事件、替代依据与 B;Agent 基于它们推荐 B,并引用旧决定和修订来源。新增选项 C 但没有替代依据时,不得把 B 判为失效;决定 B 也不能被回答为 B 已执行完成。 + +| 验收 | 对应故事 | 通过条件 | +| --- | --- | --- | +| 决定演变 | US-S28 | 完成上述旧决定 → 有证据修订 → 当前建议及引用链;冲突没有证据解决时保持待核对 | +| 时间 | US-S28、US-S29 | 迟录旧事件不变成新事件;确认与有效性分开;timeline 不声称当时已知;`as_of` 明确不支持 | +| 分页与来源 | US-S29 | 匹配项、演变和长原文均可读到末页;覆盖未知与零命中可区分;游标失效不混页;旧版不可用不返回伪历史 | +| 启停与只读 | US-S30 | 应用关闭后发现与直接调用均不可用;开启但无计划可读已有数据;工具自身模型调用与业务写入均为零 | +| Runtime 一致性 | US-S30 | 本地及 GoodBuddy Agent 远程实际链路具有相同 scope、版本、分页、引用与关闭行为,不能以共享 schema 代替接线验收 | +| 记忆及范围复用 | US-S31 | 确认偏好与已有引用保留;同版本成功叶子跨 scope 复用且能检索事实,不只返回摘要;历史缺口不补造 | + +验证命令和证据集中记录在[实施进度](./progress.md#2026-09-28-story-graph-只读工具)。本次只运行定向测试、类型检查、限定文件 lint 和确定性 Host 探针,没有全量测试、付费模型请求或发布操作。真实模型如何根据冲突证据形成最终建议,仍需与工具读取正确性分开验收。 diff --git a/docs/features/conversation-supervision/supervisor-prd.md b/docs/features/conversation-supervision/supervisor-prd.md index 2fa5e2d0..9a7cbd6e 100644 --- a/docs/features/conversation-supervision/supervisor-prd.md +++ b/docs/features/conversation-supervision/supervisor-prd.md @@ -51,6 +51,8 @@ FR-S9:来源事实、自动归纳、待核对关联和用户确认内容分别 ## 落地范围 +FR-S11(D1 已实现,D2/D3 待实现):监督者以时态 Story Graph 为主要产出,统一用户侧工作记忆概念并保留已确认偏好与已有数据。Agent 在继续工作、查询此前决定及其修订时,按需使用只读的 `story_graph_search`、`story_graph_get_context`、`story_graph_read_source`,读取范围、时间、演变、覆盖及可引用来源;工具不调用模型或触发回顾,监督者关闭时不暴露且拒绝调用。本地和远程 Runtime 共用读取语义。后续目标为同一来源版本提取一次并跨 scope 复用叶子事实;当前精确 scope 独立处理及 memory 读取继续保留。`as_of` 仅在历史重建能力具备后另行交付。合同与过渡阶段由[专项设计](./story-graph-mcp-design.md)定义,验收见 US-S28 至 US-S31;本轮没有数据迁移。 + 触发、整理与结果共享规则,以及整体架构和图谱对象关系见[逻辑设计](./logic-design.md)。 首个完整流程为:启用监督者、选择范围、生成回顾、进入图谱、核对来源、携带选定上下文继续讨论。知识库双向定位与内容补充随后接入同一流程。图谱关系辅助检索需单独验证效果,不作为首版默认检索策略。 diff --git a/docs/features/conversation-supervision/technical-design.md b/docs/features/conversation-supervision/technical-design.md index 47aeb367..ec359cee 100644 --- a/docs/features/conversation-supervision/technical-design.md +++ b/docs/features/conversation-supervision/technical-design.md @@ -13,6 +13,8 @@ 本文回答如何在现有 GoodBuddy 桌面端中实现监督者。它不改变产品范围,也不把模拟 Demo 当作生产数据模型。 +FR-S11 的读取架构见[时态 Story Graph 内置 MCP 设计](./story-graph-mcp-design.md)。三个只读工具通过 `readStoryGraph` 投影现有 SQLite,Main 在发现、调用和返回前检查监督者启用及 Runtime 分配。本地 MCP、Model、Harness Main 代理和远程受管工具通道共用此入口;无 schema 迁移。该文独立定义对象/版本合同、时间语义、配置映射及后续跨 scope 复用和记忆过渡。当前 memory 背景读取、精确 scope checkpoint 与候选身份保留;结果快照不支持 `as_of` 历史重建。 + 生产入口由 `supervision-production.ts` 组装服务、SQLite、共享池和 ask Runtime。`supervision-review-store.ts` 保存冻结来源清单,按有界页和片段供 `SupervisorService` 派发;叶子及连续位置先提交,导航和完整结果之后发布。消息已有知识引用保留本地或外部 locator,已确认记忆通过独立背景输入提供。不会检索外部全库。详细存储、调度及当前限制统一见[分块调度第 0 节](./review-scheduling-design.md#0-生产接线与剩余边界)。 模型实体 `id` 只在单次输出内有效。Main 从同一 scope 的故事线提供最多 100 个未撤销实体候选,生产提示只暴露 `candidateRef`(如 `known_1`)、名称和说明。模型显式选择候选后,Main 转换为严格 UUID 类型的内部 `persistedId`。服务校验候选集成员和重复映射,保存事务再次按实体主键校验故事线归属及撤销状态。没有有效候选引用时分配新 UUID,不按名称或裸模型 ID 合并。来源仍按每次结果分配 UUID,保留原始 `source_id` 和 locator。 @@ -33,7 +35,7 @@ schema 43 在结果上增加 `graph_snapshot_json`,保存当次实体名称、 侧栏通过 `onOpenSupervisionGraph(resultId)` 交给 App 导航;App 遵守现有离页检查,向 HeartbeatCenter / SupervisorWorkspace 传递类型化 `graphNavigation`,复用 `heartbeat` 路由及 keepalive 页面。每次点击创建新的导航请求,打开 graph 页签并更新历史选择共用的 resultId。overview 未包含该 ID 时仍直接请求其 graph;旧 overview、graph、来源和操作响应通过同一请求序号失效,不覆盖新结果。没有新增 window 事件、页面或持久化字段。 -继续讨论入队时,无附件请求省略 `serializedContexts`;请求未指定 Runtime 时使用会话保存的选择。知识库预览与提交均检查目标库存在且为本地可写库,更新实体还检查其实际所属库。侧栏确认框展示 Main 返回的实体字段、目标库和来源正文,取消或写入失败后可以重新预览。 +继续讨论预览将 `supervision:continue-context` 返回的 `prompt` 直接作为可编辑草稿。侧栏与图谱入口发送当前草稿,不重新拼接原始上下文。`supervision:continue` 沿用现有正文校验(去除首尾空白,最多 12,000 字符),使用空 `attachments` 入队,无附件请求省略 `serializedContexts`;请求未指定 Runtime 时使用会话保存的选择。会话已有的记忆和知识库配置仍按普通会话发送规则处理。知识库预览与提交均检查目标库存在且为本地可写库,更新实体还检查其实际所属库。侧栏确认框展示 Main 返回的实体字段、目标库和来源正文,取消或写入失败后可以重新预览。 监督来源清单将 UI 时间区间归一化为 UTC,先筛选再按稳定键分页。项目必须存在且 active;消息按闭区间筛选,任务按区间内创建或完成时间筛选。手动和自动监督共用该路径,自动清单另读取 supervisor checkpoint;手动心跳报告保留原有 collector。 diff --git a/docs/features/conversation-supervision/ui-design.md b/docs/features/conversation-supervision/ui-design.md index 7489afe5..69c70b71 100644 --- a/docs/features/conversation-supervision/ui-design.md +++ b/docs/features/conversation-supervision/ui-design.md @@ -89,11 +89,13 @@ Modal 按[共享表单 Modal 规则](../../../UI-DESIGN.md#615-表单-modal)复 有匹配结果时提供“在图谱中查看”,打开现有监督者页面的图谱页签并读取该结果;无匹配结果不显示入口。按钮保留原生键盘操作,进入后焦点落在图谱页签,继续使用共享 PageTabs 的方向键导航。固定目标不因跳转改变;再次点击相同或不同结果均重新定位,包括页面缓存后再次进入。结果不可用时显示错误并重试原 ID,不展示默认最新结果。 -“继续讨论”直接准备上下文,不要求先查看来源。就地显示目标会话、可编辑的问题和“发送时会附带相关回顾与来源”的短提示,不重复展示或折叠整段上下文;依据仍可通过查看来源核对。只有点击“发送”才提交,取消不发送,失败保留问题。固定任务使用其关联会话,无法确定发送会话时禁用。上下文仅包含所选来源关联的事件、实体、关系及原文快照,不带入整份全局回顾摘要;没有关联事件时只使用来源。切换目标使旧来源请求和预览失效。查看来源不自动跳转,用户点击“打开会话”才导航。侧栏不再显示实体写入或知识库选择器,现有知识库数据不受影响。 +“继续讨论”直接准备上下文,不要求先查看来源。预览顶部单独显示目标会话,下方“发送内容”文本域直接填入实际回顾与来源正文,用户可编辑或删除其中任意内容。发送仅提交当前文本域内容,不再暗中拼接上下文,也不提供额外的展开区。只有点击“发送”才提交,取消不发送,失败保留整份草稿。固定任务使用其关联会话,无法确定发送会话时禁用。上下文仅包含所选来源关联的事件、实体、关系及原文快照,不带入整份全局回顾摘要;没有关联事件时只使用来源。切换目标使旧来源请求和预览失效。查看来源不自动跳转,用户点击“打开会话”才导航。侧栏不再显示实体写入或知识库选择器,现有知识库数据不受影响。 + +侧栏与图谱来源详情共用编辑区样式:目标区底部使用弱分隔线,区块间距为 `--space-4`,标签与文本域间距为 `--space-2`。文本域默认十行,可纵向调整大小;长内容在文本域内滚动。底部复用 `custom-task-dialog__actions`,取消在前、发送在后,整体右对齐。 ## 故事线图谱 -关联来源详情属于会话时,显示“打开会话”按钮,使用来源保存的会话 ID 跳转并切换到所属项目;不发送消息或启动回顾。另提供“继续讨论”,先预览该来源关联的上下文、编辑问题,点击发送后提交到来源会话并打开它。详情继续展示整理时保存的原文快照。会话已删除或读取失败时,沿用应用会话导航的提示;其他来源类型不显示此按钮。 +关联来源详情属于会话时,显示“打开会话”按钮,使用来源保存的会话 ID 跳转并切换到所属项目;不发送消息或启动回顾。另提供“继续讨论”,使用上述可编辑正文预览,点击发送后提交到来源会话并打开它。详情继续展示整理时保存的原文快照。会话已删除或读取失败时,沿用应用会话导航的提示;其他来源类型不显示此按钮。 左侧使用共享 PageTabs 切换“事件 / 实体 / 关系”,显示各类数量,一次只展示一类列表,无记录时显示本次回顾的分类空态。页签固定在顶部,列表占满剩余高度并独立滚动。切换页签不清除当前图谱选择;从画布、事件浏览或详情选择对象时同步切到对应页签。支持方向键、Home/End 和面板键盘聚焦。 diff --git a/docs/features/conversation-supervision/user-stories.md b/docs/features/conversation-supervision/user-stories.md index 75f19008..edf4ac9c 100644 --- a/docs/features/conversation-supervision/user-stories.md +++ b/docs/features/conversation-supervision/user-stories.md @@ -553,6 +553,56 @@ | 来源已经自动处理 | 用户手动回顾所选区间 | 重新整理有界历史,保留自动进度;删除报告不等同重看或重建 | | 只有已确认记忆或背景摘要变化 | 自动检查 | 背景不单独触发模型调用 | +## 时态图谱与 Agent 读取 + +### US-S28 依据决定演变继续工作 + +**状态:D1 工具读取已实现;真实模型建议质量尚未验收。** 对应 FR-S11,读取合同见[时态 Story Graph MCP 设计](./story-graph-mcp-design.md),验证范围见[实施进度](./progress.md#2026-09-28-story-graph-只读工具)。 + +| Given | When | Then | +| --- | --- | --- | +| 已保存旧决定 A,后来有明确证据将其修订为 B | 用户询问“继续上次工作,现在采用什么” | Agent 按需读取图谱,说明 A 到 B 的变化、推荐 B 的依据,并引用旧决定与修订来源 | +| 新记录只是增加选项 C | Agent 查询当前理解 | B 不因 C 更新而自动失效;替代或矛盾关系必须有明确证据 | +| 记录只表明决定采用 B,没有完成证据 | 用户询问执行进度 | 回答已决定且完成状态未知,不推断已完成 | +| 已确认旧认识与新证据冲突 | Agent 组织当前建议 | 分别展示确认状态、有效性与待核对差异,不静默覆盖 | + +### US-S29 分页核对时间与来源 + +**状态:D1 已实现,缺失历史版本明确返回不可用。** 对应 FR-S11、US-S13。 + +| Given | When | Then | +| --- | --- | --- | +| 旧事件在较晚回顾中才生成并记录 | 按发生时间查询并查看 timeline | 区分事件、回顾生成和记录时间;不把补录当成新发生,也不声称当时已知 | +| 搜索、演变或原文超过一页 | Agent 继续读取 | 每页说明范围、修订、覆盖与剩余页,能读取全部匹配内容,无静默截断 | +| 旧记录只有证据条数,没有完整覆盖信息 | Agent 读完最后一页 | 返回覆盖未知,不称已覆盖全部原始工作记录 | +| 引用版本原文已不可用或当前内容已修改 | 读取历史来源 | 返回实际保存片段或明确版本不可用;读取当前版本须显式选择并标注差异 | +| 分页期间对象版本变化 | 继续使用旧游标 | 明确提示游标失效并重新查询,不混合新旧版本 | +| 用户要求截至过去时刻的完整认识 | 调用 `as_of` | 明确不支持,不用 current 或 timeline 冒充历史重建 | + +### US-S30 按需读取并遵守应用开关 + +**状态:D1 已实现;跨平台安装包验收未执行。** 对应 FR-S11、US-S14、US-S20。 + +| Given | When | Then | +| --- | --- | --- | +| 会话属于当前项目 | Agent 未显式指定 scope 搜索 | 只查该项目;无项目时要求明确范围,不自动扩大为 Global | +| 用户进行普通聊天 | 没有继续工作或历史依据需求 | 不强制每轮查询或注入完整图谱;需要核对原话时才进一步读来源 | +| 监督者开启且内置能力已分配,没有自动计划 | 查询已有结果 | 可以读取;查询不触发模型、回顾或业务写入 | +| 监督者关闭,旧会话仍缓存工具 | 列出工具或直接调用 | 不暴露工具,调用被拒绝;已有数据保留 | +| 相同范围与证据分别通过本地和远程 Runtime 查询 | 翻页、读取版本或关闭监督者 | 返回相同业务语义与引用,远程实际桥接须通过验收 | + +### US-S31 保留偏好并复用跨范围事实 + +**状态:待实现;当前仍独立处理精确 scope,读取现有已确认 memory。** 对应 FR-S11、US-S16、US-S27。 + +| Given | When | Then | +| --- | --- | --- | +| 用户已有已确认的 Global 或项目偏好 | 逐步切换统一工作记忆入口 | 原内容、确认状态、适用范围和引用保留,不删除旧数据 | +| 旧偏好只有更新时间 | 查询演变 | 标记为当前背景和历史未知,不补造决定时间或替代关系 | +| 项目 A 的来源版本已有成功叶子事实 | 目标复用能力交付后进行 Global 整理 | 复用该版本的事件、变化、关系和来源,不因 scope 改变重复提取相同正文,也不只复用摘要 | +| 仅部分片段成功或原来源已改版 | 再次整理 | 识别未覆盖或变更部分,不把旧版本覆盖算作新版本完成 | +| Global 结果含多个项目的归纳 | 从单项目查询 | 不把跨项目摘要伪装成项目结论,复用事实仍保留来源归属 | + ## 验收优先级与依赖 首个可用版本应先打通以下路径: diff --git a/docs/features/deepseek-harness/technical-design.md b/docs/features/deepseek-harness/technical-design.md index 1645c8cd..88c7f2ad 100644 --- a/docs/features/deepseek-harness/technical-design.md +++ b/docs/features/deepseek-harness/technical-design.md @@ -19,7 +19,7 @@ DeepSeek Harness 的底层库使用 Cordis 组合服务。GoodBuddy 不采用官方产品 profile,也不允许用户配置覆盖内部 Host 或控制服务;GoodBuddy 自行维护 Host、控制协议和生命周期,同时提供一个由 Main 管理、默认关闭的 npm 插件市场。用户显式开启后,市场只搜索带精确 `dsh-plugin` 关键字的公共 npm 包,不代表 GoodBuddy 审核、推荐或承诺兼容这些包。 -用户明确安装并启用的插件以当前用户权限运行。Ask/Execute 只控制模型经过 `tools/execute` 发起的工具调用:Ask 只允许 Host 中真实注册的 `read`、`skill` 和 Main 管理的 Web Search/Fetch 代理,Execute 放行 Host 中全部已注册工具。插件不能用同名工具冒充 Ask 允许项。插件安装脚本和初始化代码不属于模型工具调用,不能由 Ask 限制,因此界面在安装前必须明确确认这一边界。 +用户明确安装并启用的插件以当前用户权限运行。Ask/Execute 只控制模型经过 `tools/execute` 发起的工具调用:Ask 只允许 Host 中真实注册的 `read`、`skill` 和 Main 管理的 Web Search/Fetch、Story Graph 只读代理,Execute 放行 Host 中全部已注册工具。插件不能用同名工具冒充 Ask 允许项。插件安装脚本和初始化代码不属于模型工具调用,不能由 Ask 限制,因此界面在安装前必须明确确认这一边界。 整体分成两个互相约束的部分: @@ -386,11 +386,17 @@ Main 的 Web/MCP ToolProvider 按工作区保存,最后一个对应 Session | Ask | 只允许 Host Registry 中真实注册的 `read` 与 `skill`;其他 Host/插件工具一律拒绝 | 能力启用时注册 Main 的精确代理对象,无逐次审批 | 不注册 | 模型工具调用保持只读 | | Execute | 放行 Host 中全部已注册的内置与插件工具 | 能力启用时注册 Main 代理 | 按分配注册并经过既有 RuntimeAuthorizer | 不增加插件权限层,以当前用户权限运行 | +2026-09-28:内置 Story Graph 的三个读取工具在 Ask/Execute 均通过 Main 代理提供, +Host 只接受 Main 注册的具体工具对象。Main 逐次检查监督者启用、Harness 分配及请求项目范围, +不逐次审批。其他普通内置 MCP 仍不支持 Harness 分配;合同与证据见 +[Story Graph 设计](../conversation-supervision/story-graph-mcp-design.md)和 +[验证进度](../conversation-supervision/progress.md#2026-09-28-story-graph-只读工具)。 + ### 10.2 Ask 模式 - Harness Control Plane 在 `tools/execute` 分发边界识别当前 Session 和在途请求。 -- 采用所有权感知的只读允许列表,只接受 Registry 中真实的 `read`、`skill` 和 Main 注册的 Web 代理对象;`write`、`edit`、Shell、MCP 及任意新插件工具默认拒绝。只比较工具名不足以授权,插件注册同名工具仍会被拒绝。 -- Ask 不注册 Main 代理的 MCP 工具。 +- 采用所有权感知的只读允许列表,只接受 Registry 中真实的 `read`、`skill` 和 Main 注册的 Web/Story Graph 代理对象;`write`、`edit`、Shell、自定义 MCP 及任意新插件工具默认拒绝。只比较工具名不足以授权,插件注册同名工具仍会被拒绝。 +- Ask 不注册 Main 代理的自定义 MCP;Story Graph 使用共享只读合同,scope 的互斥分支转换为 Harness 支持的 `oneOf`,完整参数仍由 Main 的 Zod schema 校验。 - Web Search/Fetch 的凭据、传输与结果限制保留在 Main;Utility 只看到有界 schema 和结果。 - 只读不等于无限输出,读取仍受字节和工具结果上限控制。 - 插件安装脚本和 Cordis 初始化生命周期不经过 `tools/execute`。Ask 不能把已启用第三方代码变成沙箱,也不能保证第三方代码没有启动副作用。 @@ -405,7 +411,7 @@ Main 的 Web/MCP ToolProvider 按工作区保存,最后一个对应 Session `goodbuddy/tools/list` 按 Session 保存本次发现的工具定义;`goodbuddy/tools/call` 复用该定义 校验参数与授权,不因调用健康工具而重新等待故障 MCP 的发现。发现重试留在清单刷新, Session 释放及 Runtime dispose 清除内存定义,不持久化第二份能力状态。 -- 所有工具调用仍作为活动事件记录;Ask 和 delegation 路径继续固定拒绝。 +- 所有工具调用仍作为活动事件记录;自定义 MCP 的 Ask 和 delegation 路径继续固定拒绝。 ### 10.4 Runtime OS 沙箱 @@ -689,11 +695,11 @@ Renderer 只接收公开 npm 元数据和受管插件状态。任何凭据、完 - 二进制检测、版本解析和路径规范化。 - ACP 握手、事件转换和请求关联。 - 每个会话单请求、跨会话并行。 -- Ask 在工具分发边界只允许真实注册的 `read`、`skill` 与 Main Web 代理,并拒绝同名冒充和任意新插件工具;Execute 放行插件工具。 +- Ask 在工具分发边界只允许真实注册的 `read`、`skill` 与 Main Web/Story Graph 代理,并拒绝同名冒充和任意新插件工具;Execute 放行插件工具。 - 握手只接受明确的 `execution.mode = 'host'`。 - 未分配 Skill/MCP 不可见;分配后的 Skill catalog 可调用 `skill` 加载。 - 原生能力快照只包含 Host/插件原生 Skills,不包含 GoodBuddy 分配的 Skills 或 MCP。 -- Ask 不注册 MCP 工具;Web 代理可用于 Ask 与 Execute;Execute 每轮刷新有界 MCP schema,并在调用前再次校验活动请求、模式、参数和 RuntimeAuthorizer 结果。 +- Ask 不注册自定义 MCP 工具;Web 和 Story Graph 代理可用于 Ask 与 Execute;Execute 每轮刷新有界自定义 MCP schema,并在调用前再次校验活动请求、模式、参数和 RuntimeAuthorizer 结果。 - MCP URL、启动命令和凭据不进入 Utility 启动配置或协议结果。 - 未知授权结果失败关闭。 - 超时、取消、迟到帧和进程意外退出。 @@ -760,7 +766,7 @@ npm run build - `deepseek-harness` 可被保存、选择、检测和显示。 - Runtime 详情卡内显示状态、路径、版本和当前用户执行权限。 -- Skills 与自定义 MCP 设置可把能力分配给 DeepSeek Harness;请求级 GoodBuddy 内置 MCP 当前不支持分配,并在内置 MCP 卡片中以置灰、未选择状态明确显示。布局、键盘语义、文案和保存回显通过真机检查。 +- 该阶段的 Skills 与自定义 MCP 设置可把能力分配给 DeepSeek Harness;当时请求级 GoodBuddy 内置 MCP 不支持分配,并在卡片中置灰。布局、键盘语义、文案和保存回显通过真机检查。2026-09-28 的 Story Graph 源码另接入 Harness 分配与 Main 只读代理,验证证据见上文专项记录。 - DSH 市场初始关闭且不加载 npm 目录;显式开启后可以搜索、安装、启停、配置和移除插件,安装前只出现一次准确的当前用户权限确认。关闭市场后已有启用插件继续运行,重新开启后管理状态不变。 - Ask 写入测试在 Runtime 边界失败。 - Ask 可使用已启用的 Main Web Search/Fetch,且插件无法通过同名工具绕过所有权校验。 diff --git a/docs/features/remote-host/technical-design.md b/docs/features/remote-host/technical-design.md index 0d1ef8c1..3dfcadb1 100644 --- a/docs/features/remote-host/technical-design.md +++ b/docs/features/remote-host/technical-design.md @@ -27,6 +27,12 @@ Agent `0.11.23` 提供,显式请求期限与取消仍有效。当前问答源 ## 跨会话进程复用(未发布) +2026-09-28 Story Graph 补充:远程 OpenCode/Continue 的 Ask 和 Execute +通过现有受管 HTTP MCP、工具二进制通道读取桌面图谱。Main 固定项目范围并复核监督者 +开关及 Runtime 分配;图谱读取不复制数据库到 Host,也不调用模型。三个只读工具、 +分页和版本限制由[专项设计](../conversation-supervision/story-graph-mcp-design.md)定义, +源码 Host 探针及协议测试见[验证记录](../conversation-supervision/progress.md#2026-09-28-story-graph-只读工具)。 + 已发布版本的 `RuntimeAcpBackend` 按 binding 拥有原生进程,`AgentOwnedAcpPrompt` 的一条 ACP 连接对应一个原生 Session;同一会话后续 Prompt 可复用,但不同会话不共用进程。 上文“Host 共享 Runtime 环境”指安装与 Agent 环境,不代表原生 OpenCode 进程已共享。 diff --git a/scripts/story-graph-host-probe.ts b/scripts/story-graph-host-probe.ts new file mode 100644 index 00000000..c617c9a8 --- /dev/null +++ b/scripts/story-graph-host-probe.ts @@ -0,0 +1,166 @@ +// Deterministic SSH validation. Desktop owns SQLite; only tool messages cross SSH. +import assert from 'node:assert/strict' +import { randomBytes } from 'node:crypto' +import { spawn } from 'node:child_process' +import { createServer } from 'node:http' +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { dirname, join } from 'node:path' +import { createInterface } from 'node:readline' +import { Client } from '@modelcontextprotocol/sdk/client/index.js' +import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js' +import { AgentImageToolMcp } from '../src/agent-daemon/image-tool-mcp' +import { AgentOwnedAcpPrompt } from '../src/agent-daemon/agent-owned-acp-prompt' +import { SemanticPromptStore } from '../src/agent-daemon/semantic-prompt-store' +import { createOpenCodeModelBridgeProviderConfig } from '../src/agent-daemon/model-bridge-helper' +import type { RuntimeAcpProcessOwner } from '../src/agent-daemon/runtime-acp-backend' +import { ContinueHostAdapter } from '../src/main/agent/continue-host-adapter' +import { AssistantDatabase } from '../src/main/assistant/assistant-database' +import { KnowledgeMcpGateway } from '../src/main/agent/knowledge-mcp-gateway' +import type { KnowledgeService } from '../src/main/knowledge/knowledge-service' +import { MainImageToolSession } from '../src/main/remote-agent/main-image-tool-session' +import type { RuntimeProtocolBinaryChannel, RuntimeProtocolBinaryFrame } from '../src/main/remote-agent/protocol-remote-runtime-channel' +import { isStoryGraphTool, storyGraphToolNames } from '../src/shared/story-graph-tools' + +const emit = (value: object) => process.stdout.write(`${JSON.stringify(value)}\n`) + +async function hostProbe() { + const [, , , opencode, continueEntry] = process.argv + const root = await mkdtemp(join(tmpdir(), 'goodbuddy-graph-')) + const adapter = new AgentImageToolMcp({ channelId: 'graph-probe', channelEpoch: '1', storyGraph: true }, async payload => { emit({ frame: Buffer.from(payload).toString('base64') }) }) + let disabled!: () => void + const disabling = new Promise(resolve => { disabled = resolve }) + const lines = createInterface({ input: process.stdin }) + lines.on('line', line => { const value = JSON.parse(line); if (value.frame) adapter.onReply(Buffer.from(value.frame, 'base64')); if (value.disabled) disabled() }) + const client = new Client({ name: 'story-graph-host-probe', version: '1' }) + let inferenceRequests = 0 + let backgroundRequests = 0 + const failures: unknown[] = [] + const inference = createServer((req, res) => { void (async () => { + let text = ''; for await (const part of req) text += part + const body = JSON.parse(text) + if (++inferenceRequests > 20) throw new Error('Unexpected inference loop') + const tools = body.tools as Array<{ function: { name: string } }> | undefined + const tool = tools?.find(tool => tool.function.name.endsWith('story_graph_search')) + if (tools?.length) assert.ok(tool, 'Runtime must expose Story Graph in Ask and Execute') + else backgroundRequests++ + const result = body.messages.findLast((message: { role: string }) => message.role === 'tool') + if (result) assert.match(JSON.stringify(result), /Decision B/) + const delta = !tool ? { role: 'assistant', content: 'Graph probe title' } : result ? { role: 'assistant', content: 'GRAPH_RUNTIME_OK' } : { role: 'assistant', tool_calls: [{ index: 0, id: 'graph-call', type: 'function', function: { + name: tool.function.name, arguments: JSON.stringify({ query: 'Decision B', page_size: 1 }) + } }] } + res.writeHead(200, { 'content-type': 'text/event-stream' }) + res.end(`data: ${JSON.stringify({ id: 'graph-response', object: 'chat.completion.chunk', created: 1, model: 'graph-probe', choices: [{ index: 0, delta, finish_reason: null }] })}\n\ndata: ${JSON.stringify({ id: 'graph-response', object: 'chat.completion.chunk', created: 1, model: 'graph-probe', choices: [{ index: 0, delta: {}, finish_reason: result || !tool ? 'stop' : 'tool_calls' }] })}\n\ndata: [DONE]\n\n`) + })().catch(error => { failures.push(error); res.writeHead(500).end(String(error)) }) }) + try { + await adapter.start() + await client.connect(new StreamableHTTPClientTransport(new URL(adapter.url!))) + assert.deepEqual((await client.listTools()).tools.map(tool => tool.name), storyGraphToolNames) + const call = async (name: string, args: Record) => { + const result = await client.callTool({ name, arguments: args }); assert.notEqual(result.isError, true) + return JSON.parse((result.content as { text: string }[])[0]!.text) + } + const search = await call('story_graph_search', { query: 'Decision', page_size: 1 }) + assert.equal(search.page.has_more, true) + await call('story_graph_search', { query: 'Decision', page_size: 1, cursor: search.page.next_cursor }) + const context = await call('story_graph_get_context', { object_ref: search.items[0].object_ref, mode: 'timeline' }) + assert.ok(context.items.length) + const sourceId = context.items.flatMap((item: { source_reference_ids: string[] }) => item.source_reference_ids)[0] + assert.ok(sourceId) + const source = await call('story_graph_read_source', { source_reference_id: sourceId, page_size: 7 }) + assert.equal(source.page.has_more, true) + await call('story_graph_read_source', { source_reference_id: sourceId, page_size: 7, cursor: source.page.next_cursor }) + const unsupported = await client.callTool({ name: 'story_graph_get_context', arguments: { object_ref: search.items[0].object_ref, mode: 'as_of' } }) + assert.equal(unsupported.isError, true) + await new Promise(resolve => inference.listen(0, '127.0.0.1', resolve)) + const origin = `http://127.0.0.1:${(inference.address() as { port: number }).port}/${randomBytes(32).toString('base64url')}` + for (const workMode of ['ask', 'execute'] as const) { + const config = createOpenCodeModelBridgeProviderConfig({ protocol: 'openai-chat-completions', model: 'graph-probe', loopbackOrigin: origin, workMode }) + const permission = workMode === 'execute' ? 'allow' : { '*': 'deny', 'graph_story_graph_*': 'allow' } + const child = spawn(opencode!, ['acp'], { cwd: root, stdio: ['pipe', 'pipe', 'pipe'], env: { ...process.env, + HOME: root, XDG_CONFIG_HOME: join(root, 'config'), XDG_DATA_HOME: join(root, 'data'), XDG_CACHE_HOME: join(root, 'cache'), XDG_STATE_HOME: join(root, 'state'), + OPENCODE_DISABLE_AUTOUPDATE: '1', OPENCODE_DISABLE_MODELS_FETCH: '1', OPENCODE_DISABLE_PROJECT_CONFIG: '1', OPENCODE_DISABLE_EXTERNAL_SKILLS: '1', OPENCODE_DISABLE_CLAUDE_CODE_SKILLS: '1', + OPENCODE_CONFIG_CONTENT: JSON.stringify({ ...config, permission, agent: { build: { permission } } }) + } }) + child.stderr.resume() + const exited = new Promise(resolve => child.once('close', () => resolve())) + const processOwner = { + writeStdin: (payload: Uint8Array) => new Promise((resolve, reject) => child.stdin.write(payload, error => error ? reject(error) : resolve())), + subscribeOutput: (listener: Parameters[0]) => { const data = (data: Buffer) => { void listener({ stream: 'stdout', data }) }; child.stdout.on('data', data); return () => { child.stdout.off('data', data) } }, + subscribeExit: (listener: () => void) => { child.on('close', listener); return () => { child.off('close', listener) } } + } as RuntimeAcpProcessOwner + const transcript = new SemanticPromptStore(join(root, `${workMode}.sqlite`)) + let done!: () => void + const completed = new Promise(resolve => { done = resolve }) + const owner = new AgentOwnedAcpPrompt({ bindingId: 'graph', controllerId: 'probe', workspaceDirectory: root, process: processOwner, transcript, + mcpServers: () => [{ type: 'http', name: 'graph', url: adapter.url!, headers: [] }], + completePrompt: async (_operation, _status, _response, commit) => { commit(); done() } }) + try { + transcript.prepare({ bindingId: 'graph', controllerId: 'probe', operationId: workMode, requestId: workMode, preparationDigest: `sha256:${'a'.repeat(64)}`, promptSequence: 0 }) + await owner.start({ bindingId: 'graph', operationId: workMode, requestId: workMode, prompt: [{ type: 'text', text: 'Search the graph.' }] }, workMode) + await completed + const page = transcript.page({ bindingId: 'graph', operationId: workMode, controllerId: 'probe', afterSequence: '0', limit: 128 }) + assert.equal(page.state, 'completed'); assert.match(JSON.stringify(page), /GRAPH_RUNTIME_OK/) + } finally { owner.close(); child.kill(); await exited; transcript.close() } + const continueAdapter = new ContinueHostAdapter({ binaryPath: continueEntry!, configPath: '', workspace: root, cacheRoot: join(root, 'continue'), mode: 'chat', + modelProfile: { id: 'probe', name: 'Probe', modelName: 'graph-probe', baseUrl: `${origin}/v1`, protocol: 'openai-chat-completions', authentication: 'none' }, + launchHost: (entry, args, options) => { + const process = spawn(globalThis.process.execPath, [join(dirname(entry), 'utility-bootstrap.mjs'), entry, ...args], { ...options, env: { ...options.env, ELECTRON_RUN_AS_NODE: '1' }, stdio: ['ignore', 'ignore', 'pipe'] }) + process.stderr.on('data', data => globalThis.process.stderr.write(data)) + return process + } }) + try { + const result = await continueAdapter.run('Search the graph.', AbortSignal.timeout(40_000), async approval => workMode === 'execute' || isStoryGraphTool(approval.toolName ?? '') ? 'once' : 'deny', { + workMode, sessionMcpServers: [{ name: 'graph', type: 'streamable-http', url: adapter.url!, requestOptions: { headers: {} } }] + }) + assert.match(result.text, /GRAPH_RUNTIME_OK/) + } finally { await continueAdapter.dispose() } + } + emit({ disable: true }); await disabling + assert.deepEqual((await client.listTools()).tools, []) + assert.equal((await client.callTool({ name: 'story_graph_search', arguments: { query: 'Decision' } })).isError, true) + assert.deepEqual(failures, []) + emit({ passed: true, inferenceRequests, backgroundRequests, paidCalls: 0, runtimes: ['OpenCode Ask', 'OpenCode Execute', 'Continue Ask', 'Continue Execute'] }) + } finally { lines.close(); await client.close(); adapter.close(); inference.closeAllConnections(); inference.close(); await rm(root, { recursive: true, force: true }) } +} + +async function desktopProbe() { + const [, , host, remoteScript, opencode, continueEntry] = process.argv + assert.ok(host && remoteScript && opencode && continueEntry, 'Expected host, remote bundle, OpenCode and Continue paths') + const db = new AssistantDatabase(':memory:'); db.initialize(process.cwd()) + const projectId = db.listProjects()[0]!.id + db.saveSupervisionResult({ request: { trigger: 'manual', scope: { kind: 'projects', projectIds: [projectId] }, timeRange: { from: '2026-09-01T00:00:00Z', to: '2026-09-30T00:00:00Z' } }, + evidence: [{ id: 'quote', sourceType: 'conversation', sourceId: 'probe', title: 'Decision evidence', occurredAt: '2026-09-01T00:00:00Z', content: 'Decision A explicitly revised to Decision B; implementation remains pending.' }], + output: { summary: 'Decision B', changeDigest: 'Revision A to B', openItems: ['Implementation pending'], events: [], entityChanges: [], relations: [], + entities: [{ id: 'decision', label: 'Decision B', description: 'Explicit revision with evidence', sourceReferenceIds: ['quote'] }] } }) + let enabled = true, reads = 0, passed = false + const gateway = new KnowledgeMcpGateway({} as KnowledgeService, { storyGraphService: { available: async () => enabled, + read: (...args) => { assert.ok(enabled); reads++; return db.readStoryGraph(...args) } } }) + const signal = AbortSignal.timeout(150_000) + const token = gateway.grant('probe', [], signal, 'none', undefined, undefined, undefined, undefined, undefined, { projectId, runtimeTarget: 'opencode' })! + const quote = (value: string) => `'${value.replaceAll("'", "'\\''")}'` + const child = spawn('ssh', ['-o', 'BatchMode=yes', '-o', 'StrictHostKeyChecking=yes', '-o', 'ConnectTimeout=5', host!, + `TMPDIR=/root/tmp ELECTRON_RUN_AS_NODE=1 /opt/GoodBuddy/goodbuddy ${quote(remoteScript!)} --host ${quote(opencode!)} ${quote(continueEntry!)}`], { stdio: ['pipe', 'pipe', 'inherit'], signal }) + const exited = new Promise((resolve, reject) => { child.once('close', resolve); child.once('error', reject) }) + const queue: RuntimeProtocolBinaryFrame[] = [] + let receive: ((frame: RuntimeProtocolBinaryFrame) => void) | undefined + let reject: ((error: Error) => void) | undefined + const channel: RuntimeProtocolBinaryChannel = { channelId: 'graph-probe', channelEpoch: '1', + send: async payload => { child.stdin.write(`${JSON.stringify({ frame: Buffer.from(payload).toString('base64') })}\n`) }, + receive: async () => queue.shift() ?? await new Promise((resolve, fail) => { receive = resolve; reject = fail }), + close: () => reject?.(new Error('closed')), onClose: () => () => {} } + const main = new MainImageToolSession(channel, undefined, signal, gateway.bindRemoteStoryGraph(token)) + const lines = createInterface({ input: child.stdout }) + lines.on('line', line => { + const value = JSON.parse(line) + if (value.frame) { + const frame = { payload: Buffer.from(value.frame, 'base64'), sequence: '1', consume: async () => {} } + if (receive) { const resolve = receive; receive = undefined; resolve(frame) } else queue.push(frame) + } else if (value.disable) { enabled = false; child.stdin.write('{"disabled":true}\n') } + else if (value.passed) { passed = true; emit({ ...value, desktopReads: reads, desktopOwnsDatabase: true }) } + }) + try { assert.equal(await exited, 0); assert.ok(passed) } + finally { lines.close(); main.close(); child.kill(); await gateway.dispose(); db.close() } +} + +void (process.argv[2] === '--host' ? hostProbe() : desktopProbe()).catch(error => { console.error(error); process.exitCode = 1 }) diff --git a/src/agent-daemon/continue-acp-helper.test.ts b/src/agent-daemon/continue-acp-helper.test.ts index 64c29492..365b66b8 100644 --- a/src/agent-daemon/continue-acp-helper.test.ts +++ b/src/agent-daemon/continue-acp-helper.test.ts @@ -46,14 +46,19 @@ const server = (name: string) => ({ // Keep the actual helper and adapter config builder together: mocking the // adapter constructor would miss the model-profile configuration-loss bug. -it('delivers current ACP session MCP capabilities through the bridged model config, but not Ask', async () => { +it('delivers current ACP session MCP capabilities in Execute and Ask with read-only Ask authorization', async () => { const configs: Array> = [] vi.stubGlobal('fetch', vi.fn(async () => Response.json({ operationId: 'operation', workMode: transport.mode }))) vi.spyOn(ContinueHostAdapter.prototype, 'run').mockImplementation(async function ( - this: ContinueHostAdapter, _prompt, _signal, _authorize, options + this: ContinueHostAdapter, _prompt, _signal, authorize, options ) { + if (options?.workMode === 'ask') { + expect(await authorize!({ toolName: 'story_graph_search' } as never)).toBe('once') + expect(await authorize!({ toolName: 'generate_image' } as never)).toBe('deny') + expect(await authorize!({ toolName: 'write_file' } as never)).toBe('deny') + } const path = await (this as unknown as { createRunConfig(options: ContinueHostRunOptions): Promise }).createRunConfig(options!) @@ -83,7 +88,8 @@ it('delivers current ACP session MCP capabilities through the bridged model conf requestOptions: { headers: { Authorization: 'Bearer session-test-token' } } }], [{ name: 'replacement', type: 'streamable-http', url: server('replacement').url, requestOptions: { headers: { Authorization: 'Bearer session-test-token' } } }], - [], [] + [{ name: 'replacement', type: 'streamable-http', url: server('replacement').url, + requestOptions: { headers: { Authorization: 'Bearer session-test-token' } } }], [] ]) expect(configs[0].models).toEqual([expect.objectContaining({ model: 'test-model' })]) } finally { @@ -92,7 +98,7 @@ it('delivers current ACP session MCP capabilities through the bridged model conf } }) -it('keeps local profile MCP scoping and filters explicit session servers in Ask at the adapter boundary', async () => { +it('keeps local profile MCP scoping and includes Main-bound session servers in Ask', async () => { const root = await mkdtemp(join(tmpdir(), 'goodbuddy-cn-scope-')) try { const configPath = join(root, 'native.json') @@ -112,7 +118,9 @@ it('keeps local profile MCP scoping and filters explicit session servers in Ask const path = await create({ workMode: 'ask', sessionMcpServers: [{ name: 'remote', type: 'streamable-http', url: 'http://127.0.0.1:12346', requestOptions: { headers: {} } }] }) - expect(JSON.parse(await readFile(path, 'utf8')).mcpServers ?? []).toEqual([]) + expect(JSON.parse(await readFile(path, 'utf8')).mcpServers).toEqual([{ + name: 'remote', type: 'streamable-http', url: 'http://127.0.0.1:12346', requestOptions: { headers: {} } + }]) } finally { await rm(root, { recursive: true, force: true }) } diff --git a/src/agent-daemon/continue-acp-helper.ts b/src/agent-daemon/continue-acp-helper.ts index db3a47e4..826796b5 100644 --- a/src/agent-daemon/continue-acp-helper.ts +++ b/src/agent-daemon/continue-acp-helper.ts @@ -11,6 +11,7 @@ import { promptWithUntrustedConversationHistory } from '../main/agent/runtime-co import type { AgentExecutionRequest, AgentImage } from '../main/agent/runtime' import { createUnixModelBridgeExchange } from './model-bridge-broker' import { ModelBridgeLoopbackProxy, MODEL_BRIDGE_SDK_AUTH_SENTINEL, openCodeModelBridgeModelId, type ModelBridgeProtocol } from './model-bridge-helper' +import { isStoryGraphTool } from '../shared/story-graph-tools' export async function runContinueAcpHelper(options: { socketPath: string @@ -88,11 +89,11 @@ export async function runContinueAcpHelper(options: { return await response.json() as { operationId: string; workMode: 'ask' | 'execute' } }) : undefined const workMode = route?.workMode ?? options.workMode - const sessionMcpServers = workMode === 'execute' ? session.mcpServers.map(server => { + const sessionMcpServers = session.mcpServers.map(server => { if (!('type' in server) || server.type !== 'http') throw new Error('Continue remote MCP requires HTTP') return { name: server.name, type: 'streamable-http' as const, url: server.url, requestOptions: { headers: Object.fromEntries(server.headers.map(header => [header.name, header.value])) } } - }) : [] + }) const adapter = new ContinueHostAdapter({ binaryPath: options.entrypoint, configPath: '', workspace: session.cwd, cacheRoot: root, mode: 'chat', modelProfile: { id: 'agent-bridge', name: 'GoodBuddy', modelName: options.model, @@ -111,7 +112,7 @@ export async function runContinueAcpHelper(options: { session.active = { abort, adapter, finished: new Promise(resolve => { finish = resolve }) } try { const result = await adapter.run(promptWithUntrustedConversationHistory({ prompt: text, history: session.history }, true), abort.signal, - async () => workMode === 'execute' ? 'once' : 'deny', { + async approval => workMode === 'execute' || isStoryGraphTool(approval.toolName ?? '') ? 'once' : 'deny', { workMode, images, sessionMcpServers, onEvent: async event => { if (event.type === 'text') await connection.sessionUpdate({ sessionId, update: { diff --git a/src/agent-daemon/image-tool-mcp.ts b/src/agent-daemon/image-tool-mcp.ts index e7dd07e8..0aa15558 100644 --- a/src/agent-daemon/image-tool-mcp.ts +++ b/src/agent-daemon/image-tool-mcp.ts @@ -5,6 +5,7 @@ import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/ import { CallToolRequestSchema, ListToolsRequestSchema, type CallToolResult } from '@modelcontextprotocol/sdk/types.js' import { z } from 'zod' import { imageToolInputSchema, imageToolName } from '../shared/image-generation-contracts' +import { storyGraphTools, type StoryGraphToolName } from '../shared/story-graph-tools' import { decodeRemoteImageToolMessage, encodeRemoteImageToolMessage, remoteImageToolMaximumBytes, remoteImageToolReplySchema, type remoteImageToolSchema @@ -44,12 +45,23 @@ export class AgentImageToolMcp { } const body: unknown = JSON.parse(Buffer.concat(chunks).toString('utf8')) const mcp = new McpServer({ name: 'goodbuddy-image', version: '1' }, { capabilities: { tools: {} } }) - mcp.setRequestHandler(ListToolsRequestSchema, async () => ({ tools: [{ - name: imageToolName, description: this.descriptor.description, - inputSchema: schema as { type: 'object' } - }] })) + mcp.setRequestHandler(ListToolsRequestSchema, async () => { + const available = this.descriptor.storyGraph ? await this.call({}, 'story_graph_list') : undefined + const graphEnabled = available?.content.some(part => part.type === 'text' && JSON.parse(part.text).available === true) + return { tools: [ + ...(this.descriptor.description ? [{ name: imageToolName, description: this.descriptor.description, inputSchema: schema as { type: 'object' } }] : []), + ...(graphEnabled ? storyGraphTools.map(tool => ({ name: tool.name, description: tool.description, + inputSchema: z.toJSONSchema(tool.inputSchema, { target: 'draft-7', io: 'input' }) as { type: 'object' }, + annotations: { readOnlyHint: true, destructiveHint: false } })) : []) + ] } + }) mcp.setRequestHandler(CallToolRequestSchema, async (call, extra) => { - if (call.params.name !== imageToolName) throw new Error('Unknown image tool') + const graphTool = storyGraphTools.find(tool => tool.name === call.params.name) + if (graphTool && this.descriptor.storyGraph) { + try { return await this.call(call.params.arguments ?? {}, graphTool.name) } + catch (error) { return { isError: true, content: [{ type: 'text', text: error instanceof Error ? error.message : 'Story Graph read failed' }] } } + } + if (call.params.name !== imageToolName || !this.descriptor.description) throw new Error('Unknown image tool') const input = imageToolInputSchema.parse(call.params.arguments) // Repeated delivery of an MCP request returns the same outcome, including unknown delivery. const key = `${typeof extra.requestId}:${extra.requestId}` @@ -87,8 +99,8 @@ export class AgentImageToolMcp { const reply = remoteImageToolReplySchema.parse(decodeRemoteImageToolMessage(payload)) const pending = this.pending.get(reply.callId) if (!pending) return - if (reply.error || !reply.result) pending.reject(new Error(reply.error ?? 'Image tool response has no result')) - else pending.resolve({ content: [{ type: 'text', text: JSON.stringify(reply.result) }] }) + if (reply.error || (!reply.result && !reply.storyGraphResult)) pending.reject(new Error(reply.error ?? 'Tool response has no result')) + else pending.resolve({ content: [{ type: 'text', text: JSON.stringify(reply.storyGraphResult ?? reply.result) }] }) } close(): void { @@ -100,7 +112,7 @@ export class AgentImageToolMcp { this.server?.close() } - private async call(input: z.infer): Promise { + private async call(input: Record, name?: StoryGraphToolName | 'story_graph_list'): Promise { if (this.closed) throw new Error('Image tool prompt has ended') const callId = randomUUID() let timer: NodeJS.Timeout | undefined @@ -110,7 +122,7 @@ export class AgentImageToolMcp { timer.unref() }) // Register the wait before sending; send failures must not cause a retry. - void this.send(encodeRemoteImageToolMessage({ callId, input })).catch(() => { + void this.send(encodeRemoteImageToolMessage({ callId, input, ...(name ? { name } : {}) })).catch(() => { this.pending.get(callId)?.reject(new Error('Image tool delivery may be unknown. Do not retry automatically.')) }) try { return await result } finally { diff --git a/src/agent-daemon/opencode-subagent-plugin.ts b/src/agent-daemon/opencode-subagent-plugin.ts index d5b0e934..934a0bc2 100644 --- a/src/agent-daemon/opencode-subagent-plugin.ts +++ b/src/agent-daemon/opencode-subagent-plugin.ts @@ -132,6 +132,9 @@ async function plugin(input?: { permission.push(...(child.data.permission ?? []).filter(rule => rule.permission !== '*')) } permission.push({ permission: 'goodbuddy_image_*', pattern: '*', action: 'deny' }) + if (route.imageToolName) { + permission.push({ permission: `${route.imageToolName}_story_graph_*`, pattern: '*', action: 'allow' }) + } if (route.workMode === 'execute' && route.imageToolName) { permission.push({ permission: `${route.imageToolName}_*`, pattern: '*', action: 'allow' }) } diff --git a/src/agent-daemon/runtime-acp-backend.test.ts b/src/agent-daemon/runtime-acp-backend.test.ts index 56dc497b..a25b842c 100644 --- a/src/agent-daemon/runtime-acp-backend.test.ts +++ b/src/agent-daemon/runtime-acp-backend.test.ts @@ -634,6 +634,36 @@ describe('RuntimeAcpBackend', () => { expect(fixture.lifecycle).not.toContain('bridge-listen') } finally { await fixture.backend.dispose() } }) + + it('binds Story Graph Ask discovery and reads to authenticated blob frames', async () => { + const fixture = harness() + const client = new Client({ name: 'graph-backend-test', version: '1' }) + try { + await open(fixture) + const accepted = await invoke(fixture, 'runtime/preparePrompt', fixture.preparation({ + imageTool: { channelId: 'graph-channel', channelEpoch: '1', storyGraph: true } + })) as { imageToolUrl: string } + await client.connect(new StreamableHTTPClientTransport(new URL(accepted.imageToolUrl))) + const listing = client.listTools() + await vi.waitFor(() => expect(fixture.blobSink).toHaveBeenCalledOnce()) + const reply = async (index: number, storyGraphResult: object) => { + const frame = fixture.blobSink.mock.calls[index]![0] + const call = remoteImageToolCallSchema.parse(decodeRemoteImageToolMessage(frame.payload)) + const payload = encodeRemoteImageToolMessage({ callId: call.callId, storyGraphResult }) + await fixture.backend.onBlobFrame({ header: { ...frame.header, direction: 'main-to-agent', payloadLength: payload.byteLength }, payload }, + { ...fixture.context, channelId: 'graph-channel' }) + return call + } + expect(await reply(0, { available: true })).toMatchObject({ name: 'story_graph_list' }) + expect((await listing).tools).toHaveLength(3) + const reading = client.callTool({ name: 'story_graph_search', arguments: { query: 'Decision' } }) + await vi.waitFor(() => expect(fixture.blobSink).toHaveBeenCalledTimes(2)) + expect(await reply(1, { items: [], coverage: { status: 'unknown' } })).toMatchObject({ name: 'story_graph_search', input: { query: 'Decision' } }) + expect(await reading).not.toHaveProperty('isError', true) + await invoke(fixture, 'runtime/completePrompt', { bindingId: 'binding-1', operationId: 'request-1', requestId: 'request-1' }) + await expect(fetch(accepted.imageToolUrl)).rejects.toThrow() + } finally { await client.close(); await fixture.backend.dispose() } + }) it.each([false, true])('allows mode changes only on a shared Session (shared=%s)', async (shared) => { const fixture = harness({ agentOwned: true, shareOwnedProcesses: shared, workMode: 'execute' }) try { diff --git a/src/agent-daemon/runtime-acp-backend.ts b/src/agent-daemon/runtime-acp-backend.ts index f542fb67..df85209a 100644 --- a/src/agent-daemon/runtime-acp-backend.ts +++ b/src/agent-daemon/runtime-acp-backend.ts @@ -1173,7 +1173,7 @@ export class RuntimeAcpBackend { } } } - if (preparation.imageTool && (preparation.workMode !== 'execute' || !this.#options.blobSink)) { + if (preparation.imageTool && ((preparation.imageTool.description && preparation.workMode !== 'execute') || !this.#options.blobSink)) { throw new RuntimeAcpBackendError('Image tools require Execute and a desktop transport', 'identity') } if (requestedModelBridgePolicy !== undefined) { diff --git a/src/agent-daemon/story-graph-integration.test.ts b/src/agent-daemon/story-graph-integration.test.ts new file mode 100644 index 00000000..d34928f9 --- /dev/null +++ b/src/agent-daemon/story-graph-integration.test.ts @@ -0,0 +1,115 @@ +// @vitest-environment node +import { randomUUID } from 'node:crypto' +import { Client } from '@modelcontextprotocol/sdk/client/index.js' +import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js' +import { afterEach, expect, it, vi } from 'vitest' +import { AssistantDatabase } from '../main/assistant/assistant-database' +import type { KnowledgeService } from '../main/knowledge/knowledge-service' +import { KnowledgeMcpGateway } from '../main/agent/knowledge-mcp-gateway' +import { ModelToolProvider } from '../main/agent/model-tool-provider' +import { AgentImageToolMcp } from './image-tool-mcp' +import { MainImageToolSession } from '../main/remote-agent/main-image-tool-session' +import type { RuntimeProtocolBinaryChannel, RuntimeProtocolBinaryFrame } from '../main/remote-agent/protocol-remote-runtime-channel' +import { storyGraphToolNames } from '../shared/story-graph-tools' + +const cleanups: Array<() => void | Promise> = [] +afterEach(async () => { for (const cleanup of cleanups.splice(0).reverse()) await cleanup() }) + +function fixture() { + const db = new AssistantDatabase(':memory:'); db.initialize(process.cwd()) + cleanups.push(() => db.close()) + const projectId = db.listProjects()[0]!.id + db.saveSupervisionResult({ request: { trigger: 'manual', scope: { kind: 'projects', projectIds: [projectId] }, timeRange: { from: '2026-09-01T00:00:00Z', to: '2026-09-30T00:00:00Z' } }, + evidence: [{ id: 'quote', sourceType: 'conversation', sourceId: 'original', title: 'Evidence', occurredAt: '2026-09-01T00:00:00Z', content: 'Decision A is revised to B, C remains only an option.' }], + output: { summary: 'Decision history', changeDigest: '', openItems: ['Not yet executed'], entities: [{ id: 'decision', label: 'Decision', description: 'Decision A revised to B', sourceReferenceIds: ['quote'] }], + events: [], relations: [], entityChanges: [] } }) + let enabled = true, assigned = true + const read = vi.fn(db.readStoryGraph.bind(db)) + const available = vi.fn(async () => enabled && assigned) + const gateway = new KnowledgeMcpGateway({} as KnowledgeService, { storyGraphService: { available, read } }) + cleanups.push(() => gateway.dispose()) + const wait = new AbortController() + const token = gateway.grant('graph', [], wait.signal, 'none', undefined, undefined, undefined, undefined, undefined, { projectId, runtimeTarget: 'model' })! + return { db, projectId, gateway, token, wait, read, available, + disable: () => { enabled = false }, unassign: () => { assigned = false } } +} + +async function client(url: string, token?: string) { + const connection = new Client({ name: 'graph-test', version: '1' }) + await connection.connect(new StreamableHTTPClientTransport(new URL(url), token ? { requestInit: { headers: { Authorization: `Bearer ${token}` } } } : undefined)) + cleanups.push(() => connection.close()) + return connection +} + +it.each(['local', 'remote'] as const)('%s HTTP MCP reads SQLite through Main and revokes discovery and old calls when disabled', async transport => { + const f = fixture() + let connection: Client + if (transport === 'local') { + await f.gateway.start() + connection = await client(f.gateway.getEndpoint()!, f.token) + } else { + // Real Agent HTTP adapter, binary messages and Main dispatcher; only the wire carrier is in-process. + const queue: RuntimeProtocolBinaryFrame[] = [] + let receive: ((frame: RuntimeProtocolBinaryFrame) => void) | undefined + let reject: ((error: Error) => void) | undefined + const channel: RuntimeProtocolBinaryChannel = { + channelId: randomUUID(), channelEpoch: '1', + send: async payload => adapter.onReply(payload), + receive: async () => queue.shift() ?? await new Promise((resolve, fail) => { receive = resolve; reject = fail }), + close: () => reject?.(new Error('closed')), onClose: () => () => {} + } + const adapter = new AgentImageToolMcp({ channelId: channel.channelId, channelEpoch: '1', storyGraph: true }, async payload => { + const frame = { payload, sequence: '1', consume: async () => {} } + if (receive) { const resolve = receive; receive = undefined; resolve(frame) } else queue.push(frame) + }) + const main = new MainImageToolSession(channel, undefined, f.wait.signal, f.gateway.bindRemoteStoryGraph(f.token)) + await adapter.start() + cleanups.push(() => { main.close(); adapter.close() }) + connection = await client(adapter.url!) + } + const tools = (await connection.listTools()).tools + expect(tools.map(tool => tool.name)).toEqual(storyGraphToolNames) + expect(tools.every(tool => tool.annotations?.readOnlyHint)).toBe(true) + const search = await connection.callTool({ name: 'story_graph_search', arguments: { query: 'Decision', page_size: 1 } }) + expect(search.isError).not.toBe(true) + const result = JSON.parse((search.content as { text: string }[])[0]!.text) + expect(result.resolved_scope).toEqual({ kind: 'projects', projectIds: [f.projectId] }) + expect(result.page.has_more).toBe(true) + const context = await connection.callTool({ name: 'story_graph_get_context', arguments: { object_ref: result.items[0].object_ref, mode: 'timeline' } }) + expect(context.isError).not.toBe(true) + const reference = (f.db.getSupervisionGraph({}).sources as { id: string }[])[0]!.id + const source = await connection.callTool({ name: 'story_graph_read_source', arguments: { source_reference_id: reference } }) + expect(JSON.stringify(source)).toContain('revised to B') + const unsupported = await connection.callTool({ name: 'story_graph_get_context', arguments: { object_ref: result.items[0].object_ref, mode: 'as_of' } }) + expect(unsupported.isError).toBe(true) + expect(JSON.stringify(unsupported)).toContain('unsupported_mode') + const before = f.read.mock.calls.length + f.disable() + expect((await connection.listTools()).tools).toEqual([]) + const blocked = await connection.callTool({ name: 'story_graph_search', arguments: { query: 'Decision', cursor: result.page.next_cursor, page_size: 1 } }) + expect(blocked.isError).toBe(true) + expect(f.read).toHaveBeenCalledTimes(before) +}) + +it.each(['ask', 'execute'] as const)('direct Model %s uses the same reader and runtime assignment gate', async workMode => { + const f = fixture() + const provider = new ModelToolProvider(process.cwd(), [], undefined, f.gateway) + cleanups.push(() => provider.dispose()) + const context = { conversationId: 'graph', workMode, knowledgeCapabilityToken: f.token } + expect((await provider.listTools(context, f.wait.signal)).filter(tool => tool.name.startsWith('story_graph_')).map(tool => tool.name)).toEqual(storyGraphToolNames) + const result = await provider.callTool('story_graph_search', { query: 'Decision' }, f.wait.signal, context) + expect(JSON.stringify(result)).toContain(f.projectId) + f.unassign() + expect((await provider.listTools(context, f.wait.signal)).some(tool => tool.name.startsWith('story_graph_'))).toBe(false) + await expect(provider.callTool('story_graph_search', { query: 'Decision' }, f.wait.signal, context)).rejects.toThrow('story_graph_unavailable') +}) + +it('rejects disabled in-flight delivery and cancellation before the synchronous read', async () => { + const f = fixture() + f.available.mockImplementationOnce(async () => { f.wait.abort(); return true }) + await expect(f.gateway.callStoryGraphTool(f.token, 'story_graph_search', { query: 'Decision' })).rejects.toThrow() + expect(f.read).not.toHaveBeenCalled() + const other = fixture() + other.read.mockImplementationOnce((...args) => { const result = other.db.readStoryGraph(...args); other.disable(); return result }) + await expect(other.gateway.callStoryGraphTool(other.token, 'story_graph_search', { query: 'Decision' })).rejects.toThrow('story_graph_unavailable') +}) diff --git a/src/main/agent/acp-remote-runtime.ts b/src/main/agent/acp-remote-runtime.ts index 8bc8bca7..d2ddb75a 100644 --- a/src/main/agent/acp-remote-runtime.ts +++ b/src/main/agent/acp-remote-runtime.ts @@ -1188,8 +1188,8 @@ export class AcpRemoteRuntime implements AgentRuntime { raw = await this.awaitOperation( context, '启动远端请求', - request.imageToolBinding - ? context.channel.preparePrompt(preparation, request.imageToolBinding, signal) + request.imageToolBinding || request.storyGraphBinding + ? context.channel.preparePrompt(preparation, request.imageToolBinding, signal, request.storyGraphBinding) : context.channel.preparePrompt(preparation), signal ) diff --git a/src/main/agent/continue-host-adapter.ts b/src/main/agent/continue-host-adapter.ts index df7e7516..0b7d5026 100644 --- a/src/main/agent/continue-host-adapter.ts +++ b/src/main/agent/continue-host-adapter.ts @@ -1270,9 +1270,7 @@ export class ContinueHostAdapter { ) } const capabilityServers = [ - ...(runOptions.workMode === 'execute' - ? runOptions.sessionMcpServers ?? [] - : []), + ...(runOptions.sessionMcpServers ?? []), ...(knowledgeCapability ? [ createLoopbackMcpServer( @@ -1340,7 +1338,7 @@ export class ContinueHostAdapter { ) } const retainedServers = - runOptions.workMode === 'ask' && Boolean(knowledgeCapability) + runOptions.workMode === 'ask' && Boolean(knowledgeCapability || runOptions.sessionMcpServers?.length) ? [] : servers.filter( (server) => @@ -1524,7 +1522,7 @@ export class ContinueHostAdapter { } if ( runOptions.workMode === 'ask' && - runOptions.knowledgeCapability + (runOptions.knowledgeCapability || runOptions.sessionMcpServers?.length) ) { for (const toolName of scopedReadToolNames) { args.push('--allow', toolName) diff --git a/src/main/agent/deepseek-harness-acp-e2e.test.ts b/src/main/agent/deepseek-harness-acp-e2e.test.ts index ecf2c4bd..bc46d127 100644 --- a/src/main/agent/deepseek-harness-acp-e2e.test.ts +++ b/src/main/agent/deepseek-harness-acp-e2e.test.ts @@ -43,6 +43,8 @@ import { defaultRuntimeSettings } from '../../shared/contracts' import type { ResolvedRuntimeSettings } from '../runtime-settings-store' import { AssistantDatabase } from '../assistant/assistant-database' import { ImageGenerationService } from './image-generation-service' +import { KnowledgeMcpGateway } from './knowledge-mcp-gateway' +import type { KnowledgeService } from '../knowledge/knowledge-service' vi.mock('electron', () => ({ nativeImage: {} })) @@ -101,6 +103,41 @@ it('generates and edits durable images through the controlled Harness Main proxy }, 60_000) const CREDENTIAL_REF = 'GOODBUDDY_HARNESS_MODEL_API_KEY' + +it('reads Story Graph through the controlled Harness ACP Main proxy in Ask and Execute, then removes it when disabled', async () => { + const root = await realpath(await mkdtemp(join(tmpdir(), 'goodbuddy-dsh-graph-'))) + const db = new AssistantDatabase(':memory:'); db.initialize(root) + let enabled = true + const projectId = db.listProjects()[0]!.id + const gateway = new KnowledgeMcpGateway({} as KnowledgeService, { storyGraphService: { + available: async () => enabled, read: db.readStoryGraph.bind(db) + } }) + const signal = new AbortController().signal + const token = gateway.grant('harness-graph', [], signal, 'none', undefined, undefined, undefined, undefined, undefined, + { projectId, runtimeTarget: 'deepseek-harness' })! + const inProcess = createInProcessLaunch(root, { stream(options) { + const prompt = latestUserText(options) + const tool = options.tools?.find(tool => tool.name === 'story_graph_search') + if (!enabled) { expect(tool).toBeUndefined(); return textResponse('DISABLED') } + expect(tool).toBeDefined() + if (!toolResultText(options, prompt)) return toolCall(prompt, 'story_graph_search', { query: 'Decision' }) + expect(toolResultText(options, prompt)).toContain(projectId) + return textResponse('GRAPH_READ') + } }) + const provider = new ModelToolProvider(root, [], undefined, gateway) + const runtime = new DeepSeekHarnessRuntime({ defaultWorkspace: root, baseUrl: 'https://chat.test/v1', model: 'fixture', + launch: inProcess.launch, credentialRefs: { [CREDENTIAL_REF]: 'fixture' }, toolProvider: provider, + initializationTimeoutMs: 20_000, promptTimeoutMs: 20_000, shutdownTimeoutMs: 5_000 }) + try { + for (const mode of ['ask', 'execute', 'disabled'] as const) { + enabled = mode !== 'disabled' + const events = await collect(runtime.run({ requestId: crypto.randomUUID(), conversationId: 'graph', prompt: mode, + workMode: mode === 'execute' ? 'execute' : 'ask', knowledgeCapabilityToken: token }, signal)) + expect(events.at(-1)?.type).toBe('done') + expect(JSON.stringify(events)).toContain(enabled ? 'GRAPH_READ' : 'DISABLED') + } + } finally { await runtime.dispose(); await provider.dispose(); await gateway.dispose(); db.close(); await rm(root, { recursive: true, force: true }) } +}, 60_000) const SKILL_CALL_ID = 'e2e-skill-call' const MCP_CALL_ID = 'e2e-mcp-call' const ASK_MCP_CALL_ID = 'e2e-ask-mcp-call' diff --git a/src/main/agent/deepseek-harness-runtime.ts b/src/main/agent/deepseek-harness-runtime.ts index 0e342605..393012b3 100644 --- a/src/main/agent/deepseek-harness-runtime.ts +++ b/src/main/agent/deepseek-harness-runtime.ts @@ -350,6 +350,14 @@ function proxyToolInputSchema( ReturnType >[number] ): Record { + if (isStoryGraphTool(tool.name)) { + const schema = structuredClone(tool.inputSchema) + const scope = (schema.properties as Record>).scope! + // Scope is a discriminated union: Global and projects are mutually exclusive. + scope.oneOf = scope.anyOf + delete scope.anyOf + return dshCompatibleMainInputSchema(schema) + } return isMainWebTool(tool) || tool.name === 'generate_image' ? dshCompatibleMainInputSchema(tool.inputSchema) : tool.inputSchema @@ -367,7 +375,7 @@ function boundedProxyToolCatalog( }> { const catalog = tools.filter( (tool) => - isMainWebTool(tool) || + isMainWebTool(tool) || isStoryGraphTool(tool.name) || (workMode === 'execute' && (tool.source === 'mcp' || tool.name === 'generate_image')) ) if (catalog.length > MAX_MCP_PROXY_TOOLS) { @@ -856,7 +864,7 @@ export class DeepSeekHarnessRuntime implements AgentRuntime { ) const catalog = boundedProxyToolCatalog(tools, context.workMode) this.proxyToolCatalogs.set(params.sessionId, tools.filter( - (tool) => tool.source === 'mcp' || isMainWebTool(tool) || tool.name === 'generate_image' + (tool) => tool.source === 'mcp' || isMainWebTool(tool) || isStoryGraphTool(tool.name) || tool.name === 'generate_image' )) return { tools: catalog } } @@ -897,7 +905,7 @@ export class DeepSeekHarnessRuntime implements AgentRuntime { const tool = tools.find( (candidate) => candidate.name === name && - (candidate.source === 'mcp' || candidate.name === 'generate_image' || + (candidate.source === 'mcp' || candidate.name === 'generate_image' || isStoryGraphTool(candidate.name) || isMainWebTool(candidate)) ) if (!tool) { @@ -907,7 +915,7 @@ export class DeepSeekHarnessRuntime implements AgentRuntime { } const isWebTool = isMainWebTool(tool) if ( - !isWebTool && + !isWebTool && !isStoryGraphTool(name) && (context.workMode !== 'execute' || !run.authorize) ) { throw new Error( @@ -937,7 +945,7 @@ export class DeepSeekHarnessRuntime implements AgentRuntime { .slice(0, 1_000)}` ) } - if (!isWebTool) { + if (!isWebTool && !isStoryGraphTool(name)) { const approval = run.toolProvider.getApproval( tool, @@ -1758,3 +1766,4 @@ export class DeepSeekHarnessRuntime implements AgentRuntime { } } import { imageToolDescriptionLimit } from '../../shared/image-generation-contracts' +import { isStoryGraphTool } from '../../shared/story-graph-tools' diff --git a/src/main/agent/goodbuddy-harness-control-plane.ts b/src/main/agent/goodbuddy-harness-control-plane.ts index 9b327982..14249266 100644 --- a/src/main/agent/goodbuddy-harness-control-plane.ts +++ b/src/main/agent/goodbuddy-harness-control-plane.ts @@ -922,7 +922,7 @@ export class GoodBuddyHarnessControlPlane { definition, dispose }) - if (MAIN_WEB_TOOL_NAMES.has(tool.name)) { + if (MAIN_WEB_TOOL_NAMES.has(tool.name) || isStoryGraphTool(tool.name)) { record.askToolDefinitions.set(tool.name, definition) } } @@ -1454,3 +1454,4 @@ export class GoodBuddyHarnessControlPlane { } } import { imageToolDescriptionLimit } from '../../shared/image-generation-contracts' +import { isStoryGraphTool } from '../../shared/story-graph-tools' diff --git a/src/main/agent/knowledge-mcp-gateway.ts b/src/main/agent/knowledge-mcp-gateway.ts index 77fbb9e4..be5deeac 100644 --- a/src/main/agent/knowledge-mcp-gateway.ts +++ b/src/main/agent/knowledge-mcp-gateway.ts @@ -1,4 +1,16 @@ import { randomBytes } from 'node:crypto' +import { isStoryGraphTool, storyGraphToolNames, type StoryGraphToolName } from '../../shared/story-graph-tools' +import type { RuntimeTarget } from '../../shared/capability-contracts' + +export type StoryGraphBinding = { projectId?: string; runtimeTarget: RuntimeTarget } +export type StoryGraphRemoteBinding = { + available(): Promise + call(name: StoryGraphToolName, input: unknown, signal: AbortSignal): Promise> +} +export type StoryGraphService = { + available(binding: StoryGraphBinding): Promise + read(name: StoryGraphToolName, input: unknown, projectId?: string, signal?: AbortSignal): Record +} import { knowledgeReferenceKey, toKnowledgeReference } from '../../shared/knowledge-reference' import { createServer, @@ -191,6 +203,7 @@ export type KnowledgeLibraryListItem = { } type Capability = { + storyGraph?: StoryGraphBinding imageToolBinding?: ImageToolBinding requestId: string libraryIds: readonly string[] @@ -242,6 +255,7 @@ type DownstreamMcpSession = { } export type KnowledgeMcpGatewayOptions = { + storyGraphService?: StoryGraphService capabilityTtlMs?: number maximumBodyBytes?: number now?: () => number @@ -364,6 +378,7 @@ async function readBoundedJson( } export class KnowledgeMcpGateway { + private readonly storyGraphService?: StoryGraphService private readonly capabilities = new Map() private readonly downstreamMcpSessions = new Map< string, @@ -407,6 +422,7 @@ export class KnowledgeMcpGateway { this.maximumBodyBytes = options.maximumBodyBytes ?? MAX_REQUEST_BODY_BYTES this.now = options.now ?? Date.now + this.storyGraphService = options.storyGraphService this.magicNotesDatabase = options.magicNotesDatabase this.configService = options.configService this.obsidianService = options.obsidianService @@ -466,7 +482,8 @@ export class KnowledgeMcpGateway { browserConversationId?: string, browserTabId?: BrowserTabId, browserUsageLease?: BrowserTabUsageLease, - obsidian?: { settings: ObsidianSettings; access: 'read' | 'write' } + obsidian?: { settings: ObsidianSettings; access: 'read' | 'write' }, + storyGraph?: StoryGraphBinding ): string | undefined { const effectiveMagicNotesAccess = this.magicNotesDatabase ? magicNotesAccess @@ -516,7 +533,8 @@ export class KnowledgeMcpGateway { effectiveMagicNotesAccess === 'none' && effectiveConfigAccess === 'none' && !effectiveBrowserConversationId && - !effectiveObsidian + !effectiveObsidian && + !(storyGraph && this.storyGraphService) ) { return undefined } @@ -529,6 +547,7 @@ export class KnowledgeMcpGateway { magicNotesAccess: effectiveMagicNotesAccess, configAccess: effectiveConfigAccess, obsidian: effectiveObsidian, + storyGraph: this.storyGraphService ? storyGraph : undefined, browserConversationId: effectiveBrowserConversationId, browserTabId: effectiveBrowserTabId, browserUsageLease: effectiveBrowserUsageLease, @@ -799,6 +818,7 @@ export class KnowledgeMcpGateway { getAvailableToolNames(token: string): GoodBuddyBuiltinToolName[] { const capability = this.getCapability(token) return [ + ...(capability.storyGraph ? storyGraphToolNames : []), ...(capability.imageToolBinding ? ['generate_image' as const] : []), ...(capability.libraryIds.length > 0 ? knowledgeToolNames @@ -1544,6 +1564,29 @@ export class KnowledgeMcpGateway { return { deleted: true, noteId: parsed.noteId } } + async isStoryGraphAvailable(token: string): Promise { + const binding = this.getCapability(token).storyGraph + return Boolean(binding && this.storyGraphService && await this.storyGraphService.available(binding)) + } + + bindRemoteStoryGraph(token: string): StoryGraphRemoteBinding | undefined { + if (!this.getCapability(token).storyGraph) return undefined + return { available: () => this.isStoryGraphAvailable(token), call: (name, input, signal) => this.callStoryGraphTool(token, name, input, signal) } + } + + async callStoryGraphTool(token: string, name: StoryGraphToolName, input: unknown, signal?: AbortSignal): Promise> { + const capability = this.getCapability(token) + const effectiveSignal = AbortSignal.any([capability.signal, capability.brokerController.signal, ...(signal ? [signal] : [])]) + effectiveSignal.throwIfAborted() + if (!await this.isStoryGraphAvailable(token)) throw new Error('story_graph_unavailable: Supervisor or runtime capability is disabled') + effectiveSignal.throwIfAborted() + const result = this.storyGraphService!.read(name, input, capability.storyGraph!.projectId, effectiveSignal) + effectiveSignal.throwIfAborted() + if (!await this.isStoryGraphAvailable(token)) throw new Error('story_graph_unavailable: Supervisor or runtime capability is disabled') + effectiveSignal.throwIfAborted() + return result + } + private async callScopedTool( token: string, name: ScopedDataToolName, @@ -1551,6 +1594,10 @@ export class KnowledgeMcpGateway { signal?: AbortSignal ): Promise> { switch (name) { + case 'story_graph_search': + case 'story_graph_get_context': + case 'story_graph_read_source': + return this.callStoryGraphTool(token, name, input, signal) case 'knowledge_list': return { libraries: this.listLibraries(token, input) } case 'knowledge_search': @@ -1651,7 +1698,8 @@ export class KnowledgeMcpGateway { token, extra.signal ) - const scopedTools = [...availableTools].flatMap( + const storyGraphAvailable = await this.isStoryGraphAvailable(token) + const scopedTools = [...availableTools].filter(name => !name.startsWith('story_graph_') || storyGraphAvailable).flatMap( (name): Tool[] => { const definition = scopedDataToolByName.get( name as ScopedDataToolName @@ -1724,6 +1772,10 @@ export class KnowledgeMcpGateway { async (call, extra) => { const name = call.params.name const input = call.params.arguments ?? {} + if (isStoryGraphTool(name)) { + try { return { content: [{ type: 'text' as const, text: JSON.stringify(await this.callStoryGraphTool(token, name, input, extra.signal)) }] } } + catch (error) { return { isError: true, content: [{ type: 'text' as const, text: error instanceof Error ? error.message : 'Story Graph read failed' }] } } + } if (name === 'generate_image') { const capability = this.getCapability(token) if (!capability.imageToolBinding) throw new Error('Image capability is unavailable') diff --git a/src/main/agent/model-tool-provider.test.ts b/src/main/agent/model-tool-provider.test.ts index b44e2f11..89540e23 100644 --- a/src/main/agent/model-tool-provider.test.ts +++ b/src/main/agent/model-tool-provider.test.ts @@ -1015,6 +1015,7 @@ describe('ModelToolProvider', () => { listLibraries: vi.fn(() => []), search: vi.fn(async () => []), searchMagicNotes: vi.fn(() => []), + isStoryGraphAvailable: vi.fn(async () => true), getAvailableToolNames: vi.fn(() => [...scopedDataToolByName.keys()]) } as unknown as KnowledgeMcpGateway const context = { @@ -1072,6 +1073,7 @@ describe('ModelToolProvider', () => { it('rechecks cached and refreshed MCP catalogs against each request scope without shrinking the cache', async () => { const gateway = { + isStoryGraphAvailable: vi.fn(async () => true), getAvailableToolNames: vi.fn(() => [...scopedDataToolByName.keys()]) } as unknown as KnowledgeMcpGateway const provider = new ModelToolProvider(await createWorkspace(), [createMcpServer(true)], @@ -1117,6 +1119,7 @@ describe('ModelToolProvider', () => { it('enforces the aggregate MCP limit when a cached catalog gains scoped tools', async () => { const gateway = { + isStoryGraphAvailable: vi.fn(async () => true), getAvailableToolNames: vi.fn(() => [...scopedDataToolByName.keys()]) } as unknown as KnowledgeMcpGateway mocks.client.listTools.mockResolvedValue({ tools: Array.from({ length: 40 }, (_, index) => ({ diff --git a/src/main/agent/model-tool-provider.ts b/src/main/agent/model-tool-provider.ts index efb97131..5dc8e0ec 100644 --- a/src/main/agent/model-tool-provider.ts +++ b/src/main/agent/model-tool-provider.ts @@ -1252,7 +1252,10 @@ export class ModelToolProvider implements ModelToolProviderLike { signal: AbortSignal ): Promise { signal.throwIfAborted() - const scopedTools = this.getScopedTools(context) + const grantedTools = this.getScopedTools(context) + const storyGraphAvailable = grantedTools.some(tool => tool.name.startsWith('story_graph_')) && context.knowledgeCapabilityToken && this.knowledgeGateway + ? await this.knowledgeGateway.isStoryGraphAvailable(context.knowledgeCapabilityToken) : false + const scopedTools = grantedTools.filter(tool => !tool.name.startsWith('story_graph_') || storyGraphAvailable) const imageTool = context.workMode === 'execute' ? await imageToolDefinition(context.imageToolBinding) : undefined const webTools = this.webSearchEnabled ? this.getWebSearchDefinitions() @@ -1406,6 +1409,12 @@ export class ModelToolProvider implements ModelToolProviderLike { ): Promise { signal.throwIfAborted() assertToolAuthorizedForWorkMode(name, context) + if (name.startsWith('story_graph_') && scopedDataToolByName.has(name as ScopedDataToolName)) { + if (!this.knowledgeGateway || !context.knowledgeCapabilityToken) throw new Error('Story Graph capability is unavailable') + return createTextToolResult(JSON.stringify(await this.knowledgeGateway.callStoryGraphTool( + context.knowledgeCapabilityToken, name as import('../../shared/story-graph-tools').StoryGraphToolName, argumentsValue, signal + ))) + } if (name.startsWith('obsidian_') && scopedDataToolByName.has(name as ScopedDataToolName)) { if (!this.knowledgeGateway || !context.knowledgeCapabilityToken) { throw new Error('Obsidian 工具授权不可用') diff --git a/src/main/agent/native-client-coordinator.ts b/src/main/agent/native-client-coordinator.ts index e68a075b..f4be64e2 100644 --- a/src/main/agent/native-client-coordinator.ts +++ b/src/main/agent/native-client-coordinator.ts @@ -101,7 +101,7 @@ export class NativeClientCoordinator { target: selected.target, profile: selected.target === 'deepseek-harness' ? selected.settings.deepseekHarnessModelProfile : selected.target === 'continue' ? selected.settings.continueModelProfile : selected.settings.opencodeModelProfile, workMode, skills, mcpServers, builtin, - libraries: conversation.knowledgeLibraryIds, magicNotes: application.magicNotesEnabled, obsidian, + libraries: conversation.knowledgeLibraryIds, magicNotes: application.magicNotesEnabled, supervisor: application.heartbeatEnabled, obsidian, node: application.localToolEnvironment.node, binary: selected.target === 'continue' ? selected.settings.continueBinaryPath : selected.target === 'opencode' ? selected.settings.opencodeBinaryPath : undefined, @@ -162,9 +162,11 @@ export class NativeClientCoordinator { const token = gateway.grant(requestId, builtin.includes('knowledge-base') ? conversation.knowledgeLibraryIds ?? [] : [], signal, builtin.includes('magic-notes') && application.magicNotesEnabled ? access : 'none', builtin.includes('goodbuddy-config') ? { access, workspacePath: space.rootPath } : undefined, - undefined, undefined, undefined, builtin.includes('obsidian') ? { settings: obsidian, access } : undefined) + undefined, undefined, undefined, builtin.includes('obsidian') ? { settings: obsidian, access } : undefined, + builtin.includes('story-graph') && application.heartbeatEnabled ? { projectId: project.id, runtimeTarget: selected.target } : undefined) const customToken = workMode === 'execute' ? gateway.grantCustomMcp(requestId, mcpServers, signal) : undefined - const endpoints = [token, customToken].flatMap((value, index) => value ? [{ name: `goodbuddy-${index}`, url: gateway.getEndpoint()!, headers: { Authorization: `Bearer ${value}` } }] : []) + const endpoints = [token, customToken].flatMap((value, index) => value ? [{ name: `goodbuddy-${index}`, url: gateway.getEndpoint()!, headers: { Authorization: `Bearer ${value}` }, + readOnlyTools: index === 0 && workMode === 'ask' ? gateway.getAvailableToolNames(value) : [] }] : []) if (selected.target === 'deepseek-harness') { const profile = selected.settings.deepseekHarnessModelProfile if (!profile) throw new Error('DS Web requires a text model connection') diff --git a/src/main/agent/native-terminal-client.test.ts b/src/main/agent/native-terminal-client.test.ts index 1ae6c4f6..6fb9151f 100644 --- a/src/main/agent/native-terminal-client.test.ts +++ b/src/main/agent/native-terminal-client.test.ts @@ -91,4 +91,18 @@ describe('native terminal client', () => { expect(config.mcp.tools.url).toBe('http://127.0.0.1:12345/mcp') expect(await readFile(new URL(config.plugin[0]), 'utf8')).toContain('execute') }) + + it('allows only the bound read tools through native OpenCode Ask permissions and execution hook', async () => { + const { client, input } = await fixture() + await client.open(1, { ...input, mcpServers: [{ name: 'goodbuddy-0', url: 'http://127.0.0.1:12345/mcp', headers: {}, + readOnlyTools: ['story_graph_search', 'story_graph_get_context', 'story_graph_read_source'] }] }) + const config = JSON.parse(launches[0]!.spawnSpec.env.OPENCODE_CONFIG_CONTENT!) + expect(config.mcp['goodbuddy-0']).toBeDefined() + const hooks = await (await import(/* @vite-ignore */ new URL(config.plugin[0]).href)).default() + for (const name of ['story_graph_search', 'story_graph_get_context', 'story_graph_read_source']) { + expect(config.permission[`goodbuddy-0_${name}`]).toBe('allow') + await expect(hooks['tool.execute.before']({ tool: `goodbuddy-0_${name}` })).resolves.toBeUndefined() + } + await expect(hooks['tool.execute.before']({ tool: 'goodbuddy-0_generate_image' })).rejects.toThrow('read-only') + }) }) diff --git a/src/main/agent/native-terminal-client.ts b/src/main/agent/native-terminal-client.ts index 182e48c0..7455eb06 100644 --- a/src/main/agent/native-terminal-client.ts +++ b/src/main/agent/native-terminal-client.ts @@ -32,7 +32,7 @@ export type NativeTerminalClientInput = { size?: TerminalSize skillPackages?: RuntimeSkillPackage[] /** Session-lived, Main-owned MCP endpoints, such as the existing knowledge gateway. */ - mcpServers?: Array<{ name: string; url: string; headers: Record }> + mcpServers?: Array<{ name: string; url: string; headers: Record; readOnlyTools?: readonly string[] }> } export type NativeTerminalClientOptions = { @@ -141,7 +141,9 @@ export class NativeTerminalClient { await mkdir(history, { recursive: true }) const origin = await proxy.listen() const skills = input.skillPackages ?? [] - const mcp = input.workMode === 'execute' ? input.mcpServers ?? [] : [] + const mcp = input.mcpServers ?? [] + const allowedContinueTools = [...continueReadTools, ...mcp.flatMap(server => server.readOnlyTools ?? [])] + const allowedOpenCodeTools = [...openCodeReadTools, 'skill', ...mcp.flatMap(server => (server.readOnlyTools ?? []).map(name => `${server.name}_${name}`))] if (input.runtime === 'continue') { // Reuse the pinned adapter's protocol, permission-order and Windows fixes. const adapter = new ContinueHostAdapter({ binaryPath: detection.path, @@ -151,7 +153,7 @@ export class NativeTerminalClient { const bundle = await readFile(bundlePath, 'utf8') const executionMarker = 'async function hti(e,t={parallelToolCallCount:1}){' if (bundle.split(executionMarker).length !== 2) throw new Error('Continue native tool execution entry point is incompatible') - await writeFile(bundlePath, bundle.replace(executionMarker, `${executionMarker}if(${JSON.stringify(input.workMode)}==="ask"&&!${JSON.stringify(continueReadTools)}.includes(e.name))throw new Error("GoodBuddy Ask mode is read-only");`)) + await writeFile(bundlePath, bundle.replace(executionMarker, `${executionMarker}if(${JSON.stringify(input.workMode)}==="ask"&&!${JSON.stringify(allowedContinueTools)}.includes(e.name))throw new Error("GoodBuddy Ask mode is read-only");`)) args[0] = prepared.entryPath const configured = input.settings.continueConfigPath.trim() ? await loadContinueConfig(input.settings.continueConfigPath.trim()) : {} @@ -172,7 +174,7 @@ export class NativeTerminalClient { Object.assign(env, { CONTINUE_GLOBAL_DIR: history, GOODBUDDY_DISABLE_CONTINUE_UPDATES: '1', CONTINUE_CLI_AUTO_UPDATED: '1', CONTINUE_CLI_ENABLE_TELEMETRY: '0', CONTINUE_METRICS_ENABLED: '0', CONTINUE_CLI_DISABLE_COMMIT_SIGNATURE: '1' }) args.push('--config', configPath) if (input.workMode === 'execute') args.push('--auto') - else args.push('--readonly', ...continueReadTools.flatMap(name => ['--allow', name]), '--exclude', '*') + else args.push('--readonly', ...allowedContinueTools.flatMap(name => ['--allow', name]), '--exclude', '*') } else { const configDirectory = join(temporary, 'config') if (this.options.bundledRuntimePaths.opencodeConfig) { @@ -184,11 +186,11 @@ export class NativeTerminalClient { loopbackOrigin: origin, supportsImageInput: selected.supportsImageInput, workMode: input.workMode }) const permission = input.workMode === 'execute' ? 'allow' : Object.fromEntries([ - ['*', 'deny'], ...openCodeReadTools.map(name => [name, 'allow']), ['skill', 'allow'] + ['*', 'deny'], ...allowedOpenCodeTools.map(name => [name, 'allow']) ]) // A tool hook keeps the launch mode fixed even when the TUI changes agents. const pluginPath = join(temporary, 'work-mode.mjs') - await writeFile(pluginPath, `export default async () => ({'tool.execute.before': async ({tool}) => { if (${JSON.stringify(input.workMode)} === 'ask' && !${JSON.stringify([...openCodeReadTools, 'skill'])}.includes(tool)) throw new Error('GoodBuddy Ask mode is read-only'); }});`, { mode: 0o600 }) + await writeFile(pluginPath, `export default async () => ({'tool.execute.before': async ({tool}) => { if (${JSON.stringify(input.workMode)} === 'ask' && !${JSON.stringify(allowedOpenCodeTools)}.includes(tool)) throw new Error('GoodBuddy Ask mode is read-only'); }});`, { mode: 0o600 }) Object.assign(env, { OPENCODE_CONFIG_DIR: configDirectory, OPENCODE_CONFIG_CONTENT: JSON.stringify({ ...config, permission, diff --git a/src/main/agent/remote-runtime-channel.ts b/src/main/agent/remote-runtime-channel.ts index 15c5a42d..fd822295 100644 --- a/src/main/agent/remote-runtime-channel.ts +++ b/src/main/agent/remote-runtime-channel.ts @@ -114,7 +114,8 @@ export interface RemoteRuntimeChannel { preparePrompt( preparation: RemotePromptOperationPreparation, imageToolBinding?: ImageToolBinding, - waitSignal?: AbortSignal + waitSignal?: AbortSignal, + storyGraphBinding?: import('./knowledge-mcp-gateway').StoryGraphRemoteBinding ): Promise /** Starts an Agent-owned ACP prompt exactly once for its stable operation. */ startOwnedPrompt?( diff --git a/src/main/agent/runtime.ts b/src/main/agent/runtime.ts index 0dcf81f9..53afd079 100644 --- a/src/main/agent/runtime.ts +++ b/src/main/agent/runtime.ts @@ -146,6 +146,7 @@ export type AgentExecutionRequest = Omit & { trustedInstructions?: string /** Main-process-only request-scoped authorization for built-in data tools. */ knowledgeCapabilityToken?: string + storyGraphBinding?: import('./knowledge-mcp-gateway').StoryGraphRemoteBinding imageToolBinding?: ImageToolBinding /** Main-process-only browser tab fixed for this request. */ browserTabId?: BrowserTabId diff --git a/src/main/assistant/assistant-database.ts b/src/main/assistant/assistant-database.ts index 68a97488..ac394a26 100644 --- a/src/main/assistant/assistant-database.ts +++ b/src/main/assistant/assistant-database.ts @@ -8742,6 +8742,10 @@ export class AssistantDatabase { } } + readStoryGraph(name: import('../../shared/story-graph-tools').StoryGraphToolName, input: unknown, projectId?: string, signal?: AbortSignal): Record { + return readStoryGraph(this.requireDatabase(), name, input, projectId, signal) + } + getSupervisionResult(id: string): { id: string; summary: string } | undefined { return this.requireDatabase().prepare('SELECT id, summary FROM supervision_results WHERE id = ?').get(id) as { id: string; summary: string } | undefined } @@ -11488,3 +11492,4 @@ export class AssistantDatabase { } import type { ImageOperation } from '../../shared/image-generation-contracts' import { SupervisionReviewStore, supervisionReviewMigration } from './supervision-review-store' +import { readStoryGraph } from './story-graph-reader' diff --git a/src/main/assistant/story-graph-reader.test.ts b/src/main/assistant/story-graph-reader.test.ts new file mode 100644 index 00000000..10c43c69 --- /dev/null +++ b/src/main/assistant/story-graph-reader.test.ts @@ -0,0 +1,138 @@ +// @vitest-environment node +import { randomUUID } from 'node:crypto' +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { DatabaseSync } from 'node:sqlite' +import { afterEach, expect, it } from 'vitest' +import { AssistantDatabase } from './assistant-database' +import type { StoredSupervisionResult } from './supervisor-service' +import type { StoryGraphToolName } from '../../shared/story-graph-tools' +import { readStoryGraph } from './story-graph-reader' + +const cleanups: Array<() => void | Promise> = [] +afterEach(async () => { for (const cleanup of cleanups.splice(0).reverse()) await cleanup() }) +type Page = { items: Array<{ object_ref: { type: string; id: string }; result_id: string | null; event_time: string | null; + content: string; source_reference_ids: string[]; validity: string }>; page: { next_cursor: string | null; has_more: boolean; total_count: number } } + +async function fixture() { + const root = await mkdtemp(join(tmpdir(), 'story-graph-')) + cleanups.push(() => rm(root, { recursive: true, force: true })) + const path = join(root, 'assistant.sqlite') + const db = new AssistantDatabase(path) + db.initialize(root) + cleanups.push(() => db.close()) + const sql = new DatabaseSync(path) + cleanups.push(() => sql.close()) + const project = db.listProjects()[0]! + const other = db.createProject({ name: 'Other', description: '', rootPath: root, defaultWorkMode: 'ask' }) + const messageId = randomUUID(), conversationId = randomUUID() + const body = 'Choose A. Then explicitly revise A to B because of measured latency. Option C is only a proposal. ' + const update = (content: string, projectId = project.id) => db.saveLocalConversations([{ header: { id: conversationId, projectId, title: 'Decision', updatedAt: 1 }, + messages: [{ id: messageId, role: 'user', content, createdAt: Date.parse('2026-09-01T00:00:00Z'), state: 'complete' }] }]) + update(body) + const request: StoredSupervisionResult['request'] = { trigger: 'manual', scope: { kind: 'projects', projectIds: [project.id] }, + timeRange: { from: '2026-09-01T00:00:00Z', to: '2026-09-30T00:00:00Z' } } + const save = (description: string, occurredAt: string, persistedId?: string) => { + const result: StoredSupervisionResult = { request, candidates: db.listSupervisionCandidates(request), evidence: [{ id: 'quote', + sourceType: 'conversation', sourceId: conversationId, title: 'Decision evidence', content: body, + occurredAt: '2026-09-01T00:00:00Z', locator: { source: `message:${messageId}`, projectId: project.id, conversationId, messageId, start: 0, end: Array.from(body).length, length: Array.from(body).length } }], + output: { summary: description, changeDigest: '', openItems: ['Execution still pending'], + entities: [{ id: 'choice', label: 'Decision', description, sourceReferenceIds: ['quote'], ...(persistedId ? { persistedId } : {}) }], + events: [{ title: description, description, occurredAt, eventType: 'decision', entityIds: ['choice'], sourceReferenceIds: ['quote'] }], + entityChanges: [{ entityId: 'choice', changeType: persistedId ? 'revised' : 'proposed', description, sourceReferenceIds: ['quote'] }], relations: [] } } + db.saveSupervisionResult(result) + return db.listSupervisionCandidates(request)[0]!.id + } + const read = (name: StoryGraphToolName, input: Record) => readStoryGraph(sql, name, input, project.id) + return { db, sql, read, save, project, other, request, update, body } +} + +it('retains A, explicit revision B and proposal C without latest-wins, with event-time order and complete nested pagination', async () => { + const f = await fixture() + const id = f.save('Decision A', '2026-09-01T00:00:00Z') + f.save('Decision B explicitly revises A; execution pending', '2026-09-03T00:00:00Z', id) + f.save('Decision C is an option, not a replacement', '2026-09-04T00:00:00Z', id) + f.save('Decision late-recorded discussion', '2026-09-02T09:00:00+09:00', id) + const input = { object_ref: { type: 'entity', id }, mode: 'timeline', page_size: 2, content_page_size: 71 } + let cursor: string | null = null + const fragments = new Map() + const eventTimes = new Map() + do { + const result = f.read('story_graph_get_context', { ...input, ...(cursor ? { cursor } : {}) }) as Page + for (const item of result.items) { + const key = `${item.object_ref.type}:${item.object_ref.id}:${item.result_id}` + fragments.set(key, (fragments.get(key) ?? '') + item.content) + if (item.event_time) eventTimes.set(item.object_ref.id, item.event_time) + expect(item.validity).toBe('unknown') + } + cursor = result.page.next_cursor + } while (cursor) + const data = [...fragments.values()].map(text => JSON.parse(text)) + expect(data.filter(item => item.canonical_label).map(item => item.description)).toEqual(expect.arrayContaining([ + 'Decision A', 'Decision B explicitly revises A; execution pending', 'Decision C is an option, not a replacement' + ])) + expect([...eventTimes.values()]).toEqual(['2026-09-01T00:00:00.000Z', '2026-09-02T00:00:00.000Z', '2026-09-03T00:00:00.000Z', '2026-09-04T00:00:00.000Z']) + expect(data.some(item => item.content?.includes('measured latency'))).toBe(true) + const search = f.read('story_graph_search', { query: 'Decision', object_types: ['event'], time_range: { from: '2026-09-02T09:00:00+09:00', to: '2026-09-02T00:00:00Z' } }) as Page + expect(search.items).toHaveLength(1) + expect(search.items[0]!.event_time).toBe('2026-09-02T00:00:00.000Z') +}) + +it('enforces exact scope, rejects as_of and stale/changed cursors, and never writes during reads', async () => { + const f = await fixture() + const id = f.save('Decision A', '2026-09-01T00:00:00Z') + const input = { query: 'Decision', page_size: 1 } + const first = f.read('story_graph_search', input) as Page + const before = f.sql.prepare('SELECT total_changes() AS n').get()!.n + f.sql.exec('PRAGMA query_only = ON') + expect(f.db.readStoryGraph('story_graph_search', input, f.other.id)).toMatchObject({ items: [], coverage: { status: 'unknown' } }) + expect(f.read('story_graph_search', { ...input, scope: { kind: 'global' } })).toMatchObject({ items: [] }) + expect(() => f.db.readStoryGraph('story_graph_get_context', { object_ref: { type: 'entity', id } }, f.other.id)).toThrow('scope_mismatch') + expect(() => f.read('story_graph_get_context', { object_ref: { type: 'entity', id: 'missing' } })).toThrow('object_not_found') + expect(() => f.db.readStoryGraph('story_graph_search', input)).toThrow('scope_required') + expect(() => f.read('story_graph_search', { ...input, as_of: '2026-09-01' })).toThrow('unsupported_mode') + expect(() => f.read('story_graph_get_context', { object_ref: { type: 'entity', id }, mode: 'as_of' })).toThrow('unsupported_mode') + expect(() => f.read('story_graph_search', { ...input, query: 'Other', cursor: first.page.next_cursor })).toThrow('invalid_cursor') + expect(f.sql.prepare('SELECT total_changes() AS n').get()!.n).toBe(before) + f.sql.exec('PRAGMA query_only = OFF') + f.db.applySupervisionEntityAction({ entityId: id, action: 'revise', description: 'Human confirmed preference' }) + expect(() => f.read('story_graph_search', { ...input, cursor: first.page.next_cursor })).toThrow('stale_cursor') + const abort = new AbortController(); abort.abort() + expect(() => f.db.readStoryGraph('story_graph_search', input, f.project.id, abort.signal)).toThrow() +}) + +it('pages Unicode snapshots, reads explicit current versions, and reports deletion and moved scope without substituting history', async () => { + const f = await fixture() + f.save('Decision A', '2026-09-01T00:00:00Z') + const source = f.sql.prepare('SELECT id FROM supervision_sources').get()! + f.sql.prepare('UPDATE supervision_sources SET content = ? WHERE id = ?').run('A\u{1f600}B\u{1f601}C', source.id!) + const input = { source_reference_id: source.id, page_size: 2 } + let cursor: string | null = null, text = '' + do { + const page = f.read('story_graph_read_source', { ...input, ...(cursor ? { cursor } : {}) }) as { content: string; page: { next_cursor: string | null } } + text += page.content; cursor = page.page.next_cursor + } while (cursor) + expect(text).toBe('A\u{1f600}B\u{1f601}C') + const current = f.read('story_graph_read_source', { source_reference_id: source.id, content_kind: 'current' }) + expect(current.content).toBe(f.body) + // Released incremental references can have messageId without the newer source key. + f.sql.prepare("UPDATE supervision_sources SET locator_json = json_remove(locator_json, '$.source') WHERE id = ?").run(source.id!) + expect(f.read('story_graph_read_source', { source_reference_id: source.id, content_kind: 'current' })).toMatchObject({ content: f.body }) + expect(f.read('story_graph_read_source', { source_reference_id: source.id, content_kind: 'version', source_version: current.source_version })).toMatchObject({ content: f.body, content_kind: 'version' }) + f.update('Changed original') + const currentPage = f.read('story_graph_read_source', { ...input, content_kind: 'current' }) as { page: { next_cursor: string } } + f.update('Changed again') + expect(() => f.read('story_graph_read_source', { ...input, content_kind: 'current', cursor: currentPage.page.next_cursor })).toThrow('stale_cursor') + expect(f.read('story_graph_read_source', { source_reference_id: source.id, content_kind: 'version', source_version: current.source_version })).toMatchObject({ error: 'historical_version_unavailable' }) + f.update('Moved original', f.other.id) + expect(() => f.read('story_graph_read_source', { source_reference_id: source.id, content_kind: 'current' })).toThrow('scope_mismatch') + expect(() => f.db.readStoryGraph('story_graph_read_source', { source_reference_id: source.id }, f.other.id)).toThrow('scope_mismatch') + f.sql.exec('DELETE FROM messages') + expect(f.read('story_graph_read_source', { source_reference_id: source.id, content_kind: 'current' })).toMatchObject({ error: 'current_source_unavailable' }) + expect(f.read('story_graph_read_source', { source_reference_id: source.id })).toMatchObject({ content: text, content_kind: 'snapshot' }) + f.sql.prepare('UPDATE supervision_sources SET source_type = ?, locator_json = ? WHERE id = ?').run('knowledge', JSON.stringify({ length: 999 }), source.id!) + expect(f.read('story_graph_read_source', { source_reference_id: source.id })).toMatchObject({ original_source_length: 'unknown', reviewed_source_length: 999 }) + f.sql.prepare('UPDATE supervision_sources SET locator_json = ? WHERE id = ?').run(JSON.stringify({ detail: 'x'.repeat(100_001) }), source.id!) + expect(() => f.read('story_graph_read_source', { source_reference_id: source.id })).toThrow('response_too_large') +}) diff --git a/src/main/assistant/story-graph-reader.ts b/src/main/assistant/story-graph-reader.ts new file mode 100644 index 00000000..bfab643c --- /dev/null +++ b/src/main/assistant/story-graph-reader.ts @@ -0,0 +1,236 @@ +import { createHash } from 'node:crypto' +import type { DatabaseSync } from 'node:sqlite' +import { z } from 'zod' +import type { HeartbeatScope } from '../../shared/assistant-contracts' +import { storyGraphContextSchema, storyGraphSearchSchema, storyGraphSourceSchema, type StoryGraphToolName } from '../../shared/story-graph-tools' +import type { SupervisionEvidence, SupervisionSummaryOutput } from '../../shared/supervision-contracts' + +type Row = Record +type Fact = { + object_ref: { type: string; id: string } + object_revision: string + result_id: string | null + scope: HeartbeatScope + event_time: string | null + review_generated_at: string | null + recorded_at: string | null + confirmation_state: string + validity: 'unknown' + source_reference_ids: string[] + data: Row +} +const digest = (value: unknown): string => createHash('sha256').update(JSON.stringify(value)).digest('hex') +const cursorSchema = z.object({ key: z.string(), revision: z.string(), index: z.number().int().nonnegative(), offset: z.number().int().nonnegative() }).strict() +const normalizeScope = (scope: HeartbeatScope): HeartbeatScope => scope.kind === 'global' ? scope : { kind: 'projects', projectIds: [...new Set(scope.projectIds)].sort() } +const timestamp = (value: unknown): string | null => typeof value === 'string' && Number.isFinite(Date.parse(value)) ? new Date(value).toISOString() : null +const bounded = (output: Row): Row => { + if (Buffer.byteLength(JSON.stringify(output)) > 100_000) throw new Error('response_too_large: retry with a smaller page_size') + return output +} + +// This is a projection of existing storage, not a second graph or a historical state store. +export function readStoryGraph(db: DatabaseSync, name: StoryGraphToolName, input: unknown, projectId?: string, signal?: AbortSignal): Record { + signal?.throwIfAborted() + if (input && typeof input === 'object' && ('as_of' in input || ('mode' in input && input.mode === 'as_of'))) throw new Error('unsupported_mode: as_of is not supported') + const args = name === 'story_graph_search' ? storyGraphSearchSchema.parse(input) + : name === 'story_graph_get_context' ? storyGraphContextSchema.parse(input) : storyGraphSourceSchema.parse(input) + const requested = args.scope ?? (projectId ? { kind: 'projects' as const, projectIds: [projectId] } : undefined) + if (!requested) throw new Error('scope_required: no current project; specify scope explicitly') + const scope = normalizeScope(requested) + if (scope.kind === 'projects') for (const id of scope.projectIds) { + if (!db.prepare("SELECT id FROM projects WHERE id = ? AND status = 'active'").get(id)) throw new Error('scope_unavailable: project does not exist or is inactive') + } + const base = { requested_scope: args.scope ?? 'current_project', resolved_scope: scope, read_at: new Date().toISOString(), + coverage: { status: 'unknown', semantic_completeness: 'unknown', unprocessed_sources: 'unknown', historical_reconstruction: false, + note: 'Exact stored scope only. Saved results and fragments do not establish complete source or semantic coverage.' } } + const key = digest({ name, ...args, scope, cursor: undefined }) + let cursor: z.infer | undefined + if (args.cursor) { + try { cursor = cursorSchema.parse(JSON.parse(Buffer.from(args.cursor, 'base64url').toString('utf8'))) } + catch { throw new Error('invalid_cursor') } + if (cursor.key !== key) throw new Error('invalid_cursor: repeat the original filters and page sizes') + } + const page = (revision: string, index: number, offset: number, more: boolean, count: number, total: number) => ({ + returned_count: count, total_count: total, has_more: more, complete: !more, + next_cursor: more ? Buffer.from(JSON.stringify({ key, revision, index, offset })).toString('base64url') : null, + limit_reason: more ? 'page_size' : null + }) + const checkRevision = (revision: string): void => { if (cursor && cursor.revision !== revision) throw new Error('stale_cursor: stored evidence changed; restart the query') } + const sameScope = (value: unknown): boolean => JSON.stringify(normalizeScope(JSON.parse(String(value)))) === JSON.stringify(scope) + if (name === 'story_graph_read_source') { + const sourceArgs = storyGraphSourceSchema.parse(input) + const source = db.prepare(`SELECT s.*, sr.scope_json, r.created_at AS generated_at FROM supervision_sources s + JOIN supervision_results r ON r.id = s.result_id JOIN supervision_runs sr ON sr.id = r.run_id WHERE s.id = ?`).get(sourceArgs.source_reference_id) + if (!source) throw new Error('source_not_found') + if (!sameScope(source.scope_json)) throw new Error('scope_mismatch') + const locator = source.locator_json ? JSON.parse(String(source.locator_json)) as Row : {} + const version = typeof locator.revision === 'string' ? locator.revision : 'unknown' + if (sourceArgs.content_kind === 'version' && !sourceArgs.source_version) throw new Error('source_version_required') + let body = String(source.content) + let returnedVersion = version + let location = locator + let originalLength: unknown = source.source_type === 'knowledge' ? 'unknown' : locator.length ?? 'unknown' + if (sourceArgs.content_kind !== 'snapshot') { + // Resolve only an exact saved locator. Never replace a missing historical original with a snapshot. + const sourceKey = typeof locator.source === 'string' ? locator.source + : source.source_type === 'conversation' && typeof locator.messageId === 'string' ? `message:${locator.messageId}` + : source.source_type === 'task' ? `task:${source.source_id}` : '' + const kind = sourceKey.slice(0, sourceKey.indexOf(':')) + const key = sourceKey.slice(sourceKey.indexOf(':') + 1) + const separator = key.lastIndexOf(':') + const current = sourceKey ? db.prepare(`SELECT body, locator, project_id, context + FROM supervision_review_current WHERE record_id = ? AND reference_index = ? AND type = ?`).get( + kind === 'knowledge' ? key.slice(0, separator) : key, + kind === 'knowledge' ? Number(key.slice(separator + 1)) : 0, + kind === 'message' ? 'conversation' : kind) : undefined + if (current && scope.kind === 'projects' && !scope.projectIds.includes(String(current.project_id))) throw new Error('scope_mismatch: source moved out of the requested scope') + const currentRevision = current ? createHash('sha256').update(String(current.context)).digest('hex') : undefined + if (!current || (sourceArgs.content_kind === 'version' && currentRevision !== sourceArgs.source_version)) { + return { ...base, error: sourceArgs.content_kind === 'version' ? 'historical_version_unavailable' : 'current_source_unavailable', + original_source_availability: current ? 'available' : 'unavailable', available_content_kinds: current ? ['snapshot', 'current'] : ['snapshot'] } + } + body = String(current.body) + returnedVersion = currentRevision! + location = JSON.parse(String(current.locator)) as Row + originalLength = source.source_type === 'knowledge' ? 'unknown' : Array.from(body).length + } + const content = Array.from(body) + const revision = digest({ source, body, returnedVersion, location }) + checkRevision(revision) + const start = cursor?.offset ?? 0 + if (start > content.length) throw new Error('invalid_cursor') + const end = Math.min(content.length, start + sourceArgs.page_size) + signal?.throwIfAborted() + return bounded({ ...base, source_reference_id: source.id, result_id: source.result_id, original_source_id: source.source_id, + source_type: source.source_type, content_kind: sourceArgs.content_kind, requested_version: sourceArgs.source_version ?? null, + content_extent: sourceArgs.content_kind === 'snapshot' ? 'saved_fragment' : source.source_type === 'knowledge' ? 'current_reference_fragment' : 'source_record', + source_version: returnedVersion, reference_version: version, differs_from_reference: version === 'unknown' ? 'unknown' : returnedVersion !== version, + object_revision: revision, source_location: location, availability: source.availability, + original_source_availability: sourceArgs.content_kind === 'snapshot' ? 'unknown' : 'available', source_record_time: source.occurred_at, event_time: null, + review_generated_at: source.generated_at, recorded_at: source.generated_at, + content: content.slice(start, end).join(''), position: { start, end, unit: 'unicode_code_point' }, + saved_fragment: { start: locator.start ?? 'unknown', end: locator.end ?? 'unknown', length: Array.from(String(source.content)).length }, + reviewed_source_length: locator.length ?? 'unknown', + original_source_length: originalLength, + page: page(revision, 0, end, end < content.length, end - start, content.length) }) + } + const facts: Fact[] = [] + const results = new Map() + const stories = db.prepare('SELECT * FROM story_lines ORDER BY id').all().filter(row => sameScope(row.scope_json)) + const add = (type: string, data: Row, result?: Row, sources?: string[]): void => { + facts.push({ object_ref: { type, id: String(data.id) }, object_revision: digest(data), result_id: result ? String(result.id) : null, scope, + event_time: type === 'event' ? timestamp(data.occurred_at) : null, + review_generated_at: result ? timestamp(result.created_at) : null, + recorded_at: result ? timestamp(result.created_at) : type === 'story_line' ? timestamp(data.created_at) : null, + confirmation_state: String(data.confirmation_state ?? 'automatic'), validity: 'unknown', + source_reference_ids: sources ?? (data.source_reference_ids_json ? JSON.parse(String(data.source_reference_ids_json)) : []), data }) + } + for (const story of stories) { + signal?.throwIfAborted() + add('story_line', story) + for (const row of db.prepare(`SELECT r.*, sr.time_range_json FROM supervision_results r JOIN supervision_runs sr ON sr.id = r.run_id WHERE sr.scope_json = ? ORDER BY r.id`).all(String(story.scope_json))) { + if (results.has(String(row.id))) continue + results.set(String(row.id), row) + const { graph_snapshot_json, ...summary } = row + add('summary', summary, row) + const snapshot = JSON.parse(String(graph_snapshot_json)) as { entities: Row[]; relations: Row[] } + for (const entity of snapshot.entities) add('entity', entity, row) + for (const relation of snapshot.relations) add('relation', relation, row) + const sources = db.prepare('SELECT * FROM supervision_sources WHERE result_id = ? ORDER BY id').all(String(row.id)) + for (const source of sources) add('source_reference', source, row, [String(source.id)]) + for (const change of db.prepare('SELECT * FROM supervision_entity_changes WHERE result_id = ? ORDER BY id').all(String(row.id))) add('entity_change', change, row) + // Full successful leaves retain descriptions that the navigation summary and identity projection can omit. + for (const leaf of db.prepare('SELECT id, output_json, evidence_json FROM supervision_review_batches WHERE run_id = ? ORDER BY id').all(String(row.run_id))) { + const output = JSON.parse(String(leaf.output_json)) as SupervisionSummaryOutput + const evidence = JSON.parse(String(leaf.evidence_json)) as SupervisionEvidence[] + const refs = new Map(evidence.map(item => [item.id, sources.filter(source => source.source_type === item.sourceType && + source.source_id === item.sourceId && source.content === item.content && source.locator_json === (item.locator ? JSON.stringify(item.locator) : null)).map(source => String(source.id))])) + const references = (ids: string[]) => [...new Set(ids.flatMap(id => refs.get(id) ?? []))] + add('summary', { id: `leaf:${leaf.id}`, summary: output.summary, change_digest: output.changeDigest, + open_items: output.openItems, content_kind: 'successful_leaf' }, row, references(evidence.map(item => item.id))) + // Persisted IDs are explicit identity evidence; local model IDs are namespaced to the saved leaf. + for (const entity of output.entities) add('entity', { id: `leaf:${leaf.id}:${entity.id}`, + canonical_label: entity.label, description: entity.description, persisted_entity_id: entity.persistedId ?? null, + content_kind: 'successful_leaf', leaf_id: leaf.id }, row, references(entity.sourceReferenceIds)) + for (const relation of output.relations) add('relation', { id: `leaf:${leaf.id}:${digest(relation)}`, + from_entity_id: `leaf:${leaf.id}:${relation.fromEntityId}`, to_entity_id: `leaf:${leaf.id}:${relation.toEntityId}`, + relation_type: relation.relationType, reason: relation.reason, content_kind: 'successful_leaf' }, row, references(relation.sourceReferenceIds)) + } + } + for (const entity of db.prepare('SELECT * FROM supervision_entities WHERE story_line_id = ? ORDER BY id').all(String(story.id))) add('entity', entity) + for (const relation of db.prepare('SELECT * FROM supervision_relations WHERE story_line_id = ? ORDER BY id').all(String(story.id))) add('relation', relation) + for (const event of db.prepare('SELECT * FROM supervision_events WHERE story_line_id = ? ORDER BY id').all(String(story.id))) { + const sources = db.prepare('SELECT source_id FROM supervision_event_sources WHERE event_id = ? ORDER BY source_id').all(String(event.id)).map(row => String(row.source_id)) + const entities = db.prepare('SELECT entity_id FROM supervision_event_entities WHERE event_id = ? ORDER BY entity_id').all(String(event.id)).map(row => String(row.entity_id)) + add('event', { ...event, entity_ids: entities }, results.get(String(event.result_id)), sources) + } + } + const revision = digest(facts) + checkRevision(revision) + const timed = args as z.infer + const matchesTime = (fact: Fact): boolean => !timed.time_range || Boolean(fact[timed.time_basis] && + Date.parse(fact[timed.time_basis]!) >= Date.parse(timed.time_range.from) && Date.parse(fact[timed.time_basis]!) <= Date.parse(timed.time_range.to)) + const metadata = { ...base, coverage: { ...base.coverage, published_result_count: results.size }, time_basis: timed.time_basis, + time_range: timed.time_range ? { from: timestamp(timed.time_range.from), to: timestamp(timed.time_range.to) } : null, + interpretation: 'Stored claims within the selected evidence scope; no inferred supersession, completion, or historical state.' } + const query = 'query' in args ? args.query.toLowerCase() : '' + let selected: Fact[] + if (name === 'story_graph_search') { + const search = storyGraphSearchSchema.parse(input) + selected = facts.filter(fact => (!search.object_types || search.object_types.includes(fact.object_ref.type as typeof search.object_types[number])) && JSON.stringify(fact.data).toLowerCase().includes(query)) + } else { + const context = storyGraphContextSchema.parse(input) + const targets = facts.filter(fact => fact.object_ref.type === context.object_ref.type && fact.object_ref.id === context.object_ref.id) + if (!targets.length) { + const tables = { story_line: 'story_lines', event: 'supervision_events', entity: 'supervision_entities', + entity_change: 'supervision_entity_changes', relation: 'supervision_relations', summary: 'supervision_results', source_reference: 'supervision_sources' } + const leafId = context.object_ref.id.startsWith('leaf:') ? context.object_ref.id.split(':')[1] : undefined + const exists = leafId ? db.prepare('SELECT id FROM supervision_review_batches WHERE id = ?').get(leafId) + : db.prepare(`SELECT id FROM ${tables[context.object_ref.type]} WHERE id = ?`).get(context.object_ref.id) + throw new Error(exists ? 'scope_mismatch: object is outside the requested scope' : 'object_not_found') + } + const related = facts.filter(fact => targets.includes(fact) || (context.object_ref.type === 'entity' && + (fact.data.entity_id === context.object_ref.id || fact.data.persisted_entity_id === context.object_ref.id || fact.data.from_entity_id === context.object_ref.id || fact.data.to_entity_id === context.object_ref.id || (Array.isArray(fact.data.entity_ids) && fact.data.entity_ids.includes(context.object_ref.id))))) + const targetSources = new Set(related.flatMap(fact => fact.source_reference_ids)) + const entityIds = new Set(related.flatMap(fact => [fact.data.entity_id, fact.data.from_entity_id, fact.data.to_entity_id, ...(Array.isArray(fact.data.entity_ids) ? fact.data.entity_ids : [])]).filter((id): id is string => typeof id === 'string')) + const targetResults = new Set(targets.flatMap(fact => fact.result_id ? [fact.result_id] : [])) + selected = facts.filter(fact => { + if (context.object_ref.type === 'story_line') return true + if (related.includes(fact) || (fact.object_ref.type === 'entity' && entityIds.has(fact.object_ref.id))) return true + if (context.object_ref.type === 'summary') return fact.result_id !== null && targetResults.has(fact.result_id) + return fact.source_reference_ids.some(id => targetSources.has(id)) + }) + } + const unknownExcluded = timed.time_range ? selected.filter(fact => !fact[timed.time_basis]).length : 0 + selected = selected.filter(matchesTime) + const score = (fact: Fact): number => { + if (name !== 'story_graph_search') return 0 + return Object.entries(fact.data).reduce((sum, [field, value]) => sum + (JSON.stringify(value).toLowerCase().includes(query) ? (['title', 'canonical_label', 'summary'].includes(field) ? 3 : 1) : 0), 0) + } + selected.sort((a, b) => score(b) - score(a) || (name === 'story_graph_get_context' && 'mode' in args && args.mode === 'timeline' + ? (a.event_time ?? '\uffff').localeCompare(b.event_time ?? '\uffff') : 0) || + a.object_ref.type.localeCompare(b.object_ref.type) || a.object_ref.id.localeCompare(b.object_ref.id) || (a.result_id ?? '').localeCompare(b.result_id ?? '')) + let index = cursor?.index ?? 0 + let offset = cursor?.offset ?? 0 + if (index > selected.length) throw new Error('invalid_cursor') + const items: Row[] = [] + for (; index < selected.length && items.length < args.page_size; index++) { + signal?.throwIfAborted() + const { data, ...fact } = selected[index]! + const text = Array.from(JSON.stringify(data)) + if (name === 'story_graph_search') { + const fields = Object.keys(data).filter(field => JSON.stringify(data[field]).toLowerCase().includes(query)) + items.push({ ...fact, matched_fields: fields, preview: text.slice(0, 400).join(''), preview_is_excerpt: true }) + } else { + const size = storyGraphContextSchema.parse(input).content_page_size + const end = Math.min(text.length, offset + size) + items.push({ ...fact, content: text.slice(offset, end).join(''), content_format: 'json_fragment', position: { start: offset, end, length: text.length, unit: 'unicode_code_point' } }) + if (end < text.length) { offset = end; break } + offset = 0 + } + // Fail explicitly rather than allowing a downstream runtime to truncate metadata. + if (Buffer.byteLength(JSON.stringify(items)) > 90_000) throw new Error('response_too_large: retry with a smaller page_size') + } + const output = { ...metadata, unknown_time_excluded_count: unknownExcluded, ...('mode' in args ? { mode: args.mode } : {}), items, page: page(revision, index, offset, index < selected.length, items.length, selected.length) } + return bounded(output) +} diff --git a/src/main/assistant/supervision-production.test.ts b/src/main/assistant/supervision-production.test.ts index 8e87f65e..d1af6f75 100644 --- a/src/main/assistant/supervision-production.test.ts +++ b/src/main/assistant/supervision-production.test.ts @@ -86,6 +86,12 @@ it('persists publication failure and resumes with saved navigation without model expect(batch.output.summary).toBe(summary) expect(batch.output.events[0]!.description).toBe(description) } + const calls = f.respond.mock.calls.length + expect(f.db.readStoryGraph('story_graph_search', { query: 'Retained source qualification', scope: { kind: 'global' }, object_types: ['event'] })) + .toMatchObject({ page: { total_count: 2 }, items: [expect.objectContaining({ source_reference_ids: [] }), expect.objectContaining({ source_reference_ids: [] })] }) + expect(f.db.readStoryGraph('story_graph_search', { query: 'Atlas', scope: { kind: 'global' }, object_types: ['entity'] })) + .toMatchObject({ page: { has_more: false } }) + expect(f.respond).toHaveBeenCalledTimes(calls) }) it('stops oversized runtime responses with a readable error while preserving resumable progress', async () => { diff --git a/src/main/capabilities/capability-service.test.ts b/src/main/capabilities/capability-service.test.ts index 46f8761a..f08b3c1d 100644 --- a/src/main/capabilities/capability-service.test.ts +++ b/src/main/capabilities/capability-service.test.ts @@ -165,6 +165,7 @@ describe('CapabilityService', () => { const stored = JSON.parse(await readFile(filePath, 'utf8')) delete stored.obsidian delete stored.builtinMcpServers.obsidian + delete stored.builtinMcpServers['story-graph'] stored.version = version if (version === 5) delete stored.builtinMcpServers['builtin-browser'] if (version === 1) { @@ -322,6 +323,7 @@ describe('CapabilityService', () => { await expect(service.getSnapshot()).resolves.toMatchObject({ builtinMcpServers: [ + { id: 'story-graph', enabled: true, assignments: ['model', 'opencode', 'continue', 'deepseek-harness'] }, { id: 'knowledge-base', enabled: true, @@ -366,19 +368,23 @@ describe('CapabilityService', () => { await expect( service.getEnabledBuiltinMcpServerIds('model') - ).resolves.toEqual(['knowledge-base', 'goodbuddy-config']) + ).resolves.toEqual(['story-graph', 'knowledge-base', 'goodbuddy-config']) await expect( service.getEnabledBuiltinMcpServerIds('opencode') - ).resolves.toEqual(['goodbuddy-config']) + ).resolves.toEqual(['story-graph', 'goodbuddy-config']) await expect( service.getEnabledBuiltinMcpServerIds('continue') ).resolves.toEqual([ + 'story-graph', 'goodbuddy-config', 'builtin-browser' ]) await expect( service.getEnabledBuiltinMcpServerIds('deepseek-harness') - ).resolves.toEqual([]) + ).resolves.toEqual(['story-graph']) + + await service.setBuiltinMcpServerAssignments('story-graph', ['deepseek-harness']) + await expect(service.getEnabledBuiltinMcpServerIds('model')).resolves.not.toContain('story-graph') const reloaded = new CapabilityService( filePath, @@ -388,6 +394,7 @@ describe('CapabilityService', () => { ) await expect(reloaded.getSnapshot()).resolves.toMatchObject({ builtinMcpServers: expect.arrayContaining([ + expect.objectContaining({ id: 'story-graph', assignments: ['deepseek-harness'] }), expect.objectContaining({ id: 'knowledge-base', assignments: ['model'] diff --git a/src/main/capabilities/capability-service.ts b/src/main/capabilities/capability-service.ts index 2fb54c51..b8e5c555 100644 --- a/src/main/capabilities/capability-service.ts +++ b/src/main/capabilities/capability-service.ts @@ -134,6 +134,7 @@ const legacyBuiltinMcpServerStatesSchema = z const builtinMcpServerStatesSchema = legacyBuiltinMcpServerStatesSchema.extend({ + 'story-graph': builtinMcpServerStateSchema.extend({ assignments: capabilityAssignmentsSchema }).default({ enabled: true, assignments: ['model', 'opencode', 'continue', 'deepseek-harness'] }), 'builtin-browser': builtinMcpServerStateSchema, obsidian: builtinMcpServerStateSchema.default({ enabled: false, @@ -317,6 +318,7 @@ function defaultBuiltinMcpServerStates( }) return { 'knowledge-base': defaultState(), + 'story-graph': { enabled: true, assignments: ['model', 'opencode', 'continue', 'deepseek-harness'] }, 'magic-notes': defaultState(), 'goodbuddy-config': defaultState(), obsidian: { ...defaultState(), enabled: false }, @@ -880,6 +882,7 @@ export class CapabilityService { version: 6, obsidian: { vaultPath: '' }, builtinMcpServers: { + 'story-graph': defaultBuiltinMcpServerStates()['story-graph'], obsidian: defaultBuiltinMcpServerStates().obsidian, ...legacy.builtinMcpServers, 'builtin-browser': { @@ -1750,7 +1753,7 @@ export class CapabilityService { assignments: CapabilityAssignments ): Promise { return this.updateBuiltinMcpServerState(serverId, { - assignments: builtinMcpAssignmentsSchema.parse(assignments) + assignments: (serverId === 'story-graph' ? capabilityAssignmentsSchema : builtinMcpAssignmentsSchema).parse(assignments) }) } @@ -2053,11 +2056,9 @@ export class CapabilityService { target: RuntimeTarget ): Promise { const runtime = runtimeTargetSchema.parse(target) - if (runtime === 'deepseek-harness') { - return [] - } const state = await this.load() return builtinMcpServerIdSchema.options.filter((id) => { + if (runtime === 'deepseek-harness' && id !== 'story-graph') return false const server = state.builtinMcpServers[id] if (!server.enabled || !server.assignments.includes(runtime)) { return false diff --git a/src/main/index.ts b/src/main/index.ts index 5e7ec0f2..a9edcade 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -976,6 +976,11 @@ if (hasSingleInstanceLock) { const startupKnowledgeGateway = new KnowledgeMcpGateway( startupKnowledgeService, { + storyGraphService: { + available: async ({ runtimeTarget }) => (await applicationSettingsStore.get()).heartbeatEnabled === true && + (await capabilityService.getEnabledBuiltinMcpServerIds(runtimeTarget)).includes('story-graph'), + read: (name, input, projectId, signal) => startupAssistantDatabase.readStoryGraph(name, input, projectId, signal) + }, magicNotesDatabase: startupAssistantDatabase, configService: goodbuddyConfigService, obsidianService, @@ -1436,6 +1441,11 @@ if (hasSingleInstanceLock) { resourcesPath: app.isPackaged ? process.resourcesPath : undefined, openExternal: (url) => shell.openExternal(url), createGateway: () => new KnowledgeMcpGateway(startupKnowledgeService, { + storyGraphService: { + available: async ({ runtimeTarget }) => (await applicationSettingsStore.get()).heartbeatEnabled === true && + (await capabilityService.getEnabledBuiltinMcpServerIds(runtimeTarget)).includes('story-graph'), + read: (name, input, projectId, signal) => startupAssistantDatabase.readStoryGraph(name, input, projectId, signal) + }, // This dedicated gateway lives until native-client disposal, not a chat request timeout. now: () => 0, magicNotesDatabase: startupAssistantDatabase, configService: goodbuddyConfigService, diff --git a/src/main/ipc.test.ts b/src/main/ipc.test.ts index c9d7bb62..4f23ab16 100644 --- a/src/main/ipc.test.ts +++ b/src/main/ipc.test.ts @@ -9211,6 +9211,32 @@ describe('registerIpcHandlers agent terminal state', () => { } }) + it.each(['model', 'opencode', 'continue', 'deepseek-harness'])('binds Story Graph to the current project for %s and omits it when Supervisor is off', async runtimeId => { + const received: AgentExecutionRequest[] = [] + const binding = { available: vi.fn(async () => true), call: vi.fn() } + const gateway = { grant: vi.fn(() => 'graph-capability'), getAvailableToolNames: vi.fn(() => ['story_graph_search', 'story_graph_get_context', 'story_graph_read_source']), + bindRemoteStoryGraph: vi.fn(() => binding), drainReferences: vi.fn(() => []), revoke: vi.fn() } + const harness = createHarness({ runtimeId, capability: 'chat', supportsToolExecution: true, + async *run(request: AgentExecutionRequest) { received.push(request); yield { requestId: request.requestId, type: 'done' } } + }, undefined, 'always', undefined, false, undefined, undefined, gateway, false, undefined, { + getEnabledBuiltinMcpServerIds: vi.fn(async () => ['story-graph']) + }) + try { + const projectId = '00000000-0000-4000-8000-000000000088' + for (const enabled of [true, false]) { + harness.getApplicationSettings.mockResolvedValue({ heartbeatEnabled: enabled }) + const requestId = crypto.randomUUID() + await harness.handler!(trustedEvent(harness.webContents), { requestId, projectId, conversationId: 'graph-grant', prompt: 'Continue work', workMode: 'ask', knowledgeLibraryIds: [] }) + await vi.waitFor(() => expect(harness.assistantDatabase.updateTaskStatus).toHaveBeenCalledWith(requestId, 'completed')) + } + expect(gateway.grant).toHaveBeenCalledExactlyOnceWith(expect.any(String), [], expect.any(AbortSignal), 'none', + undefined, undefined, undefined, undefined, undefined, { projectId, runtimeTarget: runtimeId }) + expect(received[0]).toMatchObject({ knowledgeCapabilityToken: 'graph-capability', storyGraphBinding: binding }) + expect(received[1]).not.toHaveProperty('storyGraphBinding') + expect(gateway.bindRemoteStoryGraph).toHaveBeenCalledOnce() + } finally { await harness.dispose() } + }) + it('grants read-only Magic Notes tools in Ask and write tools in Execute', async () => { const runtime = { runtimeId: 'model', diff --git a/src/main/ipc.ts b/src/main/ipc.ts index a9efff4a..ebc73582 100644 --- a/src/main/ipc.ts +++ b/src/main/ipc.ts @@ -450,6 +450,7 @@ async function grantScopedDataCapability(input: { magicNotesAccess: MagicNotesCapabilityAccess configAccess?: MagicNotesCapabilityAccess obsidian?: { settings: ObsidianSettings; access: 'read' | 'write' } + storyGraph?: import('./agent/knowledge-mcp-gateway').StoryGraphBinding workspacePath?: string browserConversationId?: string browserTabId?: BrowserTabId @@ -462,6 +463,7 @@ async function grantScopedDataCapability(input: { }): Promise { const enabledServers = new Set(input.enabledServers) const obsidian = enabledServers.has('obsidian') ? input.obsidian : undefined + const storyGraph = enabledServers.has('story-graph') ? input.storyGraph : undefined const libraryIds = enabledServers.has('knowledge-base') ? input.libraryIds : [] @@ -480,7 +482,7 @@ async function grantScopedDataCapability(input: { magicNotesAccess === 'none' && configAccess === 'none' && !browserConversationId && - !obsidian) + !obsidian && !storyGraph) ) { return { toolNames: [] } } @@ -506,7 +508,10 @@ async function grantScopedDataCapability(input: { authorizeApply: input.authorizeConfigApply } : undefined - const token = obsidian + const token = storyGraph + ? input.gateway.grant(input.requestId, libraryIds, input.signal, magicNotesAccess, config, + browserConversationId, browserTabId, browserUsageLease, obsidian, storyGraph) + : obsidian ? input.gateway.grant( input.requestId, libraryIds, @@ -2280,6 +2285,8 @@ export function registerIpcHandlers( ) : [] const notesCapability = await grantScopedDataCapability({ + storyGraph: requestRuntimeTarget && (await applicationSettingsStore?.get())?.heartbeatEnabled + ? { runtimeTarget: requestRuntimeTarget, projectId: schedule.projectId ?? undefined } : undefined, obsidian: enabledBuiltinMcpServers.includes('obsidian') ? { settings: await capabilityService.getObsidianSettings(), @@ -2397,7 +2404,8 @@ export function registerIpcHandlers( }), trustedInstructions, ...(knowledgeCapabilityToken - ? { knowledgeCapabilityToken } + ? { knowledgeCapabilityToken, ...(notesCapability.toolNames.includes('story_graph_search') + ? { storyGraphBinding: knowledgeGateway?.bindRemoteStoryGraph(knowledgeCapabilityToken) } : {}) } : {}), ...(notesCapability.browserTabId ? { browserTabId: notesCapability.browserTabId } @@ -3807,6 +3815,8 @@ export function registerIpcHandlers( : undefined const imageToolAvailable = Boolean(await imageToolBinding?.describe()) const scopedCapability = await grantScopedDataCapability({ + storyGraph: selectedRuntimeTarget && applicationSettings?.heartbeatEnabled + ? { runtimeTarget: selectedRuntimeTarget, projectId: enrichedRequest.projectId } : undefined, obsidian: enabledBuiltinMcpServers.includes('obsidian') ? { settings: await capabilityService.getObsidianSettings(), @@ -3873,7 +3883,8 @@ export function registerIpcHandlers( } : enrichedRequest const request: AgentExecutionRequest = knowledgeCapabilityToken - ? { ...baseRequest, knowledgeCapabilityToken } + ? { ...baseRequest, knowledgeCapabilityToken, ...(scopedCapability.toolNames.includes('story_graph_search') + ? { storyGraphBinding: knowledgeGateway?.bindRemoteStoryGraph(knowledgeCapabilityToken) } : {}) } : baseRequest if (scopedCapability.browserTabId) { request.browserTabId = scopedCapability.browserTabId diff --git a/src/main/remote-agent/main-image-tool-session.ts b/src/main/remote-agent/main-image-tool-session.ts index cf7407a7..0c38e6fa 100644 --- a/src/main/remote-agent/main-image-tool-session.ts +++ b/src/main/remote-agent/main-image-tool-session.ts @@ -12,8 +12,9 @@ export class MainImageToolSession { constructor( private readonly channel: RuntimeProtocolBinaryChannel, - private readonly binding: ImageToolBinding, - signal?: AbortSignal + private readonly binding: ImageToolBinding | undefined, + signal?: AbortSignal, + private readonly storyGraph?: import('../agent/knowledge-mcp-gateway').StoryGraphRemoteBinding ) { this.unsubscribe = channel.onClose(() => this.close()) const abort = (): void => this.close() @@ -45,9 +46,17 @@ export class MainImageToolSession { let reply: ReturnType try { this.wait.signal.throwIfAborted() - if (this.binding.context.workMode !== 'execute') throw new Error('Image tools are unavailable in Ask mode') - const result = await this.binding.call(call.input, call.callId, this.wait.signal) - reply = remoteImageToolReplySchema.parse({ callId: call.callId, result }) + if ('name' in call) { + if (!this.storyGraph) throw new Error('Story Graph capability unavailable') + const storyGraphResult = call.name === 'story_graph_list' + ? { available: await this.storyGraph.available() } + : await this.storyGraph.call(call.name, call.input, this.wait.signal) + reply = remoteImageToolReplySchema.parse({ callId: call.callId, storyGraphResult }) + } else { + if (!this.binding || this.binding.context.workMode !== 'execute') throw new Error('Image tools are unavailable in Ask mode') + const result = await this.binding.call(call.input, call.callId, this.wait.signal) + reply = remoteImageToolReplySchema.parse({ callId: call.callId, result }) + } } catch (error) { reply = { callId: call.callId, error: (error instanceof Error ? error.message : 'Image tool failed').slice(0, 4_000) } } diff --git a/src/main/remote-agent/protocol-remote-runtime-channel.test.ts b/src/main/remote-agent/protocol-remote-runtime-channel.test.ts index 3f21028f..2b0bba32 100644 --- a/src/main/remote-agent/protocol-remote-runtime-channel.test.ts +++ b/src/main/remote-agent/protocol-remote-runtime-channel.test.ts @@ -525,9 +525,17 @@ describe('ProtocolRemoteRuntimeChannel', () => { expect(describe).toHaveBeenCalledTimes(2) expect(fixture.client.blobBinaries[1]!.closed).toBe(true) expect(fixture.client.requests.at(-1)!.params).not.toHaveProperty('imageTool', expect.anything()) + const graph = { available: vi.fn(async () => true), call: vi.fn() } + await fixture.channel.preparePrompt(prepare('graph-ask', 'ask'), undefined, wait.signal, graph) + expect(fixture.client.requests.at(-1)!.params).toMatchObject({ imageTool: { storyGraph: true } }) + expect((fixture.client.requests.at(-1)!.params as { imageTool: object }).imageTool).not.toHaveProperty('description') + graph.available.mockResolvedValue(false) + await fixture.channel.preparePrompt(prepare('graph-disabled', 'ask'), undefined, wait.signal, graph) + expect(fixture.client.blobBinaries[2]!.closed).toBe(true) + expect(fixture.client.requests.at(-1)!.params).not.toHaveProperty('imageTool', expect.anything()) fixture.client.responder = () => { throw new Error('preparation response lost') } await expect(fixture.channel.preparePrompt(prepare('operation-4'), binding, wait.signal)).rejects.toThrow() - expect(fixture.client.blobBinaries[2]!.closed).toBe(true) + expect(fixture.client.blobBinaries[3]!.closed).toBe(true) } finally { await fixture.channel.close() } }) it('sends question replies on the authenticated control channel and rejects foreign bindings', async () => { diff --git a/src/main/remote-agent/protocol-remote-runtime-channel.ts b/src/main/remote-agent/protocol-remote-runtime-channel.ts index 15e81ea3..0d659cfe 100644 --- a/src/main/remote-agent/protocol-remote-runtime-channel.ts +++ b/src/main/remote-agent/protocol-remote-runtime-channel.ts @@ -588,21 +588,23 @@ export class ProtocolRemoteRuntimeChannel async preparePrompt( preparation: z.input, imageToolBinding?: ImageToolBinding, - waitSignal?: AbortSignal + waitSignal?: AbortSignal, + storyGraphBinding?: import('../agent/knowledge-mcp-gateway').StoryGraphRemoteBinding ): Promise< z.infer > { this.#imageTool?.close() this.#imageTool = undefined const description = preparation.workMode === 'execute' ? await imageToolBinding?.describe() : undefined + const storyGraph = await storyGraphBinding?.available() waitSignal?.throwIfAborted() let imageTool: z.infer['imageTool'] - if (description && imageToolBinding) { + if ((description && imageToolBinding) || storyGraph) { this.#assertCurrent() const binary = this.#state.client.allocateBinaryChannel?.({ kind: 'blob' }) if (!binary) throw new Error('Remote image tool transport is unavailable') - imageTool = { channelId: binary.channelId, channelEpoch: binary.channelEpoch, description } - this.#imageTool = new MainImageToolSession(binary, imageToolBinding, waitSignal) + imageTool = { channelId: binary.channelId, channelEpoch: binary.channelEpoch, ...(description ? { description } : {}), ...(storyGraph ? { storyGraph: true } : {}) } + this.#imageTool = new MainImageToolSession(binary, imageToolBinding, waitSignal, storyGraphBinding) } try { const parsed = remotePromptOperationPreparationSchema.parse({ ...preparation, imageTool }) diff --git a/src/renderer/src/RightAssistantSidebar.tsx b/src/renderer/src/RightAssistantSidebar.tsx index e8fae5da..a26e7bc5 100644 --- a/src/renderer/src/RightAssistantSidebar.tsx +++ b/src/renderer/src/RightAssistantSidebar.tsx @@ -255,8 +255,7 @@ function SupervisionCardContent({ const [source, setSource] = useState>() const [loading, setLoading] = useState(false) const [message, setMessage] = useState('') - const [contextPrompt, setContextPrompt] = useState() - const [question, setQuestion] = useState('') + const [draft, setDraft] = useState() const busy = useRef(false) const targetKey = target ? JSON.stringify(target) : undefined @@ -296,20 +295,19 @@ function SupervisionCardContent({ const preview = await window.goodbuddy.supervision.continueContext({ sourceId, resultId: result.id }) if (!mounted.current) return if (typeof preview.prompt !== 'string' || !preview.prompt.trim()) throw new Error(t('supervisor.discussion.empty')) - setContextPrompt(preview.prompt) - setQuestion(t('supervisor.discussion.defaultQuestion')) + setDraft(preview.prompt) } catch (error) { if (mounted.current) setMessage(error instanceof Error ? error.message : '继续讨论失败') } finally { busy.current = false; if (mounted.current) setLoading(false) } } const sendDiscussion = async (): Promise => { - if (!contextPrompt || !question.trim() || !activeConversationId || !onContinue || busy.current) return + if (!draft?.trim() || !activeConversationId || !onContinue || busy.current) return busy.current = true setLoading(true); setMessage('') try { - await onContinue(`${contextPrompt}\n\n${question}`, activeConversationId) + await onContinue(draft, activeConversationId) if (!mounted.current) return - setContextPrompt(undefined); setQuestion('') + setDraft(undefined) } catch (error) { if (mounted.current) setMessage(error instanceof Error ? error.message : '继续讨论失败') } finally { busy.current = false; if (mounted.current) setLoading(false) } } @@ -319,7 +317,7 @@ function SupervisionCardContent({

监督反馈

{onTogglePinned && } - +
{target &&

{pinned ? '已固定 · ' : ''}{target.type === 'conversation' ? `会话:${conversationTitle?.trim() || '未命名会话'}` : `任务:${taskTitle?.trim() || '未命名任务'}`}

} @@ -330,19 +328,18 @@ function SupervisionCardContent({
{onOpenSupervisionGraph && } - +
{source &&
{String(source.title ?? '来源')}

{String(source.content ?? source.error ?? '')}

{source.error !== undefined && {String(source.error)}} {source.contextType === 'conversation' && Boolean(source.conversationId) && onOpenConversation && }
} - {contextPrompt !== undefined &&
-

{t('supervisor.discussion.target', { title: conversationTitle?.trim() || '未命名会话' })}

-