feat: release v1.0.0-beta.1

This commit is contained in:
chrishuan
2026-05-29 17:33:12 +08:00
commit 36b0537676
290 changed files with 72303 additions and 0 deletions
+4
View File
@@ -0,0 +1,4 @@
node_modules/
dist/
.env
*.tgz
+223
View File
@@ -0,0 +1,223 @@
# OpenClaw Adapter for TencentDB Agent Memory
[简体中文](./README_CN.md) · English
This directory is a reference implementation for integrating an Agent framework with TencentDB Agent Memory v2 API. It is an OpenClaw client plugin: it does not run extraction, indexing, scene generation, or persona generation itself. Instead, it connects to an already-running Memory Gateway and uses the TypeScript SDK to capture conversations, recall memories, and expose memory tools to the Agent.
For standalone local usage, the recommended Memory Gateway endpoint is `http://127.0.0.1:8420`. The default local convention is `apiKey = "local"` and `serviceId = "default"`. If your Gateway enables `TDAI_GATEWAY_API_KEY`, use the same value as `server.apiKey`.
## Architecture
```text
OpenClaw runtime
└─ memory-tencentdb-client plugin
├─ hooks/capture.ts agent_end -> addConversation (L0)
├─ hooks/recall.ts before_prompt_build -> search + prompt injection
├─ tools/memory-search.ts tdai_memory_search -> searchAtomic (L1)
├─ tools/conversation-search.ts tdai_conversation_search -> searchConversation (L0)
└─ tools/read-cos.ts tdai_read_cos -> read L2/L3 artifacts
@tencentdb-agent-memory/memory-sdk-ts
│ HTTP v2 API
TencentDB Agent Memory Gateway (:8420 standalone, or remote service)
```
## Quick Start
Recommended: run the installer from the repository root:
```bash
bash scripts/install-openclaw-plugin-v2.sh
```
The script checks/installs the OpenClaw CLI, installs plugin dependencies, builds the plugin, and installs the current `openclaw-plugin` directory into OpenClaw through `openclaw plugins install -l`. By default it also updates `~/.openclaw/openclaw.json`: it sets `plugins.slots.memory = "memory-tencentdb-client"`, enables the plugin, and writes the standalone defaults for `server`, `recall`, and `capture`. The script auto-detects the OpenClaw version: on **2026.4.24+** it also writes `hooks.allowPromptInjection` / `hooks.allowConversationAccess` (required by non-bundled plugins to enable conversation capture); on **older versions** these fields are **omitted**, because gateways before 2026.4.24 use a strict zod schema that refuses to start with these fields present.
You can override defaults with environment variables: `OPENCLAW_CONFIG_FILE`, `TDAI_MEMORY_ENDPOINT`, `TDAI_MEMORY_API_KEY`, `TDAI_MEMORY_INSTANCE_ID`, `TDAI_MEMORY_RECALL_MAX_RESULTS`, and `TDAI_MEMORY_CAPTURE_ENABLED`. Set `WRITE_OPENCLAW_CONFIG=0` if you only want to install the plugin without modifying OpenClaw config.
For manual installation, follow the steps below.
### 1. Install OpenClaw
If OpenClaw is not installed yet, install the OpenClaw CLI first:
```bash
curl -fsSL https://get.openclaw.dev | bash
```
Verify that the command is available:
```bash
openclaw --version
```
### 2. Install dependencies
```bash
cd openclaw-plugin
npm install
```
The plugin depends on `@tencentdb-agent-memory/memory-sdk-ts`, the TypeScript SDK for the v2 API.
### 3. Build
```bash
npm run build
```
### 4. Install the plugin into OpenClaw
For local source development, install the current directory as a linked plugin:
```bash
openclaw plugins install -l .
```
### 5. Configure the plugin
If you use the `agentmemory/openclaw-memory` container image, the entrypoint already generates this configuration for you, so you usually do not need to edit it manually.
If you use `scripts/install-openclaw-plugin-v2.sh`, the script writes this configuration by default as well, and version-gates the `hooks.*` policy fields automatically. You only need to edit the OpenClaw config yourself when running with `WRITE_OPENCLAW_CONFIG=0` or when installing manually.
> ⚠️ **Important — `hooks.*` is version-gated.**
> The `hooks.allowPromptInjection` / `hooks.allowConversationAccess` fields were added to the gateway schema in **OpenClaw `2026.4.24`**. Earlier versions (including `2026.4.23`) use a strict zod schema and will **refuse to start** if these fields are present. **Pick the example that matches your installed OpenClaw version.** Run `openclaw --version` to check.
**Example A — OpenClaw `>= 2026.4.24` (recommended):**
```jsonc
{
"plugins": {
"slots": {
"memory": "memory-tencentdb-client"
},
"entries": {
"memory-tencentdb-client": {
"enabled": true,
// hooks.* is REQUIRED on >= 2026.4.24 for non-bundled plugins.
// Without allowConversationAccess=true, L0 capture is silently blocked.
"hooks": {
"allowPromptInjection": true,
"allowConversationAccess": true
},
"config": {
"server": {
"url": "http://127.0.0.1:8420",
"apiKey": "local",
"instanceId": "default"
},
"recall": {
"maxResults": 5,
"includePersona": true,
"includeSceneNav": true
},
"capture": {
"enabled": true
}
}
}
}
}
}
```
**Example B — OpenClaw `< 2026.4.24` (e.g. `2026.4.23`): omit the `hooks` block entirely.**
```jsonc
{
"plugins": {
"slots": {
"memory": "memory-tencentdb-client"
},
"entries": {
"memory-tencentdb-client": {
"enabled": true,
// ⚠️ DO NOT add a "hooks" block here. The strict schema in 2026.4.23 and
// earlier rejects allowPromptInjection / allowConversationAccess and the
// gateway will fail to start. Upgrade OpenClaw to use those fields.
"config": {
"server": {
"url": "http://127.0.0.1:8420",
"apiKey": "local",
"instanceId": "default"
},
"recall": {
"maxResults": 5,
"includePersona": true,
"includeSceneNav": true
},
"capture": {
"enabled": true
}
}
}
}
}
}
```
If the Gateway is protected by `TDAI_GATEWAY_API_KEY`, set `server.apiKey` to that value. `server.instanceId` is sent as `x-tdai-service-id`; standalone mode uses `default`.
`hooks.allowPromptInjection` allows `before_prompt_build` to inject recalled context. `hooks.allowConversationAccess` allows `agent_end` to read the raw conversation and write L0. **Version compatibility:**
- **OpenClaw `>= 2026.4.24`** — these fields are recognised by the gateway schema. `allowConversationAccess` must be set to `true` for non-bundled plugins (otherwise conversation hooks are silently blocked and L0 capture stops working). `allowPromptInjection` defaults to allowed when unset; we still write `true` to lock the intent.
- **OpenClaw `< 2026.4.24`** (including 4.23 and earlier) — the gateway uses a strict zod schema that **rejects** these fields. **Do not include the `hooks.*` block at all**, otherwise the gateway will fail to start. The installer script auto-detects the version and skips the block on older hosts.
### 6. Enable the plugin in OpenClaw
The plugin manifest ID is `memory-tencentdb-client`. The container image already wires it as the memory slot. If you install it manually, make sure OpenClaw loads the plugin and selects it for the memory slot. After changing the config, restart the Gateway:
```bash
openclaw gateway restart
```
## Adapter Responsibilities
| Area | Implementation | Description |
|---|---|---|
| Capture | `src/hooks/capture.ts` | Writes completed turns to L0 through `addConversation()` |
| Recall | `src/hooks/recall.ts` | Searches memories before prompt construction and injects concise context |
| L1 tool | `src/tools/memory-search.ts` | Lets the Agent actively search structured memories |
| L0 tool | `src/tools/conversation-search.ts` | Lets the Agent search raw conversation history |
| L2/L3 read tool | `src/tools/read-cos.ts` | Lets the Agent read scene/core artifacts when needed |
## Configuration
| Field | Default | Description |
|---|---|---|
| `server.url` | `http://127.0.0.1:8420` | Memory Gateway URL |
| `server.apiKey` | `""` | Bearer token sent by the SDK. Use `local` for default standalone mode. |
| `server.instanceId` | `default` | Memory space ID, sent as `x-tdai-service-id` |
| `recall.maxResults` | `5` | Max L1 memories injected per turn |
| `recall.includePersona` | `true` | Whether to include L3 core/profile context |
| `recall.includeSceneNav` | `true` | Whether to include L2 scene navigation |
| `capture.enabled` | `true` | Whether to auto-capture completed turns |
| `hooks.allowPromptInjection` | `true` | Allows prompt-build context injection. Defaults to enabled when unset; we write `true` explicitly to lock the intent. **Only write this field on OpenClaw `>= 2026.4.24`** — older gateways reject it with a strict schema. |
| `hooks.allowConversationAccess` | `true` | Allows `agent_end` / `llm_input` / `llm_output` to read raw conversation content for L0 writes. **Required (`true`) for non-bundled plugins** — without it, conversation hooks are silently blocked at registration and L0 capture stops working. **Only write this field on OpenClaw `>= 2026.4.24`** — older gateways reject it with a strict schema. |
## Files
```text
openclaw-plugin/
├── openclaw.plugin.json # plugin manifest
├── package.json # dependencies and build script
├── index.ts # OpenClaw entrypoint
├── src/hooks/capture.ts # L0 capture hook
├── src/hooks/recall.ts # recall hook
├── src/tools/ # Agent-callable tools
└── src/format.ts # prompt formatting helpers
```
## Using This as an Adapter Template
When adapting another Agent framework, copy the same pattern:
1. Capture completed user/assistant turns and call `addConversation()`.
2. Before the next prompt, call `searchAtomic()`, `readCore()`, and optionally `listScenarios()`.
3. Inject only concise, labeled memory context into the Agent prompt.
4. Expose active tools for L1 search, L0 conversation search, and L2 scene read.
5. Keep the adapter stateless; the Memory Gateway owns storage and asynchronous L1/L2/L3 processing.
## Notes
This plugin is a client-side adapter only. It should not start a Memory Gateway subprocess and should not implement memory extraction logic locally. For the standalone Gateway startup and SDK examples, see the root README.
+223
View File
@@ -0,0 +1,223 @@
# OpenClaw 适配参考:TencentDB Agent Memory
简体中文 · [English](./README.md)
该目录是一个 Agent 框架接入 TencentDB Agent Memory v2 API 的参考实现。它是 OpenClaw 的客户端插件:插件本身不做抽取、索引、场景归纳或画像生成,而是连接一个已经运行的 Memory Gateway,通过 TypeScript SDK 完成对话捕获、记忆召回和工具暴露。
在 standalone 本地模式下,推荐的 Memory Gateway 地址是 `http://127.0.0.1:8420`。默认约定是 `apiKey = "local"``serviceId = "default"`。如果 Gateway 显式启用了 `TDAI_GATEWAY_API_KEY`,则 `server.apiKey` 应使用同一个值。
## 架构
```text
OpenClaw runtime
└─ memory-tencentdb-client plugin
├─ hooks/capture.ts agent_end -> addConversation (L0)
├─ hooks/recall.ts before_prompt_build -> search + prompt 注入
├─ tools/memory-search.ts tdai_memory_search -> searchAtomic (L1)
├─ tools/conversation-search.ts tdai_conversation_search -> searchConversation (L0)
└─ tools/read-cos.ts tdai_read_cos -> 读取 L2/L3 文件
@tencentdb-agent-memory/memory-sdk-ts
│ HTTP v2 API
TencentDB Agent Memory Gatewaystandalone :8420 或远端服务)
```
## 快速开始
推荐从仓库根目录执行自动安装脚本:
```bash
bash scripts/install-openclaw-plugin-v2.sh
```
脚本会检查/安装 OpenClaw CLI,安装插件依赖,构建插件,并通过 `openclaw plugins install -l` 将当前 `openclaw-plugin` 目录安装到 OpenClaw。默认还会更新 `~/.openclaw/openclaw.json`:设置 `plugins.slots.memory = "memory-tencentdb-client"`,启用插件,写入 standalone 默认的 `server``recall``capture` 配置。脚本会自动探测 OpenClaw 版本:**2026.4.24+** 会同时写入 `hooks.allowPromptInjection` / `hooks.allowConversationAccess`non-bundled 插件需要 `allowConversationAccess=true` 才能采集对话);**更老的版本会自动跳过**这两个字段——因为 2026.4.24 之前的 gateway 使用 strict zod schema,遇到这两个字段会启动失败。
可通过环境变量覆盖默认值:`OPENCLAW_CONFIG_FILE``TDAI_MEMORY_ENDPOINT``TDAI_MEMORY_API_KEY``TDAI_MEMORY_INSTANCE_ID``TDAI_MEMORY_RECALL_MAX_RESULTS``TDAI_MEMORY_CAPTURE_ENABLED`。如果只想安装插件、不修改 OpenClaw 配置,可设置 `WRITE_OPENCLAW_CONFIG=0`
如需手动安装,可按下面步骤执行。
### 1. 安装 OpenClaw
如果你还没有安装 OpenClaw,请先安装 OpenClaw CLI
```bash
curl -fsSL https://get.openclaw.dev | bash
```
确认命令可用:
```bash
openclaw --version
```
### 2. 安装依赖
```bash
cd openclaw-plugin
npm install
```
插件依赖 `@tencentdb-agent-memory/memory-sdk-ts`,也就是 v2 API 的 TypeScript SDK。
### 3. 构建
```bash
npm run build
```
### 4. 安装插件到 OpenClaw
开发或本地源码调试时,推荐以软链方式安装当前目录:
```bash
openclaw plugins install -l .
```
### 5. 配置插件
如果使用 `agentmemory/openclaw-memory` 容器镜像,镜像启动脚本已经自动完成下面这段配置,通常不需要手动修改。
如果使用 `scripts/install-openclaw-plugin-v2.sh`,脚本默认也会自动写入这段配置,并会自动按 OpenClaw 版本对 `hooks.*` 字段做版本门控。只有在 `WRITE_OPENCLAW_CONFIG=0` 或手动安装插件时,才需要自己编辑 OpenClaw 配置文件。
> ⚠️ **重要 —— `hooks.*` 与 OpenClaw 版本强相关。**
> `hooks.allowPromptInjection` / `hooks.allowConversationAccess` 是 **OpenClaw `2026.4.24`** 起才被 gateway schema 接受的字段。更老的版本(含 `2026.4.23`)使用 strict zod schema**包含这两个字段会让 gateway 启动失败**。请按你本机 `openclaw --version` 显示的版本,**选择对应的示例**复制:
**示例 A —— OpenClaw `>= 2026.4.24`(推荐):**
```jsonc
{
"plugins": {
"slots": {
"memory": "memory-tencentdb-client"
},
"entries": {
"memory-tencentdb-client": {
"enabled": true,
// 2026.4.24+ 上,non-bundled 插件必须显式 hooks.*。
// 缺少 allowConversationAccess=true 会让 L0 capture 被静默拦截。
"hooks": {
"allowPromptInjection": true,
"allowConversationAccess": true
},
"config": {
"server": {
"url": "http://127.0.0.1:8420",
"apiKey": "local",
"instanceId": "default"
},
"recall": {
"maxResults": 5,
"includePersona": true,
"includeSceneNav": true
},
"capture": {
"enabled": true
}
}
}
}
}
}
```
**示例 B —— OpenClaw `< 2026.4.24`(如 `2026.4.23`):完全省略 `hooks` 块。**
```jsonc
{
"plugins": {
"slots": {
"memory": "memory-tencentdb-client"
},
"entries": {
"memory-tencentdb-client": {
"enabled": true,
// ⚠️ 不要添加 "hooks" 块。2026.4.23 及更早的 strict schema 会拒绝
// allowPromptInjection / allowConversationAccessgateway 启动失败。
// 如需使用这两个字段,请升级 OpenClaw。
"config": {
"server": {
"url": "http://127.0.0.1:8420",
"apiKey": "local",
"instanceId": "default"
},
"recall": {
"maxResults": 5,
"includePersona": true,
"includeSceneNav": true
},
"capture": {
"enabled": true
}
}
}
}
}
}
```
如果 Gateway 启用了 `TDAI_GATEWAY_API_KEY`,请将 `server.apiKey` 设置成同一个值。`server.instanceId` 会作为 `x-tdai-service-id` 发送;standalone 模式默认使用 `default`
`hooks.allowPromptInjection` 允许 `before_prompt_build` 注入召回上下文;`hooks.allowConversationAccess` 允许 `agent_end` 读取原始对话并写入 L0。**版本兼容性:**
- **OpenClaw `>= 2026.4.24`** —— 这两个字段才被 gateway schema 识别。non-bundled 插件必须显式 `allowConversationAccess=true`,否则会话钩子在注册阶段被静默拦截、L0 capture 不会落库;`allowPromptInjection` 未设置时默认放行,我们仍写 `true` 以锁定意图。
- **OpenClaw `< 2026.4.24`**(含 4.23 及更早)—— gateway 使用 strict zod schema**会拒绝**这两个字段。**配置中不要包含 `hooks.*` 块**,否则 gateway 启动失败。安装脚本会自动按版本探测、在老版本上跳过该块。
### 6. 在 OpenClaw 中启用
插件 ID 是 `memory-tencentdb-client`。容器镜像中已经将它接入 memory slot。如果手动安装,请确保 OpenClaw 能加载该插件,并在 memory slot 中选中它。修改配置后可重启 Gateway:
```bash
openclaw gateway restart
```
## 适配职责
| 模块 | 实现文件 | 说明 |
|---|---|---|
| 对话捕获 | `src/hooks/capture.ts` | 对话结束后调用 `addConversation()` 写入 L0 |
| 记忆召回 | `src/hooks/recall.ts` | 构建 prompt 前搜索记忆并注入简洁上下文 |
| L1 工具 | `src/tools/memory-search.ts` | 让 Agent 主动搜索结构化记忆 |
| L0 工具 | `src/tools/conversation-search.ts` | 让 Agent 主动搜索历史对话 |
| L2/L3 读取工具 | `src/tools/read-cos.ts` | 让 Agent 在需要时读取场景或画像文件 |
## 配置项
| 字段 | 默认值 | 说明 |
|---|---|---|
| `server.url` | `http://127.0.0.1:8420` | Memory Gateway 地址 |
| `server.apiKey` | `""` | SDK 发送的 Bearer token。standalone 默认可用 `local`。 |
| `server.instanceId` | `default` | Memory 空间 ID,通过 `x-tdai-service-id` 发送 |
| `recall.maxResults` | `5` | 每轮最多注入多少条 L1 记忆 |
| `recall.includePersona` | `true` | 是否注入 L3 core/profile 上下文 |
| `recall.includeSceneNav` | `true` | 是否注入 L2 场景导航 |
| `capture.enabled` | `true` | 是否自动捕获完整对话轮次 |
| `hooks.allowPromptInjection` | `true` | 允许插件在 prompt 构建阶段注入召回上下文。未设置时默认放行,我们显式写 `true` 是为了锁定意图。**仅在 OpenClaw `>= 2026.4.24` 时写入此字段**——更老的 gateway 会用 strict schema 拒绝它。 |
| `hooks.allowConversationAccess` | `true` | 允许插件在 `agent_end` / `llm_input` / `llm_output` 读取原始对话用于 L0 写入。**non-bundled 插件必须显式 `true`**——否则会话钩子会在注册阶段被静默拦截,L0 capture 不会落库。**仅在 OpenClaw `>= 2026.4.24` 时写入此字段**——更老的 gateway 会用 strict schema 拒绝它。 |
## 文件结构
```text
openclaw-plugin/
├── openclaw.plugin.json # 插件清单
├── package.json # 依赖与构建脚本
├── index.ts # OpenClaw 入口
├── src/hooks/capture.ts # L0 捕获 hook
├── src/hooks/recall.ts # 召回 hook
├── src/tools/ # Agent 可调用工具
└── src/format.ts # prompt 格式化辅助
```
## 作为其它 Agent 的适配模板
如果你要适配其它 Agent 框架,可以复用同样模式:
1. 在一轮用户/助手对话完成后调用 `addConversation()`
2. 在下一轮 prompt 构建前调用 `searchAtomic()``readCore()`,必要时调用 `listScenarios()`
3. 只向 Agent prompt 注入简洁、带标签的记忆上下文。
4. 暴露 L1 搜索、L0 对话搜索、L2 场景读取等主动工具。
5. Adapter 保持无状态;存储、异步 L1/L2/L3 处理都交给 Memory Gateway。
## 注意
该插件只是客户端 adapter,不应在插件内启动 Memory Gateway 子进程,也不应在本地实现记忆抽取逻辑。standalone Gateway 启动方式与 SDK 示例见根目录 README。
+170
View File
@@ -0,0 +1,170 @@
# TencentDB Agent Memory Client — OpenClaw 记忆插件(客户端接入版)
> 创建: 2026-05-17 | 状态: 开发中
> 插件 ID: `memory-tencentdb-client`
> 显示名称: Memory TencentDB (Client)
## 1. 背景
服务化改造完成后,四层记忆数据(L0 对话/L1 原子/L2 场景/L3 画像)全部托管在远端 Gateway:
- **数据存储**: TCVDB (向量) + COS (文件) + Redis (状态)
- **Pipeline**: Gateway Worker 自动完成 L1→L2→L3 抽取
- **API**: 15 个 v2 REST 端点覆盖全部 CRUD + Search
**原插件(memory-tencentdb**是"全栈"架构:本地 SQLite/VDB + 本地 Pipeline + 本地 Embedding + OpenClaw Hooks + CLI~15000 行。
**新插件(memory-tencentdb-client**是纯客户端:只注册 OpenClaw hooks + tools,所有数据操作通过 `@tencentdb-agent-memory/memory-sdk-ts` 委托给远端 Gateway。
## 2. 三层架构
```
┌───────────────────────────────────────────────────────┐
│ OpenClaw Plugin (memory-tencentdb-client) │ 框架适配层
│ hooks (recall/capture) + tools + prompt 注入 │ 只依赖 SDK,不碰 HTTP/存储
│ └─ import { MemoryClient, MemoryFileReader } from SDK│
├───────────────────────────────────────────────────────┤
│ @tencentdb-agent-memory/memory-sdk-ts (独立包) │ 通用 SDK 层
│ MemoryClient (14 API) + MemoryFileReader (STS 直读) │ 零框架依赖,纯 fetch
│ 以后 Dify / AutoGen / LangChain 也用这个 │
├───────────────────────────────────────────────────────┤
│ Gateway v2 API │ 远端服务
│ VDB + COS + Redis + Pipeline Worker │
└───────────────────────────────────────────────────────┘
```
## 3. 插件职责(只做框架适配层)
| 功能 | Hook/Tool | 实现 |
|------|-----------|------|
| **对话捕获** | `agent_end` hook | SDK `client.addConversation()` |
| **记忆召回** | `before_prompt_build` hook | 并行: `client.searchAtomic()` + `client.readCore()` + `client.listScenarios()` |
| **标签清理** | `before_message_write` hook | 剥离 `<relevant-memories>` 标签 |
| **L1 搜索** | `tdai_memory_search` tool | SDK `client.searchAtomic()` |
| **L0 搜索** | `tdai_conversation_search` tool | SDK `client.searchConversation()` |
| **文件读取** | `tdai_read_cos` tool | SDK `MemoryFileReader.read()` (STS 直读对象存储) |
| **Prompt 注入** | recall 内部 | 格式化: Persona + L1 记忆 + Scene Navigation + 工具引导 |
### 不做的事
- ❌ 不启动 VectorStore / SQLite / TCVDB
- ❌ 不启动 EmbeddingService
- ❌ 不启动 Pipeline / Timer / Worker
- ❌ 不做 L1/L2/L3 抽取
- ❌ 不管 COS 存储后端
- ❌ 不管 Redis 状态
- ❌ 不做本地 Checkpoint
## 4. 配置项
```jsonc
{
// Gateway 连接
"gateway.url": "http://127.0.0.1:8420",
"gateway.apiKey": "",
"gateway.instanceId": "default",
// 召回
"recall.maxResults": 5,
"recall.includePersona": true,
"recall.includeSceneNav": true,
// 捕获
"capture.enabled": true
}
```
## 5. 文件结构
```
memory-tencentdb-client/
├── openclaw.plugin.json # 插件清单
├── package.json # deps: { "@tencentdb-agent-memory/memory-sdk-ts": "^0.1.0-beta.1" }
├── index.ts # 入口:初始化 SDK + 注册 hooks/tools
├── src/
│ ├── hooks/
│ │ ├── recall.ts # before_prompt_build → SDK 召回 → prompt 注入
│ │ └── capture.ts # agent_end → SDK addConversation
│ ├── tools/
│ │ ├── memory-search.ts # tdai_memory_search → SDK searchAtomic
│ │ ├── conversation-search.ts # → SDK searchConversation
│ │ └── read-cos.ts # tdai_read_cos → SDK MemoryFileReader.read
│ └── format.ts # 召回结果格式化 + 工具引导注入
├── tests/
│ └── sdk-cos.ts # SDK COS 直读手动测试
├── .gitignore
└── README.md
```
## 6. SDK 依赖策略
```jsonc
"dependencies": {
// caret + 预发版语义:自动跟到 0.1.0-beta.* 系列最新
// 以及 0.1.x 系列正式版(发布后)
"@tencentdb-agent-memory/memory-sdk-ts": "^0.1.0-beta.1"
}
```
SDK 已发布到 npm registry,由 `npm install` 自动拉取,不再走 vendor / 本地 tgz。
SDK 保持独立包,不绑定任何框架,以后出 Dify 插件、Python 版等都复用。
## 7. read_cos 工具设计
### COS 直读(STS
- SDK 的 `MemoryFileReader` 通过 Gateway `/v2/cos/secret` 获取 STS 临时凭证
- 凭证自动缓存,过期前 2 分钟刷新
- 直接 GET COS 对象(COS V5 签名),不经 Gateway 代理中转
### AI 如何知道可以调 read_cos
1. **Persona 末尾的 Scene Navigation**
```
## 🗺️ Scene Navigation
### Path: scene_blocks/职业发展与技术实践.md
**热度**: 3 | Summary: 后端工程师,Go + TypeScript...
```
AI 看到路径后主动调 `tdai_read_cos` 读取详情。
2. **工具引导(format.ts 注入)**
```
<memory-tools-guide>
- tdai_memory_search: 搜索结构化记忆
- tdai_conversation_search: 搜索原始对话
- tdai_read_cos: 读取场景文件(使用 Scene Navigation 中的路径)
</memory-tools-guide>
```
3. **工具 description**
```
"Read a file from cloud storage. Use paths from Scene Navigation
(e.g. 'scene_blocks/xxx.md') or 'persona.md'."
```
## 8. 关键设计决策
### Q1: session_id 怎么确定?
直接使用 OpenClaw 框架传入的 `ctx.sessionKey`hook context 自带),与原插件行为一致。不需要自己生成或拼接。
### Q2: 离线/断连降级?
第一版不做——Gateway 不可达时 hook 返回空(不注入记忆),capture 失败记 warn。后续可加本地 fallback。
### Q3: 和原插件冲突吗?
插件 ID 不同(`memory-tencentdb-client` vs `memory-tencentdb`),不冲突。但同时启用会重复捕获/注入,建议只启用一个。
## 9. 实现步骤
| # | 任务 | 预计 |
|---|------|------|
| 1 | `package.json` + `openclaw.plugin.json` + `.gitignore` + `README.md` | 15 min |
| 2 | `index.ts` — 初始化 SDK Client/MemoryFileReader + 注册 hooks/tools | 30 min |
| 3 | `hooks/capture.ts` — agent_end → addConversation | 20 min |
| 4 | `hooks/recall.ts` + `format.ts` — 并行召回 + prompt 格式化 | 45 min |
| 5 | `tools/*.ts` — 3 个工具转发 | 30 min |
| 6 | SDK 测试脚本 (`tests/sdk-cos.ts`) | 15 min |
| 7 | 本地联调测试 | 30 min |
**总计**: ~3 小时
+252
View File
@@ -0,0 +1,252 @@
/**
* memory-tencentdb-client — OpenClaw 记忆插件(客户端接入版)
*
* 通过 @tencentdb-agent-memory/memory-sdk-ts 连接远端 memory server
* 提供四层记忆的自动捕获、召回和工具调用能力。
*
* 本插件不包含任何数据处理逻辑(无 VDB/Embedding/Pipeline),
* 所有操作委托给远端 server。
*/
import { MemoryClient } from "@tencentdb-agent-memory/memory-sdk-ts";
import { performRecall } from "./src/hooks/recall.js";
import { performCapture } from "./src/hooks/capture.js";
import { handleMemorySearch } from "./src/tools/memory-search.js";
import { handleConversationSearch } from "./src/tools/conversation-search.js";
import { handleReadCos } from "./src/tools/read-cos.js";
const TAG = "[memory-client]";
// ── Config types (matches openclaw.plugin.json configSchema) ──────────
interface ServerConfig {
url?: string;
apiKey?: string;
instanceId?: string;
}
interface RecallConfig {
maxResults?: number;
includePersona?: boolean;
includeSceneNav?: boolean;
}
interface CaptureConfig {
enabled?: boolean;
}
interface PluginConfig {
server?: ServerConfig;
recall?: RecallConfig;
capture?: CaptureConfig;
}
// Matches OpenClaw plugin register() signature: export default function register(api)
export default function register(api: any) {
// ── Read config (nested objects per configSchema) ──────────────────
const cfg = (api.pluginConfig ?? {}) as PluginConfig;
const server = cfg.server ?? {};
const recall = cfg.recall ?? {};
const capture = cfg.capture ?? {};
const serverUrl = server.url || "http://127.0.0.1:8420";
const apiKey = server.apiKey || "sk-xxxx";
const instanceId = server.instanceId || "default";
const recallMaxResults = recall.maxResults ?? 5;
const includePersona = recall.includePersona !== false;
const includeSceneNav = recall.includeSceneNav !== false;
const captureEnabled = capture.enabled !== false;
// ── Initialize SDK ──
// NOTE: pass config (not a raw Transport) so client.readFile can lazily
// build its internal MemoryFileReader for STS-signed reads.
const client = new MemoryClient({
endpoint: serverUrl,
apiKey,
serviceId: instanceId,
});
api.logger.info?.(
`${TAG} Initialized: server=${serverUrl}, instance=${instanceId}, ` +
`recall(persona=${includePersona},sceneNav=${includeSceneNav},max=${recallMaxResults}), ` +
`capture=${captureEnabled}`,
);
// ── Register Tools (same pattern as extensions/memory-tencentdb/index.ts) ──
api.registerTool(
{
name: "tdai_memory_search",
label: "Memory Search",
description:
"Search structured memories (L1). Returns relevant memory fragments about " +
"user preferences, past events, rules, and facts.",
parameters: {
type: "object",
properties: {
query: { type: "string", description: "Search query text (natural language)." },
limit: { type: "number", description: "Max results to return (default: 5)." },
type: { type: "string", description: "Filter by memory type." },
},
required: ["query"],
},
async execute(_toolCallId: string, params: Record<string, unknown>) {
return handleMemorySearch(client, params as any, api.logger);
},
},
{ name: "tdai_memory_search" },
);
api.registerTool(
{
name: "tdai_conversation_search",
label: "Conversation Search",
description:
"Search raw conversation history (L0). Returns original messages with timestamps.",
parameters: {
type: "object",
properties: {
query: { type: "string", description: "Search query text." },
limit: { type: "number", description: "Max results (default: 5)." },
session_key: { type: "string", description: "Filter by session ID." },
},
required: ["query"],
},
async execute(_toolCallId: string, params: Record<string, unknown>) {
return handleConversationSearch(client, params as any, api.logger);
},
},
{ name: "tdai_conversation_search" },
);
api.registerTool(
{
name: "tdai_read_cos",
label: "Read COS File",
description:
"Read a file from cloud storage. Use paths from Scene Navigation " +
"(e.g. 'scene_blocks/xxx.md') or 'persona.md'.",
parameters: {
type: "object",
properties: {
path: { type: "string", description: "File path (relative key)." },
},
required: ["path"],
},
async execute(_toolCallId: string, params: Record<string, unknown>) {
return handleReadCos(client, params as any, api.logger);
},
},
{ name: "tdai_read_cos" },
);
// ── Register Hooks (api.on pattern, same as memory-tencentdb) ──
// Per-session caches:
// - pendingOriginalPrompts: clean user prompt + messageCount captured at
// before_prompt_build, used at agent_end to (a) replace polluted user
// message and (b) position-slice this turn's new messages.
// - sessionCursors: max timestamp of last captured batch — used as a
// fallback when the position slice cannot be determined.
const pendingOriginalPrompts = new Map<string, { text: string; messageCount: number }>();
const sessionCursors = new Map<string, number>();
api.on("before_prompt_build", async (event: any, ctx: any) => {
const sessionKey = ctx?.sessionKey;
if (!sessionKey) return;
const userText = event?.prompt;
if (!userText) return;
// Cache original prompt for agent_end (only if capture is enabled — it is
// the only consumer; recall doesn't need this data).
if (captureEnabled) {
const messageCount = Array.isArray(event?.messages) ? event.messages.length : 0;
pendingOriginalPrompts.set(sessionKey, { text: userText, messageCount });
}
try {
const result = await performRecall(client, {
query: userText,
maxResults: recallMaxResults,
includePersona,
includeSceneNav,
}, api.logger);
// OpenClaw consumes the *return value* of before_prompt_build,
// not mutations on the event object. Map our RecallResult to the
// PluginHookBeforePromptBuildResult shape.
const out: { prependContext?: string; appendSystemContext?: string } = {};
if (result.prependContext) out.prependContext = result.prependContext;
if (result.appendSystemContext) out.appendSystemContext = result.appendSystemContext;
return out;
} catch (err) {
api.logger.warn(`${TAG} [recall] Failed: ${err instanceof Error ? err.message : String(err)}`);
}
});
if (captureEnabled) {
api.logger.info?.(`${TAG} Registering agent_end hook for auto-capture`);
api.on("agent_end", async (event: any, ctx: any) => {
const startMs = Date.now();
const sessionKey = ctx?.sessionKey;
const messages = (event?.messages ?? []) as unknown[];
api.logger.debug?.(
`${TAG} [agent_end] hook triggered: success=${event?.success}, ` +
`messages=${messages.length}, sessionKey=${sessionKey ?? "(none)"}`,
);
// Skip on agent failure — partial / errored turns shouldn't pollute L0.
if (event?.success === false) {
api.logger.info(`${TAG} [agent_end] agent did not succeed, skip capture`);
return;
}
if (!sessionKey) {
api.logger.warn(`${TAG} [agent_end] no sessionKey in ctx, skip capture`);
return;
}
if (messages.length === 0) {
api.logger.debug?.(`${TAG} [agent_end] event.messages is empty, skip capture`);
return;
}
const cached = pendingOriginalPrompts.get(sessionKey);
// Don't delete on read — keep until we successfully send (in case of retry),
// or let it be overwritten on next before_prompt_build.
try {
const result = await performCapture(
client,
{
sessionKey,
sessionId: ctx?.sessionId,
rawMessages: messages,
originalUserText: cached?.text,
originalUserMessageCount: cached?.messageCount,
afterTimestamp: sessionCursors.get(sessionKey),
},
api.logger,
);
if (result.maxTimestamp) {
sessionCursors.set(sessionKey, result.maxTimestamp);
}
// Cached prompt has been used — clear it so a stale value doesn't
// bleed into the next turn (e.g. after agent restart).
pendingOriginalPrompts.delete(sessionKey);
const elapsed = Date.now() - startMs;
api.logger.info(
`${TAG} [agent_end] capture done in ${elapsed}ms ` +
`(captured=${result.capturedCount}, serverTotal=${result.serverTotalCount ?? "?"})`,
);
} catch (err) {
const elapsed = Date.now() - startMs;
api.logger.warn(
`${TAG} [capture] Failed after ${elapsed}ms: ` +
(err instanceof Error ? err.message : String(err)),
);
}
});
} else {
api.logger.info?.(`${TAG} capture disabled by config`);
}
}
+75
View File
@@ -0,0 +1,75 @@
{
"id": "memory-tencentdb-client",
"name": "Memory TencentDB (Client)",
"version": "0.1.0",
"description": "长期记忆插件(客户端接入版)— 通过远端 memory server 提供四层记忆能力",
"author": "TDAI Team",
"activation": {
"onStartup": true
},
"contracts": {
"tools": [
"tdai_memory_search",
"tdai_conversation_search",
"tdai_read_cos"
]
},
"configSchema": {
"type": "object",
"properties": {
"server": {
"type": "object",
"description": "远端 memory server 连接配置",
"properties": {
"url": {
"type": "string",
"default": "http://127.0.0.1:8420",
"description": "Memory server URL"
},
"apiKey": {
"type": "string",
"default": "sk-xxxx",
"description": "API Key for server authentication"
},
"instanceId": {
"type": "string",
"default": "default",
"description": "Memory instance idHTTP header x-tdai-service-id"
}
}
},
"recall": {
"type": "object",
"description": "记忆召回设置",
"properties": {
"maxResults": {
"type": "number",
"default": 5,
"description": "每轮最多注入多少条 L1 记忆"
},
"includePersona": {
"type": "boolean",
"default": true,
"description": "是否注入 L3 画像"
},
"includeSceneNav": {
"type": "boolean",
"default": true,
"description": "是否注入 L2 场景导航索引"
}
}
},
"capture": {
"type": "object",
"description": "对话捕获设置",
"properties": {
"enabled": {
"type": "boolean",
"default": true,
"description": "是否启用自动对话捕获 (L0)"
}
}
}
}
}
}
+35
View File
@@ -0,0 +1,35 @@
{
"name": "@tencentdb-agent-memory/openclaw-plugin",
"version": "1.0.0-beta.1",
"description": "OpenClaw memory plugin — client mode for TencentDB Agent Memory Gateway",
"type": "module",
"main": "dist/index.js",
"types": "dist/index.d.ts",
"openclaw": {
"extensions": [
"./dist/index.js"
]
},
"scripts": {
"clean": "rm -rf dist *.tgz",
"build": "tsc",
"prepack": "npm run clean && npm install --no-save --no-audit --no-fund && npm run build",
"test:sdk": "node --env-file=.env --import tsx tests/sdk-cos.ts"
},
"dependencies": {
"@tencentdb-agent-memory/memory-sdk-ts": "^1.0.0"
},
"devDependencies": {
"typescript": "^5.5.0"
},
"engines": {
"node": ">=22.0.0"
},
"files": [
"dist/",
"src/",
"index.ts",
"openclaw.plugin.json",
"README.md"
]
}
+109
View File
@@ -0,0 +1,109 @@
/**
* Format recall results into prompt context.
*
* Output structure (mirrors original memory-tencentdb plugin):
* - prependContext: dynamic L1 memories (changes per turn, injected before user message)
* - appendSystemContext: stable content (Persona + Scene Nav + tools guide, appended to system prompt)
*/
import type { RecallResult } from "./hooks/recall.js";
interface L1Item {
id: string;
content: string;
type: string;
score?: number;
}
interface SceneEntry {
path: string;
created_at?: string;
updated_at?: string;
}
// ── Memory Tools Guide ──
const MEMORY_TOOLS_GUIDE = `<memory-tools-guide>
## 记忆工具调用指南
当上方注入的记忆片段不足以回答用户问题时,可主动调用以下工具获取更多信息:
- **tdai_memory_search**:搜索结构化记忆(L1),适用于回忆用户偏好、历史事件、规则等。
- **tdai_conversation_search**:搜索原始对话(L0),适用于查找具体消息原文、时间线、上下文细节。
- **tdai_read_cos**:读取场景文件详情(使用下方 Scene Navigation 中的路径,如 \`scene_blocks/xxx.md\`)。
### ⚠️ 调用次数限制
每轮对话中,tdai_memory_search 和 tdai_conversation_search **合计最多调用 3 次**。
- 首次搜索无结果时,可换关键词或换工具重试,但总调用次数不要超过 3 次。
- 若 3 次搜索后仍无结果,说明该信息不在记忆中,请直接根据已有信息回复用户。
</memory-tools-guide>`;
/**
* Format L1 memories as prependContext.
*/
function formatL1Memories(items: L1Item[]): string | undefined {
if (items.length === 0) return undefined;
const lines: string[] = [
"<relevant-memories>",
"",
];
for (const item of items) {
const typeTag = item.type ? `[${item.type}]` : "";
lines.push(`- ${typeTag} ${item.content}`);
}
lines.push("");
lines.push("</relevant-memories>");
return lines.join("\n");
}
/**
* Format stable system context: Persona + Scene Navigation + Tools Guide.
*/
function formatSystemContext(
persona: string | null,
scenes: SceneEntry[],
): string | undefined {
const parts: string[] = [];
// Persona (L3)
if (persona) {
parts.push("<user-persona>");
parts.push(persona);
parts.push("</user-persona>");
}
// Scene Navigation (L2 index) — only if not already in persona
if (scenes.length > 0 && (!persona || !persona.includes("Scene Navigation"))) {
parts.push("");
parts.push("## 🗺️ Scene Navigation");
parts.push("*以下是当前场景记忆索引,可使用 tdai_read_cos 读取详细内容。*");
parts.push("");
for (const scene of scenes) {
parts.push(`- \`${scene.path}\``);
}
}
// Tools guide (always append)
parts.push("");
parts.push(MEMORY_TOOLS_GUIDE);
const result = parts.join("\n").trim();
return result || undefined;
}
/**
* Main format function: produce RecallResult for prompt injection.
*/
export function formatRecallResult(
l1Items: L1Item[],
persona: string | null,
scenes: SceneEntry[],
): RecallResult {
return {
prependContext: formatL1Memories(l1Items),
appendSystemContext: formatSystemContext(persona, scenes),
};
}
+235
View File
@@ -0,0 +1,235 @@
/**
* Capture hook (client mode):
* 1. Extract user/assistant messages from the agent_end raw message array
* 2. Apply position slice + (optional) timestamp cursor to keep only this turn
* 3. Replace the polluted user message with the cached original prompt
* 4. Sanitize text + strip code blocks (assistant) + filter noise
* 5. POST the cleaned messages to the gateway via SDK addConversation
*
* Mirrors the structural cleanup in extensions/memory-tencentdb/src/core/conversation/l0-recorder.ts
* (recordConversation), but does not write any local JSONL — the server is
* authoritative for L0 storage.
*/
import type { MemoryClient } from "@tencentdb-agent-memory/memory-sdk-ts";
import { sanitizeText, stripCodeBlocks, shouldCaptureL0 } from "../sanitize.js";
const TAG = "[memory-client][capture]";
interface Logger {
debug?: (msg: string) => void;
info: (msg: string) => void;
warn: (msg: string) => void;
error: (msg: string) => void;
}
interface ExtractedMessage {
role: "user" | "assistant";
content: string;
timestamp: number;
}
export interface CaptureContext {
/** sessionKey from agent_end ctx — used as conversation key on the server. */
sessionKey: string;
/** sessionId from agent_end ctx — passed to addConversation when present. */
sessionId?: string;
/** Raw event.messages array (full session history at agent_end time). */
rawMessages: unknown[];
/** Clean original user prompt (cached at before_prompt_build, pre-pollution). */
originalUserText?: string;
/**
* Number of messages in the session at before_prompt_build time.
* Used to position-slice rawMessages so we only re-send messages added in this turn.
*/
originalUserMessageCount?: number;
/**
* Epoch ms cursor: only messages with timestamp > this are sent.
* Used as a fallback when position slice is unavailable.
*/
afterTimestamp?: number;
}
export interface CaptureResult {
/** Number of messages actually sent to the gateway (post-filter). */
capturedCount: number;
/** Server-reported total count after this batch. */
serverTotalCount?: number;
/** Max timestamp among captured messages — caller should advance its cursor to this. */
maxTimestamp?: number;
}
/**
* Run a single agent_end capture.
*
* Returns immediately with `capturedCount: 0` if there is nothing to send.
* Errors from the gateway are thrown — caller should wrap in try/catch.
*/
export async function performCapture(
client: MemoryClient,
ctx: CaptureContext,
logger?: Logger,
): Promise<CaptureResult> {
const { sessionKey, sessionId, rawMessages, originalUserText, originalUserMessageCount, afterTimestamp } = ctx;
// ── Step 1. Position slice ──
// Only consider messages added AFTER before_prompt_build, i.e. this turn's input.
const usePositionSlice =
originalUserMessageCount != null &&
originalUserMessageCount > 0 &&
originalUserMessageCount <= rawMessages.length;
const slicedMessages = usePositionSlice
? rawMessages.slice(originalUserMessageCount)
: rawMessages;
if (usePositionSlice) {
logger?.debug?.(
`${TAG} Position slice: ${rawMessages.length} raw → ${slicedMessages.length} new ` +
`(sliceStart=${originalUserMessageCount})`,
);
}
// ── Step 2. Extract user/assistant messages ──
const allExtracted = extractUserAssistantMessages(slicedMessages);
logger?.debug?.(
`${TAG} Extracted ${allExtracted.length} user/assistant messages from ${slicedMessages.length} raw`,
);
// ── Step 3. Timestamp cursor (fallback when position slice unavailable) ──
const cursor = afterTimestamp ?? 0;
const filteredByTime = cursor !== 0
? allExtracted.filter((m) => m.timestamp > cursor)
: allExtracted;
if (cursor > 0) {
logger?.debug?.(
`${TAG} Timestamp filter: ${allExtracted.length}${filteredByTime.length} (cursor=${cursor})`,
);
}
if (filteredByTime.length === 0) {
logger?.debug?.(`${TAG} No new messages to capture`);
return { capturedCount: 0 };
}
// ── Step 4. Replace polluted user message with cached original ──
// The framework appends the user's message AFTER before_prompt_build and
// injects prependContext into it. Without this swap, the captured user
// text would contain the recall blob, causing a feedback loop.
if (originalUserText) {
const targetRaw = usePositionSlice
? (slicedMessages[0] as Record<string, unknown> | undefined)
: (originalUserMessageCount != null && originalUserMessageCount >= 0 && originalUserMessageCount < rawMessages.length)
? (rawMessages[originalUserMessageCount] as Record<string, unknown> | undefined)
: undefined;
const targetTs = typeof targetRaw?.timestamp === "number" ? targetRaw.timestamp : undefined;
if (targetTs != null) {
let replaced = false;
for (let i = 0; i < filteredByTime.length; i++) {
if (filteredByTime[i].role === "user" && filteredByTime[i].timestamp === targetTs) {
logger?.debug?.(
`${TAG} Replacing polluted user message (ts=${targetTs}, ` +
`${filteredByTime[i].content.length}${originalUserText.length} chars)`,
);
filteredByTime[i] = { ...filteredByTime[i], content: originalUserText };
replaced = true;
break;
}
}
if (!replaced) {
logger?.warn?.(`${TAG} Could not match cached prompt to any extracted user message — relying on sanitizeText()`);
}
}
}
// ── Step 5. Sanitize + strip code + filter ──
const cleaned = filteredByTime
.map((m) => {
let content = sanitizeText(m.content);
if (m.role === "assistant") content = stripCodeBlocks(content);
return { role: m.role, content, timestamp: m.timestamp };
})
.filter((m) => shouldCaptureL0(m.content));
logger?.debug?.(
`${TAG} After sanitize+filter: ${cleaned.length} messages (from ${filteredByTime.length})`,
);
if (cleaned.length === 0) {
logger?.info(`${TAG} All messages filtered out, skipping POST`);
return { capturedCount: 0 };
}
// ── Step 6. POST to gateway ──
const result = await client.addConversation({
session_id: sessionId ?? sessionKey,
messages: cleaned.map((m) => ({
role: m.role,
content: m.content,
timestamp: new Date(m.timestamp).toISOString(),
})),
});
const maxTimestamp = Math.max(...cleaned.map((m) => m.timestamp));
logger?.info(
`${TAG} Captured ${cleaned.length} message(s) (server total=${result.total_count}, ` +
`sessionKey=${sessionKey.slice(0, 32)}${sessionKey.length > 32 ? "…" : ""})`,
);
return {
capturedCount: cleaned.length,
serverTotalCount: result.total_count,
maxTimestamp,
};
}
/**
* Extract user/assistant entries from the framework's raw message array.
*
* Handles both content shapes the framework may produce:
* - `content: string`
* - `content: Array<{ type: "text", text: string } | ...>`
*
* Strips inline base64 image data URIs (replaces with `[image]`) so they do
* not bloat the request payload or pollute downstream FTS / embeddings.
*/
function extractUserAssistantMessages(messages: unknown[]): ExtractedMessage[] {
const result: ExtractedMessage[] = [];
for (const msg of messages) {
if (!msg || typeof msg !== "object") continue;
const m = msg as Record<string, unknown>;
const role = m.role as string | undefined;
if (role !== "user" && role !== "assistant") continue;
let content: string | undefined;
if (typeof m.content === "string") {
content = m.content;
} else if (Array.isArray(m.content)) {
const parts: string[] = [];
for (const part of m.content) {
if (part && typeof part === "object" && (part as Record<string, unknown>).type === "text") {
const text = (part as Record<string, unknown>).text;
if (typeof text === "string") parts.push(text);
}
}
content = parts.join("\n");
}
if (content && /data:image\/[a-z+]+;base64,/i.test(content)) {
content = content.replace(/data:image\/[a-z+]+;base64,[A-Za-z0-9+/=]+/gi, "[image]");
}
if (content && content.trim()) {
const ts = typeof m.timestamp === "number" ? m.timestamp : Date.now();
result.push({
role: role as "user" | "assistant",
content: content.trim(),
timestamp: ts,
});
}
}
return result;
}
+55
View File
@@ -0,0 +1,55 @@
/**
* Recall hook: search memories from Gateway + format prompt injection.
*/
import type { MemoryClient } from "@tencentdb-agent-memory/memory-sdk-ts";
import { formatRecallResult } from "../format.js";
const TAG = "[memory-client][recall]";
interface Logger {
debug?: (msg: string) => void;
info: (msg: string) => void;
warn: (msg: string) => void;
error: (msg: string) => void;
}
export interface RecallOptions {
query: string;
maxResults: number;
includePersona: boolean;
includeSceneNav: boolean;
}
export interface RecallResult {
prependContext?: string;
appendSystemContext?: string;
}
export async function performRecall(
client: MemoryClient,
opts: RecallOptions,
logger?: Logger,
): Promise<RecallResult> {
const startMs = Date.now();
// Parallel requests: L1 search + L3 persona + L2 scenario list
const [searchResult, persona, scenarios] = await Promise.allSettled([
client.searchAtomic({ query: opts.query, limit: opts.maxResults }),
opts.includePersona ? client.readCore() : Promise.resolve(null),
opts.includeSceneNav ? client.listScenarios({}) : Promise.resolve(null),
]);
// Extract results (graceful on failures)
const l1Items = searchResult.status === "fulfilled" ? (searchResult.value?.items ?? []) : [];
const personaContent = persona.status === "fulfilled" && persona.value ? persona.value.content : null;
const sceneEntries = scenarios.status === "fulfilled" && scenarios.value ? (scenarios.value.entries ?? []) : [];
const elapsedMs = Date.now() - startMs;
logger?.info(
`${TAG} Recall complete (${elapsedMs}ms): L1=${l1Items.length}, ` +
`persona=${personaContent ? "yes" : "no"}, scenes=${sceneEntries.length}`,
);
return formatRecallResult(l1Items, personaContent, sceneEntries);
}
+88
View File
@@ -0,0 +1,88 @@
/**
* Text sanitization for L0 capture (client-side).
* Mirrors the cleaning logic in extensions/memory-tencentdb/src/utils/sanitize.ts
* — kept in sync to ensure the client and server filter the same noise.
*/
/** Strip injected memory tags + framework metadata blocks + media markers. */
export function sanitizeText(text: string): string {
let cleaned = text;
// Remove injected memory context tags (prevent feedback loops on re-capture)
cleaned = cleaned.replace(/<relevant-memories>[\s\S]*?<\/relevant-memories>/g, "");
cleaned = cleaned.replace(/<user-persona>[\s\S]*?<\/user-persona>/g, "");
cleaned = cleaned.replace(/<relevant-scenes>[\s\S]*?<\/relevant-scenes>/g, "");
cleaned = cleaned.replace(/<scene-navigation>[\s\S]*?<\/scene-navigation>/g, "");
cleaned = cleaned.replace(/<memory-tools-guide>[\s\S]*?<\/memory-tools-guide>/g, "");
// Offload-injected task context blocks
cleaned = cleaned.replace(/<current_task_context>[\s\S]*?<\/current_task_context>/g, "");
cleaned = cleaned.replace(/<history_task_context[\s\S]*?<\/history_task_context>/g, "");
// Framework-injected inbound metadata blocks (label + ```json ... ```)
cleaned = cleaned.replace(
/(?:Conversation info|Sender|Thread starter|Replied message|Forwarded message context|Chat history since last reply)\s*\(untrusted[\s\S]*?\):\s*```json\s*[\s\S]*?```/g,
"",
);
// Legacy conversation metadata JSON blocks
cleaned = cleaned.replace(/```json\s*\{[\s\S]*?"session[\s\S]*?\}\s*```/g, "");
// Reply directive tags: [[reply_to_current]]
cleaned = cleaned.replace(/\[\[reply_to[^\]]*\]\]\s*/g, "");
// Skill-selection wrappers: ¥¥[ ... ]¥¥
cleaned = cleaned.replace(/¥¥\[[\s\S]*?\]¥¥/g, "");
// Line-leading timestamps: [Tue 2026-03-24 03:48 UTC] / GMT+8 / GMT+5:30
cleaned = cleaned.replace(/^\[[\w\d\-:+ ]+\]\s*/gm, "");
// Gateway media-attachment markers
cleaned = cleaned.replace(/\[media attached:[^\]]*\]\s*/g, "");
// Gateway image-reply instructions
cleaned = cleaned.replace(
/To send an image back,[\s\S]*?(?:Keep caption in the text body\.)\s*/g,
"",
);
// System exec blocks: "System: [timestamp] Exec completed ..."
cleaned = cleaned.replace(/^System:\s*\[[\s\S]*?$/gm, "");
// Inline base64 image data URIs
cleaned = cleaned.replace(/data:image\/[a-z+]+;base64,[A-Za-z0-9+/=]+/gi, "");
// Null chars + collapse whitespace
cleaned = cleaned.replace(/\0/g, "").replace(/\n{3,}/g, "\n\n").trim();
return cleaned;
}
/**
* Strip fenced code blocks from assistant replies before L0 capture.
* Only applied to role=assistant — keeps explanatory text but drops noisy code.
*/
export function stripCodeBlocks(text: string): string {
return text.replace(/```[^\n]*\n[\s\S]*?```/g, "").replace(/\n{3,}/g, "\n\n").trim();
}
/**
* L0 capture filter — permissive. Only drops messages that are structurally
* useless (empty, framework bootstrap noise, slash commands).
*/
export function shouldCaptureL0(text: string): boolean {
if (!text || !text.trim()) return false;
if (isFrameworkNoise(text)) return false;
if (text.startsWith("/")) return false;
return true;
}
function isFrameworkNoise(text: string): boolean {
const t = text.trim();
if (t === "(session bootstrap)") return true;
if (t.startsWith("A new session was started via")) return true;
if (/^✅\s*New session started/.test(t)) return true;
if (t.startsWith("Pre-compaction memory flush")) return true;
if (/^NO_REPLY\s*$/.test(t)) return true;
return false;
}
@@ -0,0 +1,57 @@
/**
* tdai_conversation_search tool — delegates to SDK searchConversation.
*/
import type { MemoryClient } from "@tencentdb-agent-memory/memory-sdk-ts";
interface Logger {
debug?: (msg: string) => void;
warn: (msg: string) => void;
}
export async function handleConversationSearch(
client: MemoryClient,
params: { query: string; limit?: number; session_key?: string },
logger?: Logger,
) {
const { query, limit = 5, session_key } = params;
if (!query?.trim()) {
return { content: [{ type: "text" as const, text: "Query cannot be empty." }] };
}
try {
logger?.debug?.(`[conversation-search] query="${query}", limit=${limit}, session=${session_key ?? "(all)"}`);
const result = await client.searchConversation({
query,
limit,
session_id: session_key,
});
const messages = result.messages ?? [];
logger?.debug?.(`[conversation-search] ✅ ${messages.length} results`);
if (messages.length === 0) {
return { content: [{ type: "text" as const, text: "No matching conversation messages found." }] };
}
const lines: string[] = [`Found ${messages.length} matching message(s):`, ""];
for (const msg of messages) {
const scoreStr = msg.score != null ? ` (score: ${msg.score.toFixed(3)})` : "";
const dateStr = msg.timestamp ? ` [${msg.timestamp}]` : "";
lines.push(`---`);
lines.push(`**[${msg.role}]**${dateStr}${scoreStr}`);
lines.push("");
lines.push(msg.content);
lines.push("");
}
return {
content: [{ type: "text" as const, text: lines.join("\n") }],
details: { count: messages.length },
};
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
logger?.warn(`[conversation-search] Failed: ${msg}`);
return { content: [{ type: "text" as const, text: `Conversation search failed: ${msg}` }] };
}
}
@@ -0,0 +1,50 @@
/**
* tdai_memory_search tool — delegates to SDK searchAtomic.
*/
import type { MemoryClient } from "@tencentdb-agent-memory/memory-sdk-ts";
interface Logger {
debug?: (msg: string) => void;
warn: (msg: string) => void;
}
export async function handleMemorySearch(
client: MemoryClient,
params: { query: string; limit?: number; type?: string },
logger?: Logger,
) {
const { query, limit = 5, type } = params;
if (!query?.trim()) {
return { content: [{ type: "text" as const, text: "Query cannot be empty." }] };
}
try {
logger?.debug?.(`[memory-search] query="${query}", limit=${limit}, type=${type ?? "(all)"}`);
const result = await client.searchAtomic({ query, limit, type });
const items = result.items ?? [];
logger?.debug?.(`[memory-search] ✅ ${items.length} results`);
if (items.length === 0) {
return { content: [{ type: "text" as const, text: "No matching memories found." }] };
}
const lines: string[] = [`Found ${items.length} matching memories:`, ""];
for (const item of items) {
const scoreStr = item.score != null ? ` (score: ${item.score.toFixed(3)})` : "";
lines.push(`- **[${item.type}]**${scoreStr}`);
lines.push(` ${item.content}`);
lines.push("");
}
return {
content: [{ type: "text" as const, text: lines.join("\n") }],
details: { count: items.length },
};
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
logger?.warn(`[memory-search] Failed: ${msg}`);
return { content: [{ type: "text" as const, text: `Memory search failed: ${msg}` }] };
}
}
+39
View File
@@ -0,0 +1,39 @@
/**
* tdai_read_cos tool — reads memory pipeline artifacts (persona.md,
* scene_blocks/*.md, ...) by relative path via the SDK's `client.readFile`.
*/
import type { MemoryClient } from "@tencentdb-agent-memory/memory-sdk-ts";
interface Logger {
debug?: (msg: string) => void;
warn: (msg: string) => void;
}
export async function handleReadCos(
client: MemoryClient,
params: { path: string },
logger?: Logger,
) {
const { path } = params;
if (!path?.trim()) {
return { content: [{ type: "text" as const, text: "Path cannot be empty." }] };
}
// Security: reject path traversal
if (path.includes("..") || path.startsWith("/")) {
return { content: [{ type: "text" as const, text: `Invalid path: "${path}"` }] };
}
try {
logger?.debug?.(`[read-cos] read: "${path}"`);
const content = await client.readFile(path);
logger?.debug?.(`[read-cos] ✅ "${path}" (${content.length} chars)`);
return { content: [{ type: "text" as const, text: content }] };
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
logger?.warn(`[read-cos] Failed to read "${path}": ${msg}`);
return { content: [{ type: "text" as const, text: `Failed to read file: ${msg}` }] };
}
}
+96
View File
@@ -0,0 +1,96 @@
/**
* SDK 直读测试脚本
*
* 验证 @tencentdb-agent-memory/memory-sdk-ts 的 MemoryFileReader 能否正确:
* 1. 获取 STS 临时凭证(通过 Gateway /v2/cos/secret
* 2. 直读 persona.md
* 3. 直读 scene_blocks/*.md
*
* 前置条件:
* - Gateway 已启动
* - 后端存储上有数据(先跑过 E2E pipeline 测试)
*
* Usage:
* E2E_ENDPOINT=http://127.0.0.1:8420 \
* E2E_API_KEY=test-key-e2e \
* E2E_SERVICE_ID=tdai-mem-dev001 \
* npx tsx tests/sdk-cos.ts
*/
import { MemoryClient, HttpTransport, createMemoryFileReader } from "@tencentdb-agent-memory/memory-sdk-ts";
const ENDPOINT = process.env.E2E_ENDPOINT || "http://127.0.0.1:8420";
const API_KEY = process.env.E2E_API_KEY || "test-key-e2e";
const SERVICE_ID = process.env.E2E_SERVICE_ID || "tdai-mem-dev001";
async function main() {
console.log(`\n🧪 SDK 直读测试`);
console.log(` Endpoint: ${ENDPOINT}`);
console.log(` ServiceId: ${SERVICE_ID}`);
// 1. 初始化 SDK
const transport = new HttpTransport({
endpoint: ENDPOINT,
apiKey: API_KEY,
serviceId: SERVICE_ID,
});
const client = new MemoryClient(transport);
const fileReader = createMemoryFileReader({
endpoint: ENDPOINT,
apiKey: API_KEY,
serviceId: SERVICE_ID,
});
console.log(`\n── Step 1: 通过 Gateway API 列举 scenario 文件`);
const ls = await client.listScenarios({});
console.log(` 找到 ${ls.entries.length} 个文件:`);
for (const e of ls.entries) {
console.log(` - ${e.path}`);
}
if (ls.entries.length === 0) {
console.log(`\n⚠️ 后端存储上没有 scenario 文件,请先跑 E2E pipeline 测试生成数据。`);
process.exit(1);
}
// 2. 直读 persona.md
console.log(`\n── Step 2: 直读 persona.md`);
try {
const persona = await fileReader.read("persona.md");
console.log(` ✅ 读取成功 (${persona.length} chars)`);
console.log(` 内容前 200 字: ${persona.slice(0, 200)}...`);
} catch (err) {
console.log(` ❌ 读取失败: ${err instanceof Error ? err.message : String(err)}`);
}
// 3. 直读第一个 scene block
const firstScene = ls.entries[0];
const cosPath = `scene_blocks/${firstScene.path}`;
console.log(`\n── Step 3: 直读 ${cosPath}`);
try {
const content = await fileReader.read(cosPath);
console.log(` ✅ 读取成功 (${content.length} chars)`);
console.log(` 内容前 200 字: ${content.slice(0, 200)}...`);
} catch (err) {
console.log(` ❌ 读取失败: ${err instanceof Error ? err.message : String(err)}`);
}
// 4. 对比:通过 Gateway API 读同一个文件
console.log(`\n── Step 4: 对比 — Gateway API 读同一个文件`);
try {
const apiResult = await client.readScenario({ path: firstScene.path });
console.log(` ✅ Gateway API 读取成功 (${apiResult.content.length} chars)`);
console.log(` 内容前 200 字: ${apiResult.content.slice(0, 200)}...`);
} catch (err) {
console.log(` ❌ Gateway API 读取失败: ${err instanceof Error ? err.message : String(err)}`);
}
console.log(`\n${"═".repeat(50)}`);
console.log(` ✅ SDK 直读测试完成`);
console.log(`${"═".repeat(50)}\n`);
}
main().catch((err) => {
console.error("Fatal:", err);
process.exit(1);
});
+18
View File
@@ -0,0 +1,18 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "bundler",
"declaration": true,
"outDir": "./dist",
"rootDir": ".",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"lib": ["ES2022", "DOM"],
"types": ["node"]
},
"include": ["index.ts", "src/**/*.ts"],
"exclude": ["node_modules", "dist", "tests"]
}