From 480e05012cf752d2f12c91d1f6c9a74ad48d5e98 Mon Sep 17 00:00:00 2001 From: heimanba <371510756@qq.com> Date: Tue, 21 Jul 2026 00:22:09 +0800 Subject: [PATCH 1/2] Add complete memory module Change-Id: Iea5dbdcf29c9ac1dd1b8d55f824f9e96e82cfb8d --- .changeset/complete-memory-module.md | 8 + README.md | 2 +- README.zh-CN.md | 2 +- docs/examples.md | 4 +- docs/guides/configure-an-agent.md | 2 +- docs/guides/configure-an-agent.zh-CN.md | 2 +- docs/guides/deploy-to-claude.md | 4 +- docs/reference/cli.md | 29 ++ docs/reference/configuration.md | 7 +- docs/reference/providers.md | 4 +- docs/reference/providers.zh-CN.md | 12 +- examples/README.md | 8 +- examples/ark/full/agents.yaml | 9 +- examples/claude/with-memory/agents.yaml | 37 +++ examples/memory/README.md | 57 ++++ examples/qoder/with-memory/agents.yaml | 2 + packages/cli/src/commands/memory.ts | 161 ++++++++++ packages/cli/src/program.ts | 121 ++++++++ packages/sdk/src/index.ts | 36 +++ .../sdk/src/internal/core/memory-runtime.ts | 156 ++++++++++ .../sdk/src/internal/executor/executor.ts | 52 +++- packages/sdk/src/internal/parser/schema.ts | 1 + .../sdk/src/internal/providers/ark/adapter.ts | 68 ++++- .../sdk/src/internal/providers/ark/mapper.ts | 1 + .../src/internal/providers/capabilities.ts | 13 +- .../src/internal/providers/claude/adapter.ts | 106 ++++++- .../internal/providers/claude/capabilities.ts | 6 +- .../sdk/src/internal/providers/interface.ts | 30 ++ .../sdk/src/internal/providers/memory-api.ts | 279 ++++++++++++++++++ .../src/internal/providers/qoder/adapter.ts | 80 ++++- .../src/internal/providers/qoder/mapper.ts | 1 + .../internal/providers/resource-workflow.ts | 14 + packages/sdk/src/internal/types/config.ts | 1 + packages/sdk/src/internal/types/memory.ts | 141 +++++++++ .../sdk/tests/e2e/memory-adapters.test.ts | 137 +++++++++ packages/sdk/tests/e2e/memory-live.ts | 66 +++++ .../unit/executor-conflict-adopt.test.ts | 88 ++++++ packages/sdk/tests/unit/memory-api.test.ts | 190 ++++++++++++ packages/sdk/tests/unit/planner.test.ts | 10 +- .../tests/unit/provider-conformance.test.ts | 15 +- 40 files changed, 1901 insertions(+), 61 deletions(-) create mode 100644 .changeset/complete-memory-module.md create mode 100644 examples/claude/with-memory/agents.yaml create mode 100644 examples/memory/README.md create mode 100644 packages/cli/src/commands/memory.ts create mode 100644 packages/sdk/src/internal/core/memory-runtime.ts create mode 100644 packages/sdk/src/internal/providers/memory-api.ts create mode 100644 packages/sdk/src/internal/types/memory.ts create mode 100644 packages/sdk/tests/e2e/memory-adapters.test.ts create mode 100644 packages/sdk/tests/e2e/memory-live.ts create mode 100644 packages/sdk/tests/unit/memory-api.test.ts diff --git a/.changeset/complete-memory-module.md b/.changeset/complete-memory-module.md new file mode 100644 index 0000000..c7e0a45 --- /dev/null +++ b/.changeset/complete-memory-module.md @@ -0,0 +1,8 @@ +--- +"@openagentpack/sdk": minor +"@openagentpack/cli": minor +--- + +Add a portable Memory Store and Memory lifecycle API across Qoder, Claude, and +Volcengine Ark, including provider capability differences, CLI commands, +declarative entry reconciliation, version history, and Ark batch creation. diff --git a/README.md b/README.md index aa9c66e..89c0c46 100644 --- a/README.md +++ b/README.md @@ -145,7 +145,7 @@ Beta testers can install `@openagentpack/cli@beta`; see the [release guide](./do | Skill | native | native | native | native | | Agent | native | native | native | native | | MCP Server | native | native | native | native | -| Memory Store | unsupported | native | unsupported | native | +| Memory Store | unsupported | native | native | native | | Multi-Agent | unsupported | unsupported | native | native | | Deployment | emulated | native | native | emulated | | Session | native | native | native | native | diff --git a/README.zh-CN.md b/README.zh-CN.md index 5e18c9c..b316783 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -145,7 +145,7 @@ Beta 用户可以安装 `@openagentpack/cli@beta`;固定版本及切回稳定 | Skill | native | native | native | native | | Agent | native | native | native | native | | MCP Server | native | native | native | native | -| Memory Store | unsupported | native | unsupported | native | +| Memory Store | unsupported | native | native | native | | Multi-Agent | unsupported | unsupported | native | native | | Deployment | emulated | native | native | emulated | | Session | native | native | native | native | diff --git a/docs/examples.md b/docs/examples.md index 08a60e3..d1edbe2 100644 --- a/docs/examples.md +++ b/docs/examples.md @@ -13,7 +13,7 @@ The [`examples/`](../examples) directory has runnable configs for every provider | Use an official MCP server | [`examples/bailian/with-mcp/`](../examples/bailian/with-mcp/) | | Use a vault | [`examples/bailian/with-vault/`](../examples/bailian/with-vault/) · [`examples/qoder/with-vault/`](../examples/qoder/with-vault/) | | Connect a credential-based IM Channel | [`examples/qoder/with-channel/`](../examples/qoder/with-channel/) | -| Use memory stores | [`examples/qoder/with-memory/`](../examples/qoder/with-memory/) · [`examples/ark/full/`](../examples/ark/full/) | +| Use memory stores | [`examples/qoder/with-memory/`](../examples/qoder/with-memory/) · [`examples/claude/with-memory/`](../examples/claude/with-memory/) · [`examples/ark/full/`](../examples/ark/full/) · [runtime lifecycle](../examples/memory/README.md) | | Upload local files (Files API) | [`examples/bailian/with-files/`](../examples/bailian/with-files/) · [`examples/ark/with-files/`](../examples/ark/with-files/) | | Coordinate multiple agents | [`examples/claude/multiagent/`](../examples/claude/multiagent/) · [`examples/ark/multiagent/`](../examples/ark/multiagent/) | | Deploy to multiple providers | [`examples/claude/multi-provider/`](../examples/claude/multi-provider/) · [`examples/qoder/multi-provider/`](../examples/qoder/multi-provider/) | @@ -51,7 +51,7 @@ agents destroy | Skill | native | native | native | native | | Agent | native | native | native | native | | MCP Server | native | native | native | native | -| Memory Store | unsupported | native | unsupported | native | +| Memory Store | unsupported | native | native | native | | Multi-Agent | unsupported | unsupported | native | native | | Deployment | emulated | native | native | emulated | | Session | native | native | native | native | diff --git a/docs/guides/configure-an-agent.md b/docs/guides/configure-an-agent.md index 2feb7e5..20911d8 100644 --- a/docs/guides/configure-an-agent.md +++ b/docs/guides/configure-an-agent.md @@ -128,7 +128,7 @@ agents: See [Use MCP and vaults](./use-mcp-and-vaults.md). -## Memory stores (Qoder, Ark) +## Memory stores (Qoder, Claude beta, Ark) ```yaml memory_stores: diff --git a/docs/guides/configure-an-agent.zh-CN.md b/docs/guides/configure-an-agent.zh-CN.md index d027351..5fc706a 100644 --- a/docs/guides/configure-an-agent.zh-CN.md +++ b/docs/guides/configure-an-agent.zh-CN.md @@ -307,7 +307,7 @@ agents: Memory Store 为 Agent 提供持久化上下文,适合存储项目知识、约定规范等长期信息。 -> 注意:Memory Store 目前由 Qoder 和 火山方舟 Provider 原生支持。 +> 注意:Memory Store 目前由 Qoder、Claude(beta)和火山方舟 Provider 原生支持。 ```yaml memory_stores: diff --git a/docs/guides/deploy-to-claude.md b/docs/guides/deploy-to-claude.md index 196c03b..657db2f 100644 --- a/docs/guides/deploy-to-claude.md +++ b/docs/guides/deploy-to-claude.md @@ -21,9 +21,9 @@ providers: | Feature | Tier | |---------|:----:| | Environment, Vault, Skill, Agent, MCP Server, Multi-Agent, Deployment, Session | native | -| Memory Store | unsupported | +| Memory Store | native (beta) | -Use a **skill** or **MCP** for context persistence where Claude has no memory store. +Memory stores require the Claude `agent-memory-2026-07-22` beta, enabled by the adapter by default. ## Minimal agent diff --git a/docs/reference/cli.md b/docs/reference/cli.md index 261216e..1c568a6 100644 --- a/docs/reference/cli.md +++ b/docs/reference/cli.md @@ -123,6 +123,35 @@ Manage scheduled / triggered deployments. | `deployment get ` | Show a deployment's status and resolved bindings. | | `deployment run ` | Trigger a deployment run (native on Qoder/Claude, emulated as a session on Bailian/Volcengine Ark). | +## `agents memory-store` + +Manage persistent stores directly. Store creation through `agents apply` remains +the recommended declarative workflow. + +| Command | Description | +|---------|-------------| +| `create ` | Create a store (`--description`). | +| `list` | List stores (`--limit`, `--cursor`, `--include-archived`). | +| `get ` | Retrieve a store. | +| `update ` | Update `--name` and/or `--description`. | +| `archive ` | Archive a store (Qoder/Claude). | +| `delete ` | Permanently delete a store and its memories. | + +## `agents memory` + +Manage individual text memories. Content can be passed with `--content` or +`--content-file`. Portable paths are relative; adapters handle wire-format differences. + +| Command | Description | +|---------|-------------| +| `create ` | Create one memory. | +| `batch-create ` | Ark batch create; supports `--on-conflict overwrite\|fail`. | +| `list ` | List memories; supports pagination, prefix/depth and `--full`. | +| `get ` | Retrieve full content. | +| `update ` | Update content/path; `--expected-sha256` enables optimistic concurrency where supported. | +| `delete ` | Delete one memory. | +| `version list|get|redact` | Immutable history operations (Qoder/Claude). | + ## `agents models` | Subcommand | Description | diff --git a/docs/reference/configuration.md b/docs/reference/configuration.md index cf6d874..699e695 100644 --- a/docs/reference/configuration.md +++ b/docs/reference/configuration.md @@ -197,10 +197,15 @@ memory_stores: : description: provider: # optional + metadata: { : } # optional entries: [ { key: , content: } ] ``` -Supported on **Qoder** and **Volcengine Ark** (**Bailian** and **Claude**: `unsupported`). +Supported on **Qoder**, **Claude (beta)**, and **Volcengine Ark** (**Bailian**: `unsupported`). + +Declarative `entries` are managed seeds: apply creates or updates those paths but +preserves additional memories written by agents. Runtime CRUD and version commands +are documented in [`examples/memory/`](../../examples/memory/README.md). ## Skill diff --git a/docs/reference/providers.md b/docs/reference/providers.md index f0be425..45380d1 100644 --- a/docs/reference/providers.md +++ b/docs/reference/providers.md @@ -13,7 +13,7 @@ OpenAgentPack targets multiple agent platforms behind one declarative config. Ea | Skill | native | native | native | native | Claude uploads via `files[]`; the other providers upload zip archives. Volcengine Ark is create + attach only. | | Agent | native | native | native | native | Core managed-agent resource. | | MCP Server | native | native | native | native | Bailian uses official managed servers referenced by name. | -| Memory Store | unsupported | native | unsupported | native | Qoder and Ark are implemented. Claude's upstream API now has Memory Stores, but its OpenAgentPack adapter does not yet. | +| Memory Store | unsupported | native | native | native | Qoder, Claude (beta), and Ark adapters implement the complete upstream lifecycle. | | Multi-Agent | unsupported | unsupported | native | native | Coordinator topology is available on Claude and Volcengine Ark. | | Deployment | emulated | native | native | emulated | Qoder and Claude use native deployments; Bailian and Ark expand a deployment into a session at `run` time. | | Session | native | native | native | native | Runtime sessions are native on every provider. | @@ -63,7 +63,7 @@ Last reviewed: **2026-07-17**. The evidence labels below deliberately separate u | Skills | Built-in Anthropic skills plus custom zip or individual-file uploads; max 20 per session | CRUD/list/get/download implemented; adapter uploads `files[]` | Official documentation currently lists create and get only | Create/get/attach implemented; update recreates; list/delete are unavailable upstream | | Multi-agent | Coordinator delegates to persistent, context-isolated threads sharing sandbox/files/vaults | Coordinator topology implemented | Agent schema includes `multiagent`; Session APIs expose thread list/detail/events/stream | Coordinator topology implemented; thread inspection is not exposed through `ProviderAdapter` | | Deployment | Native scheduled deployments with cron/timezone and run history | Native lifecycle and run implemented | The official Managed Agents API catalog contains no Deployment resource | Emulated locally and expanded into a Session at run time | -| Memory Store | Official API supports persistent, versioned memories mounted read-only or read-write; up to 8 stores per session | **Gap:** not implemented, so capability remains `unsupported` in OpenAgentPack | Official CRUD for stores plus create/batch-create/list/get/update/delete for memories | Store create/delete and Session binding implemented; list/get/update and memory-content operations are adapter gaps | +| Memory Store | Store CRUD/archive, memory CRUD, optimistic concurrency, immutable versions and redaction are implemented; paths are normalized from Claude's absolute form | Store CRUD, memory CRUD, Session binding, versions and redaction are implemented behind `agent-memory-2026-07-22` | Store CRUD plus memory create/batch-create/list/get/update/delete are implemented | Full upstream surface is implemented; Ark is last-write-wins and does not expose version APIs | Primary references: [Claude API overview](https://platform.claude.com/docs/en/api/overview), [Claude Managed Agents overview](https://platform.claude.com/docs/en/managed-agents/overview), [sessions and event streaming](https://platform.claude.com/docs/en/managed-agents/events-and-streaming), [skills](https://platform.claude.com/docs/en/managed-agents/skills), [multi-agent sessions](https://platform.claude.com/docs/en/managed-agents/multi-agent), [scheduled deployments](https://platform.claude.com/docs/en/managed-agents/scheduled-deployments), [memory stores](https://platform.claude.com/docs/en/managed-agents/memory), and the [Volcengine Ark Managed Agents API reference](https://console.volcengine.com/ark/region:cn-beijing/docs/82379/2555910?lang=zh). diff --git a/docs/reference/providers.zh-CN.md b/docs/reference/providers.zh-CN.md index 2a7558f..0220771 100644 --- a/docs/reference/providers.zh-CN.md +++ b/docs/reference/providers.zh-CN.md @@ -32,7 +32,7 @@ OpenAgentPack 通过 Provider 适配器与不同的 AI Agent 平台交互。每 | Skill | native | native | native | native | Claude 使用 files[];其余 Provider 上传 zip | | Agent | native | native | native | native | 核心资源 | | MCP Server | native | native | native | native | 通过 Agent 的 MCP 配置挂载 | -| Memory Store | unsupported | native | unsupported | native | Qoder、方舟已接入;Claude 上游已有 Memory Store,但 OpenAgentPack Adapter 尚未实现 | +| Memory Store | unsupported | native | native | native | Qoder、Claude(beta)、方舟均已接入 | | Multi-Agent | unsupported | unsupported | native | native | Claude 与 火山方舟 支持 coordinator | | Deployment | emulated | native | native | emulated | Qoder 和 Claude 使用原生 Deployment;百炼和火山方舟在 `run` 时展开为 Session | | Session | native | native | native | native | 四者均原生支持 | @@ -76,7 +76,7 @@ OpenAgentPack 通过 Provider 适配器与不同的 AI Agent 平台交互。每 | Skill | 支持 Anthropic 内置 Skill 与自定义 zip/多文件上传;每 Session 最多 20 个 | 已实现 CRUD、枚举、查询和下载;Adapter 使用 `files[]` 上传 | 官方文档当前仅列出创建和查询详情 | 已实现创建、查询和挂载;更新通过重建模拟;枚举、删除是上游缺失 | | Multi-Agent | Coordinator 可调度持久、上下文隔离的线程;共享沙箱、文件和 Vault | 已实现 Coordinator 拓扑 | Agent Schema 含 `multiagent`;Session API 提供 Thread 列表、详情、事件查询和流式读取 | 已实现 Coordinator 拓扑;`ProviderAdapter` 尚未暴露 Thread 查询 | | Deployment | 原生定时 Deployment,支持 cron、时区和运行历史 | 已实现原生生命周期和运行 | 官方 Managed Agents API 目录中没有 Deployment 资源 | 本地模拟,运行时展开为 Session | -| Memory Store | 官方 API 支持跨 Session 持久化、版本化记忆,可只读/读写挂载;每 Session 最多 8 个 | **能力缺口:尚未接入**,因此 OpenAgentPack 中仍为 `unsupported` | 官方提供记忆库 CRUD,以及记忆创建、批量创建、枚举、查询、更新、删除 | 已实现记忆库创建、删除和 Session 挂载;枚举、查询、更新及记忆内容操作尚未接入 | +| Memory Store | 已实现 Store CRUD/归档、Memory CRUD、乐观并发、版本查询与 redact;统一层会转换 Claude 的绝对路径 | 已通过 `agent-memory-2026-07-22` 接入 Store CRUD、Memory CRUD、Session 挂载、版本与 redact | 官方提供记忆库 CRUD,以及记忆创建、批量创建、枚举、查询、更新、删除 | 已完整接入上游能力;方舟采用 last-write-wins,且当前不暴露版本接口 | 主要依据:[Claude API 总览](https://platform.claude.com/docs/en/api/overview)、[Claude Managed Agents 总览](https://platform.claude.com/docs/en/managed-agents/overview)、[Session Event Stream](https://platform.claude.com/docs/en/managed-agents/events-and-streaming)、[Skills](https://platform.claude.com/docs/en/managed-agents/skills)、[Multi-Agent](https://platform.claude.com/docs/en/managed-agents/multi-agent)、[Scheduled Deployments](https://platform.claude.com/docs/en/managed-agents/scheduled-deployments)、[Memory Stores](https://platform.claude.com/docs/en/managed-agents/memory),以及[火山方舟 Managed Agents API 参考](https://console.volcengine.com/ark/region:cn-beijing/docs/82379/2555910?lang=zh)。 @@ -112,7 +112,7 @@ OpenAgentPack 通过 Provider 适配器与不同的 AI Agent 平台交互。每 | Agent | full | full | 待验证 | existence | 火山方舟 当前执行存在性检查 | | Skill | existence | existence | existence | existence | 可发现缺失/删除,不比较包内容 | | Vault | existence | existence | existence | existence | 凭证内容通常不可读回,不比较内容 | -| Memory Store | unsupported | existence | unsupported | existence | 可发现资源缺失 | +| Memory Store | unsupported | existence | existence | existence | 可发现资源缺失 | | Deployment | unsupported | native | native 路径待验证 | unsupported | 百炼和火山方舟的 emulated Deployment 为本地记录 | Claude 的 drift detection 接口路径已预留;本仓库中的 live baseline 因 Anthropic API 账号余额不足未完成 Agent 创建验证。 @@ -128,9 +128,9 @@ Claude 的 drift detection 接口路径已预留;本仓库中的 live baseline 示例诊断输出: ``` -claude.memory_store.unsupported: - Claude exposes Memory Stores, but the OpenAgentPack adapter has not implemented them yet. - use skill knowledge or MCP until Claude Memory Store support is added to the adapter +bailian.memory_store.unsupported: + no memory store primitive on Bailian + use skill knowledge or MCP for persistent context qoder.multiagent.unsupported: no multiagent primitive on Qoder. diff --git a/examples/README.md b/examples/README.md index 496a2cc..e4105dd 100644 --- a/examples/README.md +++ b/examples/README.md @@ -20,6 +20,7 @@ examples/ │ ├── basic/ minimal agent │ ├── with-skills/ skill + file-referenced instructions │ ├── with-mcp/ MCP server + vault + restricted network +│ ├── with-memory/ persistent memory store (beta) │ ├── multiagent/ coordinator multi-agent (Claude only) │ ├── multi-provider/ same agent on both Claude + Qoder │ ├── deployment/ schedule + outcome rubric (native) @@ -28,7 +29,7 @@ examples/ │ ├── basic/ minimal agent │ ├── with-skills/ skill + file-referenced instructions │ ├── with-mcp/ MCP server + vault + restricted network -│ ├── with-memory/ memory_store (Qoder only) +│ ├── with-memory/ persistent memory store │ ├── with-vault/ vault only │ ├── vault-only/ vault-only project │ ├── multi-provider/ same agent on both Claude + Qoder @@ -48,6 +49,9 @@ examples/ └── run-session-complex.ts tool calls + streaming events ``` +See [`memory/README.md`](./memory/README.md) for portable Memory CRUD, provider +extensions, and live-test commands. + ## Provider capability matrix | Feature | Bailian | Qoder | Claude | Volcengine Ark | Notes | @@ -57,7 +61,7 @@ examples/ | Skill | native | native | native | native | Claude uploads via `files[]`; others upload zip; Volcengine Ark is create + attach only. | | Agent | native | native | native | native | Core managed-agent resource. | | MCP Server | native | native | native | native | Bailian uses official managed servers referenced by name. | -| Memory Store | unsupported | native | unsupported | native | Qoder and Volcengine Ark. | +| Memory Store | unsupported | native | native | native | Qoder, Claude (beta), and Volcengine Ark. | | Multi-Agent | unsupported | unsupported | native | native | Claude and Volcengine Ark support coordinator. | | Deployment | emulated | native | native | emulated | Qoder and Claude schedule server-side; Bailian and Ark expand into a session at `run` time. | | Session | native | native | native | native | All four support runtime sessions. | diff --git a/examples/ark/full/agents.yaml b/examples/ark/full/agents.yaml index fc07b89..2c3df03 100644 --- a/examples/ark/full/agents.yaml +++ b/examples/ark/full/agents.yaml @@ -52,11 +52,16 @@ skills: source: ./skills/summarize/ description: "Produce concise summaries" -# Ark supports native memory stores (Claude does not). A memory store persists -# context across sessions. +# Ark supports native memory stores. It also offers partial-success batch memory +# creation, while updates use last-write-wins and version history is not exposed. memory_stores: project-notes: description: "Shared long-lived project notes" + metadata: + example: ark-full + entries: + - key: project/overview.md + content: "This store holds durable project context shared across sessions." agents: researcher: diff --git a/examples/claude/with-memory/agents.yaml b/examples/claude/with-memory/agents.yaml new file mode 100644 index 0000000..c63a302 --- /dev/null +++ b/examples/claude/with-memory/agents.yaml @@ -0,0 +1,37 @@ +version: "1" + +providers: + claude: + api_key: ${ANTHROPIC_API_KEY} + +defaults: + provider: claude + +environments: + dev: + config: + type: cloud + networking: + type: unrestricted + +memory_stores: + project-memory: + description: "Long-lived project decisions and coding conventions" + metadata: + example: with-memory + entries: + - key: decisions/architecture.md + content: "Start with a modular monolith and record extraction triggers here." + - key: conventions/testing.md + content: "Public behavior requires integration coverage." + +agents: + assistant: + description: "Project assistant with persistent memory" + model: claude-sonnet-4-6 + instructions: "Read project memory before making architectural recommendations." + environment: dev + tools: + builtin: [bash, text_editor] + memory_stores: [project-memory] + diff --git a/examples/memory/README.md b/examples/memory/README.md new file mode 100644 index 0000000..2891404 --- /dev/null +++ b/examples/memory/README.md @@ -0,0 +1,57 @@ +# Memory lifecycle examples + +The `memory-store` resource is declarative, while `agents memory*` commands manage +runtime content without editing `agents.yaml`. + +## Portable lifecycle (Qoder, Claude, Ark) + +```bash +agents apply -f examples/qoder/with-memory/agents.yaml +agents memory-store list -f examples/qoder/with-memory/agents.yaml --provider qoder + +agents memory create notes/decision.md \ + --content "Use a modular monolith" \ + -f examples/qoder/with-memory/agents.yaml --provider qoder + +agents memory list --full \ + -f examples/qoder/with-memory/agents.yaml --provider qoder + +agents memory update \ + --content "Use services after the team reaches 30 engineers" \ + --expected-sha256 \ + -f examples/qoder/with-memory/agents.yaml --provider qoder +``` + +The same commands work with `--provider claude` and `--provider ark`. Paths are +always written as portable relative paths; the Claude adapter converts them to +Claude's required leading-slash form. + +## Provider-specific extensions + +Qoder and Claude expose immutable version history: + +```bash +agents memory version list --memory-id --full -f agents.yaml --provider qoder +agents memory version redact -f agents.yaml --provider qoder +``` + +Ark exposes partial-success batch creation (`items.json` is an array of +`{"path":"...","content":"..."}` objects): + +```bash +agents memory batch-create items.json --on-conflict overwrite -f agents.yaml --provider ark +``` + +Ark currently uses last-write-wins and does not expose version retrieval or +redaction. Calling an unsupported extension returns a provider capability error. + +## Live provider verification + +The live probe creates a temporary store, exercises CRUD (plus versions or batch +creation where supported), and deletes the store in `finally`: + +```bash +QODER_PAT=... bun packages/sdk/tests/e2e/memory-live.ts qoder +ANTHROPIC_API_KEY=... bun packages/sdk/tests/e2e/memory-live.ts claude +ARK_API_KEY=... bun packages/sdk/tests/e2e/memory-live.ts ark +``` diff --git a/examples/qoder/with-memory/agents.yaml b/examples/qoder/with-memory/agents.yaml index be71d64..507678e 100644 --- a/examples/qoder/with-memory/agents.yaml +++ b/examples/qoder/with-memory/agents.yaml @@ -18,6 +18,8 @@ environments: memory_stores: project-kb: description: "Project knowledge base for the research agent" + metadata: + example: with-memory entries: - key: architecture content: | diff --git a/packages/cli/src/commands/memory.ts b/packages/cli/src/commands/memory.ts new file mode 100644 index 0000000..4b9d903 --- /dev/null +++ b/packages/cli/src/commands/memory.ts @@ -0,0 +1,161 @@ +import { readFile } from "node:fs/promises"; +import { + archiveMemoryStore, + batchCreateMemories, + createMemory, + createMemoryStore, + deleteMemory, + deleteMemoryStore, + getMemory, + getMemoryStore, + getMemoryVersion, + listMemories, + listMemoryStores, + listMemoryVersions, + redactMemoryVersion, + UserError, + updateMemory, + updateMemoryStore, +} from "@openagentpack/sdk"; +import { buildCliRuntime } from "../config-loader.ts"; +import { writeJson } from "../runtime.ts"; + +interface CommonOptions { + file: string; + provider?: string; +} + +async function runtime(options: CommonOptions) { + const ctx = await buildCliRuntime(options.file); + const provider = options.provider ?? (ctx.providers.size === 1 ? ctx.providers.keys().next().value : undefined); + if (!provider) throw new UserError("Select a provider with --provider when multiple providers are configured."); + return { ctx, provider } as const; +} + +async function content(options: { content?: string; contentFile?: string }): Promise { + if (options.content !== undefined && options.contentFile) + throw new UserError("Use either --content or --content-file, not both."); + if (options.contentFile) return readFile(options.contentFile, "utf8"); + if (options.content !== undefined) return options.content; + throw new UserError("Memory content is required; use --content or --content-file."); +} + +export async function memoryStoreListCommand( + options: CommonOptions & { limit?: number; cursor?: string; includeArchived?: boolean }, +) { + const { ctx, provider } = await runtime(options); + writeJson(await listMemoryStores(ctx.providers, provider, options)); +} +export async function memoryStoreCreateCommand(name: string, options: CommonOptions & { description?: string }) { + const { ctx, provider } = await runtime(options); + writeJson(await createMemoryStore(ctx.providers, provider, { name, description: options.description })); +} +export async function memoryStoreDeleteCommand(id: string, options: CommonOptions) { + const { ctx, provider } = await runtime(options); + await deleteMemoryStore(ctx.providers, provider, id); + writeJson({ id, type: "memory_store_deleted" }); +} +export async function memoryStoreGetCommand(id: string, options: CommonOptions) { + const { ctx, provider } = await runtime(options); + writeJson(await getMemoryStore(ctx.providers, provider, id)); +} +export async function memoryStoreUpdateCommand( + id: string, + options: CommonOptions & { name?: string; description?: string }, +) { + const { ctx, provider } = await runtime(options); + writeJson( + await updateMemoryStore(ctx.providers, provider, id, { name: options.name, description: options.description }), + ); +} +export async function memoryStoreArchiveCommand(id: string, options: CommonOptions) { + const { ctx, provider } = await runtime(options); + writeJson(await archiveMemoryStore(ctx.providers, provider, id)); +} +export async function memoryCreateCommand( + storeId: string, + path: string, + options: CommonOptions & { content?: string; contentFile?: string }, +) { + const { ctx, provider } = await runtime(options); + writeJson(await createMemory(ctx.providers, provider, storeId, { path, content: await content(options) })); +} +export async function memoryBatchCreateCommand( + storeId: string, + inputFile: string, + options: CommonOptions & { onConflict?: "overwrite" | "fail" }, +) { + const { ctx, provider } = await runtime(options); + const parsed = JSON.parse(await readFile(inputFile, "utf8")) as unknown; + if (!Array.isArray(parsed)) throw new UserError("Batch input must be a JSON array of {path, content} objects."); + const items = parsed.map((item) => { + if ( + !item || + typeof item !== "object" || + typeof (item as { path?: unknown }).path !== "string" || + typeof (item as { content?: unknown }).content !== "string" + ) { + throw new UserError("Every batch item must contain string path and content fields."); + } + return item as { path: string; content: string }; + }); + writeJson(await batchCreateMemories(ctx.providers, provider, storeId, { items, on_conflict: options.onConflict })); +} +export async function memoryListCommand( + storeId: string, + options: CommonOptions & { limit?: number; cursor?: string; prefix?: string; depth?: number; full?: boolean }, +) { + const { ctx, provider } = await runtime(options); + writeJson( + await listMemories(ctx.providers, provider, storeId, { ...options, view: options.full ? "full" : "basic" }), + ); +} +export async function memoryGetCommand(storeId: string, memoryId: string, options: CommonOptions) { + const { ctx, provider } = await runtime(options); + writeJson(await getMemory(ctx.providers, provider, storeId, memoryId)); +} +export async function memoryUpdateCommand( + storeId: string, + memoryId: string, + options: CommonOptions & { path?: string; content?: string; contentFile?: string; expectedSha256?: string }, +) { + const { ctx, provider } = await runtime(options); + const nextContent = options.content !== undefined || options.contentFile ? await content(options) : undefined; + writeJson( + await updateMemory(ctx.providers, provider, storeId, memoryId, { + path: options.path, + content: nextContent, + expected_content_sha256: options.expectedSha256, + }), + ); +} +export async function memoryDeleteCommand( + storeId: string, + memoryId: string, + options: CommonOptions & { expectedSha256?: string }, +) { + const { ctx, provider } = await runtime(options); + await deleteMemory(ctx.providers, provider, storeId, memoryId, options.expectedSha256); + writeJson({ id: memoryId, type: "memory_deleted" }); +} +export async function memoryVersionListCommand( + storeId: string, + options: CommonOptions & { limit?: number; cursor?: string; memoryId?: string; full?: boolean }, +) { + const { ctx, provider } = await runtime(options); + writeJson( + await listMemoryVersions(ctx.providers, provider, storeId, { + ...options, + memory_id: options.memoryId, + view: options.full ? "full" : "basic", + }), + ); +} +export async function memoryVersionGetCommand(storeId: string, versionId: string, options: CommonOptions) { + const { ctx, provider } = await runtime(options); + writeJson(await getMemoryVersion(ctx.providers, provider, storeId, versionId)); +} +export async function memoryVersionRedactCommand(storeId: string, versionId: string, options: CommonOptions) { + const { ctx, provider } = await runtime(options); + writeJson(await redactMemoryVersion(ctx.providers, provider, storeId, versionId)); +} diff --git a/packages/cli/src/program.ts b/packages/cli/src/program.ts index 818dd03..fbeb3a7 100644 --- a/packages/cli/src/program.ts +++ b/packages/cli/src/program.ts @@ -6,6 +6,23 @@ import { applyCommand } from "./commands/apply.ts"; import { deploymentGetCommand, deploymentListCommand, deploymentRunCommand } from "./commands/deployment.ts"; import { destroyCommand } from "./commands/destroy.ts"; import { initCommand } from "./commands/init.ts"; +import { + memoryBatchCreateCommand, + memoryCreateCommand, + memoryDeleteCommand, + memoryGetCommand, + memoryListCommand, + memoryStoreArchiveCommand, + memoryStoreCreateCommand, + memoryStoreDeleteCommand, + memoryStoreGetCommand, + memoryStoreListCommand, + memoryStoreUpdateCommand, + memoryUpdateCommand, + memoryVersionGetCommand, + memoryVersionListCommand, + memoryVersionRedactCommand, +} from "./commands/memory.ts"; import { migrateCommand } from "./commands/migrate.ts"; import { modelsListCommand } from "./commands/models.ts"; import { planCommand } from "./commands/plan.ts"; @@ -291,6 +308,110 @@ deploymentCmd .addOption(providerOption("Target provider")) .action(withResolvedConfigFile(deploymentRunCommand)); +const memoryStoreCmd = program.command("memory-store").description("Manage persistent memory stores"); +memoryStoreCmd + .command("create ") + .addOption(configFileOption()) + .addOption(providerOption("Target provider")) + .option("--description ") + .action(withResolvedConfigFile(memoryStoreCreateCommand)); +memoryStoreCmd + .command("list") + .addOption(configFileOption()) + .addOption(providerOption("Target provider")) + .option("--limit ", "Page size", parsePositiveInteger) + .option("--cursor ") + .option("--include-archived") + .action(withResolvedConfigFile(memoryStoreListCommand)); +memoryStoreCmd + .command("get ") + .addOption(configFileOption()) + .addOption(providerOption("Target provider")) + .action(withResolvedConfigFile(memoryStoreGetCommand)); +memoryStoreCmd + .command("update ") + .addOption(configFileOption()) + .addOption(providerOption("Target provider")) + .option("--name ") + .option("--description ") + .action(withResolvedConfigFile(memoryStoreUpdateCommand)); +memoryStoreCmd + .command("archive ") + .addOption(configFileOption()) + .addOption(providerOption("Target provider")) + .action(withResolvedConfigFile(memoryStoreArchiveCommand)); +memoryStoreCmd + .command("delete ") + .addOption(configFileOption()) + .addOption(providerOption("Target provider")) + .action(withResolvedConfigFile(memoryStoreDeleteCommand)); + +const memoryCmd = program.command("memory").description("Manage memories inside a store"); +memoryCmd + .command("create ") + .addOption(configFileOption()) + .addOption(providerOption("Target provider")) + .option("--content ") + .option("--content-file ") + .action(withResolvedConfigFile(memoryCreateCommand)); +memoryCmd + .command("batch-create ") + .addOption(configFileOption()) + .addOption(providerOption("Target provider")) + .addOption(new Option("--on-conflict ", "Conflict handling (Ark)").choices(["overwrite", "fail"])) + .action(withResolvedConfigFile(memoryBatchCreateCommand)); +memoryCmd + .command("list ") + .addOption(configFileOption()) + .addOption(providerOption("Target provider")) + .option("--limit ", "Page size", parsePositiveInteger) + .option("--cursor ") + .option("--prefix ") + .option("--depth ", "Hierarchy depth", parsePositiveInteger) + .option("--full", "Include content") + .action(withResolvedConfigFile(memoryListCommand)); +memoryCmd + .command("get ") + .addOption(configFileOption()) + .addOption(providerOption("Target provider")) + .action(withResolvedConfigFile(memoryGetCommand)); +memoryCmd + .command("update ") + .addOption(configFileOption()) + .addOption(providerOption("Target provider")) + .option("--path ") + .option("--content ") + .option("--content-file ") + .option("--expected-sha256 ", "Optimistic concurrency precondition") + .action(withResolvedConfigFile(memoryUpdateCommand)); +memoryCmd + .command("delete ") + .addOption(configFileOption()) + .addOption(providerOption("Target provider")) + .option("--expected-sha256 ", "Optimistic concurrency precondition") + .action(withResolvedConfigFile(memoryDeleteCommand)); + +const memoryVersionCmd = memoryCmd.command("version").description("Inspect immutable memory history"); +memoryVersionCmd + .command("list ") + .addOption(configFileOption()) + .addOption(providerOption("Target provider")) + .option("--limit ", "Page size", parsePositiveInteger) + .option("--cursor ") + .option("--memory-id ") + .option("--full", "Include version content") + .action(withResolvedConfigFile(memoryVersionListCommand)); +memoryVersionCmd + .command("get ") + .addOption(configFileOption()) + .addOption(providerOption("Target provider")) + .action(withResolvedConfigFile(memoryVersionGetCommand)); +memoryVersionCmd + .command("redact ") + .addOption(configFileOption()) + .addOption(providerOption("Target provider")) + .action(withResolvedConfigFile(memoryVersionRedactCommand)); + const modelsCmd = program.command("models").description("Discover available models from providers"); modelsCmd diff --git a/packages/sdk/src/index.ts b/packages/sdk/src/index.ts index c20fc58..9755dcc 100644 --- a/packages/sdk/src/index.ts +++ b/packages/sdk/src/index.ts @@ -78,6 +78,42 @@ export { listProviderModelsForContext, listProviderNames, } from "./internal/core/models-runtime.ts"; +export { + archiveMemoryStore, + batchCreateMemories, + createMemory, + createMemoryStore, + deleteMemoryStore, + deleteMemory, + getMemory, + getMemoryProviderCapabilities, + getMemoryStore, + getMemoryVersion, + listMemories, + listMemoryStores, + listMemoryVersions, + redactMemoryVersion, + updateMemory, + updateMemoryStore, +} from "./internal/core/memory-runtime.ts"; +export type { + BatchCreateMemoryInput, + BatchCreateMemoryResult, + CreateMemoryInput, + CreateMemoryStoreInput, + MemoryInfo, + MemoryListOptions, + MemoryListItem, + MemoryPrefixInfo, + MemoryPage, + MemoryProviderCapabilities, + MemoryStoreInfo, + MemoryStoreListOptions, + MemoryVersionInfo, + MemoryVersionListOptions, + UpdateMemoryInput, + UpdateMemoryStoreInput, +} from "./internal/types/memory.ts"; export type { AgentBuildInput, diff --git a/packages/sdk/src/internal/core/memory-runtime.ts b/packages/sdk/src/internal/core/memory-runtime.ts new file mode 100644 index 0000000..da2e942 --- /dev/null +++ b/packages/sdk/src/internal/core/memory-runtime.ts @@ -0,0 +1,156 @@ +import { UserError } from "../errors.ts"; +import type { ProviderAdapter } from "../providers/interface.ts"; +import type { + BatchCreateMemoryInput, + CreateMemoryInput, + CreateMemoryStoreInput, + MemoryListOptions, + MemoryStoreListOptions, + MemoryVersionListOptions, + UpdateMemoryInput, + UpdateMemoryStoreInput, +} from "../types/memory.ts"; + +function adapter(providers: ReadonlyMap, provider: string): ProviderAdapter { + const value = providers.get(provider); + if (!value) throw new UserError(`Provider '${provider}' is not configured.`); + return value; +} + +function method(value: ProviderAdapter, name: T): NonNullable { + const fn = value[name]; + if (typeof fn !== "function") + throw new UserError(`Provider '${value.name}' does not support memory operation '${String(name)}'.`); + return fn.bind(value) as NonNullable; +} + +export function listMemoryStores( + providers: ReadonlyMap, + provider: string, + options?: MemoryStoreListOptions, +) { + const value = adapter(providers, provider); + return method(value, "listMemoryStores")(options); +} +export function getMemoryProviderCapabilities(providers: ReadonlyMap, provider: string) { + const value = adapter(providers, provider); + if (!value.memoryCapabilities) throw new UserError(`Provider '${provider}' does not support memory stores.`); + return value.memoryCapabilities; +} +export async function createMemoryStore( + providers: ReadonlyMap, + provider: string, + input: CreateMemoryStoreInput, +) { + const value = adapter(providers, provider); + const created = await method(value, "createMemoryStore")(input.name, { + description: input.description ?? "", + metadata: input.metadata, + }); + if (!created.id) throw new UserError(`Provider '${provider}' returned no memory store id.`); + return method(value, "getMemoryStore")(created.id); +} +export function deleteMemoryStore(providers: ReadonlyMap, provider: string, id: string) { + const value = adapter(providers, provider); + return method(value, "deleteMemoryStore")(id); +} +export function getMemoryStore(providers: ReadonlyMap, provider: string, id: string) { + const value = adapter(providers, provider); + return method(value, "getMemoryStore")(id); +} +export function updateMemoryStore( + providers: ReadonlyMap, + provider: string, + id: string, + input: UpdateMemoryStoreInput, +) { + const value = adapter(providers, provider); + return method(value, "updateMemoryStore")(id, input); +} +export function archiveMemoryStore(providers: ReadonlyMap, provider: string, id: string) { + const value = adapter(providers, provider); + return method(value, "archiveMemoryStore")(id); +} +export function createMemory( + providers: ReadonlyMap, + provider: string, + storeId: string, + input: CreateMemoryInput, +) { + const value = adapter(providers, provider); + return method(value, "createMemory")(storeId, input); +} +export function batchCreateMemories( + providers: ReadonlyMap, + provider: string, + storeId: string, + input: BatchCreateMemoryInput, +) { + const value = adapter(providers, provider); + return method(value, "batchCreateMemories")(storeId, input); +} +export function listMemories( + providers: ReadonlyMap, + provider: string, + storeId: string, + options?: MemoryListOptions, +) { + const value = adapter(providers, provider); + return method(value, "listMemories")(storeId, options); +} +export function getMemory( + providers: ReadonlyMap, + provider: string, + storeId: string, + memoryId: string, +) { + const value = adapter(providers, provider); + return method(value, "getMemory")(storeId, memoryId); +} +export function updateMemory( + providers: ReadonlyMap, + provider: string, + storeId: string, + memoryId: string, + input: UpdateMemoryInput, +) { + const value = adapter(providers, provider); + return method(value, "updateMemory")(storeId, memoryId, input); +} +export function deleteMemory( + providers: ReadonlyMap, + provider: string, + storeId: string, + memoryId: string, + expected?: string, +) { + const value = adapter(providers, provider); + return method(value, "deleteMemory")(storeId, memoryId, expected); +} +export function listMemoryVersions( + providers: ReadonlyMap, + provider: string, + storeId: string, + options?: MemoryVersionListOptions, +) { + const value = adapter(providers, provider); + return method(value, "listMemoryVersions")(storeId, options); +} +export function getMemoryVersion( + providers: ReadonlyMap, + provider: string, + storeId: string, + versionId: string, +) { + const value = adapter(providers, provider); + return method(value, "getMemoryVersion")(storeId, versionId); +} +export function redactMemoryVersion( + providers: ReadonlyMap, + provider: string, + storeId: string, + versionId: string, +) { + const value = adapter(providers, provider); + return method(value, "redactMemoryVersion")(storeId, versionId); +} diff --git a/packages/sdk/src/internal/executor/executor.ts b/packages/sdk/src/internal/executor/executor.ts index 5c44e47..95d3d56 100644 --- a/packages/sdk/src/internal/executor/executor.ts +++ b/packages/sdk/src/internal/executor/executor.ts @@ -13,7 +13,7 @@ import type { RuntimeFeedbackSink } from "../types/runtime-feedback.ts"; import { emitRuntimeFeedback } from "../types/runtime-feedback.ts"; import type { ResourceAddress, ResourceType } from "../types/state.ts"; import { addressKey } from "../types/state.ts"; -import { contentHash } from "../utils/hash.ts"; +import { contentHash, sha256 } from "../utils/hash.ts"; import { skillNameFromFiles } from "../utils/skill-manifest.ts"; import type { ExecContext } from "./context.ts"; import { resolveAgentRefs, resolveChannelRefs, resolveDeploymentRefs, resolveTemplateRefs } from "./resolver.ts"; @@ -444,23 +444,51 @@ async function executeActionInner( const deleteMemoryStore = provider.deleteMemoryStore?.bind(provider); if (!createMemoryStore || !deleteMemoryStore) throw memoryStoreUnsupported(address.provider); const decl = ctx.config.memory_stores![name]!; - if (isUpdate) { - try { - result = await createMemoryStore(name, decl); - await deleteMemoryStore(existingId!); - } catch { - await deleteMemoryStore(existingId!); - result = await createMemoryStore(name, decl); + if (!provider.updateMemoryStore || !provider.listMemories || !provider.createMemory || !provider.updateMemory) { + throw memoryStoreUnsupported(address.provider); + } + const reconcile = async (storeId: string): Promise => { + const store = await provider.updateMemoryStore!(storeId, { + name, + description: decl.description, + metadata: decl.metadata ?? {}, + }); + + // Declarative entries are managed seeds. Update/create those paths in place, + // while preserving memories learned by agents at runtime. + const current = new Map(); + let cursor: string | undefined; + do { + const page = await provider.listMemories!(storeId, { limit: 100, cursor, view: "basic" }); + for (const memory of page.data) { + if (memory.type === "memory") current.set(memory.path, memory); + } + cursor = page.has_more ? page.next_cursor : undefined; + } while (cursor); + + for (const entry of decl.entries ?? []) { + const existing = current.get(entry.key.replace(/^\/+/, "")); + if (existing) { + if (existing.content_sha256 !== sha256(entry.content)) { + await provider.updateMemory!(storeId, existing.id, { + content: entry.content, + expected_content_sha256: existing.content_sha256, + }); + } + } else { + await provider.createMemory!(storeId, { path: entry.key, content: entry.content }); + } } + return store; + }; + if (isUpdate) { + result = await reconcile(existingId!); } else { try { result = await createMemoryStore(name, decl); } catch (err) { result = await adoptOnConflict(err, address, provider, ctx.onFeedback, { - onExisting: async (existing) => { - await deleteMemoryStore(existing.id!); - return createMemoryStore(name, decl); - }, + onExisting: async (existing) => reconcile(existing.id!), }); adopted = true; } diff --git a/packages/sdk/src/internal/parser/schema.ts b/packages/sdk/src/internal/parser/schema.ts index 528f5e6..190dc7f 100644 --- a/packages/sdk/src/internal/parser/schema.ts +++ b/packages/sdk/src/internal/parser/schema.ts @@ -76,6 +76,7 @@ const memoryEntrySchema = z.object({ const memoryStoreSchema = z.object({ description: z.string(), provider: z.string().optional(), + metadata: z.record(z.string(), z.string()).optional(), entries: z.array(memoryEntrySchema).optional(), }); diff --git a/packages/sdk/src/internal/providers/ark/adapter.ts b/packages/sdk/src/internal/providers/ark/adapter.ts index bfdc488..755e49f 100644 --- a/packages/sdk/src/internal/providers/ark/adapter.ts +++ b/packages/sdk/src/internal/providers/ark/adapter.ts @@ -12,6 +12,14 @@ import type { } from "../../types/config.ts"; import type { CloudAgent, CloudEnvironment, CloudVault } from "../../types/dto.ts"; import type { ProviderFileInfo } from "../../types/file.ts"; +import type { + BatchCreateMemoryInput, + CreateMemoryInput, + MemoryListOptions, + MemoryStoreListOptions, + UpdateMemoryInput, + UpdateMemoryStoreInput, +} from "../../types/memory.ts"; import type { ProviderSessionInfo, SessionBindings, SessionFilter, SessionListResult } from "../../types/session.ts"; import type { EventListOptions, @@ -35,6 +43,7 @@ import type { ResolvedAgentRefs, ResolvedDeploymentRefs, } from "../interface.ts"; +import { ProviderMemoryApi } from "../memory-api.ts"; import { arkEnvironmentWireNameAttempt, normalizeWireResourceName } from "../resource-naming.ts"; import { extractCreatedEventId, listSessionEventsPaged } from "../session-event-response.ts"; import { @@ -66,11 +75,29 @@ import { export class ArkAdapter implements ProviderAdapter { readonly name = "ark" as const; readonly eventResume = false; + readonly memoryCapabilities = { + archive_store: false, + batch_create: true, + versions: false, + optimistic_concurrency: false, + memory_metadata: false, + } as const; private client: ArkClient; + private memoryApi: ProviderMemoryApi; private projectName: string; constructor(apiKey: string, projectName?: string) { this.client = new ArkClient({ apiKey }); + this.memoryApi = new ProviderMemoryApi(this.client, { + pathStyle: "absolute", + cursorParam: "page", + updatePrecondition: "none", + prefixParam: "path_prefix", + supportsView: false, + supportsMemoryMetadata: false, + supportsDeletePrecondition: false, + supportsIncludeArchived: false, + }); this.projectName = projectName ?? ""; } @@ -264,14 +291,13 @@ export class ArkAdapter implements ProviderAdapter { const body = mapMemoryStore(name, decl); const res = (await this.client.post("/memory_stores", body)) as Record; const storeId = res.id as string; - - if (decl.entries?.length) { - for (const entry of decl.entries) { - await this.client.post(`/memory_stores/${storeId}/memories`, { - content: entry.content, - path: entry.key, - }); + try { + for (const entry of decl.entries ?? []) { + await this.memoryApi.createMemory(storeId, { content: entry.content, path: entry.key }); } + } catch (error) { + await this.client.delete(`/memory_stores/${storeId}`).catch(() => undefined); + throw error; } return toRemoteResource(res); @@ -281,6 +307,34 @@ export class ArkAdapter implements ProviderAdapter { await this.client.delete(`/memory_stores/${id}`); } + listMemoryStores(options?: MemoryStoreListOptions) { + return this.memoryApi.listStores(options); + } + getMemoryStore(id: string) { + return this.memoryApi.getStore(id); + } + updateMemoryStore(id: string, input: UpdateMemoryStoreInput) { + return this.memoryApi.updateStore(id, input); + } + createMemory(storeId: string, input: CreateMemoryInput) { + return this.memoryApi.createMemory(storeId, input); + } + batchCreateMemories(storeId: string, input: BatchCreateMemoryInput) { + return this.memoryApi.batchCreateMemories(storeId, input); + } + listMemories(storeId: string, options?: MemoryListOptions) { + return this.memoryApi.listMemories(storeId, options); + } + getMemory(storeId: string, memoryId: string) { + return this.memoryApi.getMemory(storeId, memoryId); + } + updateMemory(storeId: string, memoryId: string, input: UpdateMemoryInput) { + return this.memoryApi.updateMemory(storeId, memoryId, input); + } + deleteMemory(storeId: string, memoryId: string, expected?: string) { + return this.memoryApi.deleteMemory(storeId, memoryId, expected); + } + // --- Deployment (emulated) --- // Ark has no /deployments endpoint. A deployment is recorded in state with // remote_id = null and materialized into a session at run time (mirrors qoder). diff --git a/packages/sdk/src/internal/providers/ark/mapper.ts b/packages/sdk/src/internal/providers/ark/mapper.ts index be58f2a..471c529 100644 --- a/packages/sdk/src/internal/providers/ark/mapper.ts +++ b/packages/sdk/src/internal/providers/ark/mapper.ts @@ -197,6 +197,7 @@ export function mapMemoryStore(name: string, decl: MemoryStoreDecl): unknown { return { name, description: decl.description, + metadata: decl.metadata, }; } diff --git a/packages/sdk/src/internal/providers/capabilities.ts b/packages/sdk/src/internal/providers/capabilities.ts index 998898f..67a332e 100644 --- a/packages/sdk/src/internal/providers/capabilities.ts +++ b/packages/sdk/src/internal/providers/capabilities.ts @@ -54,7 +54,18 @@ export const REQUIRED_METHODS_BY_KIND: Partial { // Claude archives agents (POST /agents/{id}/archive) instead of hard-deleting // them; an archived ghost must not count as existing for refresh/adoption. - const raw = await locateRemote(this.client, ClaudeAdapter.ENDPOINT_MAP[type], name, id, notArchived); + const raw = await locateRemote( + type === "memory_store" ? this.memoryClient : this.client, + ClaudeAdapter.ENDPOINT_MAP[type], + name, + id, + notArchived, + ); return raw ? toRemoteResource(raw) : null; } @@ -255,6 +299,64 @@ export class ClaudeAdapter implements ProviderAdapter { await this.client.post(`/agents/${id}/archive`, {}); } + async createMemoryStore(name: string, decl: MemoryStoreDecl): Promise { + const res = (await this.memoryClient.post("/memory_stores", { + name, + description: decl.description, + metadata: decl.metadata, + })) as Record; + const storeId = String(res.id); + try { + for (const entry of decl.entries ?? []) { + await this.memoryApi.createMemory(storeId, { path: entry.key, content: entry.content }); + } + } catch (error) { + await this.memoryClient.delete(`/memory_stores/${storeId}`).catch(() => undefined); + throw error; + } + return toRemoteResource(res); + } + + async deleteMemoryStore(id: string): Promise { + await this.memoryClient.delete(`/memory_stores/${id}`); + } + listMemoryStores(options?: MemoryStoreListOptions) { + return this.memoryApi.listStores(options); + } + getMemoryStore(id: string) { + return this.memoryApi.getStore(id); + } + updateMemoryStore(id: string, input: UpdateMemoryStoreInput) { + return this.memoryApi.updateStore(id, input); + } + archiveMemoryStore(id: string) { + return this.memoryApi.archiveStore(id); + } + createMemory(storeId: string, input: CreateMemoryInput) { + return this.memoryApi.createMemory(storeId, input); + } + listMemories(storeId: string, options?: MemoryListOptions) { + return this.memoryApi.listMemories(storeId, options); + } + getMemory(storeId: string, memoryId: string) { + return this.memoryApi.getMemory(storeId, memoryId); + } + updateMemory(storeId: string, memoryId: string, input: UpdateMemoryInput) { + return this.memoryApi.updateMemory(storeId, memoryId, input); + } + deleteMemory(storeId: string, memoryId: string, expected?: string) { + return this.memoryApi.deleteMemory(storeId, memoryId, expected); + } + listMemoryVersions(storeId: string, options?: MemoryVersionListOptions) { + return this.memoryApi.listVersions(storeId, options); + } + getMemoryVersion(storeId: string, versionId: string) { + return this.memoryApi.getVersion(storeId, versionId); + } + redactMemoryVersion(storeId: string, versionId: string) { + return this.memoryApi.redactVersion(storeId, versionId); + } + async createDeployment( name: string, decl: DeploymentDecl, diff --git a/packages/sdk/src/internal/providers/claude/capabilities.ts b/packages/sdk/src/internal/providers/claude/capabilities.ts index 6ca58f1..73f06d8 100644 --- a/packages/sdk/src/internal/providers/claude/capabilities.ts +++ b/packages/sdk/src/internal/providers/claude/capabilities.ts @@ -6,11 +6,7 @@ export const CLAUDE_CAPABILITIES: ProviderCapabilities = { skill: { tier: "native", reason: "skills API with files[] upload" }, agent: { tier: "native", reason: "managed agents API" }, template: { tier: "unsupported", reason: "no Forward Template equivalent on Claude" }, - memory_store: { - tier: "unsupported", - reason: "Claude exposes Memory Stores, but the OpenAgentPack adapter has not implemented them yet", - remediation: "use skill knowledge or MCP until Claude Memory Store support is added to the adapter", - }, + memory_store: { tier: "native", reason: "beta memory_stores API" }, mcp_server: { tier: "native", reason: "mcp_servers field on agent" }, multiagent: { tier: "native", reason: "coordinator + roster topology" }, deployment: { tier: "native", reason: "deployments API" }, diff --git a/packages/sdk/src/internal/providers/interface.ts b/packages/sdk/src/internal/providers/interface.ts index f4aa049..806191a 100644 --- a/packages/sdk/src/internal/providers/interface.ts +++ b/packages/sdk/src/internal/providers/interface.ts @@ -10,6 +10,22 @@ import type { } from "../types/config.ts"; import type { CloudAgent, CloudEnvironment, CloudVault } from "../types/dto.ts"; import type { ProviderFileInfo } from "../types/file.ts"; +import type { + BatchCreateMemoryInput, + BatchCreateMemoryResult, + CreateMemoryInput, + MemoryInfo, + MemoryListItem, + MemoryListOptions, + MemoryPage, + MemoryProviderCapabilities, + MemoryStoreInfo, + MemoryStoreListOptions, + MemoryVersionInfo, + MemoryVersionListOptions, + UpdateMemoryInput, + UpdateMemoryStoreInput, +} from "../types/memory.ts"; import type { ProviderSessionInfo, SessionBindings, SessionFilter, SessionListResult } from "../types/session.ts"; import type { EventListOptions, @@ -114,6 +130,7 @@ export interface ProviderAdapter { * resume after. true → send-then-stream with `afterId`; false → connect-before-send. */ readonly eventResume: boolean; + readonly memoryCapabilities?: MemoryProviderCapabilities; validate(): Promise; /** @@ -179,6 +196,19 @@ export interface ProviderAdapter { createMemoryStore?(name: string, decl: MemoryStoreDecl): Promise; deleteMemoryStore?(id: string): Promise; + listMemoryStores?(options?: MemoryStoreListOptions): Promise>; + getMemoryStore?(id: string): Promise; + updateMemoryStore?(id: string, input: UpdateMemoryStoreInput): Promise; + archiveMemoryStore?(id: string): Promise; + createMemory?(storeId: string, input: CreateMemoryInput): Promise; + batchCreateMemories?(storeId: string, input: BatchCreateMemoryInput): Promise; + listMemories?(storeId: string, options?: MemoryListOptions): Promise>; + getMemory?(storeId: string, memoryId: string): Promise; + updateMemory?(storeId: string, memoryId: string, input: UpdateMemoryInput): Promise; + deleteMemory?(storeId: string, memoryId: string, expectedContentSha256?: string): Promise; + listMemoryVersions?(storeId: string, options?: MemoryVersionListOptions): Promise>; + getMemoryVersion?(storeId: string, versionId: string): Promise; + redactMemoryVersion?(storeId: string, versionId: string): Promise; createDeployment( name: string, diff --git a/packages/sdk/src/internal/providers/memory-api.ts b/packages/sdk/src/internal/providers/memory-api.ts new file mode 100644 index 0000000..d926895 --- /dev/null +++ b/packages/sdk/src/internal/providers/memory-api.ts @@ -0,0 +1,279 @@ +import type { + BatchCreateMemoryInput, + BatchCreateMemoryResult, + CreateMemoryInput, + MemoryInfo, + MemoryListItem, + MemoryListOptions, + MemoryPage, + MemoryStoreInfo, + MemoryStoreListOptions, + MemoryVersionInfo, + MemoryVersionListOptions, + UpdateMemoryInput, + UpdateMemoryStoreInput, +} from "../types/memory.ts"; +import type { BaseApiClient } from "./base-client.ts"; + +export type MemoryPathStyle = "relative" | "absolute"; + +export interface MemoryApiDialect { + pathStyle: MemoryPathStyle; + cursorParam: "after_id" | "page"; + updatePrecondition: "none" | "content_sha256" | "expected_content_sha256" | "precondition"; + prefixParam: "prefix" | "path_prefix"; + versionsSegment?: "memory_versions" | "versions"; + storeMetadataMode?: "replace" | "merge_patch"; + supportsView: boolean; + supportsMemoryMetadata: boolean; + supportsPathUpdate?: boolean; + supportsDeletePrecondition: boolean; + supportsIncludeArchived: boolean; +} + +function query(path: string, values: Record): string { + const params = new URLSearchParams(); + for (const [key, value] of Object.entries(values)) { + if (value !== undefined && value !== null) params.set(key, String(value)); + } + const encoded = params.toString(); + return encoded ? `${path}?${encoded}` : path; +} + +function canonicalPath(path: string): string { + return path.replace(/^\/+/, ""); +} + +function providerPath(path: string, style: MemoryPathStyle): string { + const relative = canonicalPath(path); + return style === "absolute" ? `/${relative}` : relative; +} + +function page(raw: unknown, map: (item: Record) => T): MemoryPage { + const body = raw as Record; + const data = ( + (body.data ?? body.items ?? body.memories ?? body.memory_stores ?? body.memory_versions ?? []) as Record< + string, + unknown + >[] + ).map(map); + const next = (body.next_cursor ?? body.next_page ?? body.last_id) as string | undefined; + return { data, has_more: Boolean(body.has_more ?? next), ...(next ? { next_cursor: next } : {}) }; +} + +export function mapMemoryStore(raw: Record): MemoryStoreInfo { + return { + id: String(raw.id), + type: "memory_store", + name: String(raw.name ?? ""), + description: String(raw.description ?? ""), + metadata: (raw.metadata as Record | undefined) ?? {}, + ...(raw.status ? { status: String(raw.status) } : {}), + ...(typeof (raw.entry_count ?? raw.memory_count) === "number" + ? { entry_count: Number(raw.entry_count ?? raw.memory_count) } + : {}), + ...(typeof (raw.total_size ?? raw.storage_bytes) === "number" + ? { total_size: Number(raw.total_size ?? raw.storage_bytes) } + : {}), + ...(typeof raw.session_count === "number" ? { session_count: raw.session_count } : {}), + created_by: raw.created_by as MemoryStoreInfo["created_by"], + created_at: String(raw.created_at ?? ""), + updated_at: String(raw.updated_at ?? raw.created_at ?? ""), + archived_at: (raw.archived_at as string | null | undefined) ?? null, + }; +} + +export function mapMemory(raw: Record): MemoryInfo { + return { + id: String(raw.id), + type: "memory", + memory_store_id: String(raw.memory_store_id ?? raw.store_id ?? ""), + path: canonicalPath(String(raw.path ?? "")), + content: raw.content as string | null | undefined, + content_size_bytes: Number(raw.content_size_bytes ?? raw.size ?? 0), + content_sha256: String(raw.content_sha256 ?? ""), + ...(typeof raw.version === "number" ? { version: raw.version } : {}), + ...(raw.memory_version_id ? { memory_version_id: String(raw.memory_version_id) } : {}), + metadata: (raw.metadata as Record | undefined) ?? {}, + created_by: raw.created_by as MemoryInfo["created_by"], + created_at: String(raw.created_at ?? ""), + updated_at: String(raw.updated_at ?? raw.created_at ?? ""), + }; +} + +function mapMemoryListItem(raw: Record): MemoryListItem { + if (raw.type === "memory_prefix") return { type: "memory_prefix", path: canonicalPath(String(raw.path ?? "")) }; + return mapMemory(raw); +} + +export function mapMemoryVersion(raw: Record): MemoryVersionInfo { + const operation = String(raw.operation ?? raw.action ?? "updated"); + return { + id: String(raw.id), + type: "memory_version", + memory_store_id: String(raw.memory_store_id ?? raw.store_id ?? ""), + memory_id: String(raw.memory_id ?? raw.entry_id ?? ""), + path: + (raw.path ?? raw.entry_path) == null + ? ((raw.path ?? raw.entry_path) as null | undefined) + : canonicalPath(String(raw.path ?? raw.entry_path)), + content: raw.content as string | null | undefined, + content_size_bytes: (raw.content_size_bytes ?? raw.size) as number | null | undefined, + content_sha256: raw.content_sha256 as string | null | undefined, + operation: (operation === "modified" ? "updated" : operation) as MemoryVersionInfo["operation"], + ...(typeof raw.version === "number" ? { version: raw.version } : {}), + ...(typeof raw.redacted === "boolean" ? { redacted: raw.redacted } : {}), + redacted_at: raw.redacted_at as string | null | undefined, + created_by: raw.created_by as MemoryVersionInfo["created_by"], + created_at: String(raw.created_at ?? ""), + }; +} + +export class ProviderMemoryApi { + constructor( + private readonly client: BaseApiClient, + private readonly dialect: MemoryApiDialect, + ) {} + + async listStores(options: MemoryStoreListOptions = {}): Promise> { + const raw = await this.client.get( + query("/memory_stores", { + limit: options.limit, + [this.dialect.cursorParam]: options.cursor, + include_archived: this.dialect.supportsIncludeArchived ? options.include_archived : undefined, + }), + ); + return page(raw, mapMemoryStore); + } + + async getStore(id: string): Promise { + return mapMemoryStore((await this.client.get(`/memory_stores/${id}`)) as Record); + } + + async updateStore(id: string, input: UpdateMemoryStoreInput): Promise { + let body: Record = { ...input }; + if (this.dialect.storeMetadataMode === "merge_patch" && input.metadata !== undefined) { + const current = await this.getStore(id); + body = { + ...input, + metadata: { + ...Object.fromEntries(Object.keys(current.metadata).map((key) => [key, null])), + ...input.metadata, + }, + }; + } + return mapMemoryStore((await this.client.post(`/memory_stores/${id}`, body)) as Record); + } + + async archiveStore(id: string): Promise { + return mapMemoryStore((await this.client.post(`/memory_stores/${id}/archive`, {})) as Record); + } + + async createMemory(storeId: string, input: CreateMemoryInput): Promise { + const body = { + path: providerPath(input.path, this.dialect.pathStyle), + content: input.content, + ...(this.dialect.supportsMemoryMetadata && input.metadata ? { metadata: input.metadata } : {}), + }; + return mapMemory((await this.client.post(`/memory_stores/${storeId}/memories`, body)) as Record); + } + + async listMemories(storeId: string, options: MemoryListOptions = {}): Promise> { + const raw = await this.client.get( + query(`/memory_stores/${storeId}/memories`, { + limit: options.limit, + [this.dialect.cursorParam]: options.cursor, + [this.dialect.prefixParam]: options.prefix ? providerPath(options.prefix, this.dialect.pathStyle) : undefined, + depth: options.depth, + view: this.dialect.supportsView ? options.view : undefined, + }), + ); + return page(raw, mapMemoryListItem); + } + + async getMemory(storeId: string, memoryId: string): Promise { + const path = `/memory_stores/${storeId}/memories/${memoryId}${this.dialect.supportsView ? "?view=full" : ""}`; + return mapMemory((await this.client.get(path)) as Record); + } + + async updateMemory(storeId: string, memoryId: string, input: UpdateMemoryInput): Promise { + const { expected_content_sha256, ...values } = input; + const body: Record = { + ...(values.content !== undefined ? { content: values.content } : {}), + ...(this.dialect.supportsMemoryMetadata && values.metadata ? { metadata: values.metadata } : {}), + ...(this.dialect.supportsPathUpdate !== false && values.path + ? { path: providerPath(values.path, this.dialect.pathStyle) } + : {}), + }; + if (expected_content_sha256 && this.dialect.updatePrecondition !== "none") { + if (this.dialect.updatePrecondition === "precondition") { + body.precondition = { type: "content_sha256", content_sha256: expected_content_sha256 }; + } else { + body[this.dialect.updatePrecondition] = expected_content_sha256; + } + } + const path = `/memory_stores/${storeId}/memories/${memoryId}${this.dialect.supportsView ? "?view=full" : ""}`; + const raw = await this.client.post(path, body); + return mapMemory(raw as Record); + } + + async deleteMemory(storeId: string, memoryId: string, expected?: string): Promise { + await this.client.delete( + query(`/memory_stores/${storeId}/memories/${memoryId}`, { + expected_content_sha256: this.dialect.supportsDeletePrecondition ? expected : undefined, + }), + ); + } + + async listVersions(storeId: string, options: MemoryVersionListOptions = {}): Promise> { + const segment = this.dialect.versionsSegment ?? "memory_versions"; + const raw = await this.client.get( + query(`/memory_stores/${storeId}/${segment}`, { + limit: options.limit, + [this.dialect.cursorParam]: options.cursor, + memory_id: options.memory_id, + view: this.dialect.supportsView ? options.view : undefined, + }), + ); + return page(raw, mapMemoryVersion); + } + + async getVersion(storeId: string, versionId: string): Promise { + const segment = this.dialect.versionsSegment ?? "memory_versions"; + return mapMemoryVersion( + (await this.client.get( + `/memory_stores/${storeId}/${segment}/${versionId}${this.dialect.supportsView ? "?view=full" : ""}`, + )) as Record, + ); + } + + async redactVersion(storeId: string, versionId: string): Promise { + const segment = this.dialect.versionsSegment ?? "memory_versions"; + return mapMemoryVersion( + (await this.client.post(`/memory_stores/${storeId}/${segment}/${versionId}/redact`, {})) as Record< + string, + unknown + >, + ); + } + + async batchCreateMemories(storeId: string, input: BatchCreateMemoryInput): Promise { + const body = { + items: input.items.map((item) => ({ + path: providerPath(item.path, this.dialect.pathStyle), + content: item.content, + })), + on_conflict: input.on_conflict, + }; + const raw = (await this.client.post(`/memory_stores/${storeId}/memories/batch_create`, body)) as { + results?: Array<{ path: string; memory?: Record; error?: { type: string; message: string } }>; + }; + return { + results: (raw.results ?? []).map((item) => ({ + path: canonicalPath(item.path), + ...(item.memory ? { memory: mapMemory(item.memory) } : {}), + ...(item.error ? { error: item.error } : {}), + })), + }; + } +} diff --git a/packages/sdk/src/internal/providers/qoder/adapter.ts b/packages/sdk/src/internal/providers/qoder/adapter.ts index 3677655..13f40cd 100644 --- a/packages/sdk/src/internal/providers/qoder/adapter.ts +++ b/packages/sdk/src/internal/providers/qoder/adapter.ts @@ -14,6 +14,14 @@ import type { } from "../../types/config.ts"; import type { CloudAgent, CloudEnvironment, CloudVault } from "../../types/dto.ts"; import type { ProviderFileInfo } from "../../types/file.ts"; +import type { + CreateMemoryInput, + MemoryListOptions, + MemoryStoreListOptions, + MemoryVersionListOptions, + UpdateMemoryInput, + UpdateMemoryStoreInput, +} from "../../types/memory.ts"; import type { ForwardSessionBindings, ProviderSessionInfo, @@ -47,6 +55,7 @@ import type { ResolvedDeploymentRefs, ResolvedTemplateRefs, } from "../interface.ts"; +import { ProviderMemoryApi } from "../memory-api.ts"; import { extractCreatedEventId, listSessionEventsPaged } from "../session-event-response.ts"; import { buildSessionInfo, @@ -88,13 +97,34 @@ function deriveForwardGateway(cloudGateway?: string): string { export class QoderAdapter implements ProviderAdapter { readonly name = "qoder" as const; readonly eventResume = true; + readonly memoryCapabilities = { + archive_store: true, + batch_create: false, + versions: true, + optimistic_concurrency: true, + memory_metadata: true, + } as const; private client: QoderClient; + private memoryApi: ProviderMemoryApi; private forwardClient: QoderClient; private projectName: string; private forwardSessionIds = new Set(); constructor(apiKey: string, gateway?: string, projectName?: string, forwardGateway?: string) { this.client = new QoderClient({ apiKey, gateway }); + this.memoryApi = new ProviderMemoryApi(this.client, { + pathStyle: "relative", + cursorParam: "after_id", + updatePrecondition: "content_sha256", + prefixParam: "prefix", + versionsSegment: "versions", + storeMetadataMode: "merge_patch", + supportsView: false, + supportsMemoryMetadata: true, + supportsPathUpdate: false, + supportsDeletePrecondition: false, + supportsIncludeArchived: true, + }); this.forwardClient = new QoderClient({ apiKey, gateway: forwardGateway ?? deriveForwardGateway(gateway), @@ -559,14 +589,13 @@ export class QoderAdapter implements ProviderAdapter { const body = mapMemoryStore(name, decl); const res = (await this.client.post("/memory_stores", body)) as Record; const storeId = res.id as string; - - if (decl.entries?.length) { - for (const entry of decl.entries) { - await this.client.post(`/memory_stores/${storeId}/memories`, { - content: entry.content, - path: entry.key, - }); + try { + for (const entry of decl.entries ?? []) { + await this.memoryApi.createMemory(storeId, { content: entry.content, path: entry.key }); } + } catch (error) { + await this.client.delete(`/memory_stores/${storeId}`).catch(() => undefined); + throw error; } return toRemoteResource(res); @@ -576,6 +605,43 @@ export class QoderAdapter implements ProviderAdapter { await this.client.delete(`/memory_stores/${id}`); } + listMemoryStores(options?: MemoryStoreListOptions) { + return this.memoryApi.listStores(options); + } + getMemoryStore(id: string) { + return this.memoryApi.getStore(id); + } + updateMemoryStore(id: string, input: UpdateMemoryStoreInput) { + return this.memoryApi.updateStore(id, input); + } + archiveMemoryStore(id: string) { + return this.memoryApi.archiveStore(id); + } + createMemory(storeId: string, input: CreateMemoryInput) { + return this.memoryApi.createMemory(storeId, input); + } + listMemories(storeId: string, options?: MemoryListOptions) { + return this.memoryApi.listMemories(storeId, options); + } + getMemory(storeId: string, memoryId: string) { + return this.memoryApi.getMemory(storeId, memoryId); + } + updateMemory(storeId: string, memoryId: string, input: UpdateMemoryInput) { + return this.memoryApi.updateMemory(storeId, memoryId, input); + } + deleteMemory(storeId: string, memoryId: string, expected?: string) { + return this.memoryApi.deleteMemory(storeId, memoryId, expected); + } + listMemoryVersions(storeId: string, options?: MemoryVersionListOptions) { + return this.memoryApi.listVersions(storeId, options); + } + getMemoryVersion(storeId: string, versionId: string) { + return this.memoryApi.getVersion(storeId, versionId); + } + redactMemoryVersion(storeId: string, versionId: string) { + return this.memoryApi.redactVersion(storeId, versionId); + } + async createDeployment( name: string, decl: DeploymentDecl, diff --git a/packages/sdk/src/internal/providers/qoder/mapper.ts b/packages/sdk/src/internal/providers/qoder/mapper.ts index a4b8450..3571f46 100644 --- a/packages/sdk/src/internal/providers/qoder/mapper.ts +++ b/packages/sdk/src/internal/providers/qoder/mapper.ts @@ -223,6 +223,7 @@ export function mapMemoryStore(name: string, decl: MemoryStoreDecl): unknown { return { name, description: decl.description, + metadata: decl.metadata, }; } diff --git a/packages/sdk/src/internal/providers/resource-workflow.ts b/packages/sdk/src/internal/providers/resource-workflow.ts index 7305d1c..ee5ccc5 100644 --- a/packages/sdk/src/internal/providers/resource-workflow.ts +++ b/packages/sdk/src/internal/providers/resource-workflow.ts @@ -9,6 +9,16 @@ import type { VaultDecl, } from "../types/config.ts"; import type { ProviderFileInfo } from "../types/file.ts"; +import type { + CreateMemoryInput, + MemoryInfo, + MemoryListItem, + MemoryListOptions, + MemoryPage, + MemoryStoreInfo, + UpdateMemoryInput, + UpdateMemoryStoreInput, +} from "../types/memory.ts"; import type { SkillFile } from "../types/skill-file.ts"; import type { ResourceType } from "../types/state.ts"; import type { @@ -67,6 +77,10 @@ export interface ResourceCrudAdapter { // providers omit them entirely (no throw-stubs). createMemoryStore?(name: string, decl: MemoryStoreDecl): Promise; deleteMemoryStore?(id: string): Promise; + updateMemoryStore?(id: string, input: UpdateMemoryStoreInput): Promise; + createMemory?(storeId: string, input: CreateMemoryInput): Promise; + listMemories?(storeId: string, options?: MemoryListOptions): Promise>; + updateMemory?(storeId: string, memoryId: string, input: UpdateMemoryInput): Promise; createDeployment( name: string, diff --git a/packages/sdk/src/internal/types/config.ts b/packages/sdk/src/internal/types/config.ts index 679f5f1..c20588d 100644 --- a/packages/sdk/src/internal/types/config.ts +++ b/packages/sdk/src/internal/types/config.ts @@ -116,6 +116,7 @@ export interface CredentialDecl { export interface MemoryStoreDecl { description: string; provider?: ProviderName; + metadata?: Record; entries?: MemoryEntryDecl[]; } diff --git a/packages/sdk/src/internal/types/memory.ts b/packages/sdk/src/internal/types/memory.ts new file mode 100644 index 0000000..7fd1e97 --- /dev/null +++ b/packages/sdk/src/internal/types/memory.ts @@ -0,0 +1,141 @@ +export type MemoryMetadata = Record; + +export interface MemoryStoreInfo { + id: string; + type: "memory_store"; + name: string; + description: string; + metadata: MemoryMetadata; + status?: "active" | "archived" | string; + entry_count?: number; + total_size?: number; + session_count?: number; + created_by?: MemoryActor; + created_at: string; + updated_at: string; + archived_at?: string | null; +} + +export interface MemoryInfo { + id: string; + type: "memory"; + memory_store_id: string; + path: string; + content?: string | null; + content_size_bytes: number; + content_sha256: string; + version?: number; + memory_version_id?: string; + metadata: MemoryMetadata; + created_by?: MemoryActor; + created_at: string; + updated_at: string; +} + +export interface MemoryPrefixInfo { + type: "memory_prefix"; + path: string; +} + +export type MemoryListItem = MemoryInfo | MemoryPrefixInfo; + +export type MemoryVersionOperation = "created" | "updated" | "modified" | "deleted"; + +export interface MemoryActor { + type: string; + api_key_id?: string; + session_id?: string; + user_id?: string; +} + +export interface MemoryVersionInfo { + id: string; + type: "memory_version"; + memory_store_id: string; + memory_id: string; + path?: string | null; + content?: string | null; + content_size_bytes?: number | null; + content_sha256?: string | null; + operation: MemoryVersionOperation; + version?: number; + redacted?: boolean; + redacted_at?: string | null; + created_by?: MemoryActor; + created_at: string; +} + +export interface MemoryPage { + data: T[]; + has_more: boolean; + next_cursor?: string; +} + +export interface MemoryProviderCapabilities { + archive_store: boolean; + batch_create: boolean; + versions: boolean; + optimistic_concurrency: boolean; + memory_metadata: boolean; +} + +export interface MemoryStoreListOptions { + limit?: number; + cursor?: string; + include_archived?: boolean; +} + +export interface MemoryListOptions { + limit?: number; + cursor?: string; + prefix?: string; + depth?: number; + view?: "basic" | "full"; +} + +export interface MemoryVersionListOptions { + limit?: number; + cursor?: string; + memory_id?: string; + view?: "basic" | "full"; +} + +export interface CreateMemoryStoreInput { + name: string; + description?: string; + metadata?: MemoryMetadata; +} + +export interface UpdateMemoryStoreInput { + name?: string; + description?: string; + metadata?: MemoryMetadata; +} + +export interface CreateMemoryInput { + path: string; + content: string; + metadata?: MemoryMetadata; +} + +export interface BatchCreateMemoryInput { + items: CreateMemoryInput[]; + on_conflict?: "overwrite" | "fail"; +} + +export interface BatchCreateMemoryResultItem { + path: string; + memory?: MemoryInfo; + error?: { type: string; message: string }; +} + +export interface BatchCreateMemoryResult { + results: BatchCreateMemoryResultItem[]; +} + +export interface UpdateMemoryInput { + path?: string; + content?: string; + metadata?: MemoryMetadata; + expected_content_sha256?: string; +} diff --git a/packages/sdk/tests/e2e/memory-adapters.test.ts b/packages/sdk/tests/e2e/memory-adapters.test.ts new file mode 100644 index 0000000..67cf834 --- /dev/null +++ b/packages/sdk/tests/e2e/memory-adapters.test.ts @@ -0,0 +1,137 @@ +import { afterEach, expect, test } from "bun:test"; +import { ArkAdapter } from "../../src/internal/providers/ark/adapter.ts"; +import { ClaudeAdapter } from "../../src/internal/providers/claude/adapter.ts"; +import { QoderAdapter } from "../../src/internal/providers/qoder/adapter.ts"; + +const originalFetch = globalThis.fetch; +afterEach(() => { + globalThis.fetch = originalFetch; +}); + +const memory = { + id: "mem_1", + type: "memory", + memory_store_id: "memstore_1", + path: "notes/a.md", + content: "hello", + content_size_bytes: 5, + content_sha256: "sha", + created_at: "2026-01-01T00:00:00Z", + updated_at: "2026-01-01T00:00:00Z", +}; + +test("Claude memory requests include the memory beta and absolute wire paths", async () => { + let request: Request | undefined; + globalThis.fetch = async (input, init) => { + request = new Request(input, init); + return Response.json({ ...memory, path: "/notes/a.md" }); + }; + const adapter = new ClaudeAdapter("test-key"); + const result = await adapter.createMemory("memstore_1", { path: "notes/a.md", content: "hello" }); + expect(request?.headers.get("anthropic-beta")).toContain("agent-memory-2026-07-22"); + expect(await request?.json()).toEqual({ path: "/notes/a.md", content: "hello" }); + expect(result.path).toBe("notes/a.md"); +}); + +test("Claude memory prefix filtering uses path_prefix", async () => { + let request: Request | undefined; + globalThis.fetch = async (input, init) => { + request = new Request(input, init); + return Response.json({ data: [], next_page: null }); + }; + const adapter = new ClaudeAdapter("test-key"); + await adapter.listMemories("memstore_1", { prefix: "notes/" }); + expect(request?.url).toContain("path_prefix=%2Fnotes%2F"); + expect(request?.url).not.toContain("?prefix="); +}); + +test("Ark batch create uses batch_create and preserves partial failures", async () => { + let request: Request | undefined; + globalThis.fetch = async (input, init) => { + request = new Request(input, init); + return Response.json({ + results: [ + { path: "a.md", memory }, + { path: "b.md", error: { type: "conflict_error", message: "exists" } }, + ], + }); + }; + const adapter = new ArkAdapter("test-key"); + const result = await adapter.batchCreateMemories("memstore-1", { + items: [ + { path: "a.md", content: "a" }, + { path: "b.md", content: "b" }, + ], + on_conflict: "fail", + }); + expect(request?.url).toEndWith("/memory_stores/memstore-1/memories/batch_create"); + expect(result.results[1]?.error?.type).toBe("conflict_error"); +}); + +test("Qoder updates memory with its documented POST contract", async () => { + let request: Request | undefined; + globalThis.fetch = async (input, init) => { + request = new Request(input, init); + return Response.json(memory); + }; + const adapter = new QoderAdapter("test-key"); + await adapter.updateMemory("memstore_1", "mem_1", { + content: "new", + path: "renamed.md", + metadata: { source: "test" }, + expected_content_sha256: "old", + }); + expect(request?.method).toBe("POST"); + expect(await request?.json()).toEqual({ + content: "new", + metadata: { source: "test" }, + content_sha256: "old", + }); +}); + +test("Qoder translates replacement metadata into its merge-patch contract", async () => { + const requests: Request[] = []; + globalThis.fetch = async (input, init) => { + requests.push(new Request(input, init)); + return Response.json({ + id: "memstore_1", + type: "memory_store", + name: "notes", + description: "notes", + metadata: requests.length === 1 ? { keep: "old", drop: "old" } : { keep: "new" }, + created_at: "2026-01-01T00:00:00Z", + updated_at: "2026-01-01T00:00:00Z", + }); + }; + const adapter = new QoderAdapter("test-key"); + await adapter.updateMemoryStore("memstore_1", { metadata: { keep: "new" } }); + expect(requests[0]?.method).toBe("GET"); + expect(requests[1]?.method).toBe("POST"); + expect(await requests[1]?.json()).toEqual({ metadata: { keep: "new", drop: null } }); +}); + +test("Qoder exposes documented version routes and fields through the portable adapter", async () => { + const requests: Request[] = []; + const versionResponse = { + id: "memver_1", + type: "memory_version", + store_id: "memstore_1", + entry_id: "mem_1", + entry_path: "notes/a.md", + action: "updated", + created_at: "2026-01-01T00:00:00Z", + }; + globalThis.fetch = async (input, init) => { + requests.push(new Request(input, init)); + return Response.json(requests.length === 1 ? versionResponse : { data: [versionResponse], has_more: false }); + }; + const adapter = new QoderAdapter("test-key"); + const version = await adapter.getMemoryVersion("memstore_1", "memver_1"); + const versions = await adapter.listMemoryVersions("memstore_1"); + expect(requests[0]?.url).toEndWith("/memory_stores/memstore_1/versions/memver_1"); + expect(requests[1]?.url).toEndWith("/memory_stores/memstore_1/versions"); + expect(version.memory_id).toBe("mem_1"); + expect(version.path).toBe("notes/a.md"); + expect(version.operation).toBe("updated"); + expect(versions.data[0]?.memory_id).toBe("mem_1"); +}); diff --git a/packages/sdk/tests/e2e/memory-live.ts b/packages/sdk/tests/e2e/memory-live.ts new file mode 100644 index 0000000..a4d63af --- /dev/null +++ b/packages/sdk/tests/e2e/memory-live.ts @@ -0,0 +1,66 @@ +#!/usr/bin/env bun + +import { createHash } from "node:crypto"; +import { ArkAdapter } from "../../src/internal/providers/ark/adapter.ts"; +import { ClaudeAdapter } from "../../src/internal/providers/claude/adapter.ts"; +import type { ProviderAdapter } from "../../src/internal/providers/interface.ts"; +import { QoderAdapter } from "../../src/internal/providers/qoder/adapter.ts"; + +const provider = process.argv[2]; +let adapter: ProviderAdapter; +if (provider === "qoder" && process.env.QODER_PAT) adapter = new QoderAdapter(process.env.QODER_PAT); +else if (provider === "claude" && process.env.ANTHROPIC_API_KEY) + adapter = new ClaudeAdapter(process.env.ANTHROPIC_API_KEY); +else if (provider === "ark" && process.env.ARK_API_KEY) adapter = new ArkAdapter(process.env.ARK_API_KEY); +else throw new Error("Usage: bun memory-live.ts with the provider API key set"); + +const required = [ + "createMemoryStore", + "deleteMemoryStore", + "createMemory", + "listMemories", + "getMemory", + "updateMemory", + "deleteMemory", +] as const; +for (const name of required) if (typeof adapter[name] !== "function") throw new Error(`${provider}.${name} is missing`); + +const suffix = Date.now().toString(36); +const store = await adapter.createMemoryStore!(`openagentpack-memory-live-${suffix}`, { + description: "Temporary OpenAgentPack memory lifecycle probe", + metadata: { test: "memory-live" }, +}); +if (!store.id) throw new Error("provider returned no memory store id"); + +try { + const created = await adapter.createMemory!(store.id, { path: `probe/${suffix}.md`, content: "version one" }); + const listed = await adapter.listMemories!(store.id, { limit: 100 }); + if (!listed.data.some((item) => item.type === "memory" && item.id === created.id)) + throw new Error("created memory absent from list"); + const read = await adapter.getMemory!(store.id, created.id); + if (read.content !== "version one") throw new Error("retrieved content mismatch"); + const expected = createHash("sha256").update("version one").digest("hex"); + const updated = await adapter.updateMemory!(store.id, created.id, { + content: "version two", + expected_content_sha256: expected, + }); + const reread = await adapter.getMemory!(store.id, created.id); + if (reread.content !== "version two") throw new Error("updated content mismatch"); + + if (adapter.memoryCapabilities?.versions) { + const versions = await adapter.listMemoryVersions!(store.id, { memory_id: created.id, view: "full" }); + if (versions.data.length < 2) throw new Error("expected at least two memory versions"); + } + if (adapter.memoryCapabilities?.batch_create) { + const batch = await adapter.batchCreateMemories!(store.id, { + items: [{ path: `probe/${suffix}-batch.md`, content: "batch" }], + on_conflict: "fail", + }); + if (!batch.results[0]?.memory) throw new Error(`batch create failed: ${JSON.stringify(batch.results[0])}`); + } + + await adapter.deleteMemory!(store.id, created.id, updated.content_sha256); + console.log(JSON.stringify({ provider, store_id: store.id, result: "passed" })); +} finally { + await adapter.deleteMemoryStore!(store.id); +} diff --git a/packages/sdk/tests/unit/executor-conflict-adopt.test.ts b/packages/sdk/tests/unit/executor-conflict-adopt.test.ts index 45e7c52..c72d7e0 100644 --- a/packages/sdk/tests/unit/executor-conflict-adopt.test.ts +++ b/packages/sdk/tests/unit/executor-conflict-adopt.test.ts @@ -57,6 +57,94 @@ function makeCtx( const existingResource: RemoteResource = { id: "env_existing", type: "environment", version: 3 }; describe("executor conflict-adopt", () => { + test("memory store conflict adopts and reconciles without deleting learned memories", async () => { + const calls: string[] = []; + let storeMetadata: Record | undefined; + const provider = { + name: "qoder", + findResource: async () => ({ id: "memstore_existing", type: "memory_store" }), + createMemoryStore: async () => { + throw new ConflictError(409, "exists", "Qoder API"); + }, + deleteMemoryStore: async () => { + calls.push("delete-store"); + }, + updateMemoryStore: async (id: string, input: { metadata?: Record }) => { + calls.push(`update-store:${id}`); + storeMetadata = input.metadata; + return { + id, + type: "memory_store", + name: "notes", + description: "notes", + metadata: {}, + created_at: "", + updated_at: "", + }; + }, + listMemories: async () => ({ + data: [ + { + id: "mem_seed", + type: "memory", + memory_store_id: "memstore_existing", + path: "seed.md", + content_size_bytes: 3, + content_sha256: "old", + metadata: {}, + created_at: "", + updated_at: "", + }, + { + id: "mem_learned", + type: "memory", + memory_store_id: "memstore_existing", + path: "learned.md", + content_size_bytes: 7, + content_sha256: "learned", + metadata: {}, + created_at: "", + updated_at: "", + }, + ], + has_more: false, + }), + updateMemory: async (_storeId: string, id: string) => { + calls.push(`update-memory:${id}`); + return { id, type: "memory" }; + }, + createMemory: async () => ({ id: "mem_new", type: "memory" }), + } as unknown as ProviderAdapter; + const memoryConfig: ProjectConfig = { + version: "1", + providers: { qoder: {} }, + defaults: { provider: "qoder" }, + memory_stores: { notes: { description: "notes", entries: [{ key: "seed.md", content: "new" }] } }, + }; + const plan: ExecutionPlan = { + actions: [ + { + action: "create", + address: { type: "memory_store", name: "notes", provider: "qoder" }, + reason: "missing", + after: { content_hash: "h" }, + dependencies: [], + }, + ], + diagnostics: [], + }; + const ctx: ExecContext = { + config: memoryConfig, + configPath: "/tmp/agents.yaml", + providers: new Map([["qoder", provider]]), + state: StateManager.initialize(tmpPath()), + }; + const result = await executePlan(plan, ctx); + expect(result.partial).toBe(false); + expect(calls).toEqual(["update-store:memstore_existing", "update-memory:mem_seed"]); + expect(storeMetadata).toEqual({}); + }); + test("ConflictError → findResource finds existing → onExisting rebuilds via updateEnvironment", async () => { const calls: string[] = []; const provider = { diff --git a/packages/sdk/tests/unit/memory-api.test.ts b/packages/sdk/tests/unit/memory-api.test.ts new file mode 100644 index 0000000..9c8c124 --- /dev/null +++ b/packages/sdk/tests/unit/memory-api.test.ts @@ -0,0 +1,190 @@ +import { describe, expect, test } from "bun:test"; +import { BaseApiClient } from "../../src/internal/providers/base-client.ts"; +import { ProviderMemoryApi } from "../../src/internal/providers/memory-api.ts"; + +class FakeClient extends BaseApiClient { + protected baseUrl = "https://example.test"; + protected errorPrefix = "test"; + protected paginationStrategy = "page" as const; + requests: Array<{ method: string; path: string; body?: unknown }> = []; + responses: unknown[] = []; + protected headers() { + return {}; + } + override async get(path: string) { + this.requests.push({ method: "GET", path }); + return this.responses.shift(); + } + override async post(path: string, body: unknown) { + this.requests.push({ method: "POST", path, body }); + return this.responses.shift(); + } + override async delete(path: string) { + this.requests.push({ method: "DELETE", path }); + } +} + +const memoryResponse = { + id: "mem_1", + type: "memory", + memory_store_id: "memstore_1", + path: "/notes/a.md", + content: "hello", + content_size_bytes: 5, + content_sha256: "sha", + metadata: {}, + created_at: "2026-01-01T00:00:00Z", + updated_at: "2026-01-01T00:00:00Z", +}; + +describe("ProviderMemoryApi", () => { + test("normalizes Claude absolute paths to portable relative paths", async () => { + const client = new FakeClient(); + client.responses.push(memoryResponse); + const api = new ProviderMemoryApi(client, { + pathStyle: "absolute", + cursorParam: "page", + updatePrecondition: "precondition", + prefixParam: "prefix", + supportsView: true, + supportsMemoryMetadata: false, + supportsDeletePrecondition: true, + supportsIncludeArchived: true, + }); + const result = await api.createMemory("memstore_1", { path: "notes/a.md", content: "hello" }); + expect(client.requests[0]).toEqual({ + method: "POST", + path: "/memory_stores/memstore_1/memories", + body: { path: "/notes/a.md", content: "hello" }, + }); + expect(result.path).toBe("notes/a.md"); + }); + + test("maps the portable optimistic concurrency field to Claude precondition", async () => { + const client = new FakeClient(); + client.responses.push(memoryResponse); + const api = new ProviderMemoryApi(client, { + pathStyle: "absolute", + cursorParam: "page", + updatePrecondition: "precondition", + prefixParam: "prefix", + supportsView: true, + supportsMemoryMetadata: false, + supportsDeletePrecondition: true, + supportsIncludeArchived: true, + }); + await api.updateMemory("memstore_1", "mem_1", { content: "hello", expected_content_sha256: "old" }); + expect(client.requests[0]?.body).toEqual({ + content: "hello", + precondition: { type: "content_sha256", content_sha256: "old" }, + }); + }); + + test("maps Qoder fields and after_id pagination", async () => { + const client = new FakeClient(); + client.responses.push({ + data: [{ ...memoryResponse, path: "notes/a.md", store_id: "memstore_1", size: 5 }], + has_more: true, + last_id: "mem_1", + }); + const api = new ProviderMemoryApi(client, { + pathStyle: "relative", + cursorParam: "after_id", + updatePrecondition: "content_sha256", + prefixParam: "prefix", + supportsView: false, + supportsMemoryMetadata: true, + supportsDeletePrecondition: false, + supportsIncludeArchived: true, + }); + const result = await api.listMemories("memstore_1", { limit: 10, cursor: "mem_0", view: "full" }); + expect(client.requests[0]?.path).toBe("/memory_stores/memstore_1/memories?limit=10&after_id=mem_0"); + expect(result.next_cursor).toBe("mem_1"); + expect(result.data[0]?.path).toBe("notes/a.md"); + }); + + test("normalizes modified version operation", async () => { + const client = new FakeClient(); + client.responses.push({ + id: "memver_1", + type: "memory_version", + memory_store_id: "memstore_1", + memory_id: "mem_1", + path: "/notes/a.md", + operation: "modified", + created_at: "2026-01-01T00:00:00Z", + }); + const api = new ProviderMemoryApi(client, { + pathStyle: "absolute", + cursorParam: "page", + updatePrecondition: "precondition", + prefixParam: "prefix", + supportsView: true, + supportsMemoryMetadata: false, + supportsDeletePrecondition: true, + supportsIncludeArchived: true, + }); + const result = await api.getVersion("memstore_1", "memver_1"); + expect(result.operation).toBe("updated"); + expect(result.path).toBe("notes/a.md"); + }); + + test("maps Ark pagination, path_prefix and last-write-wins updates", async () => { + const client = new FakeClient(); + client.responses.push({ data: [], next_page: "page_2" }, memoryResponse); + const api = new ProviderMemoryApi(client, { + pathStyle: "relative", + cursorParam: "page", + updatePrecondition: "none", + prefixParam: "path_prefix", + supportsView: false, + supportsMemoryMetadata: false, + supportsDeletePrecondition: false, + supportsIncludeArchived: false, + }); + const listed = await api.listMemories("memstore-1", { cursor: "page_1", prefix: "notes/" }); + expect(client.requests[0]?.path).toBe("/memory_stores/memstore-1/memories?page=page_1&path_prefix=notes%2F"); + expect(listed.next_cursor).toBe("page_2"); + await api.updateMemory("memstore-1", "mem-1", { content: "new", expected_content_sha256: "ignored-on-ark" }); + expect(client.requests[1]?.body).toEqual({ content: "new" }); + }); + + test("maps Ark batch create partial results", async () => { + const client = new FakeClient(); + client.responses.push({ + results: [ + { path: "a.md", memory: { ...memoryResponse, path: "a.md" } }, + { path: "b.md", error: { type: "conflict_error", message: "exists" } }, + ], + }); + const api = new ProviderMemoryApi(client, { + pathStyle: "relative", + cursorParam: "page", + updatePrecondition: "none", + prefixParam: "path_prefix", + supportsView: false, + supportsMemoryMetadata: false, + supportsDeletePrecondition: false, + supportsIncludeArchived: false, + }); + const result = await api.batchCreateMemories("memstore-1", { + items: [ + { path: "a.md", content: "a" }, + { path: "b.md", content: "b" }, + ], + on_conflict: "fail", + }); + expect(client.requests[0]).toEqual({ + method: "POST", + path: "/memory_stores/memstore-1/memories/batch_create", + body: { + items: [ + { path: "a.md", content: "a" }, + { path: "b.md", content: "b" }, + ], + on_conflict: "fail", + }, + }); + expect(result.results[1]?.error?.type).toBe("conflict_error"); + }); +}); diff --git a/packages/sdk/tests/unit/planner.test.ts b/packages/sdk/tests/unit/planner.test.ts index 126a74b..d52a101 100644 --- a/packages/sdk/tests/unit/planner.test.ts +++ b/packages/sdk/tests/unit/planner.test.ts @@ -98,13 +98,13 @@ test("diagnoses Bailian MCP servers without matching tool config", async () => { }); test("emits no action for a kind whose provider capability tier is unsupported", async () => { - // claude marks memory_store unsupported in its capability matrix; the graph must + // bailian marks memory_store unsupported in its capability matrix; the graph must // filter it so no create action reaches the executor's throw-guarded switch. const plan = await buildPlan( { version: "1", - providers: { claude: {} }, - defaults: { provider: "claude" }, + providers: { bailian: {} }, + defaults: { provider: "bailian" }, memory_stores: { notes: { description: "test store" }, }, @@ -115,8 +115,8 @@ test("emits no action for a kind whose provider capability tier is unsupported", expect(plan.actions.some((a) => a.address.type === "memory_store")).toBe(false); expect(plan.diagnostics).toContainEqual( expect.objectContaining({ - code: "claude.memory_store.unsupported", - resource: { type: "memory_store", name: "notes", provider: "claude" }, + code: "bailian.memory_store.unsupported", + resource: { type: "memory_store", name: "notes", provider: "bailian" }, }), ); }); diff --git a/packages/sdk/tests/unit/provider-conformance.test.ts b/packages/sdk/tests/unit/provider-conformance.test.ts index 6c5dbf2..97754a4 100644 --- a/packages/sdk/tests/unit/provider-conformance.test.ts +++ b/packages/sdk/tests/unit/provider-conformance.test.ts @@ -12,7 +12,20 @@ const RESOURCE_KIND_METHODS: Record Date: Tue, 21 Jul 2026 00:31:26 +0800 Subject: [PATCH 2/2] Fix beta npm dist-tag Change-Id: I5e3e201d94cdbe7a239f23a7dbd4d8fd89726b80 --- .github/workflows/release.yml | 4 +++- scripts/open-source.test.ts | 2 ++ scripts/release/publish.test.ts | 10 ++++++++++ scripts/release/publish.ts | 13 +++++++++---- 4 files changed, 24 insertions(+), 5 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index d55f7ea..c583e7f 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -33,6 +33,7 @@ jobs: outputs: channel: ${{ steps.release.outputs.channel }} version: ${{ steps.release.outputs.version }} + dist-tag: ${{ steps.release.outputs.dist-tag }} permissions: contents: read steps: @@ -113,7 +114,8 @@ jobs: - name: Publish packages with npm Trusted Publishing env: NPM_CONFIG_PROVENANCE: "true" - run: bun run release:publish + NPM_DIST_TAG: ${{ needs.preflight.outputs.dist-tag }} + run: bun run release:publish -- --tag "$NPM_DIST_TAG" - name: Create immutable Git tag env: diff --git a/scripts/open-source.test.ts b/scripts/open-source.test.ts index 3cabe60..70eb45f 100644 --- a/scripts/open-source.test.ts +++ b/scripts/open-source.test.ts @@ -145,6 +145,8 @@ describe("open-source repository invariants", () => { expect(workflow).toContain("environment: npm-release"); expect(workflow).toContain("vars.NPM_RELEASE_ENABLED == 'true'"); expect(workflow).toContain("inputs.confirm == 'PUBLISH'"); + expect(workflow).toContain(["dist-tag: $", "{{ steps.release.outputs.dist-tag }}"].join("")); + expect(workflow).toContain('bun run release:publish -- --tag "$NPM_DIST_TAG"'); expect(workflow).toContain("id-token: write"); expect(workflow).toContain("cancel-in-progress: false"); expect(workflow).toContain("workflow_dispatch:"); diff --git a/scripts/release/publish.test.ts b/scripts/release/publish.test.ts index 1765842..a27acfc 100644 --- a/scripts/release/publish.test.ts +++ b/scripts/release/publish.test.ts @@ -28,6 +28,7 @@ describe("release publish recovery", () => { test("derives a safe npm dist-tag from prerelease versions", () => { expect(inferDistTag("1.0.1-beta.5")).toBe("beta"); + expect(inferDistTag("1.0.1-beta-a1b2c3d-20260720")).toBe("beta"); expect(inferDistTag("2.0.0-rc.1")).toBe("rc"); expect(inferDistTag("1.0.1")).toBeUndefined(); }); @@ -44,6 +45,15 @@ describe("release publish recovery", () => { "--tag", "beta", ]); + expect(publishCommand(false, "1.2.3-beta-a1b2c3d-20260720", "beta")).toEqual([ + "npm", + "publish", + "--access", + "public", + "--provenance", + "--tag", + "beta", + ]); }); test("allows real publishing only inside GitHub Actions", () => { diff --git a/scripts/release/publish.ts b/scripts/release/publish.ts index 812e61c..d5cb98d 100644 --- a/scripts/release/publish.ts +++ b/scripts/release/publish.ts @@ -53,14 +53,14 @@ export function assertPublishEnvironment( } export function inferDistTag(version: string): string | undefined { - const prerelease = version.match(/^[0-9]+\.[0-9]+\.[0-9]+-([0-9A-Za-z-]+)(?:\.|$)/); + const prerelease = version.match(/^[0-9]+\.[0-9]+\.[0-9]+-([0-9A-Za-z]+)(?:[.-]|$)/); return prerelease?.[1]; } -export function publishCommand(dryRun: boolean, version: string): string[] { +export function publishCommand(dryRun: boolean, version: string, distTag?: string): string[] { if (dryRun) return ["npm", "pack", "--dry-run"]; const command = ["npm", "publish", "--access", "public", "--provenance"]; - const publishTag = inferDistTag(version); + const publishTag = distTag ?? inferDistTag(version); if (publishTag) command.push("--tag", publishTag); return command; } @@ -149,6 +149,11 @@ function trackedFileContents(path: string): string | undefined { function main(): number { const args = process.argv.slice(2); const dryRun = args.includes("--dry-run"); + const tagIndex = args.indexOf("--tag"); + const distTag = tagIndex === -1 ? undefined : args[tagIndex + 1]; + if (tagIndex !== -1 && (!distTag || !/^[a-z][a-z0-9-]*$/.test(distTag))) { + throw new Error("--tag must be a lowercase npm dist-tag"); + } assertPublishEnvironment(dryRun); console.log(`Publishing packages${dryRun ? " (dry-run)" : ""}...\n`); @@ -166,7 +171,7 @@ function main(): number { continue; } - const cmd = publishCommand(dryRun, manifest.version); + const cmd = publishCommand(dryRun, manifest.version, distTag); console.log(`\n--- Publishing ${manifest.name} ---`); let originalLicense: string | undefined;