diff --git a/README.md b/README.md index 01b7b06..15347fb 100644 --- a/README.md +++ b/README.md @@ -6,11 +6,11 @@ DeepSeek in minutes. Local small models turned into real productivity. MA is a terminal coding agent built around two practical promises: remote setup should be brainless, and local small models should become useful production tools. DeepSeek config is interactive and direct. LM Studio/Qwen gets long-context handling, tool hardening, model switching, and benchmark-driven fixes so small models can do real repo work. -`v0.2.0-alpha.1` supports LM Studio local models, DeepSeek official API, and Agora through MCP stdio. Agora is MA's local-first provider integration: it exposes real model-loading and MemoryPatch state instead of pretending memory was added to a prompt. +`v0.3.0` supports LM Studio local models, DeepSeek official API, and Agora through MCP stdio. Agora is MA's local-first provider integration: it exposes real model-loading and MemoryPatch state instead of pretending memory was added to a prompt. Website: https://zimoos.github.io/my-agent/ -Release: https://github.com/zimoos/my-agent/releases/tag/v0.2.0-alpha.1 +Release: https://github.com/zimoos/my-agent/releases/tag/v0.3.0 [Roadmap](ROADMAP.md) · [Changelog](CHANGELOG.md) · [Contributing](CONTRIBUTING.md) · [Discussions](https://github.com/zimoos/my-agent/discussions) @@ -132,6 +132,7 @@ Inside MA: ```text / show slash command suggestions /model switch model/profile with arrow keys +/memory open the project memory console Tab complete selected command Enter run selected slash command ESC ESC switch session @@ -144,6 +145,7 @@ User-facing slash commands: | Command | Purpose | | --- | --- | | `/model` | Open the model/profile picker | +| `/memory` | Manage named Memories, multi-memory mounts, multi-target intake, automatic policy, and rollback | | `/help` | Show user-facing commands | | `/clear` | Clear current conversation | | `/exit` | Exit MA | @@ -182,7 +184,16 @@ DeepSeek/deepseek-v4-flash MA can run Agora as a provider-owned MCP stdio subprocess instead of asking users to manage a local HTTP server. The TUI reports real provider stages such as local-model loading, memory mounting, and generation. -When the active provider is Agora, MA can expose verified MemoryPatch operations: mount, disable, internalize, roll back, and inspect state. MA treats a memory module as active only after Agora returns matching response metadata; it never fakes memory by injecting facts into a prompt. +When the active provider is Agora, users operate uniquely named, independently versioned Memories; MemoryPatch is the immutable version object. MA reports `mounted` only after a later Agora response returns the requested ordered Patch ids and an advanced PatchSet revision. It never fakes memory by injecting facts into a prompt. + +`/memory` can mount zero or more Memories per project or conversation and hot-swap them at the next request boundary without restarting the base model. One intake batch may mix a new Memory with increments to multiple existing Memories; the source is extracted once and each target reports completed/noop/review/conflict/failed independently. Automatic intake requires explicit targets and runs after 4 new user turns or about 2,000 pending tokens plus 60 seconds of idle time. Failed targets can be retried alone or explicitly abandoned without blocking input or adding transcript noise. + +Context Usage remains independent from MemoryPatch state: the TUI continues to show used/trigger/window/source from `agent.getContextUsage()`. Internalization never clears context, and compaction never claims to internalize memory. + +The MA portable release pins the exact Agora `0.2.0` Mach-O artifact. Agora npm user artifacts contain no `.py`, `.pyc`, `.js`, or source maps and require no login or device activation. +MA marks an npm/bundled runtime as verified only when its Developer ID signature, +published npm integrity, accepted notarization evidence, and platform manifest SHA +match `src/provider/agora-runtime-lock.json`; ad-hoc candidates remain development-only. ## Built-In Tools diff --git a/README.zh-CN.md b/README.zh-CN.md index 7d20cf4..c2caefd 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -5,11 +5,11 @@ DeepSeek 无脑配置,本地小模型也能变生产力。 MA 的核心不是“又接了一个模型”,而是两件事:远程模型配置必须无脑,本地小模型必须能做真实项目。DeepSeek 走交互式配置,LM Studio/Qwen 则通过长上下文、工具调用加固、模型切换和 benchmark 驱动修复,把小模型转化成能持续工作的生产力。 -`v0.2.0-alpha.1` 当前支持 LM Studio 本地模型、DeepSeek 官方 API,以及通过 MCP stdio 接入的 Agora。Agora 是 MA 的本地优先深度适配:它展示真实的模型加载和 MemoryPatch 状态,不会把记忆伪装成一段 prompt。 +`v0.3.0` 当前支持 LM Studio 本地模型、DeepSeek 官方 API,以及通过 MCP stdio 接入的 Agora。Agora 是 MA 的本地优先深度适配:它展示真实的模型加载和 MemoryPatch 状态,不会把记忆伪装成一段 prompt。 官网:https://zimoos.github.io/my-agent/ -发布页:https://github.com/zimoos/my-agent/releases/tag/v0.2.0-alpha.1 +发布页:https://github.com/zimoos/my-agent/releases/tag/v0.3.0 [路线图](ROADMAP.md) · [变更记录](CHANGELOG.md) · [参与贡献](CONTRIBUTING.md) · [Discussions](https://github.com/zimoos/my-agent/discussions) @@ -131,6 +131,7 @@ DeepSeek official -> Base URL -> credential name -> Keychain API key -> 发现 D ```text / 显示斜杠指令提示 /model 用上下键切换模型/profile +/memory 打开项目记忆控制台 Tab 补全当前选中的指令 Enter 执行当前选中的斜杠指令 ESC ESC 切换历史会话 @@ -143,6 +144,7 @@ ESC ESC 切换历史会话 | 指令 | 用途 | | --- | --- | | `/model` | 打开模型/profile 选择器 | +| `/memory` | 管理具名 Memory、多记忆挂载、多目标内化、自动策略和回滚 | | `/help` | 查看用户可用指令 | | `/clear` | 清空当前对话 | | `/exit` | 退出 MA | @@ -181,7 +183,16 @@ DeepSeek/deepseek-v4-flash MA 可以把 Agora 作为 provider 自己管理的 MCP stdio 子进程运行,不要求用户维护本地 HTTP 服务。TUI 会展示真实的本地模型加载、记忆挂载和生成阶段。 -当 Agora 是当前 provider 时,MA 会提供经过验证的 MemoryPatch 操作:挂载、停用、内化、回滚和状态查看。只有 Agora 响应 metadata 返回匹配的状态,MA 才会说明记忆已生效;不会把一段事实塞进 prompt 伪装成记忆。 +当 Agora 是当前 provider 时,用户操作的是名称唯一、可持续迭代的 Memory;MemoryPatch 是它的不可变版本。只有下一次 Agora 响应 metadata 返回相同的 ordered Patch ids 和更新后的 PatchSet revision,MA 才会显示 `mounted`;不会把一段事实塞进 prompt 伪装成记忆。 + +`/memory` 可在项目或单个会话中同时挂载 0~N 个 Memory,并在下一次请求边界热拔插,不重启基座。内化时可以在一个 batch 中混合“新建 Memory”和“增量到多个旧 Memory”;同一 source 只提取一次,各目标独立报告 completed/noop/review/conflict/failed。自动内化必须显式选择目标,默认在 4 个新增用户回合或约 2000 pending tokens、空闲 60 秒后运行;失败目标可单独重试或明确放弃,输入框和 transcript 不受影响。 + +Context Usage 与 MemoryPatch 是两条独立状态:底栏始终从 `agent.getContextUsage()` 显示 used/trigger/window/source;内化不会清空 context,compact 也不会冒充记忆内化。 + +正式 MA portable 包锁定 Agora `0.2.0` 的 Mach-O 平台制品。Agora npm 用户制品不包含 `.py/.pyc/.js/.map`;用户无需登录或机器授权即可运行。 +MA 只有在 Developer ID 签名、已发布 npm integrity、Apple 公证证据和平台 +manifest SHA 均与 `src/provider/agora-runtime-lock.json` 一致时才显示 verified; +ad-hoc 候选包只能用于开发联调。 ## 内置工具 diff --git a/docs/agora-vip-provider-requirements.md b/docs/agora-vip-provider-requirements.md index 524730c..4f04e68 100644 --- a/docs/agora-vip-provider-requirements.md +++ b/docs/agora-vip-provider-requirements.md @@ -17,7 +17,7 @@ MA 将 Agora 作为内部 VIP provider 适配:Agora 不再只是一个 OpenAI- 1. Agora provider 运行时 - `provider: "agora"` 必须使用 MCP stdio runtime。 - runtime readiness 仍以 `doctor`、`models_list`、`chat_complete` 为基础能力。 - - memory 能力只有在完整 memory MCP tools 存在时启用。 + - memory 使用 granular capability matrix;缺少目录、下载或 intake 工具时只降级对应功能,不能关闭 Agora chat。 - provider-owned Agora subprocess 必须在 MA 退出时关闭。 2. Agora 进度展示 @@ -27,9 +27,10 @@ MA 将 Agora 作为内部 VIP provider 适配:Agora 不再只是一个 OpenAI- - 第一次本地模型聊天时,TUI 不能只显示 `thinking`,要显示类似 `Agora · 加载本地模型 ...`。 - 如果 Agora 只提供阶段级进度,MA 只展示阶段,不伪造文件级、权重级百分比。 -3. Agora 底栏状态 - - 当 active provider 是 Agora 时,TUI 底栏展示 Agora 专属摘要。 - - 摘要至少包含:provider 标识、当前模型、memory status、已挂载 patch 数、Agora session 简写。 +3. Agora 状态层级 + - 第一行始终展示 provider、model 和 Context Usage(used/trigger/window/source)。 + - 第二行仅在 Agora 下展示具名 Memory、版本、`+N`、verified/pending/stale 和后台 intake activity。 + - session/binding/job/完整 patch id 只放在 `/memory status`,不再堆入主底栏。 - 该状态来自 runtime 当前 state 或 session meta 的 `providerState`。 - 普通 provider 不展示 Agora 专属 memory/session 信息。 @@ -37,6 +38,8 @@ MA 将 Agora 作为内部 VIP provider 适配:Agora 不再只是一个 OpenAI- - Session meta 保留 `providerState`,字段包括 `provider_id`、`agora_session_id`、`memory.status`、`profile_id`、`binding_id`、`active_memory_patch_ids`、`last_verified_at`。 - MA 只能从 Agora `chat_complete` 响应 metadata 更新 mounted 证据。 - mount、disable、internalize、rollback 必须验证下一次 `chat_complete` metadata 后才报告成功。 + - `/memory` 以具名 Memory 为第一层,提供多 Memory 挂载、新建/重命名、混合多目标内化、显式自动目标、历史和 CAS 回滚;Profile 仅作为后台 binding 实现。 + - 自动 intake 一次提交显式目标列表;completed/noop 不重复,review/conflict/failed 只重试未完成目标;后台任务不得禁用输入框或写入 transcript。 5. 普通 provider 回归边界 - DeepSeek、LM Studio、OpenAI-compatible 的请求参数、重试策略、stream parser、tool-call 解析保持不变。 diff --git a/docs/prd-ma-agora-memory-v2.md b/docs/prd-ma-agora-memory-v2.md new file mode 100644 index 0000000..c488e77 --- /dev/null +++ b/docs/prd-ma-agora-memory-v2.md @@ -0,0 +1,705 @@ +# PRD:MA × Agora Memory v2 接入改造 + +| 项目 | 内容 | +| --- | --- | +| 产品 | my-agent(MA) | +| 依赖 | Agora Memory Runtime v2 | +| 文档状态 | Ready for implementation | +| 目标版本 | MA 0.3.x 后续版本;Agora 以 capability handshake 为准 | +| 关联 | zimoos/my-agent#39、zimoos/agora#23、zimoos/agora PR #22 | + +## 1. 产品结论 + +MA 不再把 Agora 当作“带记忆的普通模型地址”,而应把它产品化为: + +> 一个可以运行本地模型,并让用户按会话挂载、组合、迭代和回滚具名记忆的 Agent 工作台。 + +用户面对的是“记忆”和“记忆版本”,不是 Profile、family、lineage、segment 等底层术语。Agora 负责记忆编译、版本、增量、运行时热切换和真实性;MA 负责用户意图、会话绑定、TUI 交互、自动调度和状态解释。 + +## 2. 背景与问题 + +现有 MA PR #40 已完成 Agora MCP stdio、本地模型目录/下载、Memory Console、Profile/Patch 选择、后台 intake 和 Context Usage,但仍建立在 v1 心智上: + +- 以 MemoryProfile 为主对象。 +- 一次只有一个 `writable_patch_family`。 +- 内化使用 `memory_intake_run` 和单 lineage advance。 +- 用户无法在一次内化中选择“新建 Memory”或多选已有 Memory。 +- 没有使用 Agora v2 的具名 Memory、batch intake、per-target 状态和 PatchSet revision。 +- 状态栏只显示 Profile 和 Patch 数量,无法让用户理解当前究竟挂载了哪些记忆。 + +Agora v2 已提供: + +- 具名 Memory CRUD 和数据库唯一性。 +- 不可变 MemoryPatch 版本和增量 segments。 +- 一次 source snapshot 扇出到多个 create/increment target。 +- 多 ModelDelta 同时挂载。 +- 单基座、请求边界热切换。 +- per-target completed/noop/review/conflict/failed。 +- `patchset_revision` 和真实 chat metadata 验证。 + +本 PRD 的任务是把 Agora 的底层能力转化为自然、低认知负担、可持续迭代的 MA 产品体验。 + +## 3. 产品目标 + +### 3.1 核心目标 + +1. 用户打开任意新会话时,可以选择 0~N 个具名 Memory,并且不同会话可以使用不同组合。 +2. 用户可以在对话过程中挂载或拔出任意兼容 Memory,不重启基座模型。 +3. 用户内化当前对话时,可以: + - 新建一个具名 Memory; + - 增量写入一个已有 Memory; + - 一次多选多个已有 Memory; + - 在同一个 batch 中混合“新建 + 增量”。 +4. 每个新 Memory 和输出 Patch 都有唯一名称;重名由 Agora 拒绝,MA 保留用户输入并允许修改后重试。 +5. 自动内化只处理尚未完成的对话增量;失败目标不能造成其他目标重复编译或漏记。 +6. 用户始终知道当前挂载了什么、是否真实生效、后台正在内化什么。 +7. Agora 记忆不污染其他 Provider,Context Usage 不因记忆操作丢失或被伪造。 + +### 3.2 成功指标 + +- 首次 Agora 使用无需理解 MCP、Profile、family 或 Patch id 即可完成模型下载和第一次对话。 +- 用户可在 3 次操作内完成“选择多个记忆并开始聊天”。 +- 用户可在一个面板内完成“新建/多选目标 → 确认范围 → 提交内化”。 +- 所有挂载成功状态均有真实 `chat_complete` metadata 证明。 +- Patch-only 切换不出现模型重新加载阶段。 +- 自动内化期间输入框持续可用,transcript 不插入进度噪声。 +- 普通 Provider 的请求、stream、tool calling、Context Usage 和状态栏无回归。 + +## 4. 非目标 + +- 不在 MA 重新实现 Memory 编译、gate、segment、CAS 或回滚算法。 +- 不把记忆事实注入 system prompt 模拟 Agora Memory。 +- 不支持生成到一半的 mid-token 热插拔;切换在下一次 chat 边界生效。 +- 不支持跨不同 base model 挂载不兼容 Patch。 +- 不引入账号、登录、机器绑定、许可证、云同步或 ZimoOS 依赖。 +- 不在 TUI 暴露完整 Patch id、binding id、batch id 等调试信息;它们进入详情页和 `/memory status`。 +- 不把旧 Agora `0.2.0` 版本号本身当作 v2 可用证明。 + +## 5. 用户与核心任务 + +### 5.1 目标用户 + +- 使用远程 API 模型、但希望在有本地模型时获得长期私有记忆的开发者。 +- 在多个项目、角色、工作上下文之间切换的 Agent 用户。 +- 没有能力维护数据库、向量库或微调流程,但希望记忆可控、可回滚的普通用户。 + +### 5.2 核心 Jobs to be Done + +- “我打开这个项目时,希望 Agent 自动带上这个项目的记忆。” +- “这次会话只需要产品记忆,不要带入私人偏好。” +- “把刚才的共识同时写入产品记忆和工程记忆。” +- “这是一类新记忆,帮我新建一个名字保存。” +- “刚才内化错了,回滚到上一个版本。” +- “我切走某个记忆后,不希望模型重载并卡住。” + +## 6. 产品对象与用户心智 + +| 用户术语 | 定义 | Agora 对应 | +| --- | --- | --- | +| 记忆 | 有唯一名称、可持续迭代的长期记忆模块 | MemoryRecord | +| 版本 | 某个记忆的一次不可变产物 | MemoryPatch | +| 挂载 | 让一个会话在下一次请求中使用指定版本 | active_memory_patch_ids | +| 记忆组合 | 当前会话挂载的多个记忆版本 | MemoryProfile + binding | +| 内化 | 把一段已确认对话增量编译进一个或多个记忆 | memory_intake_batch_run | +| 自动内化目标 | 自动内化时允许写入的具名记忆列表 | auto_intake_target_memory_ids | + +产品规则: + +- 主界面不把 Profile 当作第一层对象。 +- Profile 作为 MA 管理“项目默认组合、会话 override”的后台实现,可在高级详情中显示。 +- 列表优先显示 Memory 名称和版本,例如“Agora 产品记忆 · v4”,不显示原始 id。 +- 一个 Memory 同时只能有一个 current head,但历史版本都可查看和回滚。 +- 挂载多个 Memory 不代表它们互相合并;它们只是共同参与当前会话推理。 +- 内化到多个 Memory 时,同一 source 只提取一次,然后分别推进各自 lineage。 + +## 7. 产品原则 + +1. **开箱即用**:Agora chat 与 Memory v2 分层降级;记忆缺能力不能阻止普通聊天。 +2. **明确选择**:MA 不猜测内化目标,更不能选择第一个 mounted Patch。 +3. **真实状态**:配置成功不等于挂载成功;必须等待下一次 chat metadata。 +4. **后台执行**:下载和内化有 Activity,但不阻塞输入、不污染 transcript。 +5. **名称优先**:用户操作名称和版本,技术 id 只用于诊断。 +6. **可逆**:切换、内化和自动策略都有历史、失败恢复和回滚路径。 +7. **Provider 隔离**:只有 active provider 为 Agora 时才能操作和展示 Agora Memory。 + +## 8. 信息架构 + +### 8.1 主状态栏 + +第一行保持通用信息: + +```text +Provider: agora · Model: qwen3.6-35b-a3b-q4 · ctx: 12k/197k trigger · win 262k registry +``` + +第二行仅在 Agora 下出现,优先展示用户可读名称: + +```text +Memory: Agora产品@v4 +2 · mounted · auto: 产品, 工程 +``` + +状态规则: + +- 0 个 Patch:`Memory: 未挂载`。 +- 1 个 Patch:显示完整 Memory 名称和版本。 +- 多个 Patch:显示第一个名称、版本和 `+N`;完整列表进入 `/memory`。 +- 配置已提交但尚未经过 chat 验证:`pending next chat`。 +- metadata ids 与请求不一致:`stale`,不能显示 mounted。 +- 后台 intake:在行尾显示 `内化中 1/3`,不显示 batch id。 + +### 8.2 Memory Console + +按 `/memory` 打开,默认分为三层: + +```text +┌ Memory · 当前项目 my-agent ────────────────────────────────┐ +│ 已挂载 │ +│ ◉ Agora 产品记忆 v4 verified │ +│ ◉ 工程规范记忆 v7 verified │ +│ ○ 用户偏好记忆 v2 │ +│ │ +│ 自动内化目标 │ +│ ☑ Agora 产品记忆 ☑ 工程规范记忆 ☐ 用户偏好记忆 │ +│ │ +│ Activity: 正在内化 2 个目标 · 1 完成 / 1 编译中 │ +│ Space 挂载 · i 内化 · n 新建 · e 重命名 · h 历史 · Esc 返回│ +└────────────────────────────────────────────────────────────┘ +``` + +交互要求: + +- `Space` 多选挂载/拔出。 +- `Enter` 应用挂载组合,状态先进入 `pending next chat`。 +- `i` 打开内化目标选择器。 +- `n` 新建 Memory,不再新建一个用户可见的 Profile。 +- `e` 重命名 Memory。 +- `h` 查看当前 Memory 的版本历史和回滚。 +- 高级区才展示项目/会话 binding、Profile、Patch id 和 revision。 + +### 8.3 记忆高亮与流动颜色 + +已真实 mounted 的 Memory 名称使用动态高亮,但必须克制: + +- 默认在名称上使用低频 cyan→blue→magenta 流动高光,周期约 2 秒。 +- 动画只用于 `verified mounted`;pending、stale、failed 不使用。 +- generation、确认框、低性能终端或 `NO_COLOR`/reduced-motion 下停止动画,退化为静态 cyan bold。 +- 只更新状态栏/Memory Console 的局部区域,不触发 transcript 重绘。 +- 多个 Memory 不同时逐个动画;作为一个组合 badge 统一流动,避免视觉噪声。 + +## 9. 核心用户流程 + +### 9.1 首次使用 Agora + +1. MA 定位固定 Agora executable,启动 `agora mcp serve`。 +2. 执行 MCP initialize、list_tools、runtime_capabilities。 +3. 检查 base chat 能力:doctor、models_list、chat_complete。 +4. 检查 v2 memory tools 和五项 capability。 +5. Model Picker 展示白名单模型和本地状态。 +6. 模型缺失时提供一键下载和真实 progress。 +7. 下载完成后,用户可以“不使用记忆直接开始”或“选择已有 Memory”。 + +降级规则: + +- base chat 可用、v2 memory 不可用:允许 Agora chat,Memory Console 显示“当前 runtime 不支持 Memory v2”。 +- 工具名称存在但 capability 未开启:不显示 v2 操作。 +- 版本号相同但 schema/capability 不同:以 handshake 为准。 +- 开发环境允许命令覆盖,但必须显示 `unverified runtime`。 + +### 9.2 新会话选择记忆 + +绑定优先级固定为: + +```text +conversation override > project default > user default > no memory +``` + +行为: + +- 新会话创建时先解析项目默认组合。 +- 用户可在第一次发送消息前修改挂载列表。 +- 会话 override 不覆盖项目默认配置,只覆盖本会话。 +- 切换会话时立即恢复各自挂载组合和验证状态。 +- 非 Agora Provider 下不显示 Agora mounted 状态。 + +### 9.3 挂载和热拔插 + +1. 用户在 Console 多选 Memory 当前版本。 +2. MA 校验 base model 兼容性。 +3. MA 更新当前 binding 对应的 active patch ids。 +4. UI 显示 `pending next chat`,输入框继续可用。 +5. 下一次 chat 返回相同 ordered patch ids 和更高 PatchSet revision 后,状态变为 verified。 + +禁止行为: + +- 不因为 Patch 变化重启 Agora subprocess。 +- 不显示“重新加载模型”。 +- 不在 profile update 成功后立即声称 mounted。 +- 如果验证失败,保留旧 verified 组合并显示可重试错误。 + +### 9.4 新建和命名 + +- 新建 Memory 必须输入名称。 +- 输出 Patch 名称必须存在;MA 默认建议 `@v1`。 +- 增量版本默认建议 `@vN`,用户可编辑。 +- Agora 返回 `memory_name_conflict` 或 `memory_patch_name_conflict` 时: + - 不关闭输入框; + - 保留原输入; + - 高亮冲突字段; + - 提示用户修改后重试; + - 不自动追加随机数字。 + +### 9.5 手动内化 + +入口:`i`、`/memory internalize` 或 agent memory tool。 + +步骤: + +1. 展示本次 source range,例如“本会话第 12~28 条消息”。 +2. 用户选择一个或多个目标: + - `新建 Memory`:输入 Memory 名和输出版本名; + - `增量到已有 Memory`:多选 Memory,确认各自输出版本名。 +3. MA 展示摘要:一个 source、N 个目标,不展示实现细节。 +4. 用户确认后调用一次 `memory_intake_batch_run`。 +5. MA 轮询 `memory_intake_batch_get`,按目标独立展示状态。 +6. 只把 completed 的新 Patch 更新到当前挂载组合。 +7. noop 不生成新版本;review/conflict/failed 保持未挂载并可单独处理。 +8. 下一次 chat 验证 patch ids 和 revision 后报告最终成功。 + +同一 batch 的目标状态: + +| 状态 | 用户文案 | 后续动作 | +| --- | --- | --- | +| completed | 已生成新版本 | 可挂载并验证 | +| noop | 没有提取到可长期保存的信息 | 不重试,除非修改 source | +| review | 需要确认后再写入 | 保留任务,用户审阅 | +| conflict | 记忆已被其他任务更新 | 刷新 head,重新确认版本名后重试 | +| failed | 内化失败 | 只重试该目标 | + +### 9.6 自动内化 + +默认触发条件同时满足: + +- active provider 为 Agora。 +- runtime 具备完整 v2 capability。 +- 当前 Profile 开启 auto policy。 +- 明确配置至少一个 `auto_intake_target_memory_id`。 +- 自上次已完成 batch 后累计至少 4 个用户回合或约 2000 pending tokens。 +- 用户空闲 60 秒。 +- 当前无 generation、tool、确认框、模型切换、Memory 切换或其他 intake。 + +调度规则: + +- 每次自动任务固定一个 source range,并一次提交所有目标。 +- MA 在会话 meta 持久化 `last_auto_intake_message_end` 和当前 batch/target 状态。 +- completed/noop 目标视为完成;review/conflict/failed 不推进本轮全局自动 checkpoint。 +- 某个目标失败时,只重试失败目标,不重复提交成功目标。 +- 用户明确放弃失败目标后,才允许推进该轮 checkpoint。 +- 新加入自动目标默认只接收后续增量;用户可手动选择“用整个会话初始化该记忆”。 +- 自动内化不清空 context,不触发 compact,不改写 transcript。 + +### 9.7 历史与回滚 + +- 版本历史按 Memory 分组,而不是展示所有 Patch 平铺列表。 +- 每个版本显示:版本名、创建时间、source 摘要、状态、是否当前 head、是否已挂载。 +- 回滚使用 expected current head 做 CAS。 +- 回滚成功只改变 Memory head;当前会话是否切换到回滚版本,由用户确认。 +- 如果用户选择“回滚并挂载”,仍需下一次 chat 验证。 + +### 9.8 Provider 切换 + +- 从 Agora 切到其他 Provider:隐藏 Memory 行,保留 Agora 会话状态但标记 unavailable。 +- 切回 Agora:重新握手并恢复对应会话 binding,不沿用 stale mounted 状态。 +- 非 Agora Provider 收到记忆命令时,明确提示需要先切到 Agora;禁止 prompt 模拟。 + +## 10. 命令设计 + +| 命令 | 行为 | +| --- | --- | +| `/memory` | 打开 Memory Console | +| `/memory list` | 列出具名 Memory 和当前版本 | +| `/memory mount ` | 挂载一个或多个 Memory 当前版本 | +| `/memory unmount ` | 拔出指定或全部 Memory | +| `/memory new ` | 创建具名 Memory;没有 source 时允许空壳创建 | +| `/memory rename ` | 重命名 Memory | +| `/memory internalize` | 打开多目标内化选择器 | +| `/memory internalize --new ` | 新建并内化 | +| `/memory internalize --into ` | 多选已有 Memory 增量内化 | +| `/memory auto on --targets ` | 开启自动内化并指定目标 | +| `/memory auto off` | 关闭自动内化 | +| `/memory history ` | 查看版本历史 | +| `/memory rollback ` | CAS 回滚 | +| `/memory status` | 查看 ids、binding、batch、revision 和 capability 详情 | +| `/memory disable` | 当前 scope 不使用记忆 | + +命令、TUI 和 agent tool 必须调用同一个 Controller,禁止三套业务逻辑。 + +## 11. MA 状态模型 + +```ts +type AgoraMemoryV2State = { + providerId: 'agora'; + runtimeMode: 'v2' | 'legacy' | 'unavailable'; + sessionId?: string; + profileId?: string; + bindingId?: string; + mounted: Array<{ + memoryId: string; + memoryName: string; + patchId: string; + patchName: string; + version: string; + }>; + requestedPatchIds: string[]; + verifiedPatchIds: string[]; + patchsetRevision?: number; + status: 'unavailable' | 'disabled' | 'selecting' | 'pending' | 'mounted' | 'stale' | 'failed'; + autoTargetMemoryIds: string[]; + activeBatch?: { + batchId: string; + sourceStart: number; + sourceEnd: number; + targets: Array<{ + memoryId?: string; + name: string; + status: 'queued' | 'compiling' | 'completed' | 'noop' | 'review' | 'conflict' | 'failed'; + outputPatchId?: string; + error?: string; + }>; + }; + lastVerifiedAt?: string; +}; +``` + +状态来源: + +- Memory/版本目录:Agora MCP list/get tools。 +- requested:用户当前提交的选择。 +- verified:最近一次 `chat_complete` metadata。 +- revision:Agora runtime metadata。 +- Activity:batch get 和 progress notification。 +- Context Usage:始终来自 `agent.getContextUsage()`,不从 Agora Memory 推断。 + +## 12. Controller 改造 + +现有 `AgoraMemoryController` 升级为 v2,TUI、命令和 agent tool 共同复用: + +```text +capabilities() +memories.list/create/rename/get +memories.mount/unmount +memories.history/rollback +profiles.resolve/updateAutoTargets +intake.plan/start/get/retryTarget/abandonTarget +verification.applyChatMetadata +models.list/download/status +``` + +必须替换的 v1 主路径: + +- `writable_patch_family` 用户选择。 +- `memory_intake_run` 单目标主流程。 +- `memory_lineage_advance` 由 MA 手工推进 lineage。 +- 根据 patch family 推断主记忆/overlay。 +- 仅用 Profile 名称表达用户记忆。 + +保留为兼容层: + +- Agora v2 flag 关闭时,旧单目标 memory tool 可以继续工作。 +- legacy mode 只提供“单记忆内化”,并明确标记能力受限。 +- 任何 v2 功能都必须由 tool + runtime capability 双重确认。 + +## 13. Agora MCP 映射 + +| 产品动作 | MCP | +| --- | --- | +| 能力握手 | runtime_capabilities + list_tools | +| 创建/列出/重命名 Memory | memories_create/list/get/rename | +| 多目标内化 | memory_intake_batch_run/get | +| 挂载组合 | memory_profiles_create/update + bindings | +| 自动目标 | memory_profiles_update.auto_intake_target_memory_ids | +| 版本列表 | memory_patch_versions | +| 回滚 | memories_rollback | +| 真实挂载验证 | chat_complete metadata + patchset_revision | + +v2 必需 capability: + +- `named_memories` +- `multi_target_intake` +- `incremental_segments` +- `multi_model_delta_mount` +- `request_boundary_hot_swap` + +注意:increment target 字段以 MCP schema 为准,使用 `expected_parent_patch_id`,不能使用旧文档中的 `expected_head_patch_id`。 + +## 14. Context Usage + +- Context Usage 与 Agora Memory 是两个独立系统。 +- 所有 Provider、所有终端宽度持续显示 used/trigger/window/source。 +- 内化成功不清空 context。 +- Context compact 不推进 Memory checkpoint。 +- 模型切换分别刷新 context capability 与 Memory/Patch 兼容性。 +- 记忆通过零外部上下文运行,不代表 MA context used 归零。 + +## 15. 错误与恢复 + +| 场景 | MA 行为 | +| --- | --- | +| v2 capability 缺失 | chat 可用,Memory v2 禁用并展示升级提示 | +| 名称冲突 | 保留输入,定位冲突字段,允许重试 | +| head conflict | 刷新 Memory,展示“已被其他任务更新” | +| 某 target failed | 其他成功结果保留,只重试失败目标 | +| Patch 不兼容 | 不更新 requested 组合,旧 mounted 状态保留 | +| 下一次 chat 未验证 Patch | 状态 stale,不声称成功 | +| Agora subprocess 退出 | Memory unavailable,保留可恢复状态,不影响其他 Provider | +| 用户切换 Profile/会话 | 使用 revision/CAS,旧后台任务不得覆盖新选择 | + +## 16. 隐私与安全 + +- 内化前清晰展示 source 范围;大范围历史或敏感内容必须确认。 +- Memory 数据和模型权重放在可写 user data 目录,不放入不可变 app bundle。 +- MA 不读取或重写 Agora Patch artifact 内容。 +- 日志默认不记录对话原文、Memory 内容或 tool 参数。 +- binary package 必须通过 manifest SHA、签名和协议握手;版本号相同但 hash/capability 不同视为不同 runtime。 +- `MA_AGORA_COMMAND` 仅用于开发覆盖,TUI 显示 unverified。 + +## 17. 发布与兼容策略 + +### 17.1 开发联调 + +- 在独立 MA worktree 开发。 +- 在 Agora PR #22 未发布前,使用源码 v2 MCP 或重新构建的本地二进制。 +- 不使用当前本机旧 schema 2 的全局 `0.2.0` 作为 v2 验收对象。 + +### 17.2 正式发布 + +- Agora PR #22 先合并并发布最终平台包。 +- MA 使用 exact package version、npm integrity、manifest SHA 和 capability handshake。 +- 如果最终 v2 仍发布为 0.2.0,必须以最终 manifest SHA 区分本机旧构建;推荐避免同版本不同能力的分发歧义。 +- MA portable 包内置相同固定 Agora artifact。 + +## 18. 实施阶段 + +### Phase 1:契约与 Controller + +- capability handshake。 +- Memory/版本类型。 +- batch intake 和 per-target 状态。 +- metadata/revision verification。 +- legacy compatibility adapter。 + +### Phase 2:TUI 与命令 + +- 以 Memory 为中心重构 Console。 +- 多挂载、命名、重试、历史、回滚。 +- 状态栏名称摘要和 verified/pending/stale。 +- 动态高亮与 reduced-motion 降级。 + +### Phase 3:自动内化 + +- 明确目标列表。 +- source range 和会话 checkpoint。 +- idle trigger。 +- target 独立重试/放弃。 +- 后台 Activity。 + +### Phase 4:真实验收与发布 + +- 源码 v2 MCP E2E。 +- packaged Agora E2E。 +- 远程 Provider 回归。 +- PTY/visual/窄终端。 +- 固定版本与 hash。 + +## 19. 验收 Case + +### Case 1:首次使用 + +- clean data root 启动 Agora。 +- doctor/capability 成功。 +- 白名单模型缺失时一键下载并显示真实进度。 +- 下载后无需配置 Profile 即可开始无记忆聊天。 + +### Case 2:不同会话不同组合 + +- 会话 A 挂载“产品@v3 + 用户偏好@v2”。 +- 会话 B 只挂载“工程@v5”。 +- 交替对话,metadata 和回答无串扰。 + +### Case 3:多 Patch 热拔插 + +- 无 Patch → A → A+B+C → B+C → 无 Patch。 +- TUI 不出现模型重载阶段。 +- 每次下一 chat 的 ordered ids 和 revision 正确。 + +### Case 4:唯一命名 + +- 创建重复 Memory 名被拒绝。 +- 创建重复 Patch 输出名被拒绝。 +- 输入框不关闭,用户修改后成功。 + +### Case 5:多目标内化 + +- 一个 source 同时:新建 A、增量 B、增量 C。 +- source 只提交一次。 +- A completed、B noop、C conflict 分别展示。 +- 只挂载 A 的新版本;B 无版本;C 可单独重试。 + +### Case 6:自动内化 + +- 配置两个 auto target。 +- 达到 4 用户回合/约 2000 tokens,空闲 60 秒后启动。 +- 输入框可继续使用,transcript 无进度文本。 +- 成功/失败目标按规则推进或保留 checkpoint。 + +### Case 7:回滚 + +- Memory 从 v4 回滚到 v2。 +- CAS 冲突不会覆盖新的 head。 +- “回滚并挂载”后经下一 chat 验证。 + +### Case 8:Context Usage + +- Agora/远程 Provider、宽/窄终端都显示 used/trigger/window/source。 +- 内化、挂载、回滚、compact 后数据源和语义保持独立。 + +### Case 9:Provider 隔离 + +- 从 Agora 切到 DeepSeek/OpenAI-compatible。 +- Memory UI 不泄漏 mounted 状态。 +- stream、tool calling、retry、Context Usage 不回归。 + +### Case 10:旧 runtime + +- 使用 schema 2、无 v2 tools 的 Agora。 +- MA 允许基础 chat,Memory v2 明确不可用。 +- 不因同为 `0.2.0` 而错误开启新交互。 + +## 20. 关闭标准 + +- MA 不再以 `writable_patch_family` 作为 Memory Console 主交互。 +- TUI、命令、agent tool 全部复用同一个 v2 Controller。 +- 具名 Memory、多挂载、多目标内化、自动目标、回滚、revision 验证全部可用。 +- 所有挂载成功都有真实 chat metadata 证据。 +- Patch-only 切换不重启 Agora、不显示模型重新加载。 +- MA build、全量测试、PTY、visual、真实 MCP、packaged E2E 通过。 +- DeepSeek、LM Studio、OpenAI-compatible 等普通 Provider 回归通过。 +- Context Usage 始终存在且不与 Memory 生命周期混淆。 +- Agora 最终 runtime version、integrity、manifest SHA 和 capability 已锁定。 + +在以上标准全部满足前,不得仅以“Agora #23 已关闭”或“MA PR #40 已存在”宣称 MA × Agora 产品体验完成。 + +## 21. 当前证据 + +MA 当前实现: + +- `src/provider/agora.ts` 的主内化路径仍是 `memory_intake_run`、`writable_patch_family`、`memory_lineage_advance`。 +- `src/cli/components/MemoryConsole.tsx` 仍以 Profile、writable family 和 overlay 为用户第一层心智。 +- `src/cli/App.tsx` 的自动内化要求唯一 writable family。 +- `src/cli/utils/commands.ts` 仍提供 `--into ` 单目标命令。 +- `StatusBar`、MCP subprocess、模型目录/下载、project/conversation binding、Context Usage 和后台 Activity 已存在,可继续复用。 + +Agora v2 当前证据: + +- Agora #23 已关闭,PR #24/#25/#26 已进入 Agora PR #22 集成分支。 +- 源码 MCP stdio 已验证具名 Memory、batch intake 和 v2 capability 全部存在。 +- 核心 registry/intake/runtime v2 测试已复跑 20/20。 +- Agora 提交的真实证据覆盖 35B 单基座、A/B/C Patch 热切换、双 session 无串扰、resident compile、旧库恢复和 packaged MCP。 +- 当前本机全局 Agora `0.2.0` 仍是 registry schema 2,缺少 v2 tools;不能作为本 issue 的验收 runtime。 + +## 22. 复用与替换计划 + +### 22.1 继续复用 + +- Agora MCP stdio 启动、进程退出和 progress 管道。 +- Model Picker、白名单目录、下载和状态查询。 +- MemoryProfile/binding 作为后台 scope 绑定机制。 +- `providerState` 持久化框架。 +- StatusBar 第一行 Context Usage。 +- Memory Console 的 modal、键盘导航和 Activity 区域。 +- 命令注册和 agent tool 暴露框架。 +- 现有 PTY、provider runtime、普通 Provider 和 Context Usage 测试基础。 + +### 22.2 必须替换 + +- 以 Profile 为中心的用户界面 → 以具名 Memory 为中心。 +- 唯一 writable family → 显式多目标 Memory 列表。 +- 单目标 intake + MA 手工 lineage advance → Agora batch intake + per-target result。 +- family 推断主记忆/overlay → Memory 当前 head + ordered mounted PatchSet。 +- 配置成功即近似成功 → 下一次 chat ids + revision 真实验证。 + +### 22.3 Redline + +完成后,v2 主路径不得再调用 `memory_intake_run`、依赖 `writable_patch_family` 或由 MA 调用 `memory_lineage_advance`。这些符号只允许存在于明确标记的 legacy compatibility adapter 和对应测试中。 + +## 23. 实施清单 + +### Contract / Controller + +- [ ] 增加 runtime_capabilities 握手和五项 v2 capability gate。 +- [ ] 增加 Memory、Patch version、batch、target、PatchSet revision 类型。 +- [ ] 增加 memories list/create/get/rename/rollback Controller 方法。 +- [ ] 增加 batch plan/start/get/retry/abandon 方法。 +- [ ] 增加 auto_intake_target_memory_ids 读写。 +- [ ] 将 chat metadata 统一映射到 requested/verified/revision 状态。 +- [ ] 保留 legacy adapter,但不能让 legacy 状态伪装成 v2。 + +### TUI / Commands + +- [ ] Memory Console 以具名 Memory 为第一层,Profile 移入高级详情。 +- [ ] 支持多选挂载、拔出和不同 conversation 组合。 +- [ ] 支持新建、重命名、版本历史和 CAS 回滚。 +- [ ] 支持新建/增量混合的多目标内化表单。 +- [ ] 名称冲突保留输入并允许原地重试。 +- [ ] StatusBar 显示 Memory 名称、版本、+N、verified/pending/stale 和 Activity。 +- [ ] 实现 mounted badge 流动高光及 NO_COLOR/reduced-motion 降级。 +- [ ] 重写 `/memory` 命令并确保与 TUI、agent tool 共用 Controller。 + +### Automatic intake + +- [ ] 以明确 auto target ids 替换 writable family。 +- [ ] 持久化 source range、batch 和 target 状态。 +- [ ] 实现 4 回合/约 2000 tokens + idle 60 秒触发。 +- [ ] 失败目标单独重试;未解决失败前不推进整轮 checkpoint。 +- [ ] 自动任务不阻塞输入、不写 transcript、不触发 context compact。 + +### Verification / Release + +- [ ] 使用 Agora v2 源码 MCP 完成确定性 contract E2E。 +- [ ] 使用最终 packaged Agora 完成真实 E2E。 +- [ ] 验证 Patch-only 切换期间 MA 不重启 subprocess、不显示 model reload。 +- [ ] 验证内化期间只存在一个 Agora resident base,不启动第二个 compiler runtime。 +- [ ] 执行全量单测、PTY、visual、窄终端和真实模型流程。 +- [ ] 执行 DeepSeek、LM Studio、OpenAI-compatible 回归。 +- [ ] 锁定最终 package version、integrity、manifest SHA 和 capability。 + +## 24. Parent Scope Status + +`zimoos/my-agent#39`: **partial**。 + +本 issue 完成后,#39 的 MA 产品交互和 Agora v2 接入部分才可视为完成。以下父范围仍不由本 issue 自动完成: + +- Agora PR #22 合并到 main。 +- Developer ID 签名、公证和 Gatekeeper 证据。 +- 最终 npm 平台包发布。 +- MA 最终 release 合并、发布和 #39 关闭。 + +现有 MA PR #40 不是废弃实现:MCP runtime、模型流程、Context Usage、binding、Activity 和基础 TUI 可复用;但它的 v1 memory contract 不能作为本 issue 已完成证据。 + +## 25. Acceptance Mapping + +| Acceptance criterion | Code or system path | Validation/proof | Old path status | Notes | +| --- | --- | --- | --- | --- | +| v2 能力准确启用/降级 | `src/provider/agora.ts`, MCP startup | schema 2 与 schema 3 双 runtime contract test | old version-only gate removed | 版本号不能替代 capability | +| 具名 Memory 成为用户主对象 | `MemoryConsole.tsx`, controller types, commands | PTY + visual + create/rename E2E | Profile-first UI removed | Profile 仅高级详情 | +| 不同会话挂载不同多 Memory | binding resolution, session meta, controller | 两会话交替真实 chat,ids/revision 无串扰 | single global selection removed | conversation override 优先 | +| Patch 请求边界热拔插 | mount flow, providerState verification | 无 Patch→A→A+B+C→B+C→无 Patch | config-only success removed | 不重启 subprocess | +| 唯一命名与重试 | intake form, typed MCP errors | Memory/Patch 重名 PTY + contract test | silent suffix unchanged by design: prohibited | 保留用户输入 | +| 新建/多目标增量内化 | batch controller, Activity, profile update | create A + increment B/C 单 snapshot E2E | `memory_intake_run` bypassed | 每目标独立状态 | +| 自动内化不重复不漏记 | scheduler, session meta, target retry | success/noop/review/conflict/failed matrix | writable-family scheduler removed | 放弃失败需显式操作 | +| 回滚可验证 | history UI, memories_rollback, chat verification | CAS conflict + rollback-and-mount E2E | family replacement rollback removed | head 与 mounted 分离 | +| mounted 真实性 | chat metadata mapper, StatusBar | requested 与 returned ids/revision 不一致测试 | profile-update-only proof removed | stale 不得显示 mounted | +| 动态高亮可访问 | StatusBar/MemoryConsole rendering | color、NO_COLOR、reduced-motion visual tests | unchanged by design | 只高亮 verified | +| Context Usage 独立保留 | `agent.getContextUsage()`, StatusBar | Agora/远程、宽/窄终端 PTY | unchanged by design | 内化不 compact | +| 普通 Provider 无 Agora 泄漏 | provider switch, state projection | DeepSeek/LM Studio/OpenAI-compatible 回归 | unchanged by design | 非 Agora 不模拟记忆 | +| 单基座与单 runtime | Agora provider lifecycle, batch path | subprocess count + progress + packaged real E2E | separate compiler runtime removed | runtime 真实性由 capability + evidence 保证 | +| 正式包可复现 | package resolver, manifest verifier | exact version/integrity/SHA clean install | dev override compat only | dev override 显示 unverified | diff --git a/package-lock.json b/package-lock.json index a161c01..43c6ab0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "my-agent", - "version": "0.2.0-alpha.1", + "version": "0.3.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "my-agent", - "version": "0.2.0-alpha.1", + "version": "0.3.0", "license": "MIT", "dependencies": { "@inkjs/ui": "^2.0.0", @@ -30,6 +30,9 @@ "react": "^19.2.5", "string-width": "^8.2.1" }, + "optionalDependencies": { + "@zimoos/agora": "0.2.0" + }, "bin": { "ma": "bin/ma" }, @@ -46,6 +49,41 @@ "ws": "^8.20.0" } }, + "node_modules/@zimoos/agora": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/@zimoos/agora/-/agora-0.2.0.tgz", + "integrity": "sha512-iUCroM4rOS5lXgRSKsXG/S5OyhHDKMssioEW3/fFYkqOptayeImZBMVngmFn1kipnaxe7mhlZ2U+v1Xl8dEEQQ==", + "cpu": [ + "arm64" + ], + "license": "UNLICENSED", + "optional": true, + "os": [ + "darwin" + ], + "dependencies": { + "@zimoos/agora-darwin-arm64": "0.2.0" + }, + "bin": { + "agora": "bin/agora" + } + }, + "node_modules/@zimoos/agora-darwin-arm64": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/@zimoos/agora-darwin-arm64/-/agora-darwin-arm64-0.2.0.tgz", + "integrity": "sha512-/CxLj4ZMjaSaLPGQ9o5OlrN8Yd55sxacvWD3o3djX0hHL8BXTnL3IPcAsJVpG7+yQCuG0i43z1vSBh25O26+8g==", + "cpu": [ + "arm64" + ], + "license": "UNLICENSED", + "optional": true, + "os": [ + "darwin" + ], + "bin": { + "agora": "bin/agora" + } + }, "node_modules/@alcalzone/ansi-tokenize": { "version": "0.2.5", "resolved": "https://registry.npmjs.org/@alcalzone/ansi-tokenize/-/ansi-tokenize-0.2.5.tgz", diff --git a/package.json b/package.json index 7797cb5..0d9fa5d 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "my-agent", - "version": "0.2.0-alpha.1", + "version": "0.3.0", "description": "Local-first multi-model terminal coding agent", "homepage": "https://zimoos.github.io/my-agent/", "type": "module", @@ -10,14 +10,17 @@ "files": [ "dist", "bin", + "scripts/fix-node-pty.mjs", "README.md", "README.zh-CN.md", "LICENSE" ], "scripts": { + "postinstall": "node scripts/fix-node-pty.mjs", "start": "tsx src/cli/index.tsx", "build": "tsc && chmod +x dist/src/cli/index.js bin/ma scripts/package-portable.mjs", "package:portable": "npm run build && node scripts/package-portable.mjs --out release", + "sync:agora-release": "node scripts/sync-agora-release.mjs", "release:check": "npm test && npm run package:portable", "dev": "tsx watch src/cli/index.tsx", "test": "tsx --test test/*.test.ts", @@ -53,6 +56,9 @@ "react": "^19.2.5", "string-width": "^8.2.1" }, + "optionalDependencies": { + "@zimoos/agora": "0.2.0" + }, "devDependencies": { "@playwright/test": "^1.59.1", "@types/js-yaml": "^4.0.9", diff --git a/scripts/fix-node-pty.mjs b/scripts/fix-node-pty.mjs new file mode 100644 index 0000000..5de282e --- /dev/null +++ b/scripts/fix-node-pty.mjs @@ -0,0 +1,16 @@ +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +if (process.platform !== 'win32') { + const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); + const helper = path.join( + root, + 'node_modules', + 'node-pty', + 'prebuilds', + `${process.platform}-${process.arch}`, + 'spawn-helper' + ); + if (fs.existsSync(helper)) fs.chmodSync(helper, 0o755); +} diff --git a/scripts/package-portable.mjs b/scripts/package-portable.mjs index b43c018..2265fcb 100755 --- a/scripts/package-portable.mjs +++ b/scripts/package-portable.mjs @@ -3,9 +3,11 @@ import * as fs from 'node:fs'; import * as os from 'node:os'; import * as path from 'node:path'; import { fileURLToPath } from 'node:url'; +import { createHash } from 'node:crypto'; const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); const pkg = JSON.parse(fs.readFileSync(path.join(root, 'package.json'), 'utf-8')); +const agoraLock = JSON.parse(fs.readFileSync(path.join(root, 'src', 'provider', 'agora-runtime-lock.json'), 'utf-8')); function arg(name, fallback) { const idx = process.argv.indexOf(name); @@ -27,6 +29,77 @@ function copyRequired(src, dest) { fs.cpSync(src, dest, { recursive: true }); } +function sha256(file) { + return createHash('sha256').update(fs.readFileSync(file)).digest('hex'); +} + +function copyAgoraRuntime(appDir, target) { + if (target !== 'macos-arm64') return; + const source = process.env.MA_AGORA_ARTIFACT_DIR + ? path.resolve(process.env.MA_AGORA_ARTIFACT_DIR) + : path.join(root, 'resources', 'agora'); + const manifestPath = path.join(source, 'manifest.json'); + const binaryPath = path.join(source, 'bin', 'agora'); + if (!fs.existsSync(manifestPath) || !fs.existsSync(binaryPath)) { + throw new Error('macos-arm64 portable release requires MA_AGORA_ARTIFACT_DIR with Agora 0.2.0 native artifact'); + } + const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8')); + if (agoraLock.published !== true) { + throw new Error('macos-arm64 portable release requires a published, notarized Agora release lock'); + } + if (manifest.version !== '0.2.0' || manifest.host_protocol_major !== 1) { + throw new Error(`unexpected Agora contract: ${manifest.version}/host-v${manifest.host_protocol_major}`); + } + const requiredCapabilities = ['mcp-stdio', 'memory-profile-v2', 'memory-intake-v2']; + if (!requiredCapabilities.every((capability) => manifest.capabilities?.includes(capability))) { + throw new Error('Agora artifact is missing required Memory v2 capabilities'); + } + for (const [relative, expected] of Object.entries(manifest.files ?? {})) { + const target = path.resolve(source, relative); + if (!target.startsWith(`${source}${path.sep}`) || !fs.existsSync(target) || sha256(target) !== expected) { + throw new Error(`Agora artifact integrity mismatch: ${relative}`); + } + } + const sourceLike = []; + const visit = (directory) => { + for (const entry of fs.readdirSync(directory, { withFileTypes: true })) { + const target = path.join(directory, entry.name); + if (entry.isDirectory()) visit(target); + else if (/\.(py|pyc|js|map)$/i.test(entry.name)) sourceLike.push(path.relative(source, target)); + } + }; + visit(source); + if (sourceLike.length > 0) { + throw new Error(`Agora user artifact contains source-like files: ${sourceLike.join(', ')}`); + } + const packageIntegrity = process.env.MA_AGORA_PACKAGE_INTEGRITY?.trim(); + const expectedIntegrity = agoraLock.packages?.['@zimoos/agora-darwin-arm64']?.integrity; + if (packageIntegrity !== expectedIntegrity) { + throw new Error('macos-arm64 portable release package integrity does not match the pinned Agora release'); + } + const manifestSha = sha256(manifestPath); + if (manifestSha !== agoraLock.manifest_sha256) { + throw new Error('macos-arm64 portable release manifest SHA does not match the pinned Agora release'); + } + const destination = path.join(appDir, 'resources', 'agora'); + copyRequired(source, destination); + fs.writeFileSync( + path.join(destination, 'runtime-lock.json'), + JSON.stringify({ + version: '0.2.0', + package: '@zimoos/agora-darwin-arm64', + platform: 'darwin-arm64', + host_protocol_major: 1, + native_core_abi: 1, + published: true, + notarization_id: agoraLock.notarization_id, + package_integrity: packageIntegrity, + manifest_sha256: manifestSha, + }, null, 2) + '\n', + 'utf8' + ); +} + const outRoot = path.resolve(root, arg('--out', 'release')); const target = arg('--target', platformId()); const name = `ma-${pkg.version}-${target}`; @@ -42,6 +115,7 @@ copyRequired(path.join(root, 'dist'), path.join(app, 'dist')); copyRequired(path.join(root, 'node_modules'), path.join(app, 'node_modules')); copyRequired(path.join(root, 'package.json'), path.join(app, 'package.json')); copyRequired(path.join(root, 'README.md'), path.join(app, 'README.md')); +copyAgoraRuntime(app, target); if (fs.existsSync(path.join(root, 'LICENSE'))) { fs.copyFileSync(path.join(root, 'LICENSE'), path.join(app, 'LICENSE')); } diff --git a/scripts/sync-agora-release.mjs b/scripts/sync-agora-release.mjs new file mode 100644 index 0000000..f6865e8 --- /dev/null +++ b/scripts/sync-agora-release.mjs @@ -0,0 +1,84 @@ +#!/usr/bin/env node +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import { fileURLToPath, pathToFileURL } from 'node:url'; + +const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); +const VERSION = '0.2.0'; +const PACKAGE_NAMES = ['@zimoos/agora', '@zimoos/agora-darwin-arm64']; + +function fail(message) { + throw new Error(`Agora release evidence rejected: ${message}`); +} + +export function buildReleaseLock(evidence) { + if (evidence?.version !== VERSION || evidence?.published !== true) { + fail(`expected published Agora ${VERSION}`); + } + if (evidence?.notarization?.status !== 'Accepted' || typeof evidence?.notarization?.id !== 'string') { + fail('Apple notarization must be Accepted and include a submission id'); + } + if (!/^[a-f0-9]{64}$/.test(String(evidence.manifest_sha256 ?? ''))) { + fail('manifest_sha256 is missing or malformed'); + } + const signatures = Object.values(evidence.signatures ?? {}); + if (signatures.length < 5 || signatures.some((item) => ( + !String(item?.authority ?? '').startsWith('Developer ID Application:') || !String(item?.team_id ?? '') + ))) { + fail('all native files must carry a Developer ID Application signature and Team ID'); + } + const packages = Object.fromEntries((evidence.packages ?? []).map((item) => [item.name, item])); + for (const name of PACKAGE_NAMES) { + const item = packages[name]; + if (item?.version !== VERSION || !String(item?.integrity ?? '').startsWith('sha512-')) { + fail(`missing exact npm integrity for ${name}@${VERSION}`); + } + const forbidden = (item.files ?? []).filter((file) => /\.(py|pyc|js|map|pem|key|p12)$/i.test(file)); + if (forbidden.length > 0) fail(`${name} contains forbidden files: ${forbidden.join(', ')}`); + } + const teamIds = new Set(signatures.map((item) => item.team_id)); + if (teamIds.size !== 1) fail('native files are not signed by one Apple team'); + return { + version: VERSION, + platform: 'darwin-arm64', + host_protocol_major: 1, + native_core_abi: 1, + published: true, + notarization_id: evidence.notarization.id, + manifest_sha256: evidence.manifest_sha256, + capabilities: ['mcp-stdio', 'memory-profile-v2', 'memory-intake-v2'], + packages: Object.fromEntries(PACKAGE_NAMES.map((name) => [name, { integrity: packages[name].integrity }])), + }; +} + +export function syncReleaseEvidence(evidencePath, workspaceRoot = root) { + const evidence = JSON.parse(fs.readFileSync(evidencePath, 'utf8')); + const lock = buildReleaseLock(evidence); + const lockPath = path.join(workspaceRoot, 'src', 'provider', 'agora-runtime-lock.json'); + const packageLockPath = path.join(workspaceRoot, 'package-lock.json'); + const packageLock = JSON.parse(fs.readFileSync(packageLockPath, 'utf8')); + for (const name of PACKAGE_NAMES) { + const key = `node_modules/${name}`; + if (packageLock.packages?.[key]?.version !== VERSION) { + fail(`package-lock is missing exact ${name}@${VERSION}`); + } + packageLock.packages[key].integrity = lock.packages[name].integrity; + } + fs.writeFileSync(lockPath, `${JSON.stringify(lock, null, 2)}\n`, 'utf8'); + fs.writeFileSync(packageLockPath, `${JSON.stringify(packageLock, null, 2)}\n`, 'utf8'); + return lock; +} + +if (process.argv[1] && import.meta.url === pathToFileURL(path.resolve(process.argv[1])).href) { + const evidencePath = process.argv[2] ? path.resolve(process.argv[2]) : null; + if (!evidencePath) { + console.error('usage: node scripts/sync-agora-release.mjs /path/to/release-evidence.json'); + process.exit(2); + } + try { + console.log(JSON.stringify(syncReleaseEvidence(evidencePath), null, 2)); + } catch (error) { + console.error(error instanceof Error ? error.message : String(error)); + process.exit(1); + } +} diff --git a/src/agent.ts b/src/agent.ts index 7ba8992..6d251ac 100644 --- a/src/agent.ts +++ b/src/agent.ts @@ -660,17 +660,18 @@ function registerAgoraMemoryTools( tools: Map, controller: AgoraMemoryController ): void { - const profilePatchParameters = { + const memoryMountParameters = { type: 'object', properties: { profile_id: { type: 'string', description: 'Agora MemoryProfile id' }, - patch_id: { type: 'string', description: 'MemoryPatch id to select' }, - active_memory_patch_ids: { + memory_ids: { type: 'array', items: { type: 'string' }, - description: 'MemoryPatch ids to select', + description: 'Named Memory ids to mount together', }, + scope: { type: 'string', enum: ['user', 'project', 'conversation'] }, }, + required: ['memory_ids'], additionalProperties: false, }; tools.set( @@ -682,12 +683,107 @@ function registerAgoraMemoryTools( (args) => controller.status(args) ) ); + if (!controller.getCapabilities().memoryV2) { + tools.set( + 'agora_memory_mount', + agoraMemoryTool( + 'agora_memory_mount', + 'Legacy Agora MemoryProfile/MemoryPatch 挂载;能力受限,成功后仍需下一次 chat metadata 验证。', + { + type: 'object', + properties: { + profile_id: { type: 'string' }, + active_memory_patch_ids: { type: 'array', items: { type: 'string' } }, + writable_patch_family: { type: 'string' }, + }, + required: ['profile_id', 'active_memory_patch_ids'], + additionalProperties: false, + }, + (args) => controller.mount(args) + ) + ); + tools.set( + 'agora_memory_internalize', + agoraMemoryTool( + 'agora_memory_internalize', + 'Legacy 单目标 Memory intake;必须显式提供 Profile 和 writable family。', + { + type: 'object', + properties: { profile_id: { type: 'string' }, into: { type: 'string' } }, + required: ['profile_id', 'into'], + additionalProperties: false, + }, + (args) => controller.internalize(args) + ) + ); + tools.set( + 'agora_memory_disable', + agoraMemoryTool( + 'agora_memory_disable', + 'Legacy MemoryProfile disable。', + { type: 'object', properties: { profile_id: { type: 'string' } }, required: ['profile_id'], additionalProperties: false }, + (args) => controller.disable(args) + ) + ); + tools.set( + 'agora_memory_rollback', + agoraMemoryTool( + 'agora_memory_rollback', + 'Legacy MemoryPatch rollback。', + { + type: 'object', + properties: { profile_id: { type: 'string' }, patch_id: { type: 'string' } }, + required: ['profile_id', 'patch_id'], + additionalProperties: false, + }, + (args) => controller.rollback(args) + ) + ); + return; + } + tools.set( + 'agora_memory_list', + agoraMemoryTool( + 'agora_memory_list', + '列出当前 Agora base model 下的具名 Memory 和 current head。', + { type: 'object', properties: {}, additionalProperties: false }, + async () => ({ content: JSON.stringify({ memories: await controller.listMemories() }, null, 2), isError: false }) + ) + ); + tools.set( + 'agora_memory_create', + agoraMemoryTool( + 'agora_memory_create', + '创建一个名称唯一的 Agora Memory。重名必须把冲突返回给用户,不得自动改名。', + { + type: 'object', + properties: { name: { type: 'string' } }, + required: ['name'], + additionalProperties: false, + }, + async (args) => ({ content: JSON.stringify({ memory: await controller.createMemory(String(args.name)) }, null, 2), isError: false }) + ) + ); + tools.set( + 'agora_memory_rename', + agoraMemoryTool( + 'agora_memory_rename', + '重命名具名 Agora Memory;名称冲突时保留用户选择并请求新名称。', + { + type: 'object', + properties: { memory_id: { type: 'string' }, name: { type: 'string' } }, + required: ['memory_id', 'name'], + additionalProperties: false, + }, + async (args) => ({ content: JSON.stringify({ memory: await controller.renameMemory(String(args.memory_id), String(args.name)) }, null, 2), isError: false }) + ) + ); tools.set( 'agora_memory_mount', agoraMemoryTool( 'agora_memory_mount', - '挂载或切换 Agora MemoryProfile/MemoryPatch,并通过一次 Agora chat_complete metadata 验证成功。', - profilePatchParameters, + '挂载 0 到多个具名 Agora Memory。配置后只可声称 pending;下一次 chat_complete 返回相同 ordered patch ids 和 revision 后才可声称 mounted。', + memoryMountParameters, (args) => controller.mount(args) ) ); @@ -710,29 +806,87 @@ function registerAgoraMemoryTools( 'agora_memory_internalize', agoraMemoryTool( 'agora_memory_internalize', - '将当前 Agora runtime session 的新增对话提交 memory_intake_run,产出 MemoryPatch 后挂载并验证。', + '把当前 Agora session 的一份增量 source 同时内化到一个或多个显式目标;可混合新建 Memory 和增量 Memory。', { type: 'object', properties: { - profile_id: { type: 'string', description: 'Agora MemoryProfile id' }, + targets: { + type: 'array', + minItems: 1, + items: { + type: 'object', + properties: { + mode: { type: 'string', enum: ['create', 'increment'] }, + name: { type: 'string' }, + memory_id: { type: 'string' }, + expected_parent_patch_id: { type: ['string', 'null'] }, + output_name: { type: 'string' }, + }, + required: ['mode', 'output_name'], + additionalProperties: false, + }, + }, + source_message_start: { type: 'integer', minimum: 0 }, + source_message_end: { type: 'integer', minimum: 1 }, }, + required: ['targets'], additionalProperties: false, }, (args) => controller.internalize(args) ) ); + tools.set( + 'agora_memory_batch_status', + agoraMemoryTool( + 'agora_memory_batch_status', + '查询多目标 Memory 内化 batch 的逐目标状态;completed/noop 与 review/conflict/failed 必须分别解释。', + { + type: 'object', + properties: { batch_id: { type: 'string' } }, + required: ['batch_id'], + additionalProperties: false, + }, + async (args) => ({ content: JSON.stringify(await controller.getBatchIntake(String(args.batch_id)), null, 2), isError: false }) + ) + ); + tools.set( + 'agora_memory_auto_policy', + agoraMemoryTool( + 'agora_memory_auto_policy', + '设置当前 Memory 组合的自动内化开关和显式具名 Memory 目标;开启时目标列表不能为空。', + { + type: 'object', + properties: { + profile_id: { type: 'string' }, + enabled: { type: 'boolean' }, + target_memory_ids: { type: 'array', items: { type: 'string' } }, + }, + required: ['profile_id', 'enabled', 'target_memory_ids'], + additionalProperties: false, + }, + async (args) => ({ + content: JSON.stringify(await controller.setAutoPolicy( + String(args.profile_id), + Boolean(args.enabled), + Array.isArray(args.target_memory_ids) ? args.target_memory_ids.map(String) : [] + ), null, 2), + isError: false, + }) + ) + ); tools.set( 'agora_memory_rollback', agoraMemoryTool( 'agora_memory_rollback', - '回滚 Agora MemoryProfile 到指定或唯一上一版 MemoryPatch,并通过 chat_complete metadata 验证。', + '用 expected current head 执行具名 Memory 的 CAS 回滚。回滚不会自动覆盖当前会话挂载组合。', { type: 'object', properties: { - profile_id: { type: 'string', description: 'Agora MemoryProfile id' }, - patch_id: { type: 'string', description: 'target MemoryPatch id' }, + memory_id: { type: 'string', description: 'Named Memory id' }, + expected_head_patch_id: { type: 'string', description: 'Current head used for CAS' }, target_patch_id: { type: 'string', description: 'target MemoryPatch id' }, }, + required: ['memory_id', 'expected_head_patch_id', 'target_patch_id'], additionalProperties: false, }, (args) => controller.rollback(args) @@ -849,8 +1003,10 @@ export async function createAgent( ? [ '', '# Agora Memory', - '- Agora memory is provider-scoped. Use agora_memory_status/mount/disable/internalize/rollback only when the user asks to inspect or change Agora MemoryPatch state.', - '- Treat Agora memory as mounted only when agora_memory_status or a memory action reports providerState from Agora chat_complete metadata.', + '- Agora memory is provider-scoped. The user-facing object is a uniquely named Memory; MemoryPatch is its immutable version. Use the agora_memory_* tools only when the user asks to inspect or change Agora memory.', + '- Mount can contain zero or multiple Memory ids. Internalization requires explicit targets and may mix create and increment in one batch; never guess the first mounted target.', + '- A mount action only means pending. Treat memory as mounted only after a later Agora chat_complete returns the requested ordered patch ids and PatchSet revision.', + '- Retry only review/conflict/failed batch targets; never resubmit completed/noop targets.', '- Do not simulate memory by inserting facts into the prompt.', ].join('\n') : [ @@ -1849,6 +2005,10 @@ export async function createAgent( } } + function getMemoryController(): AgoraMemoryController | null { + return agoraMemoryController; + } + function inspectContext(): string { return contextManager.inspect(); } @@ -1894,6 +2054,7 @@ export async function createAgent( revertLastTurnContextOnly, respondConfirm, getProviderState, + getMemoryController, getContextUsage, inspectContext, searchContext, diff --git a/src/cli/App.tsx b/src/cli/App.tsx index 42ec49e..da8c5b2 100644 --- a/src/cli/App.tsx +++ b/src/cli/App.tsx @@ -24,6 +24,15 @@ import { type SessionPickerSession, } from './components/SessionPicker.js'; import { ModelPicker } from './components/ModelPicker.js'; +import { MemoryConsole } from './components/MemoryConsole.js'; +import type { + AgoraMemory, + AgoraMemoryIntakeTarget, + AgoraMemoryPatch, + AgoraMemoryProfile, +} from '../provider/agora.js'; +import { agoraProjectProfileId } from '../provider/agora.js'; +import { classifyAgoraBatchTargets, planAgoraAutoIntake } from '../provider/agora-auto-intake.js'; import type { PendingConfirm } from './hooks/useAgent.js'; import { isCommand, executeCommand } from './utils/commands.js'; import { @@ -68,14 +77,153 @@ export function App({ config, connections, agent, sessionStore, currentSessionId const state = useSyncExternalStore(store.subscribe, store.getState); const { messages, thinking, inFlightText } = state; - const providerState = - agent.getProviderState?.() ?? - sessionStore.list(100).find((session) => session.id === currentSessionId)?.providerState ?? - null; + const persistedProviderState = sessionStore.list(100).find((session) => session.id === currentSessionId)?.providerState; + const providerState = agent.getProviderState?.() ?? persistedProviderState ?? null; + const memoryController = agent.getMemoryController?.() ?? null; const [pendingImages, setPendingImages] = useState([]); const [sessionPickerSessions, setSessionPickerSessions] = useState(null); const [modelPickerModels, setModelPickerModels] = useState(null); + const [memoryConsole, setMemoryConsole] = useState<{ + memories: AgoraMemory[]; + profiles: AgoraMemoryProfile[]; + patches: AgoraMemoryPatch[]; + } | null>(null); + const [memoryActivity, setMemoryActivity] = useState(''); + const activeMemoryProfile = memoryConsole?.profiles.find( + (profile) => profile.id === providerState?.memory?.profile_id + ); + const memoryProfileId = activeMemoryProfile?.id ?? agoraProjectProfileId(process.cwd()); + const lastUserActivityRef = useRef(Date.now()); + const intakeInFlightRef = useRef(false); + + const refreshMemoryConsole = useCallback(async () => { + if (!memoryController) throw new Error('Agora memory controller is unavailable'); + const capabilities = memoryController.getCapabilities(); + if (!capabilities.memoryV2) { + throw new Error(`Agora Memory v2 不可用(当前模式: ${capabilities.runtimeMode}),基础对话仍可使用。`); + } + const [memories, profiles, patches] = await Promise.all([ + memoryController.listMemories(), + memoryController.listProfiles(), + memoryController.listPatches(true), + ]); + setMemoryConsole({ memories, profiles, patches }); + }, [memoryController]); + + const openMemoryConsole = useCallback(async () => { + try { + await refreshMemoryConsole(); + } catch (err) { + store.pushMessage({ kind: 'system', id: nextSysId(), text: `Memory error: ${(err as Error).message}` }); + } + }, [refreshMemoryConsole, store]); + + const persistMemoryRuntimeState = useCallback((memoryPatch: Record) => { + memoryController?.updateLocalMemoryState(memoryPatch); + const existing = agent.getProviderState?.() ?? + sessionStore.list(100).find((session) => session.id === currentSessionId)?.providerState ?? + { provider_id: 'agora' }; + sessionStore.updateProviderState(currentSessionId, { + ...existing, + memory: { ...(existing.memory ?? {}), ...memoryPatch }, + }); + }, [agent, currentSessionId, memoryController, sessionStore]); + + const monitorMemoryBatch = useCallback(async ( + batchId: string, + profileId: string, + runtimeSourceEnd: number, + localSourceEnd: number, + selectedPatchIds: string[], + automatic: boolean + ) => { + if (!memoryController || intakeInFlightRef.current) return; + intakeInFlightRef.current = true; + try { + for (;;) { + await new Promise((resolve) => setTimeout(resolve, 1000)); + const batch = await memoryController.getBatchIntake(batchId); + const classified = classifyAgoraBatchTargets(batch.targets); + const done = classified.completed.length; + setMemoryActivity(`内化中 ${done}/${batch.targets.length}`); + persistMemoryRuntimeState({ + active_batch: { batch_id: batchId, profile_id: profileId, source_end: runtimeSourceEnd, local_source_end: localSourceEnd, selected_patch_ids: selectedPatchIds, automatic, targets: batch.targets }, + }); + if (!classified.terminal) continue; + + const blockers = classified.blockers; + const profiles = await memoryController.listProfiles(); + const currentProfile = profiles.find((profile) => profile.id === profileId); + const selectionUnchanged = Boolean(currentProfile) && + selectedPatchIds.length === currentProfile?.active_memory_patch_ids.length && + selectedPatchIds.every((id, index) => id === currentProfile?.active_memory_patch_ids[index]); + if (selectionUnchanged) await memoryController.applyCompletedBatch(batch, profileId); + if (blockers.length === 0) { + persistMemoryRuntimeState({ + active_batch: undefined, + ...(automatic ? { + last_auto_intake_message_end: localSourceEnd, + last_auto_intake_runtime_message_end: runtimeSourceEnd, + } : {}), + }); + setMemoryActivity(`内化完成 ${done}/${batch.targets.length}${selectionUnchanged ? ' · 下一次对话验证挂载' : ' · 当前组合已变化,未自动覆盖'}`); + } else { + persistMemoryRuntimeState({ + active_batch: { batch_id: batchId, profile_id: profileId, source_end: runtimeSourceEnd, local_source_end: localSourceEnd, selected_patch_ids: selectedPatchIds, automatic, targets: batch.targets }, + }); + setMemoryActivity(`待处理 ${blockers.length}/${batch.targets.length} · 成功目标不会重复内化`); + } + await refreshMemoryConsole().catch(() => undefined); + break; + } + } catch (err) { + setMemoryActivity(`failed · ${(err as Error).message}`); + } finally { + intakeInFlightRef.current = false; + } + }, [memoryController, persistMemoryRuntimeState, refreshMemoryConsole]); + + const startMemoryBatch = useCallback(async ( + targets: AgoraMemoryIntakeTarget[], + profileId: string, + automatic = false, + sourceStart?: number, + sourceEnd?: number, + localSourceEnd?: number + ) => { + if (!memoryController || intakeInFlightRef.current) throw new Error('已有内化任务正在执行。'); + const profiles = await memoryController.listProfiles(); + const profile = profiles.find((item) => item.id === profileId); + if (!profile) throw new Error('当前项目尚未建立 Memory 组合。'); + const localEnd = localSourceEnd ?? sessionStore.load(currentSessionId).length; + const persisted = sessionStore.list(100).find((session) => session.id === currentSessionId)?.providerState; + const end = sourceEnd ?? Number( + agent.getProviderState?.()?.memory?.runtime_message_count ?? + persisted?.memory?.runtime_message_count ?? + 0 + ); + if (!Number.isInteger(end) || end <= 0) throw new Error('Agora runtime 尚未确认可内化的 source range;请先完成一次对话。'); + const batch = await memoryController.startBatchIntake({ + targets, + ...(Number.isInteger(sourceStart) ? { source_message_start: sourceStart } : {}), + ...(Number.isInteger(end) && end > 0 ? { source_message_end: end } : {}), + }); + setMemoryActivity(`内化已提交 0/${batch.targets.length}`); + persistMemoryRuntimeState({ + active_batch: { + batch_id: batch.batch_id, + profile_id: profileId, + source_start: sourceStart ?? 0, + source_end: end, + local_source_end: localEnd, + selected_patch_ids: profile.active_memory_patch_ids, + automatic, + targets: batch.targets, + }, + }); + void monitorMemoryBatch(batch.batch_id, profileId, end, localEnd, [...profile.active_memory_patch_ids], automatic); + }, [agent, currentSessionId, memoryController, monitorMemoryBatch, persistMemoryRuntimeState, sessionStore]); const handleConfirm = useCallback( (approved: boolean) => { @@ -97,7 +245,25 @@ export function App({ config, connections, agent, sessionStore, currentSessionId const openModelPicker = useCallback(async () => { try { store.pushMessage({ kind: 'system', id: nextSysId(), text: '[正在查询模型列表...]' }); - const choices = await listModelChoices(config); + let choices = await listModelChoices(config); + if (memoryController?.getCapabilities().modelCatalog) { + const models = await memoryController.listModels(); + const liveChoices: ModelChoice[] = models + .filter((item) => typeof item.id === 'string') + .map((item) => ({ + id: `agora-local/${item.id}`, + credentialId: 'agora-local', + provider: 'agora', + baseURL: 'mcp-stdio://agora', + model: String(item.id), + label: `Agora/${item.name ?? item.id}`, + current: config.model.provider === 'agora' && config.model.model === item.id, + source: 'remote', + status: typeof item.status === 'string' ? item.status : undefined, + })); + const liveIds = new Set(liveChoices.map((item) => item.id)); + choices = [...liveChoices, ...choices.filter((item) => !liveIds.has(item.id))]; + } setModelPickerModels(choices); } catch (err) { store.pushMessage({ @@ -106,9 +272,25 @@ export function App({ config, connections, agent, sessionStore, currentSessionId text: `Failed to list models: ${(err as Error).message}`, }); } - }, [config, store]); + }, [config, memoryController, store]); - const switchModelChoice = useCallback((model: ModelChoice) => { + const switchModelChoice = useCallback(async (model: ModelChoice) => { + if (model.provider === 'agora' && model.status && model.status !== 'available') { + if (!memoryController?.getCapabilities().modelDownload) { + store.pushMessage({ kind: 'system', id: nextSysId(), text: '[Agora runtime 不支持模型下载,请升级 Agora]' }); + return; + } + setMemoryActivity(`下载模型 ${model.model}`); + try { + await memoryController.downloadModel(model.model, (event) => { + setMemoryActivity(`下载模型 ${model.model} · ${event.progress ?? '?'}${event.total ? `/${event.total}` : ''}`); + }); + setMemoryActivity(`模型 ${model.model} 已下载`); + } catch (err) { + setMemoryActivity(`下载失败 · ${(err as Error).message}`); + return; + } + } setModelPickerModels(null); saveDefaultModelChoice(model); store.pushMessage({ @@ -119,10 +301,11 @@ export function App({ config, connections, agent, sessionStore, currentSessionId log(`switch model: ${model.id}`); onRestartSession?.(currentSessionId); app.exit(); - }, [app, currentSessionId, log, onRestartSession, store]); + }, [app, currentSessionId, log, memoryController, onRestartSession, store]); const handleSubmit = useCallback( (text: string) => { + lastUserActivityRef.current = Date.now(); log(`submit: ${text}`); if (isCommand(text)) { (async () => { @@ -133,6 +316,8 @@ export function App({ config, connections, agent, sessionStore, currentSessionId exit: () => app.exit(), revertLastTurn: handleRevertLastTurn, openModelPicker, + openMemoryConsole, + startMemoryIntake: (targets) => startMemoryBatch(targets, memoryProfileId), switchModelChoice, }); if (text === '/clear') { @@ -158,9 +343,70 @@ export function App({ config, connections, agent, sessionStore, currentSessionId send(text); } }, - [agent, connections, app, store, send, log, pendingImages, handleRevertLastTurn, config, openModelPicker, switchModelChoice] + [agent, connections, app, store, send, log, pendingImages, handleRevertLastTurn, config, memoryProfileId, openModelPicker, openMemoryConsole, startMemoryBatch, switchModelChoice] ); + useEffect(() => { + if (!memoryController || config.model.provider?.toLowerCase() !== 'agora') return; + const timer = setInterval(async () => { + if (intakeInFlightRef.current || thinkingRef.current || pendingConfirm || sessionPickerSessions || modelPickerModels || memoryConsole) return; + if (!memoryController.getCapabilities().memoryV2) return; + const persistedState = sessionStore.list(100).find((session) => session.id === currentSessionId)?.providerState; + const runtimeState = persistedState ?? agent.getProviderState?.(); + const activeBatch = runtimeState?.memory?.active_batch as Record | undefined; + if (activeBatch?.batch_id) { + const targets = Array.isArray(activeBatch.targets) ? activeBatch.targets : []; + const unresolved = targets.some((target: any) => !['completed', 'noop', 'review', 'conflict', 'failed'].includes(String(target?.status))); + if (unresolved) { + void monitorMemoryBatch( + String(activeBatch.batch_id), + String(activeBatch.profile_id), + Number(activeBatch.source_end ?? 0), + Number(activeBatch.local_source_end ?? 0), + Array.isArray(activeBatch.selected_patch_ids) ? activeBatch.selected_patch_ids.map(String) : [], + Boolean(activeBatch.automatic) + ); + } + return; + } + const profileId = runtimeState?.memory?.profile_id; + if (!profileId) return; + try { + const profiles = await memoryController.listProfiles(); + const profile = profiles.find((item) => item.id === profileId); + const policy = profile?.auto_intake_policy; + const targetIds = profile?.auto_intake_target_memory_ids ?? []; + if (!profile || !policy?.enabled || targetIds.length === 0) return; + const idleSeconds = policy.idle_seconds ?? 60; + if (Date.now() - lastUserActivityRef.current < idleSeconds * 1000) return; + const messages = sessionStore.load(currentSessionId); + const [memories, patches] = await Promise.all([ + memoryController.listMemories(), + memoryController.listPatches(true), + ]); + const plan = planAgoraAutoIntake({ + messages, + checkpointEnd: Number(runtimeState?.memory?.last_auto_intake_message_end ?? 0), + profile, + memories, + patches, + }); + if (!plan.ready) return; + const runtimeEnd = Number(runtimeState?.memory?.runtime_message_count ?? 0); + const runtimeStart = Number(runtimeState?.memory?.last_auto_intake_runtime_message_end ?? 0); + if (runtimeStart > 0 && runtimeStart >= runtimeEnd) { + setMemoryActivity('自动内化已暂停 · Agora source 窗口发生回退,请手动确认新范围,未重复内化'); + return; + } + if (runtimeEnd <= runtimeStart) return; + await startMemoryBatch(plan.targets, profileId, true, runtimeStart, runtimeEnd, plan.sourceEnd); + } catch (err) { + setMemoryActivity(`stale · ${(err as Error).message}`); + } + }, 5000); + return () => clearInterval(timer); + }, [agent, config.model.provider, currentSessionId, memoryConsole, memoryController, modelPickerModels, monitorMemoryBatch, pendingConfirm, sessionPickerSessions, sessionStore, startMemoryBatch]); + const openSessionPicker = useCallback(() => { const allSessions = sessionStore.list(50); const currentCwd = allSessions.find((session) => session.id === currentSessionId)?.cwd ?? process.cwd(); @@ -325,9 +571,110 @@ export function App({ config, connections, agent, sessionStore, currentSessionId /> ) : null} + {memoryConsole ? ( + | undefined} + sourceRange={{ start: 0, end: Number(providerState?.memory?.runtime_message_count ?? 0) }} + busy={Boolean(thinking || pendingConfirm)} + onApply={async (memoryIds) => { + if (!memoryController) throw new Error('Agora Memory controller unavailable'); + await memoryController.mountMemories(memoryProfileId, memoryIds, 'project'); + setMemoryActivity('记忆组合已更新 · 下一次真实对话验证 · 基座无需重载'); + await refreshMemoryConsole(); + }} + onCreate={async (name) => { + if (!memoryController) throw new Error('Agora Memory controller unavailable'); + await memoryController.createMemory(name); + await refreshMemoryConsole(); + }} + onRename={async (memoryId, name) => { + if (!memoryController) throw new Error('Agora Memory controller unavailable'); + await memoryController.renameMemory(memoryId, name); + await refreshMemoryConsole(); + }} + onAuto={async (enabled, targetIds) => { + if (!memoryController) throw new Error('Agora Memory controller unavailable'); + if (!activeMemoryProfile) { + await memoryController.mountMemories(memoryProfileId, [], 'project'); + } + if (enabled && targetIds.length === 0) throw new Error('开启自动内化前请先用 a 选择至少一个目标。'); + await memoryController.setAutoPolicy(memoryProfileId, enabled, targetIds); + setMemoryActivity(enabled ? `自动内化已开启 · ${targetIds.length} 个目标` : '自动内化已关闭'); + await refreshMemoryConsole(); + }} + onInternalize={async (targets) => { + await startMemoryBatch(targets, memoryProfileId); + }} + onRollback={async (memory, targetPatchId) => { + if (!memoryController || !memory.head_patch_id) throw new Error('Memory 没有可回滚的当前版本。'); + await memoryController.rollbackMemory(memory.id, memory.head_patch_id, targetPatchId); + setMemoryActivity('Memory head 已回滚 · 当前会话挂载未被自动覆盖'); + await refreshMemoryConsole(); + }} + onRetryBlocked={async () => { + if (!memoryController) throw new Error('Agora Memory controller unavailable'); + const active = persistedProviderState?.memory?.active_batch as Record | undefined; + const rawTargets = Array.isArray(active?.targets) ? active.targets : []; + const retryable = rawTargets.filter((target: any) => + ['review', 'conflict'].includes(String(target?.status)) || + (target?.status === 'failed' && target?.error?.retryable !== false) + ); + if (retryable.length === 0) throw new Error('没有可重试的失败目标。'); + const targets: AgoraMemoryIntakeTarget[] = []; + for (const target of retryable) { + if (target.mode === 'create') { + targets.push({ + mode: 'create', + name: String(target.memory_name ?? target.name ?? ''), + memory_id: typeof target.memory_id === 'string' ? target.memory_id : undefined, + output_name: String(target.output_name), + }); + } else { + const memory = await memoryController.getMemory(String(target.memory_id)); + targets.push({ + mode: 'increment', + memory_id: memory.id, + expected_parent_patch_id: memory.head_patch_id ?? null, + output_name: String(target.output_name), + }); + } + } + await startMemoryBatch( + targets, + String(active?.profile_id ?? memoryProfileId), + Boolean(active?.automatic), + Number(active?.source_start ?? 0), + Number(active?.source_end ?? 0), + Number(active?.local_source_end ?? sessionStore.load(currentSessionId).length) + ); + }} + onAbandonBlocked={async () => { + const active = persistedProviderState?.memory?.active_batch as Record | undefined; + if (!active) throw new Error('没有待处理的内化目标。'); + persistMemoryRuntimeState({ + active_batch: undefined, + ...(active.automatic ? { + last_auto_intake_message_end: Number(active.local_source_end ?? 0), + last_auto_intake_runtime_message_end: Number(active.source_end ?? 0), + } : {}), + }); + setMemoryActivity('未完成目标已明确放弃;成功目标保留,自动 checkpoint 已按本轮范围推进'); + await refreshMemoryConsole(); + }} + onCancel={() => setMemoryConsole(null)} + /> + ) : null} + setPendingImages([])} onOpenSessionPicker={openSessionPicker} @@ -343,6 +690,8 @@ export function App({ config, connections, agent, sessionStore, currentSessionId contextTotal={contextUsage.total} contextThreshold={contextUsage.compactThreshold} contextSource={contextUsage.source} + memoryActivity={memoryActivity} + animateMemory={!thinking && !pendingConfirm} /> ); diff --git a/src/cli/components/Banner.tsx b/src/cli/components/Banner.tsx index 139dd22..683a533 100644 --- a/src/cli/components/Banner.tsx +++ b/src/cli/components/Banner.tsx @@ -1,5 +1,6 @@ import React from 'react'; import { Box, Text } from 'ink'; +import { VERSION } from '../../version.js'; interface BannerProps { model: string; @@ -26,7 +27,7 @@ export function Banner({ model, baseURL, mcp }: BannerProps) { ))} {LOGO[LOGO.length - 1]} - v1.0.0 + v{VERSION} diff --git a/src/cli/components/MemoryConsole.tsx b/src/cli/components/MemoryConsole.tsx new file mode 100644 index 0000000..e190d5c --- /dev/null +++ b/src/cli/components/MemoryConsole.tsx @@ -0,0 +1,299 @@ +import React, { useEffect, useMemo, useState } from 'react'; +import { Box, Text, useInput } from 'ink'; +import TextInput from 'ink-text-input'; +import type { + AgoraMemory, + AgoraMemoryIntakeTarget, + AgoraMemoryPatch, + AgoraMemoryProfile, +} from '../../provider/agora.js'; + +export interface MemoryConsoleProps { + project: string; + memories: AgoraMemory[]; + patches: AgoraMemoryPatch[]; + profile?: AgoraMemoryProfile; + verifiedPatchIds?: string[]; + verificationStatus?: string; + activity?: string; + activeBatch?: Record; + sourceRange?: { start: number; end: number }; + busy?: boolean; + onApply(memoryIds: string[]): Promise | void; + onCreate(name: string): Promise | void; + onRename(memoryId: string, name: string): Promise | void; + onAuto(enabled: boolean, targetMemoryIds: string[]): Promise | void; + onInternalize(targets: AgoraMemoryIntakeTarget[]): Promise | void; + onRollback(memory: AgoraMemory, targetPatchId: string): Promise | void; + onRetryBlocked(): Promise | void; + onAbandonBlocked(): Promise | void; + onCancel(): void; +} + +type Mode = 'main' | 'intake' | 'history'; +type EditMode = 'create' | 'rename' | 'new-target' | 'output-name' | null; +const FLOW_COLORS = ['cyan', 'blue', 'magenta', 'blue'] as const; + +function nextOutputName(memory: AgoraMemory, patches: AgoraMemoryPatch[]): string { + const versions = patches.filter((patch) => patch.memory_id === memory.id); + return `${memory.name}@v${versions.length + 1}`; +} + +export function MemoryConsole(props: MemoryConsoleProps) { + const [index, setIndex] = useState(0); + const [mode, setMode] = useState('main'); + const [historyMemoryId, setHistoryMemoryId] = useState(null); + const [selected, setSelected] = useState([]); + const [autoTargets, setAutoTargets] = useState([]); + const [intakeTargets, setIntakeTargets] = useState([]); + const [newTargets, setNewTargets] = useState>([]); + const [outputNames, setOutputNames] = useState>({}); + const [intakeConfirm, setIntakeConfirm] = useState(false); + const [editMode, setEditMode] = useState(null); + const [editValue, setEditValue] = useState(''); + const [error, setError] = useState(''); + const [flowIndex, setFlowIndex] = useState(0); + + const memory = mode === 'history' + ? props.memories.find((item) => item.id === historyMemoryId) + : props.memories[index]; + const history = useMemo( + () => props.patches.filter((patch) => patch.memory_id === memory?.id), + [memory?.id, props.patches] + ); + const mountedIds = useMemo(() => new Set( + props.profile?.active_memory_patch_ids + .map((patchId) => props.patches.find((patch) => patch.id === patchId)?.memory_id) + .filter((id): id is string => Boolean(id)) ?? [] + ), [props.patches, props.profile?.active_memory_patch_ids]); + const verifiedIds = useMemo(() => new Set(props.verifiedPatchIds ?? []), [props.verifiedPatchIds]); + const canAnimate = !props.busy && !process.env.NO_COLOR && !process.env.MA_REDUCED_MOTION && process.env.TERM !== 'dumb'; + + useEffect(() => { + setSelected([...mountedIds]); + setAutoTargets(props.profile?.auto_intake_target_memory_ids ?? []); + }, [mountedIds, props.profile?.auto_intake_target_memory_ids]); + + useEffect(() => { + if (!canAnimate) return; + const timer = setInterval(() => setFlowIndex((value) => (value + 1) % FLOW_COLORS.length), 500); + return () => clearInterval(timer); + }, [canAnimate]); + + const run = async (action: () => Promise | void) => { + setError(''); + try { + await action(); + return true; + } catch (err) { + setError((err as Error).message); + return false; + } + }; + + useInput((input, key) => { + if (editMode) return; + if (key.escape) { + if (mode !== 'main') { + setMode('main'); + setHistoryMemoryId(null); + setIndex(0); + } else props.onCancel(); + return; + } + const rowCount = mode === 'history' ? history.length : props.memories.length; + if (key.upArrow) return setIndex((value) => Math.max(0, value - 1)); + if (key.downArrow) return setIndex((value) => Math.min(Math.max(0, rowCount - 1), value + 1)); + + if (mode === 'history') { + if (input === 'r' && memory && history[index]) { + void run(() => props.onRollback(memory, history[index].id)); + } + return; + } + + if (input === ' ' && memory) { + setIntakeConfirm(false); + const setter = mode === 'intake' ? setIntakeTargets : setSelected; + setter((value) => value.includes(memory.id) + ? value.filter((id) => id !== memory.id) + : [...value, memory.id]); + return; + } + if (mode === 'main' && input === 'n') { + setEditValue(''); + setEditMode('create'); + return; + } + if (mode === 'main' && input === 'e' && memory) { + setEditValue(memory.name); + setEditMode('rename'); + return; + } + if (mode === 'main' && input === 'a' && memory) { + setAutoTargets((value) => value.includes(memory.id) + ? value.filter((id) => id !== memory.id) + : [...value, memory.id]); + return; + } + if (mode === 'main' && input === 't' && props.profile) { + void run(() => props.onAuto(!props.profile?.auto_intake_policy?.enabled, autoTargets)); + return; + } + if (mode === 'main' && input === 'i') { + setIntakeTargets([]); + setNewTargets([]); + setIntakeConfirm(false); + setMode('intake'); + setIndex(0); + return; + } + if (mode === 'main' && input === 'h' && memory) { + setHistoryMemoryId(memory.id); + setMode('history'); + setIndex(0); + return; + } + if (mode === 'main' && input === 'R' && props.activeBatch) { + void run(() => props.onRetryBlocked()); + return; + } + if (mode === 'main' && input === 'D' && props.activeBatch) { + void run(() => props.onAbandonBlocked()); + return; + } + if (mode === 'intake' && input === 'n') { + setEditValue(''); + setEditMode('new-target'); + return; + } + if (mode === 'intake' && input === 'v' && memory && intakeTargets.includes(memory.id)) { + setEditValue(outputNames[memory.id] ?? nextOutputName(memory, props.patches)); + setEditMode('output-name'); + return; + } + if (key.return) { + if (mode === 'main') { + void run(() => props.onApply(selected)); + return; + } + if (!intakeConfirm) { + setIntakeConfirm(true); + return; + } + const targets: AgoraMemoryIntakeTarget[] = [ + ...intakeTargets.flatMap((memoryId) => { + const targetMemory = props.memories.find((item) => item.id === memoryId); + if (!targetMemory) return []; + return [{ + mode: 'increment' as const, + memory_id: memoryId, + expected_parent_patch_id: targetMemory.head_patch_id ?? null, + output_name: outputNames[memoryId] ?? nextOutputName(targetMemory, props.patches), + }]; + }), + ...newTargets.map((target) => ({ mode: 'create' as const, ...target })), + ]; + if (targets.length === 0) { + setError('请至少选择一个已有 Memory,或按 n 新建目标。'); + return; + } + void run(async () => { + await props.onInternalize(targets); + setMode('main'); + props.onCancel(); + }); + } + }); + + const submitEdit = async (raw: string) => { + const value = raw.trim(); + if (!value) return; + const succeeded = await run(async () => { + if (editMode === 'create') await props.onCreate(value); + if (editMode === 'rename' && memory) await props.onRename(memory.id, value); + if (editMode === 'output-name' && memory) { + setIntakeConfirm(false); + setOutputNames((current) => ({ ...current, [memory.id]: value })); + } + if (editMode === 'new-target') { + setIntakeConfirm(false); + const [name, output] = value.split('|').map((part) => part.trim()); + if (!name) throw new Error('请输入 Memory 名称。'); + setNewTargets((current) => [...current, { name, output_name: output || `${name}@v1` }]); + } + }); + if (succeeded) { + setEditMode(null); + setEditValue(''); + } + }; + + const flowColor = canAnimate ? FLOW_COLORS[flowIndex] : 'cyan'; + return ( + + Memory · {props.project} + {mode === 'history' ? ( + <> + {memory?.name ?? 'Memory'} · 版本历史 + {history.length === 0 ? 暂无版本 : history.map((patch, row) => ( + + {row === index ? '› ' : ' '}{patch.name} · {patch.version} + {patch.id === memory?.head_patch_id ? ' · current head' : ''} + {verifiedIds.has(patch.id) ? ' · mounted' : ''} + + ))} + r CAS 回滚当前版本 · Esc 返回 + + ) : ( + <> + {mode === 'intake' ? '内化目标(同一份对话增量)' : '具名记忆'} + {mode === 'intake' && props.sourceRange ? ( + + Source: 本会话消息 {props.sourceRange.start}~{props.sourceRange.end} + {intakeConfirm ? ` · 确认一次提交 ${intakeTargets.length + newTargets.length} 个目标,再按 Enter` : ''} + + ) : null} + {props.memories.length === 0 ? 没有 Memory,按 n 新建 : null} + {props.memories.map((item, row) => { + const patch = props.patches.find((candidate) => candidate.id === item.head_patch_id); + const checked = mode === 'intake' ? intakeTargets.includes(item.id) : selected.includes(item.id); + const mounted = Boolean(item.head_patch_id && verifiedIds.has(item.head_patch_id)); + return ( + + {row === index ? '› ' : ' '}{checked ? '☑' : '☐'} {item.name} + {patch ? ` · ${patch.version}` : ' · 尚无版本'} + {mounted ? ' · verified' : mountedIds.has(item.id) ? ` · ${props.verificationStatus ?? 'pending'}` : ''} + {mode === 'main' && autoTargets.includes(item.id) ? ' · auto' : ''} + {mode === 'intake' && outputNames[item.id] ? ` → ${outputNames[item.id]}` : ''} + + ); + })} + {mode === 'intake' && newTargets.map((target) => ( + + {target.name} → {target.output_name} + ))} + {mode === 'main' && props.activeBatch ? ( + + 待处理 batch: {Array.isArray(props.activeBatch.targets) + ? props.activeBatch.targets.filter((target: any) => ['review', 'conflict', 'failed'].includes(String(target?.status))).length + : 0} 个目标 · Shift+R 仅重试未完成目标 · Shift+D 放弃并推进 checkpoint + + ) : null} + {mode === 'intake' ? ( + Space 多选增量目标 · n 新建目标(名称 | 版本名)· v 修改版本名 · Enter 预览/确认 · Esc 返回 + ) : ( + Space 挂载 · Enter 应用 · i 内化 · n 新建 · e 重命名 · h 历史 · a 自动目标 · t 自动开关 · Shift+R 重试 · Shift+D 放弃 · Esc 返回 + )} + + )} + {editMode ? ( + + {editMode === 'new-target' ? 'Memory 名称 | 输出版本名: ' : editMode === 'output-name' ? '输出版本名: ' : editMode === 'create' ? '新建 Memory: ' : '重命名: '} + { void submitEdit(value); }} /> + + ) : null} + {error ? {error} · 请修改后重试 : null} + {props.activity ? Activity: {props.activity} : null} + + ); +} diff --git a/src/cli/components/ModelPicker.tsx b/src/cli/components/ModelPicker.tsx index b353c2a..6e91c40 100644 --- a/src/cli/components/ModelPicker.tsx +++ b/src/cli/components/ModelPicker.tsx @@ -38,6 +38,7 @@ export function ModelPicker({ models, onSelect, onCancel }: ModelPickerProps) { const suffix = [ model.current ? '当前' : '', model.source === 'cache' ? 'cache' : '', + model.status && model.status !== 'available' ? model.status : '', ].filter(Boolean).join(' · '); return ( @@ -47,7 +48,7 @@ export function ModelPicker({ models, onSelect, onCancel }: ModelPickerProps) { ); })} - ↑/↓ 选择 · Enter 切换 · Esc 取消 + ↑/↓ 选择 · Enter 下载/切换 · Esc 取消 ); } diff --git a/src/cli/components/StatusBar.tsx b/src/cli/components/StatusBar.tsx index 9e3c995..6b8d1f2 100644 --- a/src/cli/components/StatusBar.tsx +++ b/src/cli/components/StatusBar.tsx @@ -1,5 +1,5 @@ -import React from 'react'; -import { Text } from 'ink'; +import React, { useEffect, useState } from 'react'; +import { Box, Text } from 'ink'; import type { ProviderSessionState } from '../../mcp/types.js'; interface StatusBarProps { @@ -12,6 +12,8 @@ interface StatusBarProps { contextTotal?: number; contextThreshold?: number; contextSource?: string; + memoryActivity?: string; + animateMemory?: boolean; } export function StatusBar({ @@ -24,40 +26,63 @@ export function StatusBar({ contextTotal, contextThreshold, contextSource, + memoryActivity, + animateMemory = true, }: StatusBarProps) { - const agoraLabel = provider?.toLowerCase() === 'agora' - ? formatAgoraStatus(model, providerState) - : ''; + const isAgora = provider?.toLowerCase() === 'agora'; let ctxLabel = ''; + let ctxColor: 'red' | 'yellow' | undefined; if (contextUsed != null && contextTotal && contextTotal > 0) { const threshold = contextThreshold && contextThreshold > 0 ? contextThreshold : contextTotal; const pct = Math.round((contextUsed / threshold) * 100); - const color = pct > 100 ? 'red' : pct > 80 ? 'yellow' : undefined; + ctxColor = pct > 100 ? 'red' : pct > 80 ? 'yellow' : undefined; const source = contextSource ? ` ${contextSource}` : ''; ctxLabel = ` · ctx: ${formatK(contextUsed)}/${formatK(threshold)} trigger · win ${formatK(contextTotal)}${source}`; - if (color) { - return ( - - {' '}Ctrl+V 图片 · ESC 中断 · 双击 ESC 切会话 · /quit 退出 - {taskCount ? ` · tasks: ${taskCount}` : ''} - {agoraLabel} - {ctxLabel} - {debug ? ' · 🔧 debug' : ''} - - ); - } + if (ctxColor) ctxLabel = ` · ${pct > 100 ? 'context risk' : 'context warning'}${ctxLabel}`; } + const [memoryColorIndex, setMemoryColorIndex] = useState(0); + const memoryColors = ['cyan', 'blue', 'magenta', 'blue'] as const; + const canAnimateMemory = animateMemory && !process.env.NO_COLOR && !process.env.MA_REDUCED_MOTION && process.env.TERM !== 'dumb'; + useEffect(() => { + if (!canAnimateMemory) return; + const timer = setInterval(() => setMemoryColorIndex((value) => (value + 1) % memoryColors.length), 500); + return () => clearInterval(timer); + }, [canAnimateMemory]); + + const memory = providerState?.memory; + const mounted = Array.isArray(memory?.mounted_memories) ? memory.mounted_memories : []; + const memoryStatus = typeof memory?.status === 'string' ? memory.status : 'unknown'; + const firstMounted = mounted[0]; + const memoryLabel = firstMounted + ? `${firstMounted.memory_name}@${firstMounted.version}${mounted.length > 1 ? ` +${mounted.length - 1}` : ''}` + : (memory?.active_memory_patch_ids?.length ?? 0) > 0 + ? `${memory?.active_memory_patch_ids?.length} 个记忆` + : '未挂载'; + const autoNames = mounted + .filter((item) => memory?.auto_target_memory_ids?.includes(item.memory_id)) + .map((item) => item.memory_name); return ( - - {' '}Ctrl+V 图片 · ESC 中断 · 双击 ESC 切会话 · /quit 退出 - {taskCount ? ` · tasks: ${taskCount}` : ''} - {agoraLabel} - {ctxLabel} - {debug ? ' · 🔧 debug' : ''} - + + + {' '}Provider: {provider || 'openai'} · Model: {shortText(model)} + {ctxColor ? {ctxLabel} : ctxLabel} + {taskCount ? ` · tasks: ${taskCount}` : ''} + {debug ? ' · 🔧 debug' : ''} + + {isAgora ? ( + + {' '}Memory: {shortText(memoryLabel, 42)} + {` · ${memoryStatus}`} + {autoNames.length > 0 ? ` · auto: ${autoNames.join(', ')}` : ''} + {providerState?.runtime_trust === 'unverified' ? ' · unverified runtime' : ''} + {memoryActivity ? ` · ${memoryActivity}` : ''} + + ) : null} + {' '}Ctrl+V 图片 · ESC 中断 · 双击 ESC 切会话 · /memory 记忆 · /quit 退出 + ); } @@ -72,22 +97,3 @@ function shortText(value: string | undefined, max = 24): string { if (text.includes('/')) return text.split('/').slice(-1)[0].slice(0, max); return `${text.slice(0, max - 1)}…`; } - -function shortId(value: string | undefined): string { - const text = value?.trim(); - if (!text) return '-'; - if (text.length <= 12) return text; - return `${text.slice(0, 6)}…${text.slice(-3)}`; -} - -function formatAgoraStatus(model: string, state?: ProviderSessionState | null): string { - const memory = state?.memory; - const status = typeof memory?.status === 'string' && memory.status - ? memory.status - : 'unknown'; - const patches = Array.isArray(memory?.active_memory_patch_ids) - ? memory.active_memory_patch_ids.length - : 0; - const patchLabel = patches > 0 ? `(${patches})` : ''; - return ` · agora:${shortText(model)} · mem ${status}${patchLabel} · sess ${shortId(state?.agora_session_id)}`; -} diff --git a/src/cli/utils/commands.ts b/src/cli/utils/commands.ts index 59022da..5c3316b 100644 --- a/src/cli/utils/commands.ts +++ b/src/cli/utils/commands.ts @@ -5,6 +5,7 @@ import { listModelChoices, type ModelChoice, } from './modelProfiles.js'; +import { agoraProjectProfileId, type AgoraMemoryIntakeTarget } from '../../provider/agora.js'; interface CommandContext { agent: Agent; @@ -13,6 +14,8 @@ interface CommandContext { exit: () => void; setModel?: (model: string) => void; openModelPicker?: () => Promise | void; + openMemoryConsole?: () => Promise | void; + startMemoryIntake?: (targets: AgoraMemoryIntakeTarget[]) => Promise | void; switchModelChoice?: (choice: ModelChoice) => void; revertLastTurn?: () => boolean; } @@ -249,12 +252,164 @@ commands.set('/model', { }, }); +commands.set('/memory', { + description: 'Open Agora Memory console or manage named Memories', + suggest: true, + handler: async (args, ctx) => { + const controller = ctx.agent.getMemoryController?.(); + if (!controller) return 'Agora memory requires the Agora provider.'; + const trimmed = args.trim(); + if (!trimmed) { + await ctx.openMemoryConsole?.(); + return ctx.openMemoryConsole ? null : 'Memory console is unavailable in this UI.'; + } + try { + const capabilities = controller.getCapabilities(); + if (!capabilities.memoryV2) { + if (trimmed === 'status') return (await controller.status()).content; + const legacyIntake = trimmed.match(/^internalize(?:\s+--into\s+(\S+))?$/); + if (legacyIntake) { + const profileId = ctx.agent.getProviderState?.()?.memory?.profile_id; + if (!profileId) return 'Legacy Memory 需要先选择一个 Profile。'; + const result = await controller.startIntake({ profile_id: profileId, into: legacyIntake[1] }); + return `Legacy Memory intake queued: ${result.job_id ?? result.job?.id}`; + } + return `当前 Agora 仅支持 ${capabilities.runtimeMode} Memory;基础对话可用,具名 Memory v2 命令已禁用。`; + } + const [memories, patches] = await Promise.all([controller.listMemories(), controller.listPatches(true)]); + const profileId = ctx.agent.getProviderState?.()?.memory?.profile_id ?? agoraProjectProfileId(process.cwd()); + const profiles = await controller.listProfiles(); + const profile = profiles.find((item) => item.id === profileId); + const resolveMemories = (raw: string) => { + const exact = memories.find((memory) => memory.name === raw.trim() || memory.id === raw.trim()); + if (exact) return [exact]; + const names = raw.includes(',') ? raw.split(',').map((name) => name.trim()).filter(Boolean) : []; + if (names.length > 0) return names.map((name) => { + const found = memories.find((item) => item.name === name || item.id === name); + if (!found) throw new Error(`Memory 不存在: ${name}`); + return found; + }); + const selected: typeof memories = []; + let rest = raw.trim(); + const candidates = [...memories].sort((left, right) => right.name.length - left.name.length); + while (rest) { + const found = candidates.find((item) => rest === item.name || rest.startsWith(`${item.name} `) || rest === item.id || rest.startsWith(`${item.id} `)); + if (!found) throw new Error(`无法识别 Memory 列表: ${rest}(名称含空格时也可用逗号分隔)`); + selected.push(found); + rest = rest.slice(rest.startsWith(found.name) ? found.name.length : found.id.length).trim(); + } + return selected; + }; + if (trimmed === 'list') { + if (memories.length === 0) return '还没有 Agora Memory。'; + return memories.map((memory) => { + const head = patches.find((patch) => patch.id === memory.head_patch_id); + const mounted = head && profile?.active_memory_patch_ids.includes(head.id) ? ' · mounted/pending' : ''; + return `${memory.name} · ${head?.version ?? '尚无版本'}${mounted}`; + }).join('\n'); + } + if (trimmed === 'status') return (await controller.status()).content; + const newMatch = trimmed.match(/^new\s+(.+)$/); + if (newMatch) { + return JSON.stringify(await controller.createMemory(newMatch[1].trim()), null, 2); + } + const renameInput = trimmed.match(/^rename\s+(.+)$/)?.[1]; + if (renameInput) { + const explicit = renameInput.match(/^(.+?)\s+--to\s+(.+)$/); + const candidate = explicit + ? resolveMemories(explicit[1])[0] + : [...memories].sort((left, right) => right.name.length - left.name.length) + .find((item) => renameInput.startsWith(`${item.name} `) || renameInput.startsWith(`${item.id} `)); + if (!candidate) throw new Error('重命名格式: /memory rename '); + const prefix = explicit ? explicit[1] : renameInput.startsWith(candidate.name) ? candidate.name : candidate.id; + const nextName = explicit ? explicit[2].trim() : renameInput.slice(prefix.length).trim(); + return JSON.stringify(await controller.renameMemory(candidate.id, nextName), null, 2); + } + const mountMatch = trimmed.match(/^mount\s+(.+?)(?:\s+(--session|--user))?$/); + if (mountMatch) { + const selected = resolveMemories(mountMatch[1]); + const scope = mountMatch[2] === '--session' ? 'conversation' : mountMatch[2] === '--user' ? 'user' : 'project'; + return JSON.stringify(await controller.mountMemories(profileId, selected.map((memory) => memory.id), scope), null, 2); + } + const unmountMatch = trimmed.match(/^unmount\s+(.+)$/); + if (unmountMatch) { + const remaining = unmountMatch[1].trim() === 'all' + ? [] + : memories.filter((memory) => { + const head = patches.find((patch) => patch.id === memory.head_patch_id); + return Boolean(head && profile?.active_memory_patch_ids.includes(head.id)) && + !resolveMemories(unmountMatch[1]).some((remove) => remove.id === memory.id); + }).map((memory) => memory.id); + return JSON.stringify(await controller.mountMemories(profileId, remaining), null, 2); + } + if (trimmed === 'internalize') { + await ctx.openMemoryConsole?.(); + return ctx.openMemoryConsole ? null : '请使用 /memory internalize --new 或 --into 。'; + } + const newIntake = trimmed.match(/^internalize\s+--new\s+(.+)$/); + const intoIntake = trimmed.match(/^internalize\s+--into\s+(.+)$/); + if (newIntake || intoIntake) { + if (!profile) return '请先挂载一个 Memory 组合并完成一次 Agora 对话。'; + const targets = newIntake + ? [{ mode: 'create' as const, name: newIntake[1].trim(), output_name: `${newIntake[1].trim()}@v1` }] + : resolveMemories(intoIntake![1]).map((memory) => ({ + mode: 'increment' as const, + memory_id: memory.id, + expected_parent_patch_id: memory.head_patch_id ?? undefined, + output_name: `${memory.name}@v${patches.filter((patch) => patch.memory_id === memory.id).length + 1}`, + })); + if (ctx.startMemoryIntake) { + await ctx.startMemoryIntake(targets); + return `Memory intake queued · ${targets.length} targets`; + } + const result = await controller.startBatchIntake({ targets }); + return `Memory intake queued: ${result.batch_id} · ${targets.length} targets`; + } + const autoMatch = trimmed.match(/^auto\s+(on|off)(?:\s+--targets\s+(.+))?$/); + if (autoMatch) { + if (!profile) return '请先挂载一个 Memory 组合。'; + const targets = autoMatch[2] ? resolveMemories(autoMatch[2]).map((memory) => memory.id) : profile.auto_intake_target_memory_ids ?? []; + if (autoMatch[1] === 'on' && targets.length === 0) return '开启自动内化必须指定 --targets 。'; + return JSON.stringify(await controller.setAutoPolicy(profileId, autoMatch[1] === 'on', targets), null, 2); + } + const historyMatch = trimmed.match(/^history\s+(.+)$/); + if (historyMatch) { + const memory = resolveMemories(historyMatch[1])[0]; + return patches + .filter((patch) => patch.memory_id === memory.id) + .map((patch) => `${patch.id} ${patch.name} · ${patch.version} · ${patch.status}`) + .join('\n') || 'No versions found.'; + } + const rollbackInput = trimmed.match(/^rollback\s+(.+)$/)?.[1]; + if (rollbackInput) { + const memory = [...memories].sort((left, right) => right.name.length - left.name.length) + .find((item) => rollbackInput.startsWith(`${item.name} `) || rollbackInput.startsWith(`${item.id} `)); + if (!memory) return '回滚格式: /memory rollback '; + const prefix = rollbackInput.startsWith(memory.name) ? memory.name : memory.id; + const version = rollbackInput.slice(prefix.length).trim(); + if (!memory.head_patch_id) return '该 Memory 还没有版本。'; + const target = patches.find((patch) => patch.memory_id === memory.id && + [patch.id, patch.name, patch.version].includes(version)); + if (!target) return `找不到版本: ${version}`; + return JSON.stringify(await controller.rollbackMemory(memory.id, memory.head_patch_id, target.id), null, 2); + } + if (trimmed === 'disable') { + if (!profile) return '当前没有 Memory 组合。'; + return (await controller.disable({ profile_id: profileId })).content; + } + return 'usage: /memory | list | mount [--session|--user] | unmount | new | rename --to | internalize [--new |--into ] | auto on --targets | auto off | history | rollback | status | disable'; + } catch (err) { + return `Memory error: ${(err as Error).message}`; + } + }, +}); + commands.set('/skills', { description: 'List all available skills', handler: async (_, ctx) => { await loadSkills(); const skillCommands = Array.from(commands.entries()) - .filter(([name, cmd]) => name.startsWith('/') && !['/quit', '/exit', '/tools', '/stack', '/abort', '/archive', '/context', '/clear', '/help', '/revert', '/undo', '/models', '/model', '/skills'].includes(name)); + .filter(([name, cmd]) => name.startsWith('/') && !['/quit', '/exit', '/tools', '/stack', '/abort', '/archive', '/context', '/clear', '/help', '/revert', '/undo', '/models', '/model', '/memory', '/skills'].includes(name)); if (skillCommands.length === 0) { return 'No custom skills found. Create skills in .ma/skills/ directory.'; diff --git a/src/cli/utils/modelProfiles.ts b/src/cli/utils/modelProfiles.ts index 44e9e63..1925769 100644 --- a/src/cli/utils/modelProfiles.ts +++ b/src/cli/utils/modelProfiles.ts @@ -13,6 +13,7 @@ export interface ModelChoice { label: string; current: boolean; source: 'remote' | 'cache' | 'config'; + status?: string; } function unique(values: string[]): string[] { diff --git a/src/cli/version.ts b/src/cli/version.ts index ea1dfa3..47c8f5f 100644 --- a/src/cli/version.ts +++ b/src/cli/version.ts @@ -1,20 +1 @@ -import fs from 'node:fs'; - -export const VERSION = readPackageVersion(); - -function readPackageVersion(): string { - for (const packageUrl of [ - new URL('../../package.json', import.meta.url), - new URL('../../../package.json', import.meta.url), - ]) { - try { - const pkg = JSON.parse(fs.readFileSync(packageUrl, 'utf-8')) as { version?: unknown }; - if (typeof pkg.version === 'string' && pkg.version.length > 0) { - return pkg.version; - } - } catch { - // Source execution and compiled execution need different relative package paths. - } - } - return '0.0.0-unknown'; -} +export { VERSION } from '../version.js'; diff --git a/src/mcp/client.ts b/src/mcp/client.ts index 56db118..76c0419 100644 --- a/src/mcp/client.ts +++ b/src/mcp/client.ts @@ -1,6 +1,7 @@ import { spawn, type ChildProcess } from 'node:child_process'; import { setMaxListeners } from 'node:events'; import * as fs from 'node:fs'; +import { VERSION } from '../version.js'; import type { McpConnection, McpServerConfig, @@ -268,7 +269,7 @@ export class McpClient implements McpConnection { await this.request('initialize', { protocolVersion: PROTOCOL_VERSION, capabilities: { tools: {} }, - clientInfo: { name: 'my-agent', version: '1.0.0' }, + clientInfo: { name: 'my-agent', version: VERSION }, }); try { this.notify('notifications/initialized'); diff --git a/src/mcp/types.ts b/src/mcp/types.ts index ef4e0e8..29efc18 100644 --- a/src/mcp/types.ts +++ b/src/mcp/types.ts @@ -122,6 +122,20 @@ export interface ProviderSessionState { profile_id?: string; binding_id?: string; active_memory_patch_ids?: string[]; + requested_memory_patch_ids?: string[]; + patchset_revision?: number; + mounted_memories?: Array<{ + memory_id: string; + memory_name: string; + patch_id: string; + patch_name: string; + version: string; + }>; + auto_target_memory_ids?: string[]; + active_batch?: Record; + last_auto_intake_message_end?: number; + last_auto_intake_runtime_message_end?: number; + runtime_message_count?: number; last_verified_at?: string; [key: string]: unknown; }; @@ -170,6 +184,7 @@ export interface Agent { revertLastTurnContextOnly(): number; respondConfirm(requestId: string, approved: boolean): void; getProviderState?(): ProviderSessionState | null; + getMemoryController?(): import('../provider/agora.js').AgoraMemoryController | null; getContextUsage(): { used: number; total: number; compactThreshold: number; source: string }; inspectContext(): string; searchContext(query: string): SessionPoolEntry[]; diff --git a/src/provider/agora-auto-intake.ts b/src/provider/agora-auto-intake.ts new file mode 100644 index 0000000..8e4217d --- /dev/null +++ b/src/provider/agora-auto-intake.ts @@ -0,0 +1,74 @@ +import type { + AgoraMemory, + AgoraMemoryIntakeTarget, + AgoraMemoryIntakeTargetResult, + AgoraMemoryPatch, + AgoraMemoryProfile, +} from './agora.js'; + +export interface AgoraAutoIntakePlan { + ready: boolean; + reason: string; + sourceStart: number; + sourceEnd: number; + pendingUserTurns: number; + pendingTokens: number; + targets: AgoraMemoryIntakeTarget[]; +} + +export function planAgoraAutoIntake(args: { + messages: any[]; + checkpointEnd: number; + profile: AgoraMemoryProfile; + memories: AgoraMemory[]; + patches: AgoraMemoryPatch[]; +}): AgoraAutoIntakePlan { + const sourceStart = Math.max(0, Math.min(args.messages.length, Math.floor(args.checkpointEnd))); + const pending = args.messages.slice(sourceStart); + const pendingUserTurns = pending.filter((message) => message?.role === 'user').length; + const pendingTokens = Math.ceil(JSON.stringify(pending).length / 4); + const minTurns = Number(args.profile.auto_intake_policy?.min_user_turns ?? 4); + const minTokens = Number(args.profile.auto_intake_policy?.min_pending_tokens ?? 2000); + const thresholdReached = pendingUserTurns >= minTurns || pendingTokens >= minTokens; + const targetIds = args.profile.auto_intake_target_memory_ids ?? []; + const targets = targetIds.map((memoryId) => { + const memory = args.memories.find((item) => item.id === memoryId); + if (!memory) throw new Error(`自动内化目标不存在: ${memoryId}`); + if (!memory.head_patch_id) throw new Error(`自动内化目标没有可增量版本: ${memory.name}`); + return { + mode: 'increment' as const, + memory_id: memory.id, + expected_parent_patch_id: memory.head_patch_id, + output_name: `${memory.name}@v${args.patches.filter((patch) => patch.memory_id === memory.id).length + 1}`, + }; + }); + const reason = targetIds.length === 0 + ? 'no_explicit_targets' + : pending.length === 0 + ? 'no_pending_messages' + : thresholdReached ? 'ready' : 'below_threshold'; + return { + ready: reason === 'ready', + reason, + sourceStart, + sourceEnd: args.messages.length, + pendingUserTurns, + pendingTokens, + targets, + }; +} + +export function classifyAgoraBatchTargets(targets: AgoraMemoryIntakeTargetResult[]): { + terminal: boolean; + completed: AgoraMemoryIntakeTargetResult[]; + blockers: AgoraMemoryIntakeTargetResult[]; + retryable: AgoraMemoryIntakeTargetResult[]; +} { + const terminalStatuses = new Set(['completed', 'noop', 'review', 'conflict', 'failed']); + return { + terminal: targets.length > 0 && targets.every((target) => terminalStatuses.has(target.status)), + completed: targets.filter((target) => target.status === 'completed' || target.status === 'noop'), + blockers: targets.filter((target) => target.status === 'review' || target.status === 'conflict' || target.status === 'failed'), + retryable: targets.filter((target) => target.status === 'review' || target.status === 'conflict' || (target.status === 'failed' && target.error?.retryable !== false)), + }; +} diff --git a/src/provider/agora-runtime-lock.json b/src/provider/agora-runtime-lock.json new file mode 100644 index 0000000..aca148b --- /dev/null +++ b/src/provider/agora-runtime-lock.json @@ -0,0 +1,22 @@ +{ + "version": "0.2.0", + "platform": "darwin-arm64", + "host_protocol_major": 1, + "native_core_abi": 1, + "published": false, + "notarization_id": null, + "manifest_sha256": "5af44c6ed85e3bafc55c1ef83f574fb40aae4cdc5a6e5920841411808cdd35dc", + "capabilities": [ + "mcp-stdio", + "memory-profile-v2", + "memory-intake-v2" + ], + "packages": { + "@zimoos/agora": { + "integrity": "sha512-iUCroM4rOS5lXgRSKsXG/S5OyhHDKMssioEW3/fFYkqOptayeImZBMVngmFn1kipnaxe7mhlZ2U+v1Xl8dEEQQ==" + }, + "@zimoos/agora-darwin-arm64": { + "integrity": "sha512-/CxLj4ZMjaSaLPGQ9o5OlrN8Yd55sxacvWD3o3djX0hHL8BXTnL3IPcAsJVpG7+yQCuG0i43z1vSBh25O26+8g==" + } + } +} diff --git a/src/provider/agora.ts b/src/provider/agora.ts index 6683fcb..458ef0a 100644 --- a/src/provider/agora.ts +++ b/src/provider/agora.ts @@ -1,8 +1,11 @@ -import { spawn } from 'node:child_process'; +import { spawn, spawnSync } from 'node:child_process'; import * as fs from 'node:fs'; import * as os from 'node:os'; import * as path from 'node:path'; import { fileURLToPath } from 'node:url'; +import { createRequire } from 'node:module'; +import { createHash } from 'node:crypto'; +import agoraReleaseLock from './agora-runtime-lock.json' with { type: 'json' }; import type { ChatCompletion, ChatCompletionCreateParamsNonStreaming, @@ -31,7 +34,165 @@ export interface AgoraMemoryToolResult { isError: boolean; } +export interface AgoraCapabilities { + chat: boolean; + modelCatalog: boolean; + modelDownload: boolean; + profileRead: boolean; + profileWrite: boolean; + patchCatalog: boolean; + mount: boolean; + intake: boolean; + lineageCas: boolean; + rollback: boolean; + progress: boolean; + memoryV2: boolean; + namedMemories: boolean; + multiTargetIntake: boolean; + incrementalSegments: boolean; + multiPatchMount: boolean; + requestBoundaryHotSwap: boolean; + runtimeMode: 'v2' | 'legacy' | 'unavailable'; +} + +export interface AgoraRuntimeContract { + runtime_version?: string; + host_protocol?: string; + host_protocol_major?: number; + registry_schema_version?: number; + native_core_abi?: number; + capabilities?: Record; + memory_runtime_v2?: { enabled?: boolean; rollback?: string }; +} + +export interface AgoraMemory { + id: string; + name: string; + normalized_name?: string; + base_model_id: string; + head_patch_id?: string | null; + status: string; + metadata?: Record; +} + +export interface AgoraMemoryProfile { + id: string; + name: string; + base_model_id: string; + active_memory_patch_ids: string[]; + auto_intake_target_memory_ids?: string[]; + writable_patch_family?: string | null; + auto_intake_policy?: { + enabled?: boolean; + min_user_turns?: number; + min_pending_tokens?: number; + idle_seconds?: number; + activation_mode?: 'auto' | 'review'; + }; + memory_enabled?: boolean; + status?: string; +} + +export interface AgoraMemoryPatch { + id: string; + name: string; + base_model_id: string; + family: string; + version: string; + mountable: boolean; + status: string; + memory_id?: string | null; + normalized_name?: string | null; + parent_patch_id?: string | null; + segment_ids?: string[]; +} + +export type AgoraIntakeTargetStatus = + | 'queued' + | 'compiling' + | 'completed' + | 'noop' + | 'review' + | 'conflict' + | 'failed'; + +export interface AgoraMemoryIntakeTarget { + mode: 'create' | 'increment'; + name?: string; + memory_id?: string; + expected_parent_patch_id?: string | null; + output_name: string; +} + +export interface AgoraMemoryIntakeTargetResult { + id: string; + batch_id: string; + mode: 'create' | 'increment'; + memory_id?: string | null; + memory_name?: string | null; + output_name: string; + expected_parent_patch_id?: string | null; + status: AgoraIntakeTargetStatus; + output_patch_id?: string | null; + error?: { code?: string; message?: string; retryable?: boolean } | null; + result?: Record | null; +} + +export interface AgoraMemoryIntakeBatchResult { + status: string; + batch_id: string; + batch?: Record; + targets: AgoraMemoryIntakeTargetResult[]; +} + +export class AgoraMcpError extends Error { + readonly code?: string; + readonly field?: string; + readonly retryable?: boolean; + readonly payload: Record; + + constructor(message: string, payload: Record) { + super(message); + this.name = 'AgoraMcpError'; + const error = asRecord(payload.error) ?? {}; + this.code = typeof error.code === 'string' + ? error.code + : typeof error.type === 'string' ? error.type : undefined; + this.field = typeof error.field === 'string' ? error.field : undefined; + this.retryable = typeof error.retryable === 'boolean' ? error.retryable : undefined; + this.payload = payload; + } +} + export interface AgoraMemoryController { + getCapabilities(): AgoraCapabilities; + getRuntimeContract(): AgoraRuntimeContract | null; + updateLocalMemoryState(patch: Record): void; + listMemories(): Promise; + getMemory(memoryId: string): Promise; + createMemory(name: string, id?: string): Promise; + renameMemory(memoryId: string, name: string): Promise; + listProfiles(): Promise; + listPatches(includeDisabled?: boolean): Promise; + createProfile(args: Record): Promise>; + renameProfile(profileId: string, name: string): Promise>; + selectProfile(profileId: string, scope?: 'user' | 'project' | 'conversation'): Promise>; + applyPatchSelection(profileId: string, patchIds: string[], writableFamily?: string | null): Promise>; + mountMemories(profileId: string, memoryIds: string[], scope?: 'user' | 'project' | 'conversation'): Promise>; + getIntakeStatus(): Promise>; + startIntake(args?: Record): Promise>; + finalizeIntake(jobId: string, profileId: string): Promise>; + startBatchIntake(args: { + targets: AgoraMemoryIntakeTarget[]; + source_message_start?: number; + source_message_end?: number; + }): Promise; + getBatchIntake(batchId: string): Promise; + applyCompletedBatch(batch: AgoraMemoryIntakeBatchResult, profileId: string): Promise>; + setAutoPolicy(profileId: string, enabled: boolean, targetMemoryIds?: string[]): Promise>; + rollbackMemory(memoryId: string, expectedHeadPatchId: string, targetPatchId: string): Promise; + listModels(): Promise[]>; + downloadModel(modelId: string, onProgress?: (event: McpProgressEvent) => void): Promise>; status(args?: Record): Promise; mount(args: Record): Promise; disable(args: Record): Promise; @@ -40,29 +201,11 @@ export interface AgoraMemoryController { } const REQUIRED_TOOLS = new Set(['doctor', 'models_list', 'chat_complete']); -const REQUIRED_MEMORY_TOOLS = new Set([ - 'memory_profiles_list', - 'memory_profiles_create', - 'memory_profiles_update', - 'memory_profile_bindings_list', - 'memory_profile_bindings_create', - 'memory_sources_create', - 'memory_sources_list', - 'memory_sources_get', - 'memory_intake_run', - 'memory_intake_get', - 'memory_patch_versions', -]); - const TERMINAL_INTAKE_STATUSES = new Set(['completed', 'failed']); const DEFAULT_AGORA_MAX_TOKENS = 4096; const AGORA_TIMEOUT_CAP_SECONDS = 300; const AGORA_TIMEOUT_HEADROOM_MS = 5000; -function sleep(ms: number): Promise { - return new Promise((resolve) => setTimeout(resolve, ms)); -} - function stringifyArgs(value: unknown): string { if (typeof value === 'string') return value; if (value === undefined) return '{}'; @@ -117,25 +260,139 @@ function bundledAgoraCandidates(): string[] { ]; } -function resolveAgoraCommand(runtime?: AgoraRuntimeConfig): { command: string; args: string[] } { +interface ResolvedAgoraCommand { + command: string; + args: string[]; + trust: 'verified' | 'unverified'; + source: 'configured' | 'override' | 'bundled' | 'npm' | 'path'; + lock?: Record; +} + +export function parseDeveloperIdSignature(details: string): boolean { + return details.split(/\r?\n/).some((line) => line.startsWith('Authority=Developer ID Application:')) && + details.split(/\r?\n/).some((line) => line.startsWith('TeamIdentifier=') && line !== 'TeamIdentifier=not set'); +} + +function verifyNativeSignature(command: string): boolean { + if (process.platform !== 'darwin') return true; + if (spawnSync('/usr/bin/codesign', ['--verify', '--strict', '--verbose=2', command], { stdio: 'ignore' }).status !== 0) { + return false; + } + const details = spawnSync('/usr/bin/codesign', ['-d', '--verbose=4', command], { encoding: 'utf8' }); + return details.status === 0 && parseDeveloperIdSignature(`${details.stdout ?? ''}\n${details.stderr ?? ''}`); +} + +function sha256File(file: string): string { + return createHash('sha256').update(fs.readFileSync(file)).digest('hex'); +} + +export function verifyAgoraManifestFiles(root: string, manifest: Record): boolean { + const files = asRecord(manifest.files); + if (!files || Object.keys(files).length === 0) return false; + const canonicalRoot = path.resolve(root); + return Object.entries(files).every(([relative, expected]) => { + if (typeof expected !== 'string' || path.isAbsolute(relative)) return false; + const target = path.resolve(canonicalRoot, relative); + if (!target.startsWith(`${canonicalRoot}${path.sep}`) || !fs.statSync(target, { throwIfNoEntry: false })?.isFile()) return false; + return sha256File(target) === expected; + }); +} + +function verifyBundledAgora(command: string): Record | null { + const root = path.dirname(path.dirname(command)); + const manifestPath = path.join(root, 'manifest.json'); + const lockPath = path.join(root, 'runtime-lock.json'); + if (!fs.existsSync(manifestPath) || !fs.existsSync(lockPath)) return null; + try { + const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8')); + const lock = JSON.parse(fs.readFileSync(lockPath, 'utf8')); + if (agoraReleaseLock.published !== true || lock.published !== true) return null; + if (!verifyAgoraManifestFiles(root, manifest)) return null; + if (manifest.version !== lock.version || manifest.host_protocol_major !== lock.host_protocol_major) return null; + if (lock.native_core_abi !== undefined && manifest.native_core_abi !== lock.native_core_abi) return null; + if (lock.manifest_sha256 && sha256File(manifestPath) !== lock.manifest_sha256) return null; + if (lock.manifest_sha256 !== agoraReleaseLock.manifest_sha256) return null; + if (lock.package_integrity !== agoraReleaseLock.packages['@zimoos/agora-darwin-arm64'].integrity) return null; + if (lock.notarization_id !== agoraReleaseLock.notarization_id) return null; + if (!verifyNativeSignature(command)) return null; + return lock; + } catch { + return null; + } +} + +function resolveInstalledAgoraPackage(): ResolvedAgoraCommand | null { + try { + if (agoraReleaseLock.published !== true) return null; + const require = createRequire(import.meta.url); + const packageJsonPath = require.resolve('@zimoos/agora/package.json'); + const packageRoot = path.dirname(packageJsonPath); + const platformPackageJsonPath = require.resolve('@zimoos/agora-darwin-arm64/package.json'); + const platformRoot = path.dirname(platformPackageJsonPath); + const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, 'utf8')); + const platformPackageJson = JSON.parse(fs.readFileSync(platformPackageJsonPath, 'utf8')); + const manifestPath = path.join(platformRoot, 'manifest.json'); + const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8')); + const command = path.join(packageRoot, 'bin', 'agora'); + const platformCommand = path.join(platformRoot, 'bin', 'agora'); + const requiredCapabilities = ['mcp-stdio', 'memory-profile-v2', 'memory-intake-v2']; + if ( + packageJson.version !== '0.2.0' || + packageJson.dependencies?.['@zimoos/agora-darwin-arm64'] !== '0.2.0' || + platformPackageJson.version !== '0.2.0' || + manifest.version !== '0.2.0' || + manifest.host_protocol_major !== 1 || + manifest.native_core_abi !== 1 || + !requiredCapabilities.every((capability) => manifest.capabilities?.includes(capability)) || + sha256File(manifestPath) !== agoraReleaseLock.manifest_sha256 || + !verifyAgoraManifestFiles(platformRoot, manifest) || + !commandExists(command) || + !commandExists(platformCommand) || + !verifyNativeSignature(command) || + !verifyNativeSignature(platformCommand) + ) return null; + return { + command, + args: ['mcp', 'serve'], + trust: 'verified', + source: 'npm', + lock: { + version: '0.2.0', + host_protocol_major: 1, + native_core_abi: 1, + manifest_sha256: agoraReleaseLock.manifest_sha256, + notarization_id: agoraReleaseLock.notarization_id, + }, + }; + } catch { + return null; + } +} + +function resolveAgoraCommand(runtime?: AgoraRuntimeConfig): ResolvedAgoraCommand { const configured = runtime?.command?.trim(); - if (configured) return { command: configured, args: runtime?.args ?? ['mcp', 'serve'] }; + if (configured) return { command: configured, args: runtime?.args ?? ['mcp', 'serve'], trust: 'unverified', source: 'configured' }; const envCommand = process.env.MA_AGORA_COMMAND?.trim(); - if (envCommand) return { command: envCommand, args: runtime?.args ?? ['mcp', 'serve'] }; + if (envCommand) return { command: envCommand, args: runtime?.args ?? ['mcp', 'serve'], trust: 'unverified', source: 'override' }; for (const candidate of bundledAgoraCandidates()) { - if (commandExists(candidate)) return { command: candidate, args: ['mcp', 'serve'] }; + if (!commandExists(candidate)) continue; + const lock = verifyBundledAgora(candidate); + if (!lock) throw new Error(`Bundled Agora failed integrity verification: ${candidate}`); + return { command: candidate, args: ['mcp', 'serve'], trust: 'verified', source: 'bundled', lock }; } - const pathAgora = findOnPath('agora'); - if (pathAgora) return { command: pathAgora, args: ['mcp', 'serve'] }; + const installed = resolveInstalledAgoraPackage(); + if (installed) return installed; - const devFallback = '/Users/zhuqingyu/dev/agora/.venv/bin/agora'; - if (commandExists(devFallback)) return { command: devFallback, args: ['mcp', 'serve'] }; + if (process.env.MA_AGORA_ALLOW_UNVERIFIED === '1') { + const pathAgora = findOnPath('agora'); + if (pathAgora) return { command: pathAgora, args: ['mcp', 'serve'], trust: 'unverified', source: 'path' }; + } throw new Error( - 'Agora provider requires an Agora runtime command. Set model.agoraRuntime.command or MA_AGORA_COMMAND.' + 'Agora runtime is not installed in the verified MA bundle. Set MA_AGORA_COMMAND only for an explicit unverified development override.' ); } @@ -156,8 +413,20 @@ function safeUserId(): string { } function defaultProjectId(cwd?: string): string { - const base = path.basename(cwd || process.cwd()).trim(); - return base || 'my-agent'; + const requested = path.resolve(cwd || process.cwd()); + let canonical = requested; + try { + canonical = fs.realpathSync.native(requested); + } catch { + // Keep the normalized absolute path for a project that is not mounted yet. + } + const base = path.basename(canonical).trim() || 'my-agent'; + const suffix = createHash('sha256').update(canonical).digest('hex').slice(0, 12); + return `${base}-${suffix}`; +} + +export function agoraProjectProfileId(cwd?: string): string { + return `ma-project-${defaultProjectId(cwd).toLowerCase().replace(/[^a-z0-9._-]+/g, '-')}`; } function toStringList(value: unknown): string[] { @@ -185,7 +454,10 @@ function normalizeToolCalls(value: unknown): ChatCompletionMessageToolCall[] | u return out.length > 0 ? out : undefined; } -function stateFromChatPayload(payload: Record): ProviderSessionState { +function stateFromChatPayload( + payload: Record, + requestedPatchIds?: string[] | null +): ProviderSessionState { const metadata = payload.metadata && typeof payload.metadata === 'object' ? payload.metadata : {}; const memory = payload.memory && typeof payload.memory === 'object' @@ -207,8 +479,17 @@ function stateFromChatPayload(payload: Record): ProviderSessionStat const bindingId = typeof memory.binding_id === 'string' ? memory.binding_id : undefined; const enabled = typeof memory.enabled === 'boolean' ? memory.enabled : undefined; const reason = typeof memory.reason === 'string' ? memory.reason : undefined; + const runtime = asRecord(payload.memory_runtime) ?? asRecord(metadata.memory_runtime) ?? {}; + const revision = Number(runtime.patchset_revision); + const requested = requestedPatchIds ? [...requestedPatchIds] : undefined; + const matchesRequest = requested === undefined || ( + requested.length === activePatchIds.length && + requested.every((id, index) => id === activePatchIds[index]) + ); const status = enabled === false ? 'disabled' + : !matchesRequest + ? 'stale' : activePatchIds.length > 0 ? 'mounted' : profileId @@ -228,6 +509,8 @@ function stateFromChatPayload(payload: Record): ProviderSessionStat profile_id: profileId, binding_id: bindingId, active_memory_patch_ids: activePatchIds, + ...(requested ? { requested_memory_patch_ids: requested } : {}), + ...(Number.isFinite(revision) ? { patchset_revision: revision } : {}), enabled, reason, last_verified_at: verifiedAt, @@ -354,6 +637,11 @@ export class AgoraProviderRuntime implements AgoraMemoryController { private selectedProfileId: string | null = null; private doctorPayload: Record | null = null; private modelsPayload: Record | null = null; + private runtimeContract: AgoraRuntimeContract | null = null; + private requestedPatchIds: string[] | null = null; + private requestedAfterRevision: number | null = null; + private runtimeTrust: 'verified' | 'unverified' = 'unverified'; + private runtimeSource = 'unknown'; constructor( private readonly model: ModelConfig, @@ -370,10 +658,55 @@ export class AgoraProviderRuntime implements AgoraMemoryController { } memoryReady(): boolean { - for (const name of REQUIRED_MEMORY_TOOLS) { - if (!this.toolNames.has(name)) return false; - } - return true; + const capabilities = this.getCapabilities(); + return capabilities.profileRead && capabilities.profileWrite && capabilities.mount; + } + + getCapabilities(): AgoraCapabilities { + const has = (name: string) => this.toolNames.has(name); + const contract = this.runtimeContract?.capabilities ?? {}; + const enabled = (name: string) => contract[name] === 1 || contract[name] === true; + const namedMemories = has('memories_create') && has('memories_list') && has('memories_rename') && enabled('named_memories'); + const multiTargetIntake = has('memory_intake_batch_run') && has('memory_intake_batch_get') && enabled('multi_target_intake'); + const incrementalSegments = enabled('incremental_segments'); + const multiPatchMount = enabled('multi_model_delta_mount'); + const requestBoundaryHotSwap = enabled('request_boundary_hot_swap'); + const memoryV2 = namedMemories && multiTargetIntake && incrementalSegments && multiPatchMount && requestBoundaryHotSwap && has('memories_rollback'); + return { + chat: has('chat_complete'), + modelCatalog: has('models_list') && has('models_status'), + modelDownload: has('models_download'), + profileRead: has('memory_profiles_list'), + profileWrite: has('memory_profiles_create') && has('memory_profiles_update'), + patchCatalog: has('memory_patches_list'), + mount: has('memory_profile_bindings_list') && has('memory_profile_bindings_create'), + intake: has('memory_intake_run') && has('memory_intake_get') && has('memory_intake_status'), + lineageCas: has('memory_lineage_advance'), + rollback: has('memory_patch_versions'), + progress: true, + memoryV2, + namedMemories, + multiTargetIntake, + incrementalSegments, + multiPatchMount, + requestBoundaryHotSwap, + runtimeMode: memoryV2 + ? 'v2' + : has('memory_intake_run') && has('memory_profiles_update') ? 'legacy' : 'unavailable', + }; + } + + getRuntimeContract(): AgoraRuntimeContract | null { + return this.runtimeContract ? { ...this.runtimeContract } : null; + } + + updateLocalMemoryState(patch: Record): void { + this.lastState = { + ...(this.lastState ?? { provider_id: 'agora' }), + memory: { ...(this.lastState?.memory ?? {}), ...patch }, + runtime_trust: this.runtimeTrust, + runtime_source: this.runtimeSource, + }; } getProviderState(): ProviderSessionState | null { @@ -381,7 +714,299 @@ export class AgoraProviderRuntime implements AgoraMemoryController { } getMemoryController(): AgoraMemoryController | null { - return this.memoryReady() ? this : null; + return this.toolNames.has('chat_complete') ? this : null; + } + + async listMemories(): Promise { + this.requireV2(); + const payload = await this.callJsonTool('memories_list', { base_model_id: this.model.model }); + return Array.isArray(payload.memories) ? payload.memories as AgoraMemory[] : []; + } + + async getMemory(memoryId: string): Promise { + this.requireV2(); + const payload = await this.callJsonTool('memories_get', { memory_id: memoryId }); + const memory = asRecord(payload.memory); + if (!memory) throw new Error(`Agora did not return Memory: ${memoryId}`); + return memory as unknown as AgoraMemory; + } + + async createMemory(name: string, id?: string): Promise { + this.requireV2(); + const payload = await this.callJsonTool('memories_create', { + ...(id ? { id } : {}), + name, + base_model_id: this.model.model, + }); + const memory = asRecord(payload.memory); + if (!memory) throw new Error('Agora did not return the created Memory'); + return memory as unknown as AgoraMemory; + } + + async renameMemory(memoryId: string, name: string): Promise { + this.requireV2(); + const payload = await this.callJsonTool('memories_rename', { memory_id: memoryId, name }); + const memory = asRecord(payload.memory); + if (!memory) throw new Error(`Agora did not return renamed Memory: ${memoryId}`); + return memory as unknown as AgoraMemory; + } + + async listProfiles(): Promise { + this.requireTools('memory_profiles_list'); + const payload = await this.callJsonTool('memory_profiles_list', { base_model_id: this.model.model }); + return Array.isArray(payload.profiles) ? payload.profiles as AgoraMemoryProfile[] : []; + } + + async listPatches(includeDisabled = false): Promise { + this.requireTools('memory_patches_list'); + const payload = await this.callJsonTool('memory_patches_list', { + base_model_id: this.model.model, + include_disabled: includeDisabled, + }); + return Array.isArray(payload.patches) ? payload.patches as AgoraMemoryPatch[] : []; + } + + async createProfile(args: Record): Promise> { + this.requireTools('memory_profiles_create'); + const profileId = this.resolveProfileId(args); + const payload = await this.callJsonTool('memory_profiles_create', { + id: profileId, + name: typeof args.name === 'string' && args.name.trim() ? args.name.trim() : profileId, + base_model_id: this.model.model, + active_memory_patch_ids: this.patchIdsFromArgs(args), + auto_intake_target_memory_ids: toStringList(args.auto_intake_target_memory_ids), + writable_patch_family: typeof args.writable_patch_family === 'string' ? args.writable_patch_family : undefined, + auto_intake_policy: args.auto_intake_policy ?? { enabled: false }, + memory_enabled: args.memory_enabled !== false, + }); + await this.ensureBinding(profileId, args.scope === 'conversation' ? 'conversation' : args.scope === 'user' ? 'user' : 'project'); + this.selectedProfileId = profileId; + return payload; + } + + async renameProfile(profileId: string, name: string): Promise> { + this.requireTools('memory_profiles_update'); + return this.callJsonTool('memory_profiles_update', { profile_id: profileId, name }); + } + + async selectProfile( + profileId: string, + scope: 'user' | 'project' | 'conversation' = 'project' + ): Promise> { + const profiles = await this.listProfiles(); + const profile = profiles.find((item) => item.id === profileId); + if (!profile) throw new Error(`memory profile not found: ${profileId}`); + await this.ensureBinding(profileId, scope); + this.selectedProfileId = profileId; + return { profile, scope, mount_status: 'pending_next_chat' }; + } + + async applyPatchSelection( + profileId: string, + patchIds: string[], + writableFamily?: string | null + ): Promise> { + this.requireTools('memory_profiles_update'); + const updated = await this.callJsonTool('memory_profiles_update', { + profile_id: profileId, + active_memory_patch_ids: patchIds, + ...(this.getCapabilities().memoryV2 ? {} : { writable_patch_family: writableFamily ?? null }), + memory_enabled: true, + }); + await this.ensureBinding(profileId, 'project'); + this.selectedProfileId = profileId; + this.markPatchSelectionPending(profileId, patchIds); + return { ...updated, mount_status: 'pending_next_chat' }; + } + + async mountMemories( + profileId: string, + memoryIds: string[], + scope: 'user' | 'project' | 'conversation' = 'project' + ): Promise> { + this.requireV2(); + const baseProfileId = profileId.split('--conversation-')[0].replace(/--user-default$/, ''); + const effectiveProfileId = scope === 'conversation' + ? `${baseProfileId}--conversation-${this.safeScopeId(this.model.agoraMemory?.conversationId || this.context.sessionId || 'default')}` + : scope === 'user' ? `${baseProfileId}--user-default` : baseProfileId; + const [memories, patches] = await Promise.all([this.listMemories(), this.listPatches(true)]); + const selected = memoryIds.map((memoryId) => { + const memory = memories.find((item) => item.id === memoryId); + if (!memory) throw new Error(`Memory not found: ${memoryId}`); + if (!memory.head_patch_id) throw new Error(`Memory has no compiled version yet: ${memory.name}`); + const patch = patches.find((item) => item.id === memory.head_patch_id); + if (!patch?.mountable) throw new Error(`Memory version is not mountable: ${memory.name}`); + if (patch.base_model_id !== this.model.model) throw new Error(`Memory is incompatible with model ${this.model.model}: ${memory.name}`); + return patch.id; + }); + await this.upsertProfile(effectiveProfileId, selected, true); + const bindingId = await this.ensureBinding(effectiveProfileId, scope); + this.selectedProfileId = effectiveProfileId; + this.markPatchSelectionPending(effectiveProfileId, selected, bindingId); + return { + profile_id: effectiveProfileId, + binding_id: bindingId, + memory_ids: memoryIds, + active_memory_patch_ids: selected, + mount_status: 'pending_next_chat', + }; + } + + async getIntakeStatus(): Promise> { + this.requireTools('memory_intake_status'); + const sessionId = this.requireSessionId(); + return this.callJsonTool('memory_intake_status', { session_id: sessionId }); + } + + async startIntake(args: Record = {}): Promise> { + this.requireTools('memory_intake_run'); + const sessionId = this.requireSessionId(); + const profileId = this.resolveProfileId(args); + const profiles = await this.listProfiles(); + const profile = profiles.find((item) => item.id === profileId); + if (!profile) throw new Error(`memory profile not found: ${profileId}`); + const family = typeof args.into === 'string' && args.into.trim() + ? args.into.trim() + : typeof args.writable_patch_family === 'string' && args.writable_patch_family.trim() + ? args.writable_patch_family.trim() + : profile.writable_patch_family?.trim(); + if (!family) throw new Error('memory intake requires a writable memory module; use --into '); + const patches = await this.listPatches(true); + const expected = profile.active_memory_patch_ids.find( + (id) => patches.find((patch) => patch.id === id)?.family === family + ); + return this.callJsonTool('memory_intake_run', { + session_id: sessionId, + profile_id: profileId, + writable_patch_family: family, + ...(expected ? { expected_previous_patch_id: expected } : {}), + }); + } + + async finalizeIntake(jobId: string, profileId: string): Promise> { + this.requireTools('memory_intake_get'); + const current = await this.callJsonTool('memory_intake_get', { job_id: jobId }); + if (!TERMINAL_INTAKE_STATUSES.has(String(current.status))) return current; + if (String(current.status) === 'failed') throw new Error(errorText(current)); + const job = asRecord(current.job) ?? current; + const patchId = typeof job.output_memory_patch_id === 'string' + ? job.output_memory_patch_id + : typeof current.output_memory_patch_id === 'string' ? current.output_memory_patch_id : undefined; + if (!patchId) return { ...current, outcome: 'noop' }; + const profiles = await this.listProfiles(); + const profile = profiles.find((item) => item.id === profileId); + if (!profile) throw new Error(`memory profile not found: ${profileId}`); + if (profile.auto_intake_policy?.activation_mode === 'review') { + return { ...current, outcome: 'review_required' }; + } + this.requireTools('memory_lineage_advance'); + const lineage = asRecord(job.result)?.lineage ?? asRecord(current.result)?.lineage ?? {}; + const advanced = await this.callJsonTool('memory_lineage_advance', { + profile_id: profileId, + family: lineage.family ?? profile.writable_patch_family, + expected_previous_patch_id: lineage.previous_patch_id ?? null, + new_patch_id: patchId, + session_id: this.requireSessionId(), + job_id: jobId, + }); + return { ...advanced, outcome: 'activated', mount_status: 'pending_next_chat' }; + } + + async startBatchIntake(args: { + targets: AgoraMemoryIntakeTarget[]; + source_message_start?: number; + source_message_end?: number; + }): Promise { + this.requireV2(); + if (!Array.isArray(args.targets) || args.targets.length === 0) { + throw new Error('Memory intake requires at least one explicit target'); + } + const payload = await this.callJsonTool('memory_intake_batch_run', { + session_id: this.requireSessionId(), + targets: args.targets, + ...(Number.isInteger(args.source_message_start) ? { source_message_start: args.source_message_start } : {}), + ...(Number.isInteger(args.source_message_end) ? { source_message_end: args.source_message_end } : {}), + }); + return this.normalizeBatch(payload); + } + + async getBatchIntake(batchId: string): Promise { + this.requireV2(); + return this.normalizeBatch(await this.callJsonTool('memory_intake_batch_get', { batch_id: batchId })); + } + + async applyCompletedBatch( + batch: AgoraMemoryIntakeBatchResult, + profileId: string + ): Promise> { + this.requireV2(); + const completed = batch.targets.filter((target) => target.status === 'completed' && target.output_patch_id); + if (completed.length === 0) { + return { outcome: 'no_completed_targets', targets: batch.targets }; + } + const [profiles, patches] = await Promise.all([this.listProfiles(), this.listPatches(true)]); + const profile = profiles.find((item) => item.id === profileId); + if (!profile) throw new Error(`memory profile not found: ${profileId}`); + const completedByMemory = new Map(); + for (const target of completed) { + if (target.memory_id && target.output_patch_id) completedByMemory.set(target.memory_id, target.output_patch_id); + } + const nextIds: string[] = []; + for (const patchId of profile.active_memory_patch_ids) { + const patch = patches.find((item) => item.id === patchId); + const replacement = patch?.memory_id ? completedByMemory.get(patch.memory_id) : undefined; + nextIds.push(replacement ?? patchId); + if (patch?.memory_id) completedByMemory.delete(patch.memory_id); + } + nextIds.push(...completedByMemory.values()); + const mounted = await this.applyPatchSelection(profileId, Array.from(new Set(nextIds))); + return { ...mounted, outcome: 'completed_targets_selected', targets: batch.targets }; + } + + async setAutoPolicy( + profileId: string, + enabled: boolean, + targetMemoryIds?: string[] + ): Promise> { + this.requireTools('memory_profiles_update'); + const profiles = await this.listProfiles(); + const profile = profiles.find((item) => item.id === profileId); + if (!profile) throw new Error(`memory profile not found: ${profileId}`); + return this.callJsonTool('memory_profiles_update', { + profile_id: profileId, + ...(targetMemoryIds ? { auto_intake_target_memory_ids: targetMemoryIds } : {}), + auto_intake_policy: { ...profile.auto_intake_policy, enabled }, + }); + } + + async rollbackMemory( + memoryId: string, + expectedHeadPatchId: string, + targetPatchId: string + ): Promise { + this.requireV2(); + const payload = await this.callJsonTool('memories_rollback', { + memory_id: memoryId, + expected_head_patch_id: expectedHeadPatchId, + target_patch_id: targetPatchId, + }); + const memory = asRecord(payload.memory); + if (!memory) throw new Error(`Agora did not return rolled back Memory: ${memoryId}`); + return memory as unknown as AgoraMemory; + } + + async listModels(): Promise[]> { + this.requireTools('models_list'); + const payload = await this.callJsonTool('models_list', {}); + return Array.isArray(payload.models) ? payload.models : []; + } + + async downloadModel( + modelId: string, + onProgress?: (event: McpProgressEvent) => void + ): Promise> { + this.requireTools('models_download'); + return this.callJsonTool('models_download', { model_id: modelId }, undefined, true, onProgress); } async close(): Promise { @@ -417,10 +1042,13 @@ export class AgoraProviderRuntime implements AgoraMemoryController { provider: 'agora', ready_tools: [...this.toolNames].sort(), resources: this.resources, - memory_capability_ready: this.memoryReady(), + capabilities: this.getCapabilities(), provider_state: this.lastState, doctor: this.doctorPayload, models: this.modelsPayload, + runtime_trust: this.runtimeTrust, + runtime_source: this.runtimeSource, + runtime_contract: this.runtimeContract, }, null, 2 @@ -433,19 +1061,38 @@ export class AgoraProviderRuntime implements AgoraMemoryController { try { this.ensureMemoryReady(); const profileId = this.resolveProfileId(args); + const memoryIds = toStringList(args.memory_ids); + if (this.getCapabilities().memoryV2 && Array.isArray(args.memory_ids)) { + const mounted = await this.mountMemories( + profileId, + memoryIds, + args.scope === 'conversation' ? 'conversation' : args.scope === 'user' ? 'user' : 'project' + ); + return this.ok({ action: 'mount', ...mounted }); + } const patchIds = this.patchIdsFromArgs(args); - await this.upsertProfile(profileId, patchIds.length > 0 ? patchIds : undefined, true); - const bindingId = await this.ensureBinding(profileId); - const verified = await this.verifyMemoryState({ + await this.upsertProfile( profileId, - expectedPatchIds: patchIds.length > 0 ? patchIds : undefined, - }); + patchIds.length > 0 ? patchIds : undefined, + true, + { + name: typeof args.name === 'string' ? args.name : undefined, + writablePatchFamily: typeof args.writable_patch_family === 'string' + ? args.writable_patch_family + : undefined, + } + ); + const bindingId = await this.ensureBinding( + profileId, + args.scope === 'conversation' ? 'conversation' : 'project' + ); this.selectedProfileId = profileId; + this.markPatchSelectionPending(profileId, patchIds, bindingId); return this.ok({ action: 'mount', profile_id: profileId, binding_id: bindingId, - provider_state: verified, + mount_status: 'pending_next_chat', }); } catch (err) { return this.fail(err); @@ -457,12 +1104,12 @@ export class AgoraProviderRuntime implements AgoraMemoryController { this.ensureMemoryReady(); const profileId = this.resolveProfileId(args); await this.upsertProfile(profileId, undefined, false); - const verified = await this.verifyMemoryState({ profileId, expectedPatchIds: [] }); this.selectedProfileId = profileId; + this.markPatchSelectionPending(profileId, []); return this.ok({ action: 'disable', profile_id: profileId, - provider_state: verified, + mount_status: 'pending_next_chat', }); } catch (err) { return this.fail(err); @@ -471,53 +1118,32 @@ export class AgoraProviderRuntime implements AgoraMemoryController { async internalize(args: Record): Promise { try { - this.ensureMemoryReady(); - const sessionId = this.lastState?.agora_session_id; - if (!sessionId) { - throw new Error('Agora session is not established yet; run one Agora chat turn before internalizing memory.'); - } - const submitted = await this.callJsonTool('memory_intake_run', { session_id: sessionId }); - const jobId = typeof submitted.job_id === 'string' ? submitted.job_id : submitted.job?.id; - if (typeof jobId !== 'string' || !jobId) throw new Error('memory_intake_run did not return job_id'); - let current = submitted; - for (let i = 0; i < 120; i++) { - if (TERMINAL_INTAKE_STATUSES.has(String(current.status))) break; - await sleep(500); - current = await this.callJsonTool('memory_intake_get', { job_id: jobId }); - } - if (String(current.status) === 'failed') { - throw new Error(errorText(current)); - } - const patchId = - typeof current.output_memory_patch_id === 'string' - ? current.output_memory_patch_id - : typeof current.job?.output_memory_patch_id === 'string' - ? current.job.output_memory_patch_id - : undefined; - if (!patchId) { + if (this.getCapabilities().memoryV2) { + const targets = Array.isArray(args.targets) ? args.targets as AgoraMemoryIntakeTarget[] : []; + const batch = await this.startBatchIntake({ + targets, + ...(Number.isInteger(args.source_message_start) ? { source_message_start: args.source_message_start } : {}), + ...(Number.isInteger(args.source_message_end) ? { source_message_end: args.source_message_end } : {}), + }); return this.ok({ action: 'internalize', - status: 'noop', - job_id: jobId, - source_id: current.source_id ?? current.job?.source_id, - message: 'memory intake completed but did not produce a MemoryPatch', - job: current.job, + status: batch.status, + batch_id: batch.batch_id, + targets: batch.targets, + message: 'Memory intake is running in the background; use /memory status to inspect progress.', }); } + const submitted = await this.startIntake(args); + const jobId = typeof submitted.job_id === 'string' ? submitted.job_id : submitted.job?.id; + if (typeof jobId !== 'string' || !jobId) throw new Error('memory_intake_run did not return job_id'); const profileId = this.resolveProfileId(args); - await this.upsertProfile(profileId, [patchId], true); - const bindingId = await this.ensureBinding(profileId); - const verified = await this.verifyMemoryState({ profileId, expectedPatchIds: [patchId] }); - this.selectedProfileId = profileId; return this.ok({ action: 'internalize', - status: 'mounted', + status: 'queued', profile_id: profileId, - binding_id: bindingId, job_id: jobId, - source_id: current.source_id ?? current.job?.source_id, - output_memory_patch_id: patchId, - provider_state: verified, + source_id: submitted.source_id ?? submitted.job?.source_id, + message: 'memory intake is running in the background; use /memory status to inspect progress', }); } catch (err) { return this.fail(err); @@ -527,6 +1153,16 @@ export class AgoraProviderRuntime implements AgoraMemoryController { async rollback(args: Record): Promise { try { this.ensureMemoryReady(); + if (this.getCapabilities().memoryV2) { + const memoryId = String(args.memory_id ?? '').trim(); + const expectedHeadPatchId = String(args.expected_head_patch_id ?? '').trim(); + const targetPatchId = String(args.target_patch_id ?? args.patch_id ?? '').trim(); + if (!memoryId || !expectedHeadPatchId || !targetPatchId) { + throw new Error('rollback requires memory_id, expected_head_patch_id, and target_patch_id'); + } + const memory = await this.rollbackMemory(memoryId, expectedHeadPatchId, targetPatchId); + return this.ok({ action: 'rollback', memory, mount_status: 'pending_explicit_mount' }); + } const profileId = this.resolveProfileId(args); const targetPatchId = typeof args.patch_id === 'string' && args.patch_id.trim() ? args.patch_id.trim() @@ -534,16 +1170,21 @@ export class AgoraProviderRuntime implements AgoraMemoryController { ? args.target_patch_id.trim() : await this.resolveRollbackPatchId(); if (!targetPatchId) throw new Error('rollback requires patch_id when there is no single previous patch candidate'); - await this.upsertProfile(profileId, [targetPatchId], true); - const bindingId = await this.ensureBinding(profileId); - const verified = await this.verifyMemoryState({ profileId, expectedPatchIds: [targetPatchId] }); - this.selectedProfileId = profileId; + const [profiles, patches] = await Promise.all([this.listProfiles(), this.listPatches(true)]); + const profile = profiles.find((item) => item.id === profileId); + const target = patches.find((item) => item.id === targetPatchId); + if (!profile || !target) throw new Error('rollback profile or target patch not found'); + const nextIds = profile.active_memory_patch_ids.map((id) => { + const patch = patches.find((item) => item.id === id); + return patch?.family === target.family ? targetPatchId : id; + }); + if (!nextIds.includes(targetPatchId)) nextIds.push(targetPatchId); + const result = await this.applyPatchSelection(profileId, nextIds, profile.writable_patch_family); return this.ok({ action: 'rollback', profile_id: profileId, - binding_id: bindingId, - active_memory_patch_ids: [targetPatchId], - provider_state: verified, + active_memory_patch_ids: nextIds, + mount_status: result.mount_status, }); } catch (err) { return this.fail(err); @@ -553,6 +1194,8 @@ export class AgoraProviderRuntime implements AgoraMemoryController { private async start(): Promise { const runtime = this.model.agoraRuntime; const command = resolveAgoraCommand(runtime); + this.runtimeTrust = command.trust; + this.runtimeSource = command.source; const dataRoot = resolveAgoraDataRoot(runtime); fs.mkdirSync(dataRoot, { recursive: true }); const child = spawn(command.command, command.args, { @@ -579,6 +1222,23 @@ export class AgoraProviderRuntime implements AgoraMemoryController { this.resources = []; } this.doctorPayload = await this.callJsonTool('doctor', {}, undefined, false); + if (this.toolNames.has('runtime_capabilities')) { + const capabilityPayload = await this.callJsonTool('runtime_capabilities', {}, undefined, false); + this.runtimeContract = (asRecord(capabilityPayload.contract) ?? capabilityPayload) as AgoraRuntimeContract; + } else { + this.runtimeContract = asRecord(this.doctorPayload.contract) as AgoraRuntimeContract | null; + } + if (command.trust === 'verified') { + const expectedVersion = command.lock?.version; + const actualVersion = this.runtimeContract?.runtime_version ?? this.doctorPayload.version; + const actualMajor = this.runtimeContract?.host_protocol_major; + if (actualVersion !== expectedVersion || actualMajor !== command.lock?.host_protocol_major) { + throw new Error( + `Agora runtime contract mismatch: expected ${expectedVersion}/host-v${command.lock?.host_protocol_major}, ` + + `got ${actualVersion}/host-v${actualMajor}` + ); + } + } this.modelsPayload = await this.callJsonTool('models_list', {}, undefined, false); } catch (err) { await this.close(); @@ -592,6 +1252,31 @@ export class AgoraProviderRuntime implements AgoraMemoryController { } } + private requireTools(...names: string[]): void { + const missing = names.filter((name) => !this.toolNames.has(name)); + if (missing.length > 0) { + throw new Error(`Agora capability unavailable: ${missing.join(', ')}`); + } + } + + private requireV2(): void { + const capabilities = this.getCapabilities(); + if (!capabilities.memoryV2) { + throw new Error( + `Agora Memory Runtime v2 is unavailable (runtime mode: ${capabilities.runtimeMode}). ` + + 'Named Memory, multi-target intake, incremental segments, multi-patch mount, hot swap, and rollback are required.' + ); + } + } + + private requireSessionId(): string { + const sessionId = this.lastState?.agora_session_id; + if (!sessionId) { + throw new Error('Agora session is not established yet; run one Agora chat turn first.'); + } + return sessionId; + } + private async callJsonTool( toolName: string, args: Record, @@ -602,6 +1287,9 @@ export class AgoraProviderRuntime implements AgoraMemoryController { if (waitForReady) await this.ready(); if (!this.mcp) throw new Error('Agora MCP runtime is not running'); const result = await this.mcp.call(toolName, args, signal, onProgress); + if (result.isError) { + throw new Error(result.content || `Agora MCP tool ${toolName} failed`); + } let payload: unknown; try { payload = JSON.parse(result.content || '{}'); @@ -612,7 +1300,7 @@ export class AgoraProviderRuntime implements AgoraMemoryController { throw new Error(`Agora MCP tool ${toolName} returned invalid JSON payload`); } const record = payload as Record; - if (result.isError || isErrorPayload(record)) throw new Error(errorText(record)); + if (isErrorPayload(record)) throw new AgoraMcpError(errorText(record), record); return record; } @@ -643,7 +1331,40 @@ export class AgoraProviderRuntime implements AgoraMemoryController { ? (event) => options.onEvent?.(providerProgressFromMcp(event)) : undefined ); - this.lastState = stateFromChatPayload(payload); + const requestedPatchIds = this.requestedPatchIds; + const localMemoryState = this.lastState?.memory; + this.lastState = stateFromChatPayload(payload, requestedPatchIds); + this.lastState.memory = { + ...(localMemoryState?.active_batch !== undefined ? { active_batch: localMemoryState.active_batch } : {}), + ...(localMemoryState?.last_auto_intake_message_end !== undefined + ? { last_auto_intake_message_end: localMemoryState.last_auto_intake_message_end } : {}), + ...(localMemoryState?.last_auto_intake_runtime_message_end !== undefined + ? { last_auto_intake_runtime_message_end: localMemoryState.last_auto_intake_runtime_message_end } : {}), + ...(this.lastState.memory ?? {}), + runtime_message_count: Array.isArray(rawRequest.messages) ? rawRequest.messages.length : 0, + }; + const activePatchIds = this.lastState.memory?.active_memory_patch_ids ?? []; + const revision = this.lastState.memory?.patchset_revision; + const revisionAdvanced = this.requestedAfterRevision === null || ( + typeof revision === 'number' && revision > this.requestedAfterRevision + ); + if ( + requestedPatchIds && + requestedPatchIds.length === activePatchIds.length && + requestedPatchIds.every((id, index) => id === activePatchIds[index]) && + revisionAdvanced + ) { + this.requestedPatchIds = null; + this.requestedAfterRevision = null; + delete this.lastState.memory?.requested_memory_patch_ids; + } else if (requestedPatchIds) { + this.lastState.memory = { ...(this.lastState.memory ?? {}), status: 'stale' }; + } + if (this.getCapabilities().memoryV2 && activePatchIds.length > 0) { + await this.hydrateMountedMemories(activePatchIds); + } + this.lastState.runtime_trust = this.runtimeTrust; + this.lastState.runtime_source = this.runtimeSource; return payload; } @@ -749,7 +1470,8 @@ export class AgoraProviderRuntime implements AgoraMemoryController { private async upsertProfile( profileId: string, activePatchIds: string[] | undefined, - memoryEnabled: boolean + memoryEnabled: boolean, + options: { name?: string; writablePatchFamily?: string | null } = {} ): Promise { const listed = await this.callJsonTool('memory_profiles_list', { base_model_id: this.model.model }); const profiles = Array.isArray(listed.profiles) ? listed.profiles : []; @@ -758,6 +1480,10 @@ export class AgoraProviderRuntime implements AgoraMemoryController { await this.callJsonTool('memory_profiles_update', { profile_id: profileId, ...(activePatchIds !== undefined ? { active_memory_patch_ids: activePatchIds } : {}), + ...(options.name ? { name: options.name } : {}), + ...(options.writablePatchFamily !== undefined + ? { writable_patch_family: options.writablePatchFamily } + : {}), memory_enabled: memoryEnabled, }); return; @@ -767,59 +1493,122 @@ export class AgoraProviderRuntime implements AgoraMemoryController { } await this.callJsonTool('memory_profiles_create', { id: profileId, + name: options.name || profileId, base_model_id: this.model.model, active_memory_patch_ids: activePatchIds, + ...(options.writablePatchFamily !== undefined + ? { writable_patch_family: options.writablePatchFamily } + : {}), + auto_intake_policy: { enabled: false }, memory_enabled: memoryEnabled, }); } - private async ensureBinding(profileId: string): Promise { + private markPatchSelectionPending( + profileId: string, + patchIds: string[], + bindingId?: string + ): void { + const previousActive = this.lastState?.memory?.active_memory_patch_ids ?? []; + const changed = previousActive.length !== patchIds.length || previousActive.some((id, index) => id !== patchIds[index]); + this.requestedPatchIds = [...patchIds]; + this.requestedAfterRevision = changed && typeof this.lastState?.memory?.patchset_revision === 'number' + ? this.lastState.memory.patchset_revision + : null; + const previous = this.lastState; + this.lastState = { + provider_id: 'agora', + ...(previous?.agora_session_id ? { agora_session_id: previous.agora_session_id } : {}), + memory: { + ...(previous?.memory ?? {}), + status: 'pending', + profile_id: profileId, + ...(bindingId ? { binding_id: bindingId } : {}), + active_memory_patch_ids: previous?.memory?.active_memory_patch_ids ?? [], + requested_memory_patch_ids: [...patchIds], + }, + runtime_trust: this.runtimeTrust, + runtime_source: this.runtimeSource, + }; + } + + private async hydrateMountedMemories(activePatchIds: string[]): Promise { + try { + const [memories, patches, profiles] = await Promise.all([ + this.listMemories(), + this.listPatches(true), + this.listProfiles(), + ]); + const mounted = activePatchIds.flatMap((patchId) => { + const patch = patches.find((item) => item.id === patchId); + const memory = patch?.memory_id ? memories.find((item) => item.id === patch.memory_id) : undefined; + if (!patch || !memory) return []; + return [{ + memory_id: memory.id, + memory_name: memory.name, + patch_id: patch.id, + patch_name: patch.name, + version: patch.version, + }]; + }); + const profileId = this.lastState?.memory?.profile_id; + const profile = profiles.find((item) => item.id === profileId); + if (this.lastState?.memory) { + this.lastState.memory.mounted_memories = mounted; + this.lastState.memory.auto_target_memory_ids = profile?.auto_intake_target_memory_ids ?? []; + } + } catch { + // Chat truth remains valid even if the user-facing catalog cannot be hydrated. + } + } + + private normalizeBatch(payload: Record): AgoraMemoryIntakeBatchResult { + const batch = asRecord(payload.batch) ?? {}; + const batchId = String(payload.batch_id ?? batch.id ?? '').trim(); + if (!batchId) throw new Error('Agora did not return a Memory intake batch id'); + const rawTargets = Array.isArray(payload.targets) ? payload.targets : []; + return { + status: String(payload.status ?? batch.status ?? 'unknown'), + batch_id: batchId, + batch, + targets: rawTargets + .filter((target): target is Record => Boolean(asRecord(target))) + .map((target) => ({ + ...target, + id: String(target.id ?? ''), + batch_id: String(target.batch_id ?? batchId), + mode: target.mode === 'create' ? 'create' : 'increment', + output_name: String(target.output_name ?? target.memory_name ?? ''), + status: String(target.status ?? 'queued') as AgoraIntakeTargetStatus, + })), + }; + } + + private async ensureBinding( + profileId: string, + scope: 'user' | 'project' | 'conversation' = 'project' + ): Promise { const metadata = this.buildMetadata(undefined, { memory_profile: profileId }); const listed = await this.callJsonTool('memory_profile_bindings_list', { profile_id: profileId }); const bindings = Array.isArray(listed.bindings) ? listed.bindings : []; - const match = bindings.find((binding: any) => - binding?.user_id === metadata.user_id && - binding?.project_id === metadata.project_id && - binding?.conversation_id === metadata.conversation_id - ); + const match = bindings.find((binding: any) => { + if (binding?.scope_type !== scope || binding?.user_id !== metadata.user_id) return false; + if (scope !== 'user' && binding?.project_id !== metadata.project_id) return false; + return scope !== 'conversation' || binding?.conversation_id === metadata.conversation_id; + }); if (match?.id) return String(match.id); const created = await this.callJsonTool('memory_profile_bindings_create', { profile_id: profileId, + scope_type: scope, user_id: metadata.user_id, - project_id: metadata.project_id, - conversation_id: metadata.conversation_id, + ...(scope !== 'user' ? { project_id: metadata.project_id } : {}), + ...(scope === 'conversation' ? { conversation_id: metadata.conversation_id } : {}), }); return typeof created.binding?.id === 'string' ? created.binding.id : undefined; } - private async verifyMemoryState(input: { - profileId: string; - expectedPatchIds?: string[]; - }): Promise { - await this.chatCompletePayload( - { - model: this.model.model, - messages: [{ role: 'user', content: 'Verify Agora memory state.' }], - stream: false, - max_tokens: 4, - } as ChatCompletionCreateParamsNonStreaming, - undefined, - { memory_profile: input.profileId } - ); - const state = this.lastState; - if (!state) throw new Error('Agora verification did not produce provider state'); - if (state.memory?.profile_id !== input.profileId) { - throw new Error(`Agora verification did not mount requested profile ${input.profileId}`); - } - if (input.expectedPatchIds !== undefined) { - const actual = state.memory?.active_memory_patch_ids ?? []; - if (JSON.stringify(actual) !== JSON.stringify(input.expectedPatchIds)) { - throw new Error( - `Agora verification patch mismatch: expected ${JSON.stringify(input.expectedPatchIds)}, got ${JSON.stringify(actual)}` - ); - } - } - return state; + private safeScopeId(value: string): string { + return value.trim().toLowerCase().replace(/[^a-z0-9._-]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 80) || 'default'; } private async resolveRollbackPatchId(): Promise { diff --git a/src/session/store.ts b/src/session/store.ts index 5a90fd3..6487821 100644 --- a/src/session/store.ts +++ b/src/session/store.ts @@ -108,7 +108,13 @@ export function createSessionStore(sessionDir?: string): SessionStore { function updateProviderState(sessionId: string, providerState: ProviderSessionState): void { const meta = readMeta(metaPath(sessionId)); if (!meta) return; - meta.providerState = providerState; + meta.providerState = { + ...(meta.providerState ?? {}), + ...providerState, + ...(meta.providerState?.memory || providerState.memory + ? { memory: { ...(meta.providerState?.memory ?? {}), ...(providerState.memory ?? {}) } } + : {}), + }; writeMeta(metaPath(sessionId), meta); } diff --git a/src/version.ts b/src/version.ts new file mode 100644 index 0000000..9e1dc55 --- /dev/null +++ b/src/version.ts @@ -0,0 +1,18 @@ +import fs from 'node:fs'; + +export const VERSION = readPackageVersion(); + +function readPackageVersion(): string { + for (const packageUrl of [ + new URL('../package.json', import.meta.url), + new URL('../../package.json', import.meta.url), + ]) { + try { + const pkg = JSON.parse(fs.readFileSync(packageUrl, 'utf-8')) as { version?: unknown }; + if (typeof pkg.version === 'string' && pkg.version.length > 0) return pkg.version; + } catch { + // Source and compiled execution have different package-relative paths. + } + } + return '0.0.0-unknown'; +} diff --git a/test/agora-auto-intake.test.ts b/test/agora-auto-intake.test.ts new file mode 100644 index 0000000..80b559b --- /dev/null +++ b/test/agora-auto-intake.test.ts @@ -0,0 +1,88 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { classifyAgoraBatchTargets, planAgoraAutoIntake } from '../src/provider/agora-auto-intake.js'; + +const memory = { id: 'memory-a', name: '产品记忆', base_model_id: 'base-a', head_patch_id: 'patch-a', status: 'available' }; +const patch = { id: 'patch-a', name: '产品记忆@v1', base_model_id: 'base-a', family: 'memory-a', version: 'v1', mountable: true, status: 'available', memory_id: 'memory-a' }; + +test('auto intake plans only the uncheckpointed range after four user turns', () => { + const messages = [ + { role: 'user', content: 'old' }, + { role: 'assistant', content: 'old answer' }, + ...Array.from({ length: 4 }, (_, index) => [ + { role: 'user', content: `fact ${index}` }, + { role: 'assistant', content: `answer ${index}` }, + ]).flat(), + ]; + const plan = planAgoraAutoIntake({ + messages, + checkpointEnd: 2, + profile: { + id: 'profile-a', + name: 'project', + base_model_id: 'base-a', + active_memory_patch_ids: ['patch-a'], + auto_intake_target_memory_ids: ['memory-a'], + auto_intake_policy: { enabled: true, min_user_turns: 4, min_pending_tokens: 2000 }, + status: 'available', + }, + memories: [memory], + patches: [patch], + }); + assert.equal(plan.ready, true); + assert.equal(plan.sourceStart, 2); + assert.equal(plan.sourceEnd, messages.length); + assert.equal(plan.pendingUserTurns, 4); + assert.deepEqual(plan.targets, [{ + mode: 'increment', + memory_id: 'memory-a', + expected_parent_patch_id: 'patch-a', + output_name: '产品记忆@v2', + }]); +}); + +test('auto intake never guesses a target and can trigger on pending token threshold', () => { + const base = { + id: 'profile-a', + name: 'project', + base_model_id: 'base-a', + active_memory_patch_ids: ['patch-a'], + auto_intake_policy: { enabled: true, min_user_turns: 4, min_pending_tokens: 100 }, + status: 'available', + }; + const noTarget = planAgoraAutoIntake({ + messages: [{ role: 'user', content: 'x'.repeat(1000) }], + checkpointEnd: 0, + profile: base, + memories: [memory], + patches: [patch], + }); + assert.equal(noTarget.ready, false); + assert.equal(noTarget.reason, 'no_explicit_targets'); + const tokenReady = planAgoraAutoIntake({ + messages: [{ role: 'user', content: 'x'.repeat(1000) }], + checkpointEnd: 0, + profile: { ...base, auto_intake_target_memory_ids: ['memory-a'] }, + memories: [memory], + patches: [patch], + }); + assert.equal(tokenReady.ready, true); + assert.ok(tokenReady.pendingTokens >= 100); +}); + +test('auto checkpoint can advance only when every target is completed or noop', () => { + const successful = classifyAgoraBatchTargets([ + { id: 'a', batch_id: 'b', mode: 'increment', output_name: 'a', status: 'completed' }, + { id: 'c', batch_id: 'b', mode: 'increment', output_name: 'c', status: 'noop' }, + ]); + assert.equal(successful.terminal, true); + assert.equal(successful.blockers.length, 0); + const partial = classifyAgoraBatchTargets([ + { id: 'a', batch_id: 'b', mode: 'increment', output_name: 'a', status: 'completed' }, + { id: 'c', batch_id: 'b', mode: 'increment', output_name: 'c', status: 'conflict', error: { retryable: true } }, + { id: 'd', batch_id: 'b', mode: 'increment', output_name: 'd', status: 'failed', error: { retryable: false } }, + ]); + assert.equal(partial.terminal, true); + assert.equal(partial.blockers.length, 2); + assert.deepEqual(partial.retryable.map((target) => target.id), ['c']); +}); diff --git a/test/agora-integrity.test.ts b/test/agora-integrity.test.ts new file mode 100644 index 0000000..f64dd1d --- /dev/null +++ b/test/agora-integrity.test.ts @@ -0,0 +1,31 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { createHash } from 'node:crypto'; +import * as fs from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; +import { verifyAgoraManifestFiles } from '../src/provider/agora.js'; + +const sha256 = (value: string) => createHash('sha256').update(value).digest('hex'); + +test('Agora manifest verifies every packaged runtime file and rejects tampering', () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'ma-agora-integrity-')); + try { + fs.mkdirSync(path.join(root, 'bin'), { recursive: true }); + fs.mkdirSync(path.join(root, 'lib'), { recursive: true }); + fs.writeFileSync(path.join(root, 'bin', 'agora'), 'native-binary'); + fs.writeFileSync(path.join(root, 'lib', 'agora-core.dylib'), 'native-core'); + const manifest = { + files: { + 'bin/agora': sha256('native-binary'), + 'lib/agora-core.dylib': sha256('native-core'), + }, + }; + assert.equal(verifyAgoraManifestFiles(root, manifest), true); + fs.writeFileSync(path.join(root, 'lib', 'agora-core.dylib'), 'tampered'); + assert.equal(verifyAgoraManifestFiles(root, manifest), false); + assert.equal(verifyAgoraManifestFiles(root, { files: { '../escape': sha256('x') } }), false); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } +}); diff --git a/test/agora-provider-runtime.test.ts b/test/agora-provider-runtime.test.ts index cd2dacd..957adee 100644 --- a/test/agora-provider-runtime.test.ts +++ b/test/agora-provider-runtime.test.ts @@ -4,12 +4,13 @@ import { execFileSync } from 'node:child_process'; import * as fs from 'node:fs'; import * as os from 'node:os'; import * as path from 'node:path'; -import { AgoraProviderRuntime } from '../src/provider/agora.js'; +import { AgoraMcpError, AgoraProviderRuntime } from '../src/provider/agora.js'; import type { ModelConfig } from '../src/mcp/types.js'; -const AGORA_DEV_ROOT = '/Users/zhuqingyu/dev/agora'; -const AGORA_DEV_COMMAND = path.join(AGORA_DEV_ROOT, '.venv/bin/agora'); -const AGORA_DEV_PYTHON = path.join(AGORA_DEV_ROOT, '.venv/bin/python'); +const AGORA_DEV_ROOT = process.env.MA_TEST_AGORA_ROOT || ''; +const AGORA_DEV_COMMAND = process.env.MA_TEST_AGORA_COMMAND || ''; +const AGORA_DEV_PYTHON = process.env.MA_TEST_AGORA_PYTHON || ''; +const AGORA_REAL_MODELS = process.env.MA_TEST_AGORA_REAL_MODELS || ''; const MEMORY_TOOLS = [ 'doctor', @@ -25,9 +26,24 @@ const MEMORY_TOOLS = [ 'memory_sources_get', 'memory_intake_run', 'memory_intake_get', + 'memory_intake_status', + 'memory_patches_list', + 'memory_lineage_advance', 'memory_patch_versions', ]; +const MEMORY_V2_TOOLS = [ + ...MEMORY_TOOLS, + 'runtime_capabilities', + 'memories_create', + 'memories_get', + 'memories_list', + 'memories_rename', + 'memories_rollback', + 'memory_intake_batch_run', + 'memory_intake_batch_get', +]; + function model(): ModelConfig { return { provider: 'agora', @@ -56,6 +72,20 @@ function runtimeWithFakeCalls( (runtime as any).resources = ['agora://doctor']; (runtime as any).doctorPayload = { service: 'agora', status: 'ok' }; (runtime as any).modelsPayload = { service: 'agora', models: [{ id: 'base-a' }] }; + if (tools.includes('memory_intake_batch_run')) { + (runtime as any).runtimeContract = { + runtime_version: '0.2.0-dev', + registry_schema_version: 3, + capabilities: { + named_memories: 1, + multi_target_intake: 1, + incremental_segments: 1, + multi_model_delta_mount: 1, + request_boundary_hot_swap: 1, + memory_runtime_v2: 1, + }, + }; + } (runtime as any).ready = async () => {}; (runtime as any).callJsonTool = async (name: string, args: Record) => { calls.push({ name, args }); @@ -94,6 +124,7 @@ function runtimeWithFakeCalls( binding_id: 'binding-a', active_memory_patch_ids: patchIds, }, + memory_runtime: { patchset_revision: 2 }, }, memory: { enabled: true, @@ -105,7 +136,42 @@ function runtimeWithFakeCalls( active_memory_patch_ids: patchIds, }; } - if (name === 'memory_profiles_list') return { service: 'agora', status: 'ok', profiles: [] }; + if (name === 'memory_profiles_list') return { + service: 'agora', + status: 'ok', + profiles: [{ + id: 'profile-a', + name: 'Profile A', + base_model_id: 'base-a', + active_memory_patch_ids: ['patch-a'], + auto_intake_target_memory_ids: ['memory-a'], + writable_patch_family: 'project-memory', + auto_intake_policy: { enabled: true, activation_mode: 'auto' }, + }], + }; + if (name === 'memory_patches_list') return { + service: 'agora', + status: 'ok', + patches: [{ + id: 'patch-a', + name: 'Patch A', + base_model_id: 'base-a', + family: 'project-memory', + version: 'v1', + mountable: true, + status: 'available', + memory_id: 'memory-a', + }], + }; + if (name === 'memories_list') return { + service: 'agora', + status: 'ok', + memories: [{ id: 'memory-a', name: '产品记忆', base_model_id: 'base-a', head_patch_id: 'patch-a', status: 'available' }], + }; + if (name === 'memories_get') return { service: 'agora', status: 'ok', memory: { id: args.memory_id, name: '产品记忆', base_model_id: 'base-a', head_patch_id: 'patch-a', status: 'available' } }; + if (name === 'memories_create') return { service: 'agora', status: 'ok', memory: { id: 'memory-new', name: args.name, base_model_id: args.base_model_id, head_patch_id: null, status: 'available' } }; + if (name === 'memories_rename') return { service: 'agora', status: 'ok', memory: { id: args.memory_id, name: args.name, base_model_id: 'base-a', head_patch_id: 'patch-a', status: 'available' } }; + if (name === 'memories_rollback') return { service: 'agora', status: 'ok', memory: { id: args.memory_id, name: '产品记忆', base_model_id: 'base-a', head_patch_id: args.target_patch_id, status: 'available' } }; if (name === 'memory_profiles_create') return { service: 'agora', status: 'available', profile: { id: args.id } }; if (name === 'memory_profiles_update') return { service: 'agora', status: 'available', profile: { id: args.profile_id } }; if (name === 'memory_profile_bindings_list') return { service: 'agora', status: 'ok', bindings: [] }; @@ -117,7 +183,11 @@ function runtimeWithFakeCalls( job_id: 'job-a', source_id: 'source-a', output_memory_patch_id: 'patch-a', - job: { id: 'job-a', output_memory_patch_id: 'patch-a' }, + job: { + id: 'job-a', + output_memory_patch_id: 'patch-a', + result: { lineage: { family: 'project-memory', previous_patch_id: 'patch-a' } }, + }, }; } if (name === 'memory_intake_get') { @@ -127,9 +197,27 @@ function runtimeWithFakeCalls( job_id: args.job_id, source_id: 'source-a', output_memory_patch_id: 'patch-a', - job: { id: args.job_id, output_memory_patch_id: 'patch-a' }, + job: { + id: args.job_id, + output_memory_patch_id: 'patch-a', + result: { lineage: { family: 'project-memory', previous_patch_id: 'patch-a' } }, + }, }; } + if (name === 'memory_intake_batch_run' || name === 'memory_intake_batch_get') { + const targets = (name === 'memory_intake_batch_run' ? args.targets : [{ mode: 'increment', memory_id: 'memory-a', output_name: '产品记忆@v2' }]) + .map((target: any, index: number) => ({ + id: `target-${index}`, + batch_id: 'batch-a', + ...target, + status: 'completed', + output_patch_id: 'patch-a', + })); + return { service: 'agora', status: 'completed', batch_id: 'batch-a', batch: { id: 'batch-a', status: 'completed' }, targets }; + } + if (name === 'memory_lineage_advance') { + return { service: 'agora', status: 'activated', active_memory_patch_ids: ['patch-a'] }; + } if (name === 'memory_patch_versions') return { service: 'agora', status: 'ok', patches: [{ id: 'patch-a' }, { id: 'patch-previous' }] }; return { service: 'agora', status: 'ok' }; }; @@ -165,7 +253,8 @@ class FakeModel: def __call__(self, tokens, *args, **kwargs): import mlx.core as mx - hidden = mx.ones((1, 1, 8), dtype=mx.float32) + scale = mx.sum(tokens.astype(mx.float32)) * 0.001 + hidden = mx.sin(mx.arange(1, 9, dtype=mx.float32) * scale)[None, None, :] for layer in self.layers: hidden = layer(hidden) return hidden @@ -272,7 +361,7 @@ from pathlib import Path from agora_lab.paths import DB_PATH from agora_lab.registry import Registry -from agora_lab.schemas import AdapterRecord, BaseModelRecord, MemoryPatchRecord +from agora_lab.schemas import BaseModelRecord root = Path(DB_PATH).parent @@ -296,56 +385,6 @@ registry.upsert_base_model( architecture="dense", ) ) -adapter_dir = root / "adapter-old" -adapter_dir.mkdir(parents=True, exist_ok=True) -(adapter_dir / "adapters.safetensors").write_text("old adapter weights", encoding="utf-8") -adapter_manifest = adapter_dir / "manifest.json" -adapter_manifest.write_text(json.dumps({"adapter_id": "adapter-old"}), encoding="utf-8") -adapter_eval = adapter_dir / "eval_report.json" -adapter_eval.write_text(json.dumps({"status": "seeded"}), encoding="utf-8") -registry.upsert_adapter( - AdapterRecord( - id="adapter-old", - name="adapter-old", - base_model_id="qwen2.5-7b-fp16", - method="lora", - artifact_path=str(adapter_dir), - manifest_path=str(adapter_manifest), - training_job_id="train-adapter-old", - dataset_id="dataset-old", - training_profile={"rank": 4}, - eval_report_path=str(adapter_eval), - status="experimental", - family="ma-e2e-memory", - version="0.0.0-old", - base_architecture="dense", - weights_file="adapters.safetensors", - ) -) -patch_dir = root / "patch-old" -patch_dir.mkdir(parents=True, exist_ok=True) -patch_manifest = patch_dir / "manifest.json" -patch_manifest.write_text(json.dumps({"kind": "lora_adapter_memory_patch_v0"}), encoding="utf-8") -patch_eval = patch_dir / "eval_report.json" -patch_eval.write_text(json.dumps({"status": "seeded"}), encoding="utf-8") -registry.upsert_memory_patch( - MemoryPatchRecord( - id="patch-old", - name="patch-old", - base_model_id="qwen2.5-7b-fp16", - patch_type="lora_adapter", - compiler_backend="qlora_train", - artifact_path=str(patch_dir), - manifest_path=str(patch_manifest), - eval_report_path=str(patch_eval), - status="experimental", - family="ma-e2e-memory", - version="0.0.0-old", - source_ids=["seed-source"], - adapter_id="adapter-old", - mountable=True, - ) -) `; const pythonPath = [AGORA_DEV_ROOT, process.env.PYTHONPATH].filter(Boolean).join(path.delimiter); execFileSync(AGORA_DEV_PYTHON, ['-c', seedCode], { @@ -442,14 +481,18 @@ test('agora provider runtime wraps non-streaming chat_complete as streaming chun assert.equal(chunks.some((chunk) => chunk.choices?.[0]?.delta?.tool_calls?.[0]?.function?.name === 'test__ping'), true); assert.equal(runtime.getProviderState()?.agora_session_id, 'agora-session-a'); assert.deepEqual(runtime.getProviderState()?.memory?.active_memory_patch_ids, ['patch-a']); + assert.equal(runtime.getProviderState()?.memory?.runtime_message_count, 1); }); -test('agora provider runtime disables memory controller when required memory MCP tools are missing', async () => { +test('agora provider runtime exposes granular capability gaps without disabling chat', async () => { const runtime = runtimeWithFakeCalls([], ['doctor', 'models_list', 'chat_complete']); - assert.equal(runtime.getMemoryController(), null); + assert.ok(runtime.getMemoryController()); + assert.equal(runtime.getCapabilities().chat, true); + assert.equal(runtime.getCapabilities().profileRead, false); + assert.equal(runtime.getCapabilities().intake, false); }); -test('agora memory internalize runs intake, updates profile, binds scope, and verifies via chat metadata', async () => { +test('agora memory internalize queues intake without blocking or overwriting overlays', async () => { const calls: Array<{ name: string; args: Record }> = []; const runtime = runtimeWithFakeCalls(calls); await runtime.createChatCompletion({ @@ -461,28 +504,219 @@ test('agora memory internalize runs intake, updates profile, binds scope, and ve const result = await runtime.internalize({ profile_id: 'profile-a' }); assert.equal(result.isError, false); const payload = JSON.parse(result.content); - assert.equal(payload.output_memory_patch_id, 'patch-a'); + assert.equal(payload.status, 'queued'); + assert.equal(payload.job_id, 'job-a'); assert.deepEqual( calls.map((call) => call.name), [ 'chat_complete', - 'memory_intake_run', 'memory_profiles_list', - 'memory_profiles_create', - 'memory_profile_bindings_list', - 'memory_profile_bindings_create', - 'chat_complete', + 'memory_patches_list', + 'memory_intake_run', ] ); - assert.equal(calls.at(-1)?.args.metadata.memory_profile, 'profile-a'); assert.deepEqual(runtime.getProviderState()?.memory?.active_memory_patch_ids, ['patch-a']); }); +test('agora profile switch persists project/session scope and waits for a real chat boundary', async () => { + const calls: Array<{ name: string; args: Record }> = []; + const runtime = runtimeWithFakeCalls(calls); + + const project = await runtime.selectProfile('profile-a'); + assert.equal(project.mount_status, 'pending_next_chat'); + assert.equal(calls.some((call) => call.name === 'chat_complete'), false); + assert.deepEqual( + calls.find((call) => call.name === 'memory_profile_bindings_create')?.args, + { + profile_id: 'profile-a', + scope_type: 'project', + user_id: 'user-a', + project_id: 'project-a', + } + ); + + calls.length = 0; + const conversation = await runtime.selectProfile('profile-a', 'conversation'); + assert.equal(conversation.scope, 'conversation'); + assert.equal(calls.some((call) => call.name === 'chat_complete'), false); + assert.equal( + calls.find((call) => call.name === 'memory_profile_bindings_create')?.args.conversation_id, + 'conv-a' + ); +}); + +test('agora memory v2 requires both tools and granular runtime capabilities', () => { + const runtime = runtimeWithFakeCalls([], MEMORY_V2_TOOLS); + assert.equal(runtime.getCapabilities().memoryV2, true); + assert.equal(runtime.getCapabilities().runtimeMode, 'v2'); + (runtime as any).runtimeContract.capabilities.request_boundary_hot_swap = 0; + assert.equal(runtime.getCapabilities().memoryV2, false); + assert.equal(runtime.getCapabilities().runtimeMode, 'legacy'); +}); + +test('agora memory v2 mounts named memories without writable family and verifies on chat boundary', async () => { + const calls: Array<{ name: string; args: Record }> = []; + const runtime = runtimeWithFakeCalls(calls, MEMORY_V2_TOOLS); + + const mounted = await runtime.mountMemories('profile-a', ['memory-a'], 'project'); + assert.equal(mounted.mount_status, 'pending_next_chat'); + assert.equal(runtime.getProviderState()?.memory?.status, 'pending'); + const update = calls.find((call) => call.name === 'memory_profiles_update'); + assert.deepEqual(update?.args.active_memory_patch_ids, ['patch-a']); + assert.equal('writable_patch_family' in (update?.args ?? {}), false); + assert.equal(calls.some((call) => call.name === 'chat_complete'), false); + + await runtime.createChatCompletion({ + model: 'base-a', + messages: [{ role: 'user', content: 'verify mount' }], + stream: false, + }); + assert.equal(runtime.getProviderState()?.memory?.status, 'mounted'); + assert.equal(runtime.getProviderState()?.memory?.patchset_revision, 2); + assert.equal(runtime.getProviderState()?.memory?.mounted_memories?.[0]?.memory_name, '产品记忆'); +}); + +test('agora memory v2 conversation override uses an isolated profile and does not overwrite project default', async () => { + const calls: Array<{ name: string; args: Record }> = []; + const runtime = runtimeWithFakeCalls(calls, MEMORY_V2_TOOLS); + const result = await runtime.mountMemories('profile-a', ['memory-a'], 'conversation'); + assert.equal(result.profile_id, 'profile-a--conversation-conv-a'); + const created = calls.find((call) => call.name === 'memory_profiles_create'); + assert.equal(created?.args.id, 'profile-a--conversation-conv-a'); + const binding = calls.find((call) => call.name === 'memory_profile_bindings_create'); + assert.equal(binding?.args.scope_type, 'conversation'); + assert.equal(binding?.args.conversation_id, 'conv-a'); + assert.equal(calls.some((call) => call.name === 'memory_profiles_update' && call.args.profile_id === 'profile-a'), false); +}); + +test('agora memory v2 submits one mixed batch and never calls legacy intake or lineage advance', async () => { + const calls: Array<{ name: string; args: Record }> = []; + const runtime = runtimeWithFakeCalls(calls, MEMORY_V2_TOOLS); + await runtime.createChatCompletion({ + model: 'base-a', + messages: [{ role: 'user', content: 'source' }], + stream: false, + }); + calls.length = 0; + const result = await runtime.internalize({ + source_message_start: 0, + source_message_end: 1, + targets: [ + { mode: 'create', name: '新记忆', output_name: '新记忆@v1' }, + { mode: 'increment', memory_id: 'memory-a', expected_parent_patch_id: 'patch-a', output_name: '产品记忆@v2' }, + ], + }); + assert.equal(result.isError, false, result.content); + assert.equal(JSON.parse(result.content).batch_id, 'batch-a'); + assert.deepEqual(calls.map((call) => call.name), ['memory_intake_batch_run']); + assert.equal(calls.some((call) => ['memory_intake_run', 'memory_lineage_advance'].includes(call.name)), false); + assert.equal(calls[0].args.targets.length, 2); +}); + +test('agora memory v2 preserves explicit auto targets on the profile', async () => { + const calls: Array<{ name: string; args: Record }> = []; + const runtime = runtimeWithFakeCalls(calls, MEMORY_V2_TOOLS); + await runtime.setAutoPolicy('profile-a', true, ['memory-a']); + const update = calls.find((call) => call.name === 'memory_profiles_update'); + assert.deepEqual(update?.args.auto_intake_target_memory_ids, ['memory-a']); + assert.equal(update?.args.auto_intake_policy.enabled, true); +}); + +test('agora memory v2 does not claim mounted when PatchSet revision fails to advance', async () => { + const runtime = runtimeWithFakeCalls([], MEMORY_V2_TOOLS); + (runtime as any).lastState = { + provider_id: 'agora', + memory: { status: 'mounted', active_memory_patch_ids: ['patch-old'], patchset_revision: 2 }, + }; + (runtime as any).markPatchSelectionPending('profile-a', ['patch-a']); + await runtime.createChatCompletion({ + model: 'base-a', + messages: [{ role: 'user', content: 'stale revision' }], + stream: false, + }); + assert.equal(runtime.getProviderState()?.memory?.status, 'stale'); + assert.deepEqual(runtime.getProviderState()?.memory?.requested_memory_patch_ids, ['patch-a']); +}); + const agoraDevE2eTest = fs.existsSync(AGORA_DEV_COMMAND) && fs.existsSync(AGORA_DEV_PYTHON) ? test : test.skip; -agoraDevE2eTest('agora provider runtime uses real MCP stdio for mount, internalize, rollback, and disable', async () => { +const agoraPackagedE2eTest = fs.existsSync(AGORA_DEV_COMMAND) && fs.existsSync(AGORA_REAL_MODELS) + ? test + : test.skip; + +agoraPackagedE2eTest('packaged Agora serves a real whitelist model over MCP stdio', async () => { + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'ma-agora-packaged-e2e-')); + const dataRoot = path.join(tmp, 'agora-data'); + fs.mkdirSync(dataRoot, { recursive: true }); + fs.symlinkSync(path.resolve(AGORA_REAL_MODELS), path.join(dataRoot, 'models')); + let runtime: AgoraProviderRuntime | null = null; + try { + runtime = new AgoraProviderRuntime( + { + provider: 'agora', + baseURL: 'mcp-stdio://agora', + apiKey: 'agora-mcp', + model: 'qwen2.5-7b-fp16', + agoraRuntime: { + command: AGORA_DEV_COMMAND, + args: ['mcp', 'serve'], + dataRoot, + env: { HF_HUB_OFFLINE: '1', TRANSFORMERS_OFFLINE: '1' }, + }, + agoraMemory: { + userId: 'user-packaged-e2e', + projectId: 'my-agent', + conversationId: 'conv-packaged-e2e', + }, + }, + { requestTimeoutMs: 30000, streamIdleTimeoutMs: 30000, maxRetries: 0 }, + { sessionId: 'ma-packaged-e2e-session', cwd: tmp } + ); + await runtime.ready(); + assert.ok((await runtime.listModels()).some((item) => item.id === 'qwen2.5-7b-fp16')); + assert.equal(runtime.getCapabilities().memoryV2, true); + const before = await (runtime as any).chatCompletePayload({ + model: 'qwen2.5-7b-fp16', + messages: [{ role: 'user', content: '利博是我的同事,请记住。' }], + stream: false, + max_tokens: 2, + temperature: 0, + } as any); + assert.ok(runtime.getProviderState()?.agora_session_id); + const controller = runtime.getMemoryController(); + assert.ok(controller); + const submitted = await controller.startBatchIntake({ + targets: [{ mode: 'create', name: 'Packaged E2E Memory', output_name: 'Packaged E2E Memory@v1' }], + }); + let batch = submitted; + for (let index = 0; index < 300; index++) { + batch = await controller.getBatchIntake(submitted.batch_id); + if (batch.targets.every((target) => ['completed', 'noop', 'review', 'conflict', 'failed'].includes(target.status))) break; + await new Promise((resolve) => setTimeout(resolve, 500)); + } + assert.equal(batch.targets[0]?.status, 'completed', JSON.stringify(batch)); + const memory = (await controller.listMemories()).find((item) => item.name === 'Packaged E2E Memory'); + assert.ok(memory); + await controller.mountMemories('profile-packaged-e2e', [memory.id]); + const after = await (runtime as any).chatCompletePayload({ + model: 'qwen2.5-7b-fp16', + messages: [{ role: 'user', content: '我的同事是谁?' }], + stream: false, + max_tokens: 8, + temperature: 0, + } as any); + assert.deepEqual(runtime.getProviderState()?.memory?.active_memory_patch_ids, [memory.head_patch_id]); + assert.equal(after.metadata?.memory_runtime?.base_model_object_id, before.metadata?.memory_runtime?.base_model_object_id); + assert.equal(after.metadata?.memory_runtime?.model_load_count, before.metadata?.memory_runtime?.model_load_count); + } finally { + await runtime?.close(); + fs.rmSync(tmp, { recursive: true, force: true }); + } +}); + +agoraDevE2eTest('agora provider runtime uses real v2 MCP stdio for named Memory, batch intake, hot mount, rollback, and disable', async () => { const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'ma-agora-provider-e2e-')); const dataRoot = path.join(tmp, 'agora-data'); const fakeModules = path.join(tmp, 'fake-model-runtime'); @@ -519,13 +753,12 @@ agoraDevE2eTest('agora provider runtime uses real MCP stdio for mount, internali await runtime.ready(); const controller = runtime.getMemoryController(); assert.ok(controller, 'real Agora MCP runtime must expose memory controller'); - - const mounted = await controller.mount({ - profile_id: 'profile-e2e', - active_memory_patch_ids: ['patch-old'], - }); - assert.equal(mounted.isError, false, mounted.content); - assert.deepEqual(runtime.getProviderState()?.memory?.active_memory_patch_ids, ['patch-old']); + assert.equal(controller.getCapabilities().memoryV2, true); + await controller.createMemory('Unique Name Guard'); + await assert.rejects( + () => controller.createMemory('Unique Name Guard'), + (err: unknown) => err instanceof AgoraMcpError && err.code === 'memory_name_conflict' && err.field === 'name' + ); await runtime.createChatCompletion({ model: 'qwen2.5-7b-fp16', @@ -534,20 +767,123 @@ agoraDevE2eTest('agora provider runtime uses real MCP stdio for mount, internali max_tokens: 8, } as any); assert.ok(runtime.getProviderState()?.agora_session_id, 'chat turn must establish Agora session id'); + await assert.rejects( + () => controller.startBatchIntake({ + targets: [ + { mode: 'create', name: 'Conflict A', output_name: 'Duplicate Output' }, + { mode: 'create', name: 'Conflict B', output_name: 'Duplicate Output' }, + ], + }), + (err: unknown) => err instanceof AgoraMcpError && err.code === 'memory_patch_name_conflict' && err.field === 'output_name' + ); - const internalized = await controller.internalize({ profile_id: 'profile-e2e' }); - assert.equal(internalized.isError, false, internalized.content); - const internalizedPayload = JSON.parse(internalized.content); - const patchId = internalizedPayload.output_memory_patch_id; - assert.match(patchId, /^memory-intake-/); - assert.deepEqual(runtime.getProviderState()?.memory?.active_memory_patch_ids, [patchId]); + const submitted = await controller.startBatchIntake({ + targets: [ + { mode: 'create', name: 'MA E2E Memory', output_name: 'MA E2E Memory@v1' }, + { mode: 'create', name: 'MA E2E Overlay', output_name: 'MA E2E Overlay@v1' }, + { mode: 'create', name: 'MA E2E Preferences', output_name: 'MA E2E Preferences@v1' }, + ], + }); + let finalized = submitted; + for (let i = 0; i < 120; i++) { + finalized = await controller.getBatchIntake(submitted.batch_id); + if (finalized.targets.every((target) => ['completed', 'noop', 'review', 'conflict', 'failed'].includes(target.status))) break; + await new Promise((resolve) => setTimeout(resolve, 100)); + } + assert.deepEqual(finalized.targets.map((target) => target.status), ['completed', 'completed', 'completed'], JSON.stringify(finalized)); + const patchV1 = String(finalized.targets.find((target) => target.memory_name === 'MA E2E Memory')?.output_patch_id); + const overlayPatch = String(finalized.targets.find((target) => target.memory_name === 'MA E2E Overlay')?.output_patch_id); + const preferencesPatch = String(finalized.targets.find((target) => target.memory_name === 'MA E2E Preferences')?.output_patch_id); + const listedMemories = await controller.listMemories(); + const memory = listedMemories.find((item) => item.name === 'MA E2E Memory'); + const overlay = listedMemories.find((item) => item.name === 'MA E2E Overlay'); + const preferences = listedMemories.find((item) => item.name === 'MA E2E Preferences'); + assert.ok(memory); + assert.ok(overlay); + assert.ok(preferences); + + const mounted = await controller.mountMemories('profile-e2e', [memory.id]); + assert.equal(mounted.mount_status, 'pending_next_chat'); + assert.equal(runtime.getProviderState()?.memory?.status, 'pending'); + const mcpInstance = (runtime as any).mcp; + const verifiedA = await (runtime as any).chatCompletePayload({ + model: 'qwen2.5-7b-fp16', + messages: [{ role: 'user', content: '先验证单记忆挂载。' }], + stream: false, + max_tokens: 8, + } as any); + assert.deepEqual(runtime.getProviderState()?.memory?.active_memory_patch_ids, [patchV1]); + assert.equal(runtime.getProviderState()?.memory?.mounted_memories?.[0]?.memory_name, 'MA E2E Memory'); + assert.equal((runtime as any).mcp, mcpInstance, 'patch mount must not restart Agora subprocess'); + const baseObjectId = verifiedA.metadata?.memory_runtime?.base_model_object_id; + const loadCount = verifiedA.metadata?.memory_runtime?.model_load_count; + + await controller.mountMemories('profile-e2e', [memory.id, overlay.id, preferences.id]); + const verifiedABC = await (runtime as any).chatCompletePayload({ + model: 'qwen2.5-7b-fp16', + messages: [{ role: 'user', content: '小明的新项目代号是海鸥,请记住。' }], + stream: false, + max_tokens: 8, + } as any); + assert.deepEqual(runtime.getProviderState()?.memory?.active_memory_patch_ids, [patchV1, overlayPatch, preferencesPatch]); + assert.equal(verifiedABC.metadata?.memory_runtime?.base_model_object_id, baseObjectId); + assert.equal(verifiedABC.metadata?.memory_runtime?.model_load_count, loadCount); + + const secondBatch = await controller.startBatchIntake({ + targets: [{ + mode: 'increment', + memory_id: memory.id, + expected_parent_patch_id: patchV1, + output_name: 'MA E2E Memory@v2', + }], + }); + let second = secondBatch; + for (let i = 0; i < 120; i++) { + second = await controller.getBatchIntake(secondBatch.batch_id); + if (second.targets.every((target) => ['completed', 'noop', 'review', 'conflict', 'failed'].includes(target.status))) break; + await new Promise((resolve) => setTimeout(resolve, 100)); + } + assert.equal(second.targets[0]?.status, 'completed', JSON.stringify(second)); + const patchV2 = String(second.targets[0]?.output_patch_id); + await controller.applyCompletedBatch(second, 'profile-e2e'); + const verifiedV2 = await (runtime as any).chatCompletePayload({ + model: 'qwen2.5-7b-fp16', + messages: [{ role: 'user', content: '增量后继续。' }], + stream: false, + max_tokens: 8, + } as any); + assert.deepEqual(runtime.getProviderState()?.memory?.active_memory_patch_ids, [patchV2, overlayPatch, preferencesPatch]); + assert.equal(verifiedV2.metadata?.memory_runtime?.base_model_object_id, baseObjectId); + assert.equal(verifiedV2.metadata?.memory_runtime?.model_load_count, loadCount); - const rolledBack = await controller.rollback({ profile_id: 'profile-e2e', patch_id: 'patch-old' }); - assert.equal(rolledBack.isError, false, rolledBack.content); - assert.deepEqual(runtime.getProviderState()?.memory?.active_memory_patch_ids, ['patch-old']); + await controller.mountMemories('profile-e2e', [overlay.id, preferences.id]); + await runtime.createChatCompletion({ + model: 'qwen2.5-7b-fp16', + messages: [{ role: 'user', content: '拔出主记忆后继续。' }], + stream: false, + max_tokens: 8, + } as any); + assert.deepEqual(runtime.getProviderState()?.memory?.active_memory_patch_ids, [overlayPatch, preferencesPatch]); + + await controller.rollbackMemory(memory.id, patchV2, patchV1); + await controller.mountMemories('profile-e2e', [memory.id, overlay.id]); + await runtime.createChatCompletion({ + model: 'qwen2.5-7b-fp16', + messages: [{ role: 'user', content: '回滚后继续。' }], + stream: false, + max_tokens: 8, + } as any); + assert.deepEqual(runtime.getProviderState()?.memory?.active_memory_patch_ids, [patchV1, overlayPatch]); const disabled = await controller.disable({ profile_id: 'profile-e2e' }); assert.equal(disabled.isError, false, disabled.content); + assert.equal(JSON.parse(disabled.content).mount_status, 'pending_next_chat'); + await runtime.createChatCompletion({ + model: 'qwen2.5-7b-fp16', + messages: [{ role: 'user', content: '禁用记忆后继续。' }], + stream: false, + max_tokens: 8, + } as any); assert.deepEqual(runtime.getProviderState()?.memory?.active_memory_patch_ids, []); assert.equal(runtime.getProviderState()?.memory?.status, 'disabled'); diff --git a/test/agora-release-lock.test.ts b/test/agora-release-lock.test.ts new file mode 100644 index 0000000..c17a623 --- /dev/null +++ b/test/agora-release-lock.test.ts @@ -0,0 +1,40 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { buildReleaseLock } from '../scripts/sync-agora-release.mjs'; +import { parseDeveloperIdSignature } from '../src/provider/agora.js'; + +const evidence = () => ({ + version: '0.2.0', + published: true, + notarization: { id: 'notary-id', status: 'Accepted' }, + manifest_sha256: 'a'.repeat(64), + signatures: Object.fromEntries(Array.from({ length: 5 }, (_, index) => [ + `bin-${index}`, + { authority: 'Developer ID Application: ZimoOS (TEAM123)', team_id: 'TEAM123' }, + ])), + packages: [ + { name: '@zimoos/agora', version: '0.2.0', integrity: 'sha512-launcher', files: ['package/bin/agora'] }, + { name: '@zimoos/agora-darwin-arm64', version: '0.2.0', integrity: 'sha512-platform', files: ['package/bin/agora'] }, + ], +}); + +test('release lock accepts only published, notarized, Developer ID signed evidence', () => { + const lock = buildReleaseLock(evidence()); + assert.equal(lock.published, true); + assert.equal(lock.notarization_id, 'notary-id'); + assert.equal(lock.packages['@zimoos/agora-darwin-arm64'].integrity, 'sha512-platform'); + + assert.throws(() => buildReleaseLock({ ...evidence(), published: false }), /expected published/); + assert.throws(() => buildReleaseLock({ ...evidence(), notarization: { status: 'Invalid' } }), /notarization/); + const adHoc = evidence(); + adHoc.signatures['bin-0'] = { authority: '', team_id: '' }; + assert.throws(() => buildReleaseLock(adHoc), /Developer ID/); +}); + +test('Developer ID parser rejects ad-hoc signatures', () => { + assert.equal(parseDeveloperIdSignature('Signature=adhoc\nTeamIdentifier=not set'), false); + assert.equal( + parseDeveloperIdSignature('Authority=Developer ID Application: ZimoOS (TEAM123)\nTeamIdentifier=TEAM123'), + true + ); +}); diff --git a/test/cli-ux.test.ts b/test/cli-ux.test.ts index 2adc475..419542e 100644 --- a/test/cli-ux.test.ts +++ b/test/cli-ux.test.ts @@ -25,6 +25,7 @@ test('CLI UX PTY: DeepSeek status bar uses provider capability instead of 33k fa configPath, JSON.stringify( { + defaultProfile: '', model: { provider: 'deepseek', baseURL: 'http://127.0.0.1:9/v1', @@ -56,6 +57,7 @@ test('CLI UX PTY: DeepSeek status bar uses provider capability instead of 33k fa try { await waitFor(() => /win\s+1m\s+registry/.test(stripAnsi(output)), 10_000); const plain = stripAnsi(output); + assert.match(plain, /v0\.3\.0/); assert.match(plain, /ctx:/); assert.match(plain, /trigger/); assert.match(plain, /win 1m registry/); @@ -65,6 +67,144 @@ test('CLI UX PTY: DeepSeek status bar uses provider capability instead of 33k fa } }); +test('CLI UX PTY: Agora keeps context usage and memory activity on separate readable lines', async (t) => { + if (process.env.MA_RUN_PTY_TESTS !== '1') { + t.skip('PTY UI verification runs only in the explicit MA_RUN_PTY_TESTS E2E lane'); + return; + } + if (!canSpawnPty()) { + t.skip('node-pty cannot spawn a basic /bin/echo process in this environment'); + return; + } + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'ma-cli-agora-ux-')); + const configPath = path.join(tmp, 'config.json'); + fs.writeFileSync(configPath, JSON.stringify({ + defaultProfile: '', + model: { + provider: 'agora', + baseURL: 'mcp-stdio://agora', + model: 'qwen3.6-35b-a3b-q4', + apiKey: 'agora-mcp', + }, + mcpServers: {}, + }, null, 2)); + const tsxCli = path.join(repoRoot, 'node_modules', 'tsx', 'dist', 'cli.mjs'); + const child = pty.spawn(process.execPath, [tsxCli, 'src/cli/index.tsx', 'chat', '--config', configPath], { + name: 'xterm-256color', + cols: 80, + rows: 30, + cwd: repoRoot, + env: { ...process.env, NO_COLOR: '1' }, + }); + let output = ''; + child.onData((data) => { output += data; }); + try { + await waitFor(() => /Memory:\s+未挂载/.test(stripAnsi(output)), 10_000); + const plain = stripAnsi(output); + assert.match(plain, /ctx:/); + assert.match(plain, /trigger/); + assert.match(plain, /Memory:\s+未挂载/); + assert.match(plain, /unknown/); + assert.doesNotMatch(plain, /sess\s/); + } finally { + child.kill('SIGINT'); + } +}); + +test('CLI UX PTY: Agora Memory v2 console is name-first and readable at 80 columns', async (t) => { + if (process.env.MA_RUN_PTY_TESTS !== '1') { + t.skip('PTY UI verification runs only in the explicit MA_RUN_PTY_TESTS E2E lane'); + return; + } + if (!canSpawnPty()) { + t.skip('node-pty cannot spawn a basic /bin/echo process in this environment'); + return; + } + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'ma-cli-agora-memory-v2-')); + const fakeAgora = path.join(tmp, 'fake-agora-v2.mjs'); + const configPath = path.join(tmp, 'config.json'); + fs.writeFileSync(fakeAgora, fakeAgoraV2Source(), 'utf8'); + fs.writeFileSync(configPath, JSON.stringify({ + defaultProfile: '', + model: { + provider: 'agora', + baseURL: 'mcp-stdio://agora', + model: 'base-a', + apiKey: 'agora-mcp', + agoraRuntime: { command: process.execPath, args: [fakeAgora] }, + }, + mcpServers: {}, + }, null, 2)); + const tsxCli = path.join(repoRoot, 'node_modules', 'tsx', 'dist', 'cli.mjs'); + const child = pty.spawn(process.execPath, [tsxCli, 'src/cli/index.tsx', 'chat', '--config', configPath], { + name: 'xterm-256color', + cols: 80, + rows: 30, + cwd: repoRoot, + env: { ...process.env, NO_COLOR: '1', MA_REDUCED_MOTION: '1' }, + }); + let output = ''; + child.onData((data) => { output += data; }); + try { + await waitFor(() => /Memory:\s+未挂载/.test(stripAnsi(output)), 10_000); + child.write('/memory'); + await new Promise((resolve) => setTimeout(resolve, 200)); + child.write('\r'); + try { + await waitFor(() => /具名记忆/.test(stripAnsi(output)) && /产品记忆/.test(stripAnsi(output)), 10_000); + } catch (err) { + throw new Error(`${(err as Error).message}\nPTY output:\n${stripAnsi(output).slice(-4000)}`); + } + const plain = stripAnsi(output); + assert.match(plain, /Memory ·/); + assert.match(plain, /产品记忆 · v1/); + assert.match(plain, /Space 挂载/); + assert.doesNotMatch(plain, /writable_patch_family|主记忆\/可写|Profiles/); + } finally { + child.kill('SIGINT'); + fs.rmSync(tmp, { recursive: true, force: true }); + } +}); + +function fakeAgoraV2Source(): string { + return `let buffer = ''; +const names = ${JSON.stringify([ + 'doctor', 'runtime_capabilities', 'models_list', 'models_status', 'models_download', 'chat_complete', + 'memory_profiles_list', 'memory_profiles_create', 'memory_profiles_update', + 'memory_profile_bindings_list', 'memory_profile_bindings_create', 'memory_patches_list', 'memory_patch_versions', + 'memories_create', 'memories_get', 'memories_list', 'memories_rename', 'memories_rollback', + 'memory_intake_batch_run', 'memory_intake_batch_get', + ])}; +const payloads = { + doctor: { status: 'ok', version: '0.2.0', contract: { host_protocol_major: 1 } }, + runtime_capabilities: { status: 'ok', contract: { runtime_version: '0.2.0', host_protocol_major: 1, registry_schema_version: 3, capabilities: { named_memories: 1, multi_target_intake: 1, incremental_segments: 1, multi_model_delta_mount: 1, request_boundary_hot_swap: 1, memory_runtime_v2: 1 } } }, + models_list: { models: [{ id: 'base-a', name: 'Base A', status: 'available' }] }, + memories_list: { memories: [{ id: 'memory-a', name: '产品记忆', base_model_id: 'base-a', head_patch_id: 'patch-a', status: 'available' }] }, + memory_profiles_list: { profiles: [{ id: 'profile-a', name: 'project', base_model_id: 'base-a', active_memory_patch_ids: [], auto_intake_target_memory_ids: [], auto_intake_policy: { enabled: false }, status: 'available' }] }, + memory_patches_list: { patches: [{ id: 'patch-a', name: '产品记忆@v1', base_model_id: 'base-a', family: 'memory-a', version: 'v1', mountable: true, status: 'available', memory_id: 'memory-a' }] }, + chat_complete: { status: 'completed', id: 'chat-a', session_id: 'session-a', message: { role: 'assistant', content: 'ok' }, output_text: 'ok', finish_reason: 'stop', active_memory_patch_ids: ['patch-a'], metadata: { session_id: 'session-a', memory: { enabled: true, profile_id: 'profile-a', active_memory_patch_ids: ['patch-a'] }, memory_runtime: { patchset_revision: 2 } }, memory: { enabled: true, profile_id: 'profile-a', active_memory_patch_ids: ['patch-a'] } }, +}; +process.stdin.setEncoding('utf8'); +process.stdin.on('data', (chunk) => { + buffer += chunk; + while (true) { + const idx = buffer.indexOf('\\n'); + if (idx < 0) return; + const line = buffer.slice(0, idx).trim(); + buffer = buffer.slice(idx + 1); + if (!line) continue; + const req = JSON.parse(line); + if (req.method === 'notifications/initialized') continue; + let result = {}; + if (req.method === 'tools/list') result = { tools: names.map((name) => ({ name, inputSchema: { type: 'object' } })) }; + else if (req.method === 'resources/list') result = { resources: [] }; + else if (req.method === 'tools/call') result = { content: [{ type: 'text', text: JSON.stringify(payloads[req.params.name] || { status: 'ok' }) }] }; + process.stdout.write(JSON.stringify({ jsonrpc: '2.0', id: req.id, result }) + '\\n'); + } +}); +`; +} + async function waitFor(predicate: () => boolean, timeoutMs: number): Promise { const start = Date.now(); while (Date.now() - start < timeoutMs) { diff --git a/test/cli-version.test.ts b/test/cli-version.test.ts index 348bd7d..f05ec14 100644 --- a/test/cli-version.test.ts +++ b/test/cli-version.test.ts @@ -12,3 +12,13 @@ test('CLI version is read from package.json', () => { assert.equal(VERSION, pkg.version); assert.notEqual(VERSION, '0.1.0-alpha.0'); }); + +test('npm package includes its postinstall entrypoint', () => { + const pkg = JSON.parse(fs.readFileSync(new URL('../package.json', import.meta.url), 'utf-8')) as { + files: string[]; + scripts: { postinstall?: string }; + }; + + assert.equal(pkg.scripts.postinstall, 'node scripts/fix-node-pty.mjs'); + assert.ok(pkg.files.includes('scripts/fix-node-pty.mjs')); +}); diff --git a/test/client.test.ts b/test/client.test.ts index eb173f2..ac5ef44 100644 --- a/test/client.test.ts +++ b/test/client.test.ts @@ -6,6 +6,7 @@ import * as os from 'node:os'; import * as path from 'node:path'; import { PassThrough } from 'node:stream'; import { buildMcpEnv, McpClient } from '../src/mcp/client.js'; +import { VERSION } from '../src/version.js'; function fakeProc() { const stdin = new PassThrough(); @@ -85,6 +86,28 @@ test('McpClient.request: round-trip via id matching', async () => { assert.deepEqual(result, { pong: true }); }); +test('McpClient.initialize advertises the package version', async () => { + const proc = fakeProc(); + const client = new McpClient('exec', proc); + const seen: any[] = []; + proc.stdin.on('data', (chunk: Buffer) => { + for (const line of chunk.toString('utf-8').split('\n')) { + if (!line.trim()) continue; + const message = JSON.parse(line); + seen.push(message); + if (message.method === 'initialize') { + proc.stdout.write(`${JSON.stringify({ jsonrpc: '2.0', id: message.id, result: {} })}\n`); + } + } + }); + + await client.initialize(); + + const initialize = seen.find((message) => message.method === 'initialize'); + assert.deepEqual(initialize.params.clientInfo, { name: 'my-agent', version: VERSION }); + assert.ok(seen.some((message) => message.method === 'notifications/initialized')); +}); + test('McpClient.request: error response rejects', async () => { const proc = fakeProc(); const client = new McpClient('exec', proc); diff --git a/test/commands.test.ts b/test/commands.test.ts index 3fcc603..6dac298 100644 --- a/test/commands.test.ts +++ b/test/commands.test.ts @@ -67,6 +67,51 @@ test('/model opens the model picker when no args are provided', async () => { }); }); +test('/memory opens the console and manages named Memory v2 objects', async () => { + await withTempCwd(async () => { + let opened = 0; + const calls: Array<{ name: string; args: unknown[] }> = []; + const controller = { + getCapabilities: () => ({ memoryV2: true, runtimeMode: 'v2' }), + listMemories: async () => [{ + id: 'memory-a', + name: 'MA 核心记忆', + base_model_id: 'base-a', + head_patch_id: 'patch-a', + status: 'available', + }], + listPatches: async () => [{ + id: 'patch-a', + name: 'MA 核心记忆@v1', + version: 'v1', + memory_id: 'memory-a', + }], + listProfiles: async () => [{ + id: 'profile-a', + name: 'project binding', + active_memory_patch_ids: ['patch-a'], + auto_intake_target_memory_ids: ['memory-a'], + auto_intake_policy: { enabled: true }, + }], + createMemory: async (name: string) => { calls.push({ name: 'create', args: [name] }); return { id: 'memory-new', name }; }, + mountMemories: async (...args: unknown[]) => { calls.push({ name: 'mount', args }); return { mount_status: 'pending_next_chat' }; }, + }; + const agent = { + getMemoryController: () => controller, + getProviderState: () => ({ memory: { profile_id: 'profile-a' } }), + }; + assert.equal(await executeCommand('/memory', baseContext({ agent, openMemoryConsole: () => { opened++; } })), null); + assert.equal(opened, 1); + const listed = await executeCommand('/memory list', baseContext({ agent })); + assert.match(String(listed), /MA 核心记忆/); + assert.match(String(listed), /v1/); + await executeCommand('/memory new 工程记忆', baseContext({ agent })); + await executeCommand('/memory mount MA 核心记忆', baseContext({ agent })); + assert.deepEqual(calls[0], { name: 'create', args: ['工程记忆'] }); + assert.deepEqual(calls[1], { name: 'mount', args: ['profile-a', ['memory-a'], 'project'] }); + }); +}); + test('/context routes inspection, search, recall, and pin commands to the agent', async () => { await withTempCwd(async () => { const calls: string[] = []; @@ -124,7 +169,7 @@ test('slash suggestions only include user-facing commands', async () => { assert.deepEqual( Array.from(suggestions.keys()).sort(), - ['/clear', '/exit', '/help', '/model'] + ['/clear', '/exit', '/help', '/memory', '/model'] ); const all = await getAllCommands(); diff --git a/test/fixtures/portable-agora-config.json b/test/fixtures/portable-agora-config.json new file mode 100644 index 0000000..7165a19 --- /dev/null +++ b/test/fixtures/portable-agora-config.json @@ -0,0 +1,12 @@ +{ + "defaultProfile": "", + "model": { + "provider": "agora", + "baseURL": "mcp-stdio://agora", + "model": "qwen2.5-7b-fp16", + "apiKey": "agora-mcp", + "maxTokens": 8, + "temperature": 0 + }, + "mcpServers": {} +} diff --git a/test/session-store.test.ts b/test/session-store.test.ts index 0eb715f..064a637 100644 --- a/test/session-store.test.ts +++ b/test/session-store.test.ts @@ -37,6 +37,35 @@ test('session store: append writes one JSON line per call and updates messageCou assert.equal(meta.messageCount, 2); }); +test('session store: provider chat truth preserves MA-local Memory batch checkpoint state', () => { + const dir = mktmp('sess-'); + const store = createSessionStore(dir); + try { + const id = store.create({ createdAt: Date.now(), cwd: '/tmp/project', model: 'agora' }); + store.updateProviderState(id, { + provider_id: 'agora', + memory: { + status: 'pending', + active_batch: { batch_id: 'batch-a' }, + last_auto_intake_message_end: 12, + last_auto_intake_runtime_message_end: 13, + }, + }); + store.updateProviderState(id, { + provider_id: 'agora', + agora_session_id: 'chat-a', + memory: { status: 'mounted', active_memory_patch_ids: ['patch-a'] }, + }); + const state = store.list().find((meta) => meta.id === id)?.providerState; + assert.equal(state?.memory?.status, 'mounted'); + assert.deepEqual(state?.memory?.active_batch, { batch_id: 'batch-a' }); + assert.equal(state?.memory?.last_auto_intake_message_end, 12); + assert.equal(state?.memory?.last_auto_intake_runtime_message_end, 13); + } finally { + fs.rmSync(dir, { recursive: true, force: true }); + } +}); + test('session store: load round-trips all appended messages', () => { const dir = mktmp('sess-'); const store = createSessionStore(dir);