Refine memory system user-key flow and search output
This commit is contained in:
389
README.md
389
README.md
@ -1,32 +1,72 @@
|
||||
# Memory System API
|
||||
# Memory Gateway
|
||||
|
||||
Memory System API is a lightweight HTTP facade over two memory backends:
|
||||
Memory Gateway 是一个轻量级记忆网关项目,用统一的 HTTP API 把上层应用接到两个记忆后端:
|
||||
|
||||
- OpenViking stores session conversation memory.
|
||||
- EverOS stores user profile and episodic memory.
|
||||
- OpenViking:负责会话归档、长期记忆抽取、按 `user_id / session_id` 隔离的语义检索。
|
||||
- EverOS:负责用户画像、episodic memory、profile 查询等补充记忆能力。
|
||||
|
||||
The caller only sends `user_id`, `session_id`, and optional `user_message` / `assistant_message`.
|
||||
The API creates or reuses the OpenViking user key, writes messages to both backends, and exposes simple endpoints for commit, immediate extraction, search, and profile reads.
|
||||
当前重点模块是 `memory_system_api`。它不是 OpenViking 的替代品,而是一个更窄的业务网关:上层只面对 user、user key、session 和 message;网关内部固定使用 OpenViking `admin` 工作区。
|
||||
|
||||
## Endpoints
|
||||
## 核心模型
|
||||
|
||||
Base URL:
|
||||
### 身份字段
|
||||
|
||||
```text
|
||||
http://127.0.0.1:1934
|
||||
| 字段 | 含义 | 是否自定义 | 备注 |
|
||||
|---|---|---:|---|
|
||||
| `user_id` | 真实终端用户 | 是 | 先通过 `/users` 创建,后续写入、查询、commit 时使用 |
|
||||
| `user_key` | OpenViking 返回的用户 key | 否 | `/users` 返回并写入 SQLite;业务调用时随请求体或 query 传入 |
|
||||
| `session_id` | 一次会话 ID | 是 | OpenViking session 容器,同时用于带 session context 的搜索 |
|
||||
|
||||
建议 ID 只使用字母、数字、`_`、`-`、`.`、`@`,不要使用空格、斜杠或中文。
|
||||
|
||||
### 先创建用户再使用
|
||||
|
||||
除健康检查外,业务调用分两步:
|
||||
|
||||
1. 调用 `POST /memory-system/users` 创建 OpenViking user。
|
||||
2. 保存返回的 `user_key`,后续写入消息、commit、extract、task、search、profile 都传 `user_id + user_key`,不需要传 `account_id`。
|
||||
|
||||
如果没有先创建 user,或者 `user_key` 与数据库里的 `user_id` 不匹配,`messages`、`commit`、`extract`、`tasks`、`search`、`profile` 都会返回 `401`。
|
||||
|
||||
第一次创建业务 user 时,如果 SQLite 里还没有 OpenViking `admin` 工作区记录,网关会先调用 OpenViking:
|
||||
|
||||
```http
|
||||
POST /api/v1/admin/accounts
|
||||
{"account_id": "admin", "admin_user_id": "admin"}
|
||||
```
|
||||
|
||||
Routes:
|
||||
随后创建具体用户:
|
||||
|
||||
- `GET /memory-system/health`
|
||||
- `POST /memory-system/messages`
|
||||
- `POST /memory-system/sessions/{session_id}/commit`
|
||||
- `POST /memory-system/sessions/{session_id}/extract`
|
||||
- `GET /memory-system/openviking/tasks/{task_id}?user_id=...`
|
||||
- `POST /memory-system/search`
|
||||
- `GET /memory-system/users/{user_id}/profile`
|
||||
```http
|
||||
POST /api/v1/admin/accounts/admin/users
|
||||
{"user_id": "<user_id>", "role": "user"}
|
||||
```
|
||||
|
||||
## Install
|
||||
后续创建第二、第三个用户时,网关直接调用 `/api/v1/admin/accounts/admin/users`。所有返回的 `user_id / user_key`、创建过的 `session_id`、commit 返回的 `task_id / archive_uri` 都写入 SQLite,不放在进程内缓存里。
|
||||
|
||||
### 鉴权
|
||||
|
||||
Memory Gateway 有两类 key:
|
||||
|
||||
| Key | 请求头 | 用途 | 是否必需 |
|
||||
|---|---|---|---|
|
||||
| Memory System API key | `X-API-Key` | 保护 Memory Gateway 服务本身 | 仅当 `server.api_key` 配置非空时需要 |
|
||||
| OpenViking user key | 请求体/query 的 `user_key` | 校验具体用户并调用 OpenViking 普通用户 API | 除 `/health` 和 `/users` 外都需要 |
|
||||
|
||||
OpenViking 的 root key 配在服务端 `config.yaml` 中,由网关内部使用。调用方不需要也不应该在业务请求中传 OpenViking root key。
|
||||
|
||||
OpenViking 内部调用遵循:
|
||||
|
||||
| OpenViking 场景 | 鉴权 |
|
||||
|---|---|
|
||||
| Admin API,例如创建 account/user | `X-API-Key: <openviking root key>` |
|
||||
| 普通用户 API,例如 session/message/commit/task/search | `Authorization: Bearer <openviking user key>` |
|
||||
|
||||
### Session 与搜索范围
|
||||
|
||||
OpenViking session 由请求里的 `session_id` 创建和提交。普通向量搜索使用显式用户 memory 路径 `viking://user/<user_id>/memories/`;`use_llm=true` 的智能搜索会同时传 `session_id` 和 `target_uri: viking://user/<user_id>/<session_id>`,用于结合当前 session context。
|
||||
|
||||
## 安装
|
||||
|
||||
```bash
|
||||
cd /home/tom/memory-gateway
|
||||
@ -36,15 +76,15 @@ pip install -U pip
|
||||
pip install -e ".[dev]"
|
||||
```
|
||||
|
||||
## Configure
|
||||
## 配置
|
||||
|
||||
Copy the example config and edit backend URLs or keys as needed:
|
||||
复制配置模板:
|
||||
|
||||
```bash
|
||||
cp config.example.yaml config.yaml
|
||||
```
|
||||
|
||||
Important fields:
|
||||
主要配置:
|
||||
|
||||
```yaml
|
||||
server:
|
||||
@ -55,87 +95,318 @@ server:
|
||||
openviking:
|
||||
url: "http://127.0.0.1:1933"
|
||||
api_key: "your-secret-root-key"
|
||||
timeout: 30
|
||||
verify_ssl: true
|
||||
|
||||
everos:
|
||||
url: "http://127.0.0.1:1995"
|
||||
api_key: ""
|
||||
timeout: 180
|
||||
verify_ssl: true
|
||||
health_path: "/health"
|
||||
|
||||
storage:
|
||||
sqlite_path: "/home/tom/memory-gateway/memory_system_api.sqlite3"
|
||||
```
|
||||
|
||||
If `server.api_key` is set, clients must send `X-API-Key`.
|
||||
环境变量也可以覆盖部分配置:
|
||||
|
||||
## Start
|
||||
| 环境变量 | 覆盖字段 |
|
||||
|---|---|
|
||||
| `MEMORY_SYSTEM_SERVER_API_KEY` | `server.api_key` |
|
||||
| `MEMORY_SYSTEM_SERVER_HOST` | `server.host` |
|
||||
| `MEMORY_SYSTEM_SERVER_PORT` | `server.port` |
|
||||
| `OPENVIKING_URL` / `OPENVIKING_BASE_URL` | `openviking.url` |
|
||||
| `OPENVIKING_API_KEY` | `openviking.api_key` |
|
||||
| `EVEROS_URL` / `EVEROS_BASE_URL` | `everos.url` |
|
||||
| `EVEROS_API_KEY` | `everos.api_key` |
|
||||
| `MEMORY_SYSTEM_STORAGE_SQLITE_PATH` | `storage.sqlite_path` |
|
||||
|
||||
Start OpenViking and EverOS first, then run:
|
||||
## 启动
|
||||
|
||||
先启动 OpenViking 和 EverOS,再启动 Memory System API:
|
||||
|
||||
```bash
|
||||
python -m memory_system_api.server --config config.yaml --host 127.0.0.1 --port 1934
|
||||
```
|
||||
|
||||
## Real Test Flow
|
||||
|
||||
Health:
|
||||
如果 `server.api_key` 非空,所有请求还要加:
|
||||
|
||||
```bash
|
||||
curl -s http://127.0.0.1:1934/memory-system/health
|
||||
-H "X-API-Key: <memory-system-api-key>"
|
||||
```
|
||||
|
||||
Write user and assistant messages:
|
||||
## API 总览
|
||||
|
||||
Base URL:
|
||||
|
||||
```text
|
||||
http://127.0.0.1:1934/memory-system
|
||||
```
|
||||
|
||||
| 方法 | 路径 | 作用 | User key |
|
||||
|---|---|---|---|
|
||||
| `GET` | `/health` | 检查 OpenViking 和 EverOS 健康状态 | 不需要 |
|
||||
| `POST` | `/users` | 在固定 `admin` 工作区创建 OpenViking user,并保存 user key | 不需要 |
|
||||
| `POST` | `/messages` | 写入一轮或半轮会话消息 | 需要 |
|
||||
| `POST` | `/sessions/{session_id}/commit` | 提交会话,触发 OpenViking commit 和 EverOS flush | 需要 |
|
||||
| `POST` | `/sessions/{session_id}/extract` | 立即触发 OpenViking extract | 需要 |
|
||||
| `GET` | `/openviking/tasks/{task_id}` | 查询 OpenViking 后台任务状态 | 需要 |
|
||||
| `POST` | `/search` | 同时搜索 OpenViking 和 EverOS 记忆 | 需要 |
|
||||
| `GET` | `/users/{user_id}/profile` | 查询 EverOS profile | 需要 |
|
||||
|
||||
## API 详情
|
||||
|
||||
### `GET /health`
|
||||
|
||||
检查两个后端是否可访问。
|
||||
|
||||
```bash
|
||||
curl -s -X POST http://127.0.0.1:1934/memory-system/messages \
|
||||
curl -sS http://127.0.0.1:1934/memory-system/health
|
||||
```
|
||||
|
||||
返回字段:
|
||||
|
||||
| 字段 | 含义 |
|
||||
|---|---|
|
||||
| `status` | `success`、`partial_success` 或 `failed` |
|
||||
| `backends.openviking` | OpenViking 健康检查结果 |
|
||||
| `backends.everos` | EverOS 健康检查结果 |
|
||||
|
||||
### `POST /users`
|
||||
|
||||
创建新的 OpenViking user,并把返回的 `user_id / user_key` 保存到本地 SQLite。
|
||||
|
||||
请求体:
|
||||
|
||||
| 参数 | 类型 | 必需 | 说明 |
|
||||
|---|---|---:|---|
|
||||
| `user_id` | string | 是 | 要创建的用户 ID |
|
||||
|
||||
示例:
|
||||
|
||||
```bash
|
||||
curl -sS -X POST http://127.0.0.1:1934/memory-system/users \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"user_id": "userA"}'
|
||||
```
|
||||
|
||||
返回中的 `account.result.user_key` 是后续业务调用要传的 `user_key`:
|
||||
|
||||
```json
|
||||
{
|
||||
"status": "success",
|
||||
"account": {
|
||||
"status": "ok",
|
||||
"result": {
|
||||
"account_id": "admin",
|
||||
"user_id": "userA",
|
||||
"user_key": "..."
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### `POST /messages`
|
||||
|
||||
写入用户消息和/或助手消息。至少要传 `user_message` 或 `assistant_message` 其中一个。网关会先确保 OpenViking user/session 存在,再把消息写入 OpenViking session,同时写入 EverOS。
|
||||
|
||||
请求体:
|
||||
|
||||
| 参数 | 类型 | 必需 | 说明 |
|
||||
|---|---|---:|---|
|
||||
| `user_id` | string | 是 | 已创建的用户 ID |
|
||||
| `user_key` | string | 是 | `/users` 返回的 user key |
|
||||
| `session_id` | string | 是 | 会话 ID |
|
||||
| `user_message` | string/null | 否 | 用户消息 |
|
||||
| `assistant_message` | string/null | 否 | 助手消息 |
|
||||
| `timestamp` | int/null | 否 | 预留字段 |
|
||||
| `metadata` | object | 否 | 预留字段 |
|
||||
|
||||
示例:
|
||||
|
||||
```bash
|
||||
USER_KEY="..."
|
||||
|
||||
curl -sS -X POST http://127.0.0.1:1934/memory-system/messages \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"user_id": "real_user_001",
|
||||
"session_id": "real_sess_001",
|
||||
"user_message": "我喜欢喝拿铁,不喜欢美式。",
|
||||
"assistant_message": "好的,我会记住你的咖啡偏好。"
|
||||
"user_id": "userA",
|
||||
"user_key": "'"$USER_KEY"'",
|
||||
"session_id": "sessionA1",
|
||||
"user_message": "请记住:我喜欢拿铁。",
|
||||
"assistant_message": "好的,我记住了。"
|
||||
}'
|
||||
```
|
||||
|
||||
Commit OpenViking and flush EverOS:
|
||||
### `POST /sessions/{session_id}/commit`
|
||||
|
||||
提交会话。OpenViking 会归档 session 并异步抽取长期记忆,通常会返回 `task_id`;EverOS 会 flush 当前 session。
|
||||
|
||||
路径参数:
|
||||
|
||||
| 参数 | 类型 | 说明 |
|
||||
|---|---|---|
|
||||
| `session_id` | string | 要提交的会话 ID |
|
||||
|
||||
请求体:
|
||||
|
||||
| 参数 | 类型 | 必需 | 说明 |
|
||||
|---|---|---:|---|
|
||||
| `user_id` | string | 是 | 用户 ID |
|
||||
| `user_key` | string | 是 | `/users` 返回的 user key |
|
||||
|
||||
示例:
|
||||
|
||||
```bash
|
||||
curl -s -X POST http://127.0.0.1:1934/memory-system/sessions/real_sess_001/commit \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"user_id": "real_user_001"}'
|
||||
```
|
||||
|
||||
Search without LLM planning:
|
||||
|
||||
```bash
|
||||
curl -s -X POST http://127.0.0.1:1934/memory-system/search \
|
||||
curl -sS -X POST http://127.0.0.1:1934/memory-system/sessions/sessionA1/commit \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"user_id": "real_user_001",
|
||||
"session_id": "real_sess_001",
|
||||
"query": "我喜欢喝什么咖啡?",
|
||||
"user_id": "userA",
|
||||
"user_key": "'"$USER_KEY"'"
|
||||
}'
|
||||
```
|
||||
|
||||
如果返回里有 OpenViking `task_id`,需要继续查询任务直到完成。
|
||||
|
||||
### `POST /sessions/{session_id}/extract`
|
||||
|
||||
立即触发 OpenViking extract,只作用于 OpenViking,不触发 EverOS flush。适合调试或明确要求“现在就抽取”的场景。
|
||||
|
||||
参数同 commit:
|
||||
|
||||
```bash
|
||||
curl -sS -X POST http://127.0.0.1:1934/memory-system/sessions/sessionA1/extract \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"user_id": "userA",
|
||||
"user_key": "'"$USER_KEY"'"
|
||||
}'
|
||||
```
|
||||
|
||||
### `GET /openviking/tasks/{task_id}`
|
||||
|
||||
查询 OpenViking 后台任务状态,例如 commit 返回的任务。
|
||||
|
||||
Query 参数:
|
||||
|
||||
| 参数 | 类型 | 必需 | 说明 |
|
||||
|---|---|---:|---|
|
||||
| `user_id` | string | 是 | 用户 ID |
|
||||
| `user_key` | string | 是 | `/users` 返回的 user key |
|
||||
| `session_id` | string/null | 否 | 会话 ID;传入后会按同一 session identity 查询 |
|
||||
|
||||
示例:
|
||||
|
||||
```bash
|
||||
curl -sS -X POST "http://127.0.0.1:1934/memory-system/openviking/tasks/${TASK_ID}" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"user_id": "userA",
|
||||
"user_key": "'"$USER_KEY"'",
|
||||
"session_id": "sessionA1"
|
||||
}'
|
||||
```
|
||||
|
||||
### `POST /search`
|
||||
|
||||
同时查询 OpenViking 和 EverOS,并合并结果。
|
||||
|
||||
请求体:
|
||||
|
||||
| 参数 | 类型 | 必需 | 说明 |
|
||||
|---|---|---:|---|
|
||||
| `user_id` | string | 是 | 用户 ID |
|
||||
| `user_key` | string | 是 | `/users` 返回的 user key |
|
||||
| `session_id` | string/null | 否 | 会话 ID;`use_llm=true` 时会作为 OpenViking search 的 session context |
|
||||
| `query` | string | 是 | 查询文本 |
|
||||
| `use_llm` | bool | 否 | `false` 使用 OpenViking find + EverOS hybrid;`true` 使用 OpenViking search + EverOS agentic |
|
||||
| `limit` | int | 否 | 返回条数,默认 10,范围 1 到 100 |
|
||||
|
||||
示例:
|
||||
|
||||
```bash
|
||||
curl -sS -X POST http://127.0.0.1:1934/memory-system/search \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"user_id": "userA",
|
||||
"user_key": "'"$USER_KEY"'",
|
||||
"session_id": "sessionA1",
|
||||
"query": "我喜欢喝什么?",
|
||||
"use_llm": false,
|
||||
"limit": 10
|
||||
}'
|
||||
```
|
||||
|
||||
Search with LLM planning:
|
||||
返回字段:
|
||||
|
||||
| 字段 | 含义 |
|
||||
|---|---|
|
||||
| `status` | 总体状态 |
|
||||
| `items` | 合并后的记忆结果,含 `source_backend` |
|
||||
| `backends` | 两个后端的精简诊断信息,例如命中数量、query plan 或查询过滤条件;不再回传完整原始命中和 `original_data` |
|
||||
|
||||
### `GET /users/{user_id}/profile`
|
||||
|
||||
读取 EverOS profile。该接口需要 `user_key`,用于确认调用方属于该 user。
|
||||
|
||||
示例:
|
||||
|
||||
```bash
|
||||
curl -s -X POST http://127.0.0.1:1934/memory-system/search \
|
||||
curl -sS "http://127.0.0.1:1934/memory-system/users/userA/profile?user_key=$USER_KEY"
|
||||
```
|
||||
|
||||
## 完整测试流程
|
||||
|
||||
```bash
|
||||
API=http://127.0.0.1:1934/memory-system
|
||||
|
||||
curl -sS -X POST "$API/users" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"user_id":"userA"}'
|
||||
|
||||
USER_KEY="把上一步返回的 account.result.user_key 填到这里"
|
||||
|
||||
curl -sS -X POST "$API/messages" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"user_id": "real_user_001",
|
||||
"session_id": "real_sess_001",
|
||||
"query": "我的偏好是什么?",
|
||||
"use_llm": true,
|
||||
"user_id": "userA",
|
||||
"user_key": "'"$USER_KEY"'",
|
||||
"session_id": "sessionA1",
|
||||
"user_message": "请记住:我在 sessionA1 喜欢拿铁,项目代号 alpha-session。",
|
||||
"assistant_message": "好的。"
|
||||
}'
|
||||
|
||||
curl -sS -X POST "$API/sessions/sessionA1/commit" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"user_id":"userA","user_key":"'"$USER_KEY"'"}'
|
||||
|
||||
curl -sS -X POST "$API/search" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"user_id": "userA",
|
||||
"user_key": "'"$USER_KEY"'",
|
||||
"session_id": "sessionA1",
|
||||
"query": "我喜欢喝什么?",
|
||||
"limit": 10
|
||||
}'
|
||||
```
|
||||
|
||||
Read EverOS profile:
|
||||
OpenViking commit 返回 `task_id` 后,轮询到 `completed` 再搜索长期 memory:
|
||||
|
||||
```bash
|
||||
curl -s http://127.0.0.1:1934/memory-system/users/real_user_001/profile
|
||||
curl -sS "$API/openviking/tasks/<task_id>?user_id=userA&user_key=$USER_KEY&session_id=sessionA1"
|
||||
```
|
||||
|
||||
## Development Checks
|
||||
## 开发检查
|
||||
|
||||
```bash
|
||||
python -m pytest -q
|
||||
python -m compileall -q memory_system_api tests
|
||||
PYTHONPATH=/home/tom/memory-gateway pytest -q
|
||||
python -m compileall -q memory_system_api plugins eval tests
|
||||
```
|
||||
|
||||
## Hermes Plugin 状态
|
||||
|
||||
仓库里仍保留 Hermes memory provider:`plugins/memory/memory_system`。
|
||||
|
||||
注意:Memory System API 已切换为 `user_id + user_key` 鉴权,plugin 代码如需继续使用,需要同步传递这两个字段。
|
||||
|
||||
391
docs/openviking_memory_api_flow.md
Normal file
391
docs/openviking_memory_api_flow.md
Normal file
@ -0,0 +1,391 @@
|
||||
# OpenViking Memory API 流程说明
|
||||
|
||||
> 本文档整理 OpenViking 中「创建账号/用户 → 创建 session → 写入消息 → commit 抽取长期 memory → 查询 task 状态 → 检索 user memory/session memory」的完整 API 流程。
|
||||
>
|
||||
> 出于安全原因,示例中不写入真实 `root_api_key` 或 `user_key`,统一使用环境变量占位。
|
||||
|
||||
## 0. 前置变量
|
||||
|
||||
```bash
|
||||
# 在config.yaml里设置的 openviking url 和 api_key
|
||||
export OV_HOST="http://localhost:1933"
|
||||
export ROOT_KEY="your-secret-root-key"
|
||||
|
||||
# 创建用户后填入返回的 user_key
|
||||
export USER_A_KEY="<userA-user-key>"
|
||||
export USER_B_KEY="<userB-user-key>"
|
||||
```
|
||||
|
||||
OpenViking 的常见鉴权方式:
|
||||
|
||||
| 场景 | Header |
|
||||
|---|---|
|
||||
| Admin API,例如创建 account/user | `X-API-Key: $ROOT_KEY` |
|
||||
| 普通用户 API,例如 session/message/search | `Authorization: Bearer $USER_A_KEY` |
|
||||
|
||||
---
|
||||
|
||||
## 1. 创建 admin 工作区 / account
|
||||
|
||||
Admin API 用于多租户管理,包含 workspace/account 创建、用户注册、角色变更、key 生成等能力。创建 account 需要 root 权限。
|
||||
|
||||
```bash
|
||||
curl -X POST "$OV_HOST/api/v1/admin/accounts" \
|
||||
-H "X-API-Key: $ROOT_KEY" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"account_id": "admin",
|
||||
"admin_user_id": "admin"
|
||||
}'
|
||||
```
|
||||
|
||||
典型返回:
|
||||
|
||||
```json
|
||||
{
|
||||
"status": "ok",
|
||||
"result": {
|
||||
"account_id": "admin",
|
||||
"admin_user_id": "admin",
|
||||
"user_key": "<admin-user-key>"
|
||||
},
|
||||
"error": null,
|
||||
"telemetry": null
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 2. 创建用户
|
||||
|
||||
### 2.1 创建 userA
|
||||
|
||||
```bash
|
||||
curl -X POST "$OV_HOST/api/v1/admin/accounts/admin/users" \
|
||||
-H "X-API-Key: $ROOT_KEY" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"user_id": "userA",
|
||||
"role": "user"
|
||||
}'
|
||||
```
|
||||
|
||||
返回后保存:
|
||||
|
||||
```bash
|
||||
export USER_A_KEY="<userA-user-key>"
|
||||
```
|
||||
|
||||
### 2.2 创建 userB
|
||||
|
||||
```bash
|
||||
curl -X POST "$OV_HOST/api/v1/admin/accounts/admin/users" \
|
||||
-H "X-API-Key: $ROOT_KEY" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"user_id": "userB",
|
||||
"role": "user"
|
||||
}'
|
||||
```
|
||||
|
||||
返回后保存:
|
||||
|
||||
```bash
|
||||
export USER_B_KEY="<userB-user-key>"
|
||||
```
|
||||
|
||||
> 注意:不同用户必须使用各自的 `user_key`。用 userA 的 key 只能访问 userA 的用户空间,用 userB 的 key 只能访问 userB 的用户空间。
|
||||
|
||||
---
|
||||
|
||||
## 3. 创建 session
|
||||
|
||||
Session 是会话容器,用于保存消息、跟踪上下文使用、commit 后抽取长期 memories。
|
||||
|
||||
### 3.1 创建 userA 的 sessionA1
|
||||
|
||||
```bash
|
||||
curl -X POST "$OV_HOST/api/v1/sessions" \
|
||||
-H "Authorization: Bearer $USER_A_KEY" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"session_id": "sessionA1"
|
||||
}'
|
||||
```
|
||||
|
||||
典型返回:
|
||||
|
||||
```json
|
||||
{
|
||||
"status": "ok",
|
||||
"result": {
|
||||
"session_id": "sessionA1",
|
||||
"user": {
|
||||
"account_id": "admin",
|
||||
"user_id": "userA",
|
||||
"agent_id": "default"
|
||||
}
|
||||
},
|
||||
"error": null,
|
||||
"telemetry": null
|
||||
}
|
||||
```
|
||||
|
||||
### 3.2 创建 userB 的 sessionB1
|
||||
|
||||
```bash
|
||||
curl -X POST "$OV_HOST/api/v1/sessions" \
|
||||
-H "Authorization: Bearer $USER_B_KEY" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"session_id": "sessionB1"
|
||||
}'
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. 写入 session 消息
|
||||
|
||||
HTTP API 支持简单文本模式:`role + content`。`role` 通常为 `user` 或 `assistant`。
|
||||
|
||||
### 4.1 写入 userA / sessionA1 消息
|
||||
|
||||
```bash
|
||||
curl -X POST "$OV_HOST/api/v1/sessions/sessionA1/messages" \
|
||||
-H "Authorization: Bearer $USER_A_KEY" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"role": "user",
|
||||
"content": "我喜欢用 Python 写数据分析脚本。"
|
||||
}'
|
||||
```
|
||||
|
||||
```bash
|
||||
curl -X POST "$OV_HOST/api/v1/sessions/sessionA1/messages" \
|
||||
-H "Authorization: Bearer $USER_A_KEY" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"role": "assistant",
|
||||
"content": "好的,我会记住你偏好 Python 数据分析。"
|
||||
}'
|
||||
```
|
||||
|
||||
### 4.2 写入 userB / sessionB1 消息
|
||||
|
||||
```bash
|
||||
curl -X POST "$OV_HOST/api/v1/sessions/sessionB1/messages" \
|
||||
-H "Authorization: Bearer $USER_B_KEY" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"role": "user",
|
||||
"content": "我喜欢用 vibe coding 写项目。"
|
||||
}'
|
||||
```
|
||||
|
||||
```bash
|
||||
curl -X POST "$OV_HOST/api/v1/sessions/sessionB1/messages" \
|
||||
-H "Authorization: Bearer $USER_B_KEY" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"role": "assistant",
|
||||
"content": "好的,我会记住你偏好 vibe coding 项目。"
|
||||
}'
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. Commit session,触发长期 memory 抽取
|
||||
|
||||
`commit` 是两阶段流程:
|
||||
|
||||
1. **Phase 1,同步完成**:把当前 live messages 快照归档,创建 archive 目录,清空 live session。
|
||||
2. **Phase 2,异步完成**:生成 session 摘要,抽取长期 memories,更新关系和 active count。
|
||||
|
||||
因此 `commit` 请求会很快返回 `task_id`,后续要轮询 task 状态。
|
||||
|
||||
### 5.1 Commit userA / sessionA1
|
||||
|
||||
```bash
|
||||
curl -X POST "$OV_HOST/api/v1/sessions/sessionA1/commit" \
|
||||
-H "Authorization: Bearer $USER_A_KEY" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"keep_recent_count": 0
|
||||
}'
|
||||
```
|
||||
|
||||
典型返回:
|
||||
|
||||
```json
|
||||
{
|
||||
"status": "ok",
|
||||
"result": {
|
||||
"session_id": "sessionA1",
|
||||
"status": "accepted",
|
||||
"task_id": "fe6510e1-fdee-4f2d-9f87-5e48b519c2a2",
|
||||
"archive_uri": "viking://session/userA/sessionA1/history/archive_001",
|
||||
"archived": true
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 5.2 Commit userB / sessionB1
|
||||
|
||||
```bash
|
||||
curl -X POST "$OV_HOST/api/v1/sessions/sessionB1/commit" \
|
||||
-H "Authorization: Bearer $USER_B_KEY" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"keep_recent_count": 0
|
||||
}'
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. 查询 commit task 状态
|
||||
|
||||
```bash
|
||||
curl -s "$OV_HOST/api/v1/tasks/<task_id>" \
|
||||
-H "Authorization: Bearer $USER_A_KEY" | jq .
|
||||
```
|
||||
|
||||
成功完成后类似:
|
||||
|
||||
```json
|
||||
{
|
||||
"status": "ok",
|
||||
"result": {
|
||||
"task_id": "fe6510e1-fdee-4f2d-9f87-5e48b519c2a2",
|
||||
"task_type": "session_commit",
|
||||
"status": "completed",
|
||||
"resource_id": "sessionA1",
|
||||
"result": {
|
||||
"session_id": "sessionA1",
|
||||
"archive_uri": "viking://session/userA/sessionA1/history/archive_001",
|
||||
"memories_extracted": {
|
||||
"preferences": 1
|
||||
},
|
||||
"active_count_updated": 0
|
||||
},
|
||||
"error": null
|
||||
},
|
||||
"error": null,
|
||||
"telemetry": null
|
||||
}
|
||||
```
|
||||
|
||||
关键字段说明:
|
||||
|
||||
| 字段 | 含义 |
|
||||
|---|---|
|
||||
| `status: completed` | Phase 2 已完成,memory 抽取结束 |
|
||||
| `result.archive_uri` | 本次归档目录 URI |
|
||||
| `result.memories_extracted` | 本次 commit 提取到的 memory 分类计数,不是 memory 内容 |
|
||||
| `active_count_updated` | 本次基于 `sessions/{id}/used` 使用记录更新的活跃计数数量 |
|
||||
|
||||
如果返回:
|
||||
|
||||
```json
|
||||
"status": "running"
|
||||
```
|
||||
|
||||
说明后台任务还没完成。此时可以稍后继续查询同一个 task。
|
||||
|
||||
---
|
||||
|
||||
## 7. 用 `search/find` 向量搜索 user 长期 memory
|
||||
|
||||
`find` 是纯向量检索,不使用 session 上下文,也不做意图分析。适合直接按语义检索用户长期 memory。
|
||||
|
||||
使用显式用户路径:
|
||||
|
||||
```bash
|
||||
curl -s -X POST "$OV_HOST/api/v1/search/find" \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "Authorization: Bearer $USER_A_KEY" \
|
||||
-d '{
|
||||
"query": "我之前说了什么",
|
||||
"target_uri": "viking://user/userA/memories/",
|
||||
"limit": 3
|
||||
}' | jq .
|
||||
```
|
||||
|
||||
典型返回:
|
||||
|
||||
```json
|
||||
{
|
||||
"status": "ok",
|
||||
"result": {
|
||||
"memories": [
|
||||
{
|
||||
"context_type": "memory",
|
||||
"uri": "viking://user/userA/memories/preferences/mem_xxx.md",
|
||||
"level": 2,
|
||||
"score": 0.7411,
|
||||
"abstract": "Python 数据分析:偏好使用 Python 编写数据分析脚本"
|
||||
}
|
||||
],
|
||||
"resources": [],
|
||||
"skills": [],
|
||||
"total": 1
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 9. 用 `search/search` 做带 session 上下文的 LLM 搜索
|
||||
|
||||
`search` 是智能检索:在 `find` 的基础上增加 session context、意图分析和 query expansion。它适合「用户当前对话里有上下文,查询语义不完整」的场景。
|
||||
|
||||
```bash
|
||||
curl -s -X POST "$OV_HOST/api/v1/search/search" \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "Authorization: Bearer $USER_A_KEY" \
|
||||
-d '{
|
||||
"query": "我正在做什么",
|
||||
"target_uri": "viking://user/userA/sessionA1", #
|
||||
"session_id": "sessionA1",
|
||||
"limit": 10,
|
||||
}
|
||||
```
|
||||
|
||||
典型返回:
|
||||
```json
|
||||
{
|
||||
"status": "ok",
|
||||
"result": {
|
||||
"memories": [],
|
||||
"resources": [],
|
||||
"skills": [],
|
||||
"total": 0,
|
||||
"query_plan": {
|
||||
"reasoning": "1. Conversational task (user asking '我正在做什么' - 'What am I doing?'); 2. This is a simple conversational query about current state/activity; 3. The session context contains the user's previous interactions about Python preferences and EverOS API questions; 4. No specific context gaps need to be filled for this conversational task, but a memory query about the user's current activity context could help provide a more personalized response",
|
||||
"queries": [
|
||||
{
|
||||
"query": "User's current activity and task context",
|
||||
"context_type": "memory",
|
||||
"intent": "Understand what the user is currently working on to provide relevant context",
|
||||
"priority": 3
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 10. `find` 与 `search` 的选择
|
||||
|
||||
| 接口 | 是否使用 session 上下文 | 是否做意图分析 | 适合场景 |
|
||||
|---|---:|---:|---|
|
||||
| `/api/v1/search/find` | 否 | 否 | 直接按 query 做向量检索,稳定查 user memory |
|
||||
| `/api/v1/search/search` | 是,可传 `session_id` | 是 | 对话式检索,需要结合 session 语境、自动扩展 query |
|
||||
|
||||
推荐实践:
|
||||
|
||||
1. **验证 memory 是否已经写入**:先用 `tasks/{task_id}` 确认 `completed`。
|
||||
2. **确认 user 长期 memory 是否可召回**:用 `/api/v1/search/find`,query 写得贴近 memory 内容。
|
||||
3. **需要结合当前会话上下文**:再用 `/api/v1/search/search` 加 `session_id`。
|
||||
|
||||
@ -4,7 +4,7 @@ from __future__ import annotations
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||
|
||||
from .auth import verify_api_key
|
||||
from .schemas import MessageIngestRequest, SearchRequest, SessionUserRequest
|
||||
from .schemas import MessageIngestRequest, SearchRequest, SessionUserRequest, UserCreateRequest
|
||||
from .service import MemorySystemService
|
||||
|
||||
|
||||
@ -19,17 +19,31 @@ def get_service() -> MemorySystemService:
|
||||
return MemorySystemService()
|
||||
|
||||
|
||||
def user_auth_error(exc: PermissionError) -> HTTPException:
|
||||
return HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail=str(exc))
|
||||
|
||||
|
||||
@router.get("/health")
|
||||
async def health(service: MemorySystemService = Depends(get_service)):
|
||||
return await service.health()
|
||||
|
||||
|
||||
@router.post("/users")
|
||||
async def create_user(request: UserCreateRequest, service: MemorySystemService = Depends(get_service)):
|
||||
return await service.create_user(request.user_id)
|
||||
|
||||
|
||||
@router.post("/messages")
|
||||
async def ingest_messages(request: MessageIngestRequest, service: MemorySystemService = Depends(get_service)):
|
||||
async def ingest_messages(
|
||||
request: MessageIngestRequest,
|
||||
service: MemorySystemService = Depends(get_service),
|
||||
):
|
||||
try:
|
||||
return await service.ingest_messages(request)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=str(exc)) from exc
|
||||
except PermissionError as exc:
|
||||
raise user_auth_error(exc) from exc
|
||||
|
||||
|
||||
@router.post("/sessions/{session_id}/commit")
|
||||
@ -38,7 +52,10 @@ async def commit_session(
|
||||
request: SessionUserRequest,
|
||||
service: MemorySystemService = Depends(get_service),
|
||||
):
|
||||
return await service.commit_session(request.user_id, session_id)
|
||||
try:
|
||||
return await service.commit_session(request.user_id, request.user_key, session_id)
|
||||
except PermissionError as exc:
|
||||
raise user_auth_error(exc) from exc
|
||||
|
||||
|
||||
@router.post("/sessions/{session_id}/extract")
|
||||
@ -47,23 +64,50 @@ async def extract_session(
|
||||
request: SessionUserRequest,
|
||||
service: MemorySystemService = Depends(get_service),
|
||||
):
|
||||
return await service.extract_session(request.user_id, session_id)
|
||||
try:
|
||||
return await service.extract_session(request.user_id, request.user_key, session_id)
|
||||
except PermissionError as exc:
|
||||
raise user_auth_error(exc) from exc
|
||||
|
||||
|
||||
@router.get("/openviking/tasks/{task_id}")
|
||||
async def get_openviking_task(
|
||||
task_id: str,
|
||||
user_id: str = Query(min_length=1),
|
||||
user_key: str = Query(min_length=1),
|
||||
session_id: str | None = Query(default=None, min_length=1),
|
||||
service: MemorySystemService = Depends(get_service),
|
||||
):
|
||||
return await service.get_openviking_task(user_id, task_id)
|
||||
try:
|
||||
return await service.get_openviking_task(
|
||||
user_id,
|
||||
user_key,
|
||||
task_id,
|
||||
session_id=session_id,
|
||||
)
|
||||
except PermissionError as exc:
|
||||
raise user_auth_error(exc) from exc
|
||||
|
||||
|
||||
@router.post("/search")
|
||||
async def search(request: SearchRequest, service: MemorySystemService = Depends(get_service)):
|
||||
async def search(
|
||||
request: SearchRequest,
|
||||
service: MemorySystemService = Depends(get_service),
|
||||
):
|
||||
try:
|
||||
return await service.search(request)
|
||||
except PermissionError as exc:
|
||||
raise user_auth_error(exc) from exc
|
||||
|
||||
|
||||
@router.get("/users/{user_id}/profile")
|
||||
async def get_profile(user_id: str, service: MemorySystemService = Depends(get_service)):
|
||||
async def get_profile(
|
||||
user_id: str,
|
||||
user_key: str = Query(min_length=1),
|
||||
service: MemorySystemService = Depends(get_service),
|
||||
):
|
||||
try:
|
||||
service.openviking.credential_for_user(user_id, user_key)
|
||||
except PermissionError as exc:
|
||||
raise user_auth_error(exc) from exc
|
||||
return await service.get_profile(user_id)
|
||||
|
||||
@ -8,7 +8,7 @@ from typing import Any
|
||||
import httpx
|
||||
|
||||
from .config import get_config
|
||||
from .store import OpenVikingUserKeyStore
|
||||
from .store import ADMIN_ACCOUNT_ID, ADMIN_USER_ID, OpenVikingUserKeyStore
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
@ -16,6 +16,8 @@ class OpenVikingCredential:
|
||||
api_key: str
|
||||
account_id: str | None = None
|
||||
user_id: str | None = None
|
||||
agent_id: str | None = None
|
||||
user_key_auth: bool = False
|
||||
|
||||
|
||||
class OpenVikingMemorySystemClient:
|
||||
@ -33,36 +35,83 @@ class OpenVikingMemorySystemClient:
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
|
||||
async def ensure_user(self, user_id: str) -> OpenVikingCredential:
|
||||
existing = self.store.get_user_key(user_id)
|
||||
if existing:
|
||||
return OpenVikingCredential(api_key=existing)
|
||||
|
||||
async def create_account(self, account_id: str = ADMIN_ACCOUNT_ID, admin_user_id: str = ADMIN_USER_ID) -> dict[str, Any]:
|
||||
async with self._client(self.root_key) as client:
|
||||
response = await client.post(
|
||||
"/api/v1/admin/accounts",
|
||||
json={"account_id": user_id, "admin_user_id": user_id},
|
||||
json=self._create_account_payload(account_id, admin_user_id),
|
||||
)
|
||||
if response.status_code == 409:
|
||||
return self.root_credential(user_id)
|
||||
return response.json()
|
||||
response.raise_for_status()
|
||||
data = response.json()
|
||||
|
||||
user_key = self._extract_user_key(data)
|
||||
if not user_key:
|
||||
return self.root_credential(user_id)
|
||||
self.store.save_user_key(user_id, user_key)
|
||||
return OpenVikingCredential(api_key=user_key)
|
||||
if user_key:
|
||||
self.store.save_account_key(account_id, admin_user_id, user_key)
|
||||
self.store.save_user_key(admin_user_id, user_key)
|
||||
return data
|
||||
|
||||
def root_credential(self, user_id: str) -> OpenVikingCredential:
|
||||
return OpenVikingCredential(api_key=self.root_key, account_id=user_id, user_id=user_id)
|
||||
async def ensure_admin_workspace(self) -> None:
|
||||
if self.store.get_account_key(ADMIN_ACCOUNT_ID):
|
||||
return
|
||||
await self.create_account(ADMIN_ACCOUNT_ID, ADMIN_USER_ID)
|
||||
|
||||
async def create_user(self, user_id: str, role: str = "user") -> dict[str, Any]:
|
||||
existing = self.store.get_user_key(user_id)
|
||||
if existing:
|
||||
return {"status": "ok", "result": {"account_id": ADMIN_ACCOUNT_ID, "user_id": user_id, "user_key": existing}}
|
||||
|
||||
await self.ensure_admin_workspace()
|
||||
if user_id == ADMIN_USER_ID:
|
||||
user_key = self.store.get_user_key(user_id)
|
||||
return {"status": "ok", "result": {"account_id": ADMIN_ACCOUNT_ID, "user_id": user_id, "user_key": user_key}}
|
||||
|
||||
async with self._client(self.root_key) as client:
|
||||
response = await client.post(
|
||||
f"/api/v1/admin/accounts/{ADMIN_ACCOUNT_ID}/users",
|
||||
json={"user_id": user_id, "role": role},
|
||||
)
|
||||
response.raise_for_status()
|
||||
data = response.json()
|
||||
|
||||
user_key = self._extract_user_key(data)
|
||||
if user_key:
|
||||
self.store.save_user_key(user_id, user_key)
|
||||
return data
|
||||
|
||||
def credential_for_user(
|
||||
self,
|
||||
user_id: str,
|
||||
user_key: str,
|
||||
agent_id: str | None = None,
|
||||
) -> OpenVikingCredential:
|
||||
if not self.store.user_key_matches(user_id, user_key):
|
||||
raise PermissionError("Invalid user key")
|
||||
return self.user_credential(user_key, user_id, agent_id=agent_id)
|
||||
|
||||
def user_credential(
|
||||
self,
|
||||
user_key: str,
|
||||
user_id: str,
|
||||
agent_id: str | None = None,
|
||||
) -> OpenVikingCredential:
|
||||
return OpenVikingCredential(
|
||||
api_key=user_key,
|
||||
account_id=ADMIN_ACCOUNT_ID,
|
||||
user_id=user_id,
|
||||
agent_id=agent_id,
|
||||
user_key_auth=True,
|
||||
)
|
||||
|
||||
async def ensure_session(self, credential: OpenVikingCredential | str, session_id: str) -> dict[str, Any]:
|
||||
async with self._credential_client(credential) as client:
|
||||
response = await client.post("/api/v1/sessions", json={"session_id": session_id})
|
||||
if response.status_code in {409, 422}:
|
||||
self._save_session(credential, session_id)
|
||||
return {"session_id": session_id, "status": "exists"}
|
||||
response.raise_for_status()
|
||||
self._save_session(credential, session_id)
|
||||
return response.json()
|
||||
|
||||
async def append_message(
|
||||
@ -78,9 +127,14 @@ class OpenVikingMemorySystemClient:
|
||||
|
||||
async def commit_session(self, credential: OpenVikingCredential | str, session_id: str) -> dict[str, Any]:
|
||||
async with self._credential_client(credential) as client:
|
||||
response = await client.post(f"/api/v1/sessions/{session_id}/commit")
|
||||
response = await client.post(
|
||||
f"/api/v1/sessions/{session_id}/commit",
|
||||
json={"keep_recent_count": 0},
|
||||
)
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
data = response.json()
|
||||
self._save_commit_metadata(credential, session_id, data)
|
||||
return data
|
||||
|
||||
async def extract_session(self, credential: OpenVikingCredential | str, session_id: str) -> dict[str, Any]:
|
||||
async with self._credential_client(credential) as client:
|
||||
@ -94,15 +148,15 @@ class OpenVikingMemorySystemClient:
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
|
||||
async def find(
|
||||
self, credential: OpenVikingCredential | str, user_id: str, query: str, limit: int
|
||||
) -> dict[str, Any]:
|
||||
async def find(self, credential: OpenVikingCredential | str, query: str, limit: int) -> dict[str, Any]:
|
||||
user_id = credential.user_id if isinstance(credential, OpenVikingCredential) else None
|
||||
target_uri = f"viking://user/{user_id}/memories/" if user_id else "viking://user/memories/"
|
||||
async with self._credential_client(credential) as client:
|
||||
response = await client.post(
|
||||
"/api/v1/search/find",
|
||||
json={
|
||||
"query": query,
|
||||
"target_uri": f"viking://user/{user_id}/memories/",
|
||||
"target_uri": target_uri,
|
||||
"limit": limit,
|
||||
},
|
||||
)
|
||||
@ -115,6 +169,12 @@ class OpenVikingMemorySystemClient:
|
||||
payload: dict[str, Any] = {"query": query, "limit": limit}
|
||||
if session_id:
|
||||
payload["session_id"] = session_id
|
||||
if isinstance(credential, OpenVikingCredential) and credential.user_id:
|
||||
payload["target_uri"] = (
|
||||
f"viking://user/{credential.user_id}/{session_id}"
|
||||
if session_id
|
||||
else f"viking://user/{credential.user_id}/memories/"
|
||||
)
|
||||
async with self._credential_client(credential) as client:
|
||||
response = await client.post("/api/v1/search/search", json=payload)
|
||||
response.raise_for_status()
|
||||
@ -123,15 +183,23 @@ class OpenVikingMemorySystemClient:
|
||||
def _credential_client(self, credential: OpenVikingCredential | str) -> httpx.AsyncClient:
|
||||
if isinstance(credential, str):
|
||||
return self._client(credential)
|
||||
if credential.user_key_auth:
|
||||
return self._client(credential.api_key)
|
||||
headers = {}
|
||||
if credential.account_id:
|
||||
headers["X-OpenViking-Account"] = credential.account_id
|
||||
if credential.user_id:
|
||||
headers["X-OpenViking-User"] = credential.user_id
|
||||
if credential.agent_id:
|
||||
headers["X-OpenViking-Agent"] = credential.agent_id
|
||||
return self._client(credential.api_key, headers)
|
||||
|
||||
def _client(self, api_key: str, extra_headers: dict[str, str] | None = None) -> httpx.AsyncClient:
|
||||
headers = {"X-API-Key": api_key, "Content-Type": "application/json"}
|
||||
headers = {"Content-Type": "application/json"}
|
||||
if api_key == self.root_key:
|
||||
headers["X-API-Key"] = api_key
|
||||
else:
|
||||
headers["Authorization"] = f"Bearer {api_key}"
|
||||
if extra_headers:
|
||||
headers.update(extra_headers)
|
||||
return httpx.AsyncClient(
|
||||
@ -146,6 +214,38 @@ class OpenVikingMemorySystemClient:
|
||||
value = result.get("user_key") if isinstance(result, dict) else None
|
||||
return str(value) if value else None
|
||||
|
||||
def _create_account_payload(self, account_id: str, admin_user_id: str) -> dict[str, Any]:
|
||||
return {
|
||||
"account_id": account_id,
|
||||
"admin_user_id": admin_user_id,
|
||||
}
|
||||
|
||||
def _save_session(self, credential: OpenVikingCredential | str, session_id: str) -> None:
|
||||
if isinstance(credential, OpenVikingCredential) and credential.user_id:
|
||||
self.store.save_session(credential.user_id, session_id)
|
||||
|
||||
def _save_commit_metadata(
|
||||
self,
|
||||
credential: OpenVikingCredential | str,
|
||||
session_id: str,
|
||||
data: dict[str, Any],
|
||||
) -> None:
|
||||
if not isinstance(credential, OpenVikingCredential) or not credential.user_id:
|
||||
return
|
||||
result = data.get("result") if isinstance(data.get("result"), dict) else data
|
||||
if not isinstance(result, dict):
|
||||
return
|
||||
task_id = result.get("task_id")
|
||||
if not task_id:
|
||||
return
|
||||
archive_uri = result.get("archive_uri")
|
||||
self.store.save_task(
|
||||
user_id=credential.user_id,
|
||||
session_id=session_id,
|
||||
task_id=str(task_id),
|
||||
archive_uri=str(archive_uri) if archive_uri else None,
|
||||
)
|
||||
|
||||
|
||||
class EverOSMemorySystemClient:
|
||||
def __init__(self) -> None:
|
||||
|
||||
@ -11,6 +11,7 @@ OperationStatus = Literal["success", "partial_success", "failed"]
|
||||
|
||||
class MessageIngestRequest(BaseModel):
|
||||
user_id: str = Field(min_length=1)
|
||||
user_key: str = Field(min_length=1)
|
||||
session_id: str = Field(min_length=1)
|
||||
user_message: str | None = None
|
||||
assistant_message: str | None = None
|
||||
@ -20,10 +21,12 @@ class MessageIngestRequest(BaseModel):
|
||||
|
||||
class SessionUserRequest(BaseModel):
|
||||
user_id: str = Field(min_length=1)
|
||||
user_key: str = Field(min_length=1)
|
||||
|
||||
|
||||
class SearchRequest(BaseModel):
|
||||
user_id: str = Field(min_length=1)
|
||||
user_key: str = Field(min_length=1)
|
||||
session_id: str | None = None
|
||||
query: str = Field(min_length=1)
|
||||
use_llm: bool = False
|
||||
@ -36,6 +39,16 @@ class BackendStatus(BaseModel):
|
||||
error: str | None = None
|
||||
|
||||
|
||||
class UserCreateRequest(BaseModel):
|
||||
user_id: str = Field(min_length=1)
|
||||
|
||||
|
||||
class AccountResponse(BaseModel):
|
||||
status: OperationStatus
|
||||
account: Any = None
|
||||
backends: dict[str, BackendStatus]
|
||||
|
||||
|
||||
class MessageIngestResponse(BaseModel):
|
||||
status: OperationStatus
|
||||
message_count: int
|
||||
|
||||
@ -6,6 +6,7 @@ from typing import Any, Awaitable, Callable
|
||||
|
||||
from .clients import EverOSMemorySystemClient, OpenVikingMemorySystemClient
|
||||
from .schemas import (
|
||||
AccountResponse,
|
||||
BackendStatus,
|
||||
CommitResponse,
|
||||
ExtractResponse,
|
||||
@ -22,12 +23,21 @@ class MemorySystemService:
|
||||
self.openviking = openviking or OpenVikingMemorySystemClient()
|
||||
self.everos = everos or EverOSMemorySystemClient()
|
||||
|
||||
async def create_user(self, user_id: str) -> AccountResponse:
|
||||
backends = {"openviking": await self._capture(lambda: self.openviking.create_user(user_id))}
|
||||
account = backends["openviking"].result if backends["openviking"].status == "success" else None
|
||||
return AccountResponse(status=self._aggregate_status(backends), account=account, backends=backends)
|
||||
|
||||
async def ingest_messages(self, request: MessageIngestRequest) -> MessageIngestResponse:
|
||||
messages = self._messages_from_request(request)
|
||||
if not messages:
|
||||
raise ValueError("at least one message is required")
|
||||
|
||||
credential = await self.openviking.ensure_user(request.user_id)
|
||||
credential = self.openviking.credential_for_user(
|
||||
request.user_id,
|
||||
request.user_key,
|
||||
agent_id=request.session_id,
|
||||
)
|
||||
await self.openviking.ensure_session(credential, request.session_id)
|
||||
|
||||
async def write_openviking() -> list[dict[str, Any]]:
|
||||
@ -53,11 +63,11 @@ class MemorySystemService:
|
||||
backends=backends,
|
||||
)
|
||||
|
||||
async def commit_session(self, user_id: str, session_id: str) -> CommitResponse:
|
||||
user_key = await self.openviking.ensure_user(user_id)
|
||||
async def commit_session(self, user_id: str, user_key: str, session_id: str) -> CommitResponse:
|
||||
credential = self.openviking.credential_for_user(user_id, user_key, agent_id=session_id)
|
||||
|
||||
async def commit_openviking() -> dict[str, Any]:
|
||||
return await self.openviking.commit_session(user_key, session_id)
|
||||
return await self.openviking.commit_session(credential, session_id)
|
||||
|
||||
async def flush_everos() -> dict[str, Any]:
|
||||
return await self.everos.flush(user_id, session_id)
|
||||
@ -65,25 +75,35 @@ class MemorySystemService:
|
||||
backends = await self._run_backends(openviking=commit_openviking, everos=flush_everos)
|
||||
return CommitResponse(status=self._aggregate_status(backends), backends=backends)
|
||||
|
||||
async def extract_session(self, user_id: str, session_id: str) -> ExtractResponse:
|
||||
user_key = await self.openviking.ensure_user(user_id)
|
||||
async def extract_session(self, user_id: str, user_key: str, session_id: str) -> ExtractResponse:
|
||||
credential = self.openviking.credential_for_user(user_id, user_key, agent_id=session_id)
|
||||
backends = {
|
||||
"openviking": await self._capture(lambda: self.openviking.extract_session(user_key, session_id)),
|
||||
"openviking": await self._capture(lambda: self.openviking.extract_session(credential, session_id)),
|
||||
}
|
||||
return ExtractResponse(status=self._aggregate_status(backends), backends=backends)
|
||||
|
||||
async def get_openviking_task(self, user_id: str, task_id: str) -> dict[str, Any]:
|
||||
user_key = await self.openviking.ensure_user(user_id)
|
||||
return await self.openviking.get_task(user_key, task_id)
|
||||
async def get_openviking_task(
|
||||
self,
|
||||
user_id: str,
|
||||
user_key: str,
|
||||
task_id: str,
|
||||
session_id: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
credential = self.openviking.credential_for_user(user_id, user_key, agent_id=session_id)
|
||||
return await self.openviking.get_task(credential, task_id)
|
||||
|
||||
async def search(self, request: SearchRequest) -> SearchResponse:
|
||||
user_key = await self.openviking.ensure_user(request.user_id)
|
||||
credential = self.openviking.credential_for_user(
|
||||
request.user_id,
|
||||
request.user_key,
|
||||
agent_id=request.session_id,
|
||||
)
|
||||
everos_method = "agentic" if request.use_llm else "hybrid"
|
||||
|
||||
async def search_openviking() -> dict[str, Any]:
|
||||
if request.use_llm:
|
||||
return await self.openviking.search(user_key, request.session_id, request.query, request.limit)
|
||||
return await self.openviking.find(user_key, request.user_id, request.query, request.limit)
|
||||
return await self.openviking.search(credential, request.session_id, request.query, request.limit)
|
||||
return await self.openviking.find(credential, request.query, request.limit)
|
||||
|
||||
async def search_everos() -> dict[str, Any]:
|
||||
return await self.everos.search(
|
||||
@ -97,7 +117,12 @@ class MemorySystemService:
|
||||
backends = await self._run_backends(openviking=search_openviking, everos=search_everos)
|
||||
backends = self._remove_vectors_from_backends(backends)
|
||||
items = self._merge_search_items(backends)
|
||||
return SearchResponse(status=self._aggregate_status(backends), items=items[: request.limit], backends=backends)
|
||||
compact_backends = self._compact_search_backends(backends)
|
||||
return SearchResponse(
|
||||
status=self._aggregate_status(backends),
|
||||
items=items[: request.limit],
|
||||
backends=compact_backends,
|
||||
)
|
||||
|
||||
async def get_profile(self, user_id: str) -> ProfileResponse:
|
||||
backends = {"everos": await self._capture(lambda: self.everos.get_profile(user_id))}
|
||||
@ -157,7 +182,11 @@ class MemorySystemService:
|
||||
for key in ("memories", "resources", "episodes", "profiles", "raw_messages"):
|
||||
values = data.get(key)
|
||||
if isinstance(values, list):
|
||||
raw_items.extend(item for item in values if isinstance(item, dict))
|
||||
raw_items.extend(
|
||||
self._compact_search_item(backend_name, key, item)
|
||||
for item in values
|
||||
if isinstance(item, dict)
|
||||
)
|
||||
return [self._with_backend(backend_name, item) for item in raw_items]
|
||||
|
||||
def _with_backend(self, backend_name: str, item: dict[str, Any]) -> dict[str, Any]:
|
||||
@ -165,6 +194,72 @@ class MemorySystemService:
|
||||
return item
|
||||
return {"source_backend": backend_name, **item}
|
||||
|
||||
def _compact_search_item(self, backend_name: str, collection: str, item: dict[str, Any]) -> dict[str, Any]:
|
||||
if backend_name == "everos":
|
||||
fields = (
|
||||
"id",
|
||||
"user_id",
|
||||
"session_id",
|
||||
"timestamp",
|
||||
"summary",
|
||||
"score",
|
||||
)
|
||||
compact = {"memory_type": self._singular_memory_type(collection)}
|
||||
compact.update({field: item[field] for field in fields if field in item and item[field] is not None})
|
||||
return compact
|
||||
return item
|
||||
|
||||
def _singular_memory_type(self, collection: str) -> str:
|
||||
names = {
|
||||
"memories": "memory",
|
||||
"resources": "resource",
|
||||
"episodes": "episode",
|
||||
"profiles": "profile",
|
||||
"raw_messages": "raw_message",
|
||||
}
|
||||
return names.get(collection, collection)
|
||||
|
||||
def _compact_search_backends(self, backends: dict[str, BackendStatus]) -> dict[str, BackendStatus]:
|
||||
return {
|
||||
name: backend.model_copy(update={"result": self._compact_backend_result(name, backend.result)})
|
||||
for name, backend in backends.items()
|
||||
}
|
||||
|
||||
def _compact_backend_result(self, backend_name: str, result: Any) -> Any:
|
||||
if backend_name == "everos":
|
||||
data = result.get("data") if isinstance(result, dict) and isinstance(result.get("data"), dict) else result
|
||||
if not isinstance(data, dict):
|
||||
return result
|
||||
compact: dict[str, Any] = {
|
||||
"counts": {
|
||||
key: len(data.get(key) or [])
|
||||
for key in ("episodes", "profiles", "raw_messages")
|
||||
if isinstance(data.get(key), list)
|
||||
}
|
||||
}
|
||||
if "query" in data:
|
||||
compact["query"] = data["query"]
|
||||
return compact
|
||||
|
||||
if backend_name == "openviking":
|
||||
data = result.get("result") if isinstance(result, dict) and isinstance(result.get("result"), dict) else result
|
||||
if not isinstance(data, dict):
|
||||
return result
|
||||
compact = {
|
||||
"status": result.get("status") if isinstance(result, dict) else None,
|
||||
"total": data.get("total"),
|
||||
"counts": {
|
||||
key: len(data.get(key) or [])
|
||||
for key in ("memories", "resources", "skills")
|
||||
if isinstance(data.get(key), list)
|
||||
},
|
||||
}
|
||||
if "query_plan" in data:
|
||||
compact["query_plan"] = data["query_plan"]
|
||||
return {key: value for key, value in compact.items() if value is not None}
|
||||
|
||||
return result
|
||||
|
||||
def _remove_vectors_from_backends(self, backends: dict[str, BackendStatus]) -> dict[str, BackendStatus]:
|
||||
return {
|
||||
name: backend.model_copy(update={"result": self._remove_vectors(backend.result)})
|
||||
|
||||
@ -2,21 +2,67 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import sqlite3
|
||||
import hmac
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
|
||||
ADMIN_ACCOUNT_ID = "admin"
|
||||
ADMIN_USER_ID = "admin"
|
||||
|
||||
|
||||
class OpenVikingUserKeyStore:
|
||||
def __init__(self, sqlite_path: str) -> None:
|
||||
self.sqlite_path = sqlite_path
|
||||
self._ensure_table()
|
||||
|
||||
def get_account_key(self, account_id: str) -> str | None:
|
||||
with self._connect() as conn:
|
||||
row = conn.execute(
|
||||
"SELECT account_key FROM memory_system_openviking_accounts WHERE account_id = ?",
|
||||
(account_id,),
|
||||
).fetchone()
|
||||
if row is None:
|
||||
row = conn.execute(
|
||||
"""
|
||||
SELECT user_key FROM memory_system_openviking_users
|
||||
WHERE account_id = ?
|
||||
ORDER BY created_at ASC
|
||||
LIMIT 1
|
||||
""",
|
||||
(account_id,),
|
||||
).fetchone()
|
||||
return str(row[0]) if row else None
|
||||
|
||||
def account_key_matches(self, account_id: str, account_key: str) -> bool:
|
||||
expected = self.get_account_key(account_id)
|
||||
return bool(expected and hmac.compare_digest(expected, account_key))
|
||||
|
||||
def save_account_key(self, account_id: str, admin_user_id: str, account_key: str) -> None:
|
||||
now = datetime.now(timezone.utc).isoformat()
|
||||
with self._connect() as conn:
|
||||
conn.execute(
|
||||
"""
|
||||
INSERT INTO memory_system_openviking_accounts (account_id, admin_user_id, account_key, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
ON CONFLICT(account_id) DO UPDATE SET
|
||||
admin_user_id = excluded.admin_user_id,
|
||||
account_key = excluded.account_key,
|
||||
updated_at = excluded.updated_at
|
||||
""",
|
||||
(account_id, admin_user_id, account_key, now, now),
|
||||
)
|
||||
|
||||
def get_user_key(self, user_id: str) -> str | None:
|
||||
with self._connect() as conn:
|
||||
row = conn.execute(
|
||||
"SELECT user_key FROM memory_system_openviking_users WHERE user_id = ?",
|
||||
(user_id,),
|
||||
).fetchone()
|
||||
if row is None:
|
||||
row = conn.execute(
|
||||
"SELECT user_key FROM memory_system_openviking_users WHERE user_id = ?",
|
||||
(self._legacy_store_key(ADMIN_ACCOUNT_ID, user_id),),
|
||||
).fetchone()
|
||||
return str(row[0]) if row else None
|
||||
|
||||
def save_user_key(self, user_id: str, user_key: str) -> None:
|
||||
@ -30,13 +76,109 @@ class OpenVikingUserKeyStore:
|
||||
user_key = excluded.user_key,
|
||||
updated_at = excluded.updated_at
|
||||
""",
|
||||
(user_id, user_id, user_key, now, now),
|
||||
(user_id, ADMIN_ACCOUNT_ID, user_key, now, now),
|
||||
)
|
||||
|
||||
def user_key_matches(self, user_id: str, user_key: str) -> bool:
|
||||
expected = self.get_user_key(user_id)
|
||||
return bool(expected and hmac.compare_digest(expected, user_key))
|
||||
|
||||
def save_session(self, user_id: str, session_id: str) -> None:
|
||||
now = datetime.now(timezone.utc).isoformat()
|
||||
with self._connect() as conn:
|
||||
conn.execute(
|
||||
"""
|
||||
INSERT INTO memory_system_openviking_sessions
|
||||
(user_id, session_id, latest_task_id, latest_archive_uri, created_at, updated_at)
|
||||
VALUES (?, ?, NULL, NULL, ?, ?)
|
||||
ON CONFLICT(user_id, session_id) DO UPDATE SET
|
||||
updated_at = excluded.updated_at
|
||||
""",
|
||||
(user_id, session_id, now, now),
|
||||
)
|
||||
|
||||
def get_session(self, user_id: str, session_id: str) -> dict[str, str | None] | None:
|
||||
with self._connect() as conn:
|
||||
row = conn.execute(
|
||||
"""
|
||||
SELECT user_id, session_id, latest_task_id, latest_archive_uri
|
||||
FROM memory_system_openviking_sessions
|
||||
WHERE user_id = ? AND session_id = ?
|
||||
""",
|
||||
(user_id, session_id),
|
||||
).fetchone()
|
||||
if row is None:
|
||||
return None
|
||||
return {
|
||||
"user_id": str(row[0]),
|
||||
"session_id": str(row[1]),
|
||||
"latest_task_id": str(row[2]) if row[2] is not None else None,
|
||||
"latest_archive_uri": str(row[3]) if row[3] is not None else None,
|
||||
}
|
||||
|
||||
def save_task(self, user_id: str, session_id: str, task_id: str, archive_uri: str | None) -> None:
|
||||
now = datetime.now(timezone.utc).isoformat()
|
||||
with self._connect() as conn:
|
||||
conn.execute(
|
||||
"""
|
||||
INSERT INTO memory_system_openviking_tasks
|
||||
(task_id, user_id, session_id, archive_uri, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(task_id) DO UPDATE SET
|
||||
user_id = excluded.user_id,
|
||||
session_id = excluded.session_id,
|
||||
archive_uri = excluded.archive_uri,
|
||||
updated_at = excluded.updated_at
|
||||
""",
|
||||
(task_id, user_id, session_id, archive_uri, now, now),
|
||||
)
|
||||
conn.execute(
|
||||
"""
|
||||
INSERT INTO memory_system_openviking_sessions
|
||||
(user_id, session_id, latest_task_id, latest_archive_uri, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(user_id, session_id) DO UPDATE SET
|
||||
latest_task_id = excluded.latest_task_id,
|
||||
latest_archive_uri = excluded.latest_archive_uri,
|
||||
updated_at = excluded.updated_at
|
||||
""",
|
||||
(user_id, session_id, task_id, archive_uri, now, now),
|
||||
)
|
||||
|
||||
def get_task(self, task_id: str) -> dict[str, str | None] | None:
|
||||
with self._connect() as conn:
|
||||
row = conn.execute(
|
||||
"""
|
||||
SELECT task_id, user_id, session_id, archive_uri
|
||||
FROM memory_system_openviking_tasks
|
||||
WHERE task_id = ?
|
||||
""",
|
||||
(task_id,),
|
||||
).fetchone()
|
||||
if row is None:
|
||||
return None
|
||||
return {
|
||||
"task_id": str(row[0]),
|
||||
"user_id": str(row[1]),
|
||||
"session_id": str(row[2]),
|
||||
"archive_uri": str(row[3]) if row[3] is not None else None,
|
||||
}
|
||||
|
||||
def _ensure_table(self) -> None:
|
||||
path = Path(self.sqlite_path)
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
with self._connect() as conn:
|
||||
conn.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS memory_system_openviking_accounts (
|
||||
account_id TEXT PRIMARY KEY,
|
||||
admin_user_id TEXT NOT NULL,
|
||||
account_key TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
)
|
||||
"""
|
||||
)
|
||||
conn.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS memory_system_openviking_users (
|
||||
@ -48,6 +190,34 @@ class OpenVikingUserKeyStore:
|
||||
)
|
||||
"""
|
||||
)
|
||||
conn.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS memory_system_openviking_sessions (
|
||||
user_id TEXT NOT NULL,
|
||||
session_id TEXT NOT NULL,
|
||||
latest_task_id TEXT,
|
||||
latest_archive_uri TEXT,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL,
|
||||
PRIMARY KEY (user_id, session_id)
|
||||
)
|
||||
"""
|
||||
)
|
||||
conn.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS memory_system_openviking_tasks (
|
||||
task_id TEXT PRIMARY KEY,
|
||||
user_id TEXT NOT NULL,
|
||||
session_id TEXT NOT NULL,
|
||||
archive_uri TEXT,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
)
|
||||
"""
|
||||
)
|
||||
|
||||
def _connect(self) -> sqlite3.Connection:
|
||||
return sqlite3.connect(self.sqlite_path)
|
||||
|
||||
def _legacy_store_key(self, account_id: str, user_id: str) -> str:
|
||||
return f"{account_id}:{user_id}"
|
||||
|
||||
@ -105,9 +105,14 @@ def _parse_env_file(path: Path) -> Dict[str, str]:
|
||||
|
||||
def _memory_system_config(hermes_home: str = "") -> Dict[str, str]:
|
||||
candidates: List[Path] = []
|
||||
explicit_file = os.environ.get("MEMORY_SYSTEM_ENV_FILE", "")
|
||||
if explicit_file:
|
||||
candidates.append(Path(explicit_file).expanduser())
|
||||
candidates.extend(
|
||||
[
|
||||
Path.cwd() / ".env",
|
||||
Path.cwd() / "memory_system.env",
|
||||
Path.home() / ".hermes" / ".env",
|
||||
Path.home() / ".hermes" / "memory_system.env",
|
||||
]
|
||||
)
|
||||
if hermes_home:
|
||||
candidates.append(Path(hermes_home).expanduser() / ".env")
|
||||
candidates.append(Path(hermes_home).expanduser() / "memory_system.env")
|
||||
@ -115,14 +120,9 @@ def _memory_system_config(hermes_home: str = "") -> Dict[str, str]:
|
||||
if env_hermes_home:
|
||||
candidates.append(Path(env_hermes_home).expanduser() / ".env")
|
||||
candidates.append(Path(env_hermes_home).expanduser() / "memory_system.env")
|
||||
candidates.extend(
|
||||
[
|
||||
Path.home() / ".hermes" / ".env",
|
||||
Path.home() / ".hermes" / "memory_system.env",
|
||||
Path.cwd() / "memory_system.env",
|
||||
Path.cwd() / ".env",
|
||||
]
|
||||
)
|
||||
explicit_file = os.environ.get("MEMORY_SYSTEM_ENV_FILE", "")
|
||||
if explicit_file:
|
||||
candidates.append(Path(explicit_file).expanduser())
|
||||
|
||||
config: Dict[str, str] = {}
|
||||
seen: set[Path] = set()
|
||||
@ -478,14 +478,10 @@ class MemorySystemMemoryProvider(MemoryProvider):
|
||||
def _format_items(self, items: List[Dict[str, Any]], *, limit: int) -> str:
|
||||
parts = []
|
||||
for item in items[:limit]:
|
||||
text = (
|
||||
item.get("content")
|
||||
or item.get("text")
|
||||
or item.get("memory")
|
||||
or item.get("summary")
|
||||
or json.dumps(item, ensure_ascii=False)
|
||||
)
|
||||
source = item.get("source") or item.get("backend") or "memory"
|
||||
text = self._memory_text(item)
|
||||
if not text:
|
||||
continue
|
||||
source = item.get("source_backend") or item.get("source") or item.get("backend") or "memory"
|
||||
score = item.get("score")
|
||||
prefix = f"[{source}]"
|
||||
if isinstance(score, (int, float)):
|
||||
@ -493,6 +489,56 @@ class MemorySystemMemoryProvider(MemoryProvider):
|
||||
parts.append(f"- {prefix} {text}")
|
||||
return "\n".join(parts)
|
||||
|
||||
def _memory_text(self, item: Dict[str, Any]) -> str:
|
||||
for key in (
|
||||
"memory",
|
||||
"content",
|
||||
"text",
|
||||
"summary",
|
||||
"abstract",
|
||||
"fact",
|
||||
"value",
|
||||
):
|
||||
value = item.get(key)
|
||||
if isinstance(value, str) and value.strip():
|
||||
return value.strip()
|
||||
for key in ("memory", "content", "text", "summary"):
|
||||
value = item.get("data", {}).get(key) if isinstance(item.get("data"), dict) else None
|
||||
if isinstance(value, str) and value.strip():
|
||||
return value.strip()
|
||||
return ""
|
||||
|
||||
def _compact_search_response(self, response: Dict[str, Any], *, limit: int) -> Dict[str, Any]:
|
||||
compact_items = []
|
||||
for item in response.get("items", [])[:limit]:
|
||||
if not isinstance(item, dict):
|
||||
continue
|
||||
text = self._memory_text(item)
|
||||
if not text:
|
||||
continue
|
||||
compact: Dict[str, Any] = {
|
||||
"source_backend": item.get("source_backend") or item.get("source") or item.get("backend") or "memory",
|
||||
"text": text[:1200],
|
||||
}
|
||||
memory_type = item.get("memory_type") or item.get("type") or item.get("category")
|
||||
if memory_type:
|
||||
compact["memory_type"] = memory_type
|
||||
score = item.get("score")
|
||||
if isinstance(score, (int, float)):
|
||||
compact["score"] = score
|
||||
uri = item.get("uri")
|
||||
if isinstance(uri, str) and uri.startswith("viking://"):
|
||||
compact["uri"] = uri
|
||||
compact_items.append(compact)
|
||||
|
||||
result: Dict[str, Any] = {
|
||||
"status": response.get("status", "success"),
|
||||
"items": compact_items,
|
||||
}
|
||||
if not compact_items:
|
||||
result["message"] = "No memory items found."
|
||||
return result
|
||||
|
||||
def _should_commit_now(self) -> bool:
|
||||
if self._last_commit_turn >= self._turn_count:
|
||||
return False
|
||||
@ -527,7 +573,8 @@ class MemorySystemMemoryProvider(MemoryProvider):
|
||||
"use_llm": bool(args.get("use_llm", self._default_use_llm)),
|
||||
"limit": limit,
|
||||
}
|
||||
return json.dumps(self._client.post("/memory-system/search", payload), ensure_ascii=False)
|
||||
response = self._client.post("/memory-system/search", payload)
|
||||
return json.dumps(self._compact_search_response(response, limit=limit), ensure_ascii=False)
|
||||
|
||||
def _tool_profile(self, args: Dict[str, Any]) -> str:
|
||||
user_id = str(args.get("user_id") or self._user_id).strip()
|
||||
|
||||
@ -1,118 +1,156 @@
|
||||
---
|
||||
name: memory-system-api
|
||||
description: "Use when an AI agent needs to work with this repository's lightweight Memory System API for OpenViking session memory and EverOS user profiles: starting the API, checking health, writing user/assistant conversation messages, committing or immediately extracting memory, searching memory with hybrid/agentic modes, reading user profiles, or debugging backend partial failures."
|
||||
description: "Use when an AI agent needs to operate this repository's Memory System API, including creating users, writing session messages, committing or extracting memory, searching memories, reading profiles, or debugging OpenViking/EverOS backend results."
|
||||
---
|
||||
|
||||
# Memory System API
|
||||
|
||||
Use this skill to operate the lightweight Memory System API from an AI agent. The API hides two backends:
|
||||
This skill is for using the Memory Gateway service in this repo. Prefer it over direct OpenViking or EverOS calls unless the user explicitly asks to debug a backend.
|
||||
|
||||
- OpenViking stores session conversation memory.
|
||||
- EverOS stores user profile and episodic memory.
|
||||
## Current Contract
|
||||
|
||||
Prefer this API over direct backend calls unless the user explicitly asks to debug OpenViking or EverOS directly.
|
||||
The API is user-gated:
|
||||
|
||||
Implementation notes:
|
||||
- Create a user first with `POST /memory-system/users`.
|
||||
- Save the returned `account.result.user_key`.
|
||||
- Send that value back as `user_key` on business API calls.
|
||||
- Do not send `account_id` on business APIs.
|
||||
|
||||
- The caller never needs to manage OpenViking user keys. The API stores returned user keys locally when OpenViking provides them; if OpenViking reports that an account already exists or does not return a key, the API uses the configured root key with `X-OpenViking-Account` and `X-OpenViking-User` identity headers.
|
||||
- Search responses are sanitized before returning to callers. Large embedding arrays under JSON fields named `vector` are removed from both merged `items` and backend debug payloads. Metadata such as `vector_model` may still appear.
|
||||
Do not assume business APIs will auto-create users. Non-user-creation endpoints fail when the user was not created first or when the supplied `user_key` does not match the stored key.
|
||||
|
||||
## Before Calling
|
||||
`X-API-Key` is separate. It protects the Memory System API itself only when `server.api_key` is configured.
|
||||
|
||||
1. Confirm the service is running or start it from the repository root:
|
||||
## Identity Model
|
||||
|
||||
- `user_id`: end user.
|
||||
- `user_key`: OpenViking key returned when the user is created.
|
||||
- `session_id`: conversation ID and OpenViking session ID.
|
||||
|
||||
Internally the gateway always uses the fixed OpenViking admin workspace:
|
||||
|
||||
```json
|
||||
{"account_id": "admin", "admin_user_id": "admin"}
|
||||
```
|
||||
|
||||
On the first user creation, if the admin workspace is not stored in SQLite yet, the gateway creates it first and then creates the requested user. After that, later users go straight to `/api/v1/admin/accounts/admin/users`.
|
||||
|
||||
The SQLite store is the source of truth for:
|
||||
|
||||
- `user_id -> user_key`
|
||||
- `user_id + session_id`
|
||||
- `task_id`
|
||||
- `archive_uri`
|
||||
|
||||
Session memory is retrieved under OpenViking using the explicit user/session URI paths:
|
||||
|
||||
```text
|
||||
viking://user/<user_id>/memories/
|
||||
viking://user/<user_id>/<session_id>
|
||||
```
|
||||
|
||||
## Endpoints
|
||||
|
||||
Base path: `/memory-system`
|
||||
|
||||
| Method | Path | Purpose | Requires `user_key` |
|
||||
|---|---|---|---|
|
||||
| `GET` | `/health` | Check OpenViking and EverOS health | No |
|
||||
| `POST` | `/users` | Create OpenViking user and store user key | No |
|
||||
| `POST` | `/messages` | Write user/assistant messages to backends | Yes |
|
||||
| `POST` | `/sessions/{session_id}/commit` | Commit OpenViking session and flush EverOS | Yes |
|
||||
| `POST` | `/sessions/{session_id}/extract` | Trigger OpenViking extract only | Yes |
|
||||
| `GET` | `/openviking/tasks/{task_id}` | Poll OpenViking task status | Yes |
|
||||
| `POST` | `/search` | Search OpenViking and EverOS | Yes |
|
||||
| `GET` | `/users/{user_id}/profile` | Read EverOS profile | Yes |
|
||||
|
||||
## Required Inputs
|
||||
|
||||
For business APIs:
|
||||
|
||||
```text
|
||||
user_id: <user id from /users>
|
||||
user_key: <account.result.user_key from /users>
|
||||
```
|
||||
|
||||
If configured:
|
||||
|
||||
```text
|
||||
X-API-Key: <server.api_key>
|
||||
```
|
||||
|
||||
Never place OpenViking root keys in user-facing examples unless the user is explicitly configuring the server. Never ask callers to send `X-Account-Key`; that contract is obsolete.
|
||||
|
||||
## Examples
|
||||
|
||||
Create user:
|
||||
|
||||
```bash
|
||||
python -m memory_system_api.server --config config.yaml --host 127.0.0.1 --port 1934
|
||||
curl -sS -X POST "$BASE/memory-system/users" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"user_id":"userA"}'
|
||||
```
|
||||
|
||||
2. Check health:
|
||||
Write messages:
|
||||
|
||||
```bash
|
||||
curl -s http://127.0.0.1:1934/memory-system/health
|
||||
curl -sS -X POST "$BASE/memory-system/messages" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"user_id": "userA",
|
||||
"user_key": "'"$USER_KEY"'",
|
||||
"session_id": "sessionA1",
|
||||
"user_message": "请记住:我喜欢拿铁。",
|
||||
"assistant_message": "好的。"
|
||||
}'
|
||||
```
|
||||
|
||||
3. If `server.api_key` is set in `config.yaml`, include `X-API-Key` on every request.
|
||||
Commit:
|
||||
|
||||
See [references/api.md](references/api.md) for request examples and response handling.
|
||||
|
||||
## Write Conversation Memory
|
||||
|
||||
Use `POST /memory-system/messages`.
|
||||
|
||||
Pass:
|
||||
|
||||
- `user_id`: stable end-user ID.
|
||||
- `session_id`: stable conversation/session ID.
|
||||
- `user_message`: optional.
|
||||
- `assistant_message`: optional.
|
||||
|
||||
At least one of `user_message` or `assistant_message` must be present. If both are present, the backend writes them as two separate messages, in user then assistant order.
|
||||
|
||||
Important EverOS rule: assistant messages must not use the user ID as `sender_id`. The API handles this internally; do not bypass it by calling EverOS directly unless debugging.
|
||||
|
||||
## Trigger Memory Extraction
|
||||
|
||||
Use commit when the conversation turn/session should be finalized:
|
||||
|
||||
```text
|
||||
POST /memory-system/sessions/{session_id}/commit
|
||||
```bash
|
||||
curl -sS -X POST "$BASE/memory-system/sessions/sessionA1/commit" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"user_id":"userA","user_key":"'"$USER_KEY"'"}'
|
||||
```
|
||||
|
||||
This runs OpenViking commit and EverOS flush concurrently. OpenViking commit is asynchronous; if the response includes a task ID, check it with:
|
||||
Search:
|
||||
|
||||
```text
|
||||
GET /memory-system/openviking/tasks/{task_id}?user_id=...
|
||||
```bash
|
||||
curl -sS -X POST "$BASE/memory-system/search" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"user_id": "userA",
|
||||
"user_key": "'"$USER_KEY"'",
|
||||
"session_id": "sessionA1",
|
||||
"query": "我喜欢喝什么?",
|
||||
"use_llm": false,
|
||||
"limit": 10
|
||||
}'
|
||||
```
|
||||
|
||||
EverOS flush may be slower than OpenViking because it can call LLM extraction/rerank services. Treat `partial_success` as retryable when OpenViking succeeds but EverOS fails; inspect `backends.everos.error`, wait for the upstream service to recover, then commit the same session again.
|
||||
## Response Handling
|
||||
|
||||
Use immediate extract only when the user explicitly asks to remember something now or when validating recent writes:
|
||||
|
||||
```text
|
||||
POST /memory-system/sessions/{session_id}/extract
|
||||
```
|
||||
|
||||
This wraps OpenViking extract only.
|
||||
|
||||
## Search Memory
|
||||
|
||||
Use `POST /memory-system/search`.
|
||||
|
||||
- `use_llm=false`: OpenViking `find` plus EverOS `method=hybrid`.
|
||||
- `use_llm=true`: OpenViking `search` plus EverOS `method=agentic`.
|
||||
|
||||
The API queries both backends concurrently and returns merged `items`. Preserve `source_backend` when presenting or using results so the caller can tell where each memory came from.
|
||||
|
||||
The response also includes `backends` for debugging. Do not expect raw embedding vectors there; fields named `vector` are intentionally stripped to keep responses small.
|
||||
|
||||
## Read User Profile
|
||||
|
||||
Use:
|
||||
|
||||
```text
|
||||
GET /memory-system/users/{user_id}/profile
|
||||
```
|
||||
|
||||
This calls EverOS `memories/get` with `memory_type=profile`.
|
||||
|
||||
## Error Handling
|
||||
|
||||
Expect `status` to be one of:
|
||||
Top-level `status` is one of:
|
||||
|
||||
- `success`: all attempted backends succeeded.
|
||||
- `partial_success`: at least one backend succeeded and at least one failed.
|
||||
- `partial_success`: at least one backend succeeded and one failed.
|
||||
- `failed`: all attempted backends failed.
|
||||
|
||||
When `partial_success` or `failed`, inspect `backends.openviking.error` and `backends.everos.error`. Do not hide backend-specific failures behind the top-level status.
|
||||
Search responses include merged `items` and compact backend diagnostics under `backends`. Keep `source_backend` when using results. Fields named `vector` are stripped from returned payloads, and the raw EverOS `original_data` blob is not returned by search anymore.
|
||||
|
||||
Empty backend exception messages are normalized to the exception type, for example `ReadTimeout`, so an empty `error` string should not be expected from current API versions.
|
||||
## Common Mistakes
|
||||
|
||||
- Calling `/messages` before `/users`.
|
||||
- Omitting `user_key` on business calls.
|
||||
- Sending `account_id` to business APIs.
|
||||
- Confusing `X-API-Key` with `user_key`.
|
||||
- Expecting `backends.everos.result` to still contain full `episodes` or `original_data`.
|
||||
- Assuming the gateway stores these values only in memory; it persists them in SQLite.
|
||||
|
||||
## Validation
|
||||
|
||||
After changing the API or this skill, run:
|
||||
After changing API behavior or this skill:
|
||||
|
||||
```bash
|
||||
python -m pytest -q
|
||||
python -m compileall -q memory_system_api tests
|
||||
python /home/tom/.codex/skills/.system/skill-creator/scripts/quick_validate.py skills/memory-system-api
|
||||
PYTHONPATH=/home/tom/memory-gateway pytest -q
|
||||
python -m compileall -q memory_system_api plugins eval tests
|
||||
```
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
interface:
|
||||
display_name: "Memory System API"
|
||||
short_description: "Use the lightweight Memory System API from AI agents."
|
||||
default_prompt: "Use the Memory System API skill to write conversation memory, trigger extraction, search memory, and read user profiles."
|
||||
short_description: "Use the configured Memory System API endpoint from AI agents."
|
||||
default_prompt: "Use the Memory System API skill with the configured endpoint to create users, write conversation memory with user_id and user_key, trigger extraction, search memory, and read user profiles."
|
||||
|
||||
@ -1,12 +1,14 @@
|
||||
# Memory System API Reference
|
||||
|
||||
Base URL defaults to:
|
||||
Use the deployed service URL supplied by the user, runtime, or configuration:
|
||||
|
||||
```text
|
||||
http://127.0.0.1:1934
|
||||
<MEMORY_SYSTEM_BASE_URL>
|
||||
```
|
||||
|
||||
If `server.api_key` is configured, add:
|
||||
Do not assume a localhost address. In agent workflows, resolve the endpoint from `MEMORY_SYSTEM_ENDPOINT`, Hermes memory config, platform config, or user input.
|
||||
|
||||
If an API key is configured, add:
|
||||
|
||||
```bash
|
||||
-H "X-API-Key: <gateway-api-key>"
|
||||
@ -15,17 +17,18 @@ If `server.api_key` is configured, add:
|
||||
## Health
|
||||
|
||||
```bash
|
||||
curl -s http://127.0.0.1:1934/memory-system/health
|
||||
curl -s <MEMORY_SYSTEM_BASE_URL>/memory-system/health
|
||||
```
|
||||
|
||||
## Write Messages
|
||||
|
||||
```bash
|
||||
curl -s -X POST http://127.0.0.1:1934/memory-system/messages \
|
||||
curl -s -X POST <MEMORY_SYSTEM_BASE_URL>/memory-system/messages \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"user_id": "real_user_001",
|
||||
"session_id": "real_sess_001",
|
||||
"user_id": "<USER_ID>",
|
||||
"user_key": "<USER_KEY>",
|
||||
"session_id": "<SESSION_ID>",
|
||||
"user_message": "我喜欢喝拿铁,不喜欢美式。",
|
||||
"assistant_message": "好的,我会记住你的咖啡偏好。"
|
||||
}'
|
||||
@ -36,15 +39,15 @@ curl -s -X POST http://127.0.0.1:1934/memory-system/messages \
|
||||
## Commit Session
|
||||
|
||||
```bash
|
||||
curl -s -X POST http://127.0.0.1:1934/memory-system/sessions/real_sess_001/commit \
|
||||
curl -s -X POST <MEMORY_SYSTEM_BASE_URL>/memory-system/sessions/<SESSION_ID>/commit \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"user_id": "real_user_001"}'
|
||||
-d '{"user_id": "<USER_ID>", "user_key": "<USER_KEY>"}'
|
||||
```
|
||||
|
||||
Use the returned OpenViking task ID, if present:
|
||||
|
||||
```bash
|
||||
curl -s "http://127.0.0.1:1934/memory-system/openviking/tasks/<TASK_ID>?user_id=real_user_001"
|
||||
curl -s "<MEMORY_SYSTEM_BASE_URL>/memory-system/openviking/tasks/<TASK_ID>?user_id=<USER_ID>&user_key=<USER_KEY>&session_id=<SESSION_ID>"
|
||||
```
|
||||
|
||||
`commit` can return `partial_success` if OpenViking accepted the archive but EverOS flush failed or timed out. This is retryable:
|
||||
@ -64,9 +67,9 @@ Wait for EverOS or its upstream LLM/rerank service to recover, then call the sam
|
||||
## Immediate Extract
|
||||
|
||||
```bash
|
||||
curl -s -X POST http://127.0.0.1:1934/memory-system/sessions/real_sess_001/extract \
|
||||
curl -s -X POST <MEMORY_SYSTEM_BASE_URL>/memory-system/sessions/<SESSION_ID>/extract \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"user_id": "real_user_001"}'
|
||||
-d '{"user_id": "<USER_ID>", "user_key": "<USER_KEY>"}'
|
||||
```
|
||||
|
||||
## Search
|
||||
@ -74,11 +77,12 @@ curl -s -X POST http://127.0.0.1:1934/memory-system/sessions/real_sess_001/extra
|
||||
Without LLM planning:
|
||||
|
||||
```bash
|
||||
curl -s -X POST http://127.0.0.1:1934/memory-system/search \
|
||||
curl -s -X POST <MEMORY_SYSTEM_BASE_URL>/memory-system/search \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"user_id": "real_user_001",
|
||||
"session_id": "real_sess_001",
|
||||
"user_id": "<USER_ID>",
|
||||
"user_key": "<USER_KEY>",
|
||||
"session_id": "<SESSION_ID>",
|
||||
"query": "我喜欢喝什么咖啡?",
|
||||
"use_llm": false,
|
||||
"limit": 10
|
||||
@ -88,23 +92,72 @@ curl -s -X POST http://127.0.0.1:1934/memory-system/search \
|
||||
With LLM planning:
|
||||
|
||||
```bash
|
||||
curl -s -X POST http://127.0.0.1:1934/memory-system/search \
|
||||
curl -s -X POST <MEMORY_SYSTEM_BASE_URL>/memory-system/search \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"user_id": "real_user_001",
|
||||
"session_id": "real_sess_001",
|
||||
"user_id": "<USER_ID>",
|
||||
"user_key": "<USER_KEY>",
|
||||
"session_id": "<SESSION_ID>",
|
||||
"query": "我的偏好是什么?",
|
||||
"use_llm": true,
|
||||
"limit": 10
|
||||
}'
|
||||
```
|
||||
|
||||
Search responses include merged `items` plus backend debug data. Fields named `vector` are stripped recursively before the API returns JSON, so responses stay small even when EverOS includes `original_data`. Metadata keys such as `vector_model` may still be present.
|
||||
Raw API search responses include merged `items` plus compact backend diagnostics. Fields named `vector` are stripped recursively before the API returns JSON. The API does not return EverOS `original_data` or full episode payloads inside `backends` anymore.
|
||||
|
||||
The search response shape should now look more like:
|
||||
|
||||
```json
|
||||
{
|
||||
"status": "success",
|
||||
"items": [
|
||||
{
|
||||
"source_backend": "everos",
|
||||
"memory_type": "episode",
|
||||
"id": "episode-1",
|
||||
"user_id": "userB",
|
||||
"session_id": "sessionB1",
|
||||
"timestamp": "2026-05-22T07:50:51.750000Z",
|
||||
"summary": "userB 在对话中表示自己喜欢拿铁。",
|
||||
"score": 0.72
|
||||
}
|
||||
],
|
||||
"backends": {
|
||||
"everos": {
|
||||
"status": "success",
|
||||
"result": {
|
||||
"counts": {"episodes": 1, "profiles": 0, "raw_messages": 0},
|
||||
"query": {"text": "我喜欢喝什么?", "method": "agentic"}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
When the API is used through the Hermes `memory_system` provider, the tool result is intentionally compact and should look like:
|
||||
|
||||
```json
|
||||
{
|
||||
"status": "success",
|
||||
"items": [
|
||||
{
|
||||
"source_backend": "openviking",
|
||||
"memory_type": "event",
|
||||
"score": 0.92,
|
||||
"text": "Relevant recalled memory text",
|
||||
"uri": "viking://..."
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
Use the compact `text` fields for answering. Only inspect raw `backends` when debugging API/backend failures.
|
||||
|
||||
## Profile
|
||||
|
||||
```bash
|
||||
curl -s http://127.0.0.1:1934/memory-system/users/real_user_001/profile
|
||||
curl -s "<MEMORY_SYSTEM_BASE_URL>/memory-system/users/<USER_ID>/profile?user_key=<USER_KEY>"
|
||||
```
|
||||
|
||||
## Response Interpretation
|
||||
@ -123,4 +176,4 @@ Inspect backend status:
|
||||
|
||||
Use backend-specific errors for debugging.
|
||||
|
||||
OpenViking user keys are intentionally hidden from callers. If OpenViking already has an account but the local API has no stored key, Memory System API falls back to the configured OpenViking root key plus identity headers internally.
|
||||
OpenViking user keys are intentionally hidden from other users, but the caller must present the matching `user_key` for business APIs. The gateway stores the created `user_id/user_key`, `session_id`, `task_id`, and `archive_uri` in SQLite.
|
||||
|
||||
@ -30,7 +30,25 @@ class FakeClient:
|
||||
if path == "/memory-system/search":
|
||||
return {
|
||||
"status": "success",
|
||||
"items": [{"source": "openviking", "content": "likes latte", "score": 0.9}],
|
||||
"items": [
|
||||
{
|
||||
"source_backend": "openviking",
|
||||
"content": "likes latte",
|
||||
"score": 0.9,
|
||||
"uri": "viking://user/user-1/memories/a",
|
||||
"vector": [0.1, 0.2],
|
||||
},
|
||||
{
|
||||
"source_backend": "everos",
|
||||
"memory": "prefers warm coffee",
|
||||
"memory_type": "profile",
|
||||
"original_data": {"large": "payload"},
|
||||
},
|
||||
],
|
||||
"backends": {
|
||||
"openviking": {"status": "success", "result": {"verbose": True}},
|
||||
"everos": {"status": "success", "result": {"verbose": True}},
|
||||
},
|
||||
}
|
||||
if path.endswith("/commit"):
|
||||
return {"status": self.commit_status}
|
||||
@ -48,6 +66,8 @@ def make_provider():
|
||||
provider._endpoint = "http://127.0.0.1:1934"
|
||||
provider._user_id = "user-1"
|
||||
provider._session_id = "session-1"
|
||||
provider._commit_every_turns = 0
|
||||
provider._commit_interval_seconds = 0
|
||||
return provider
|
||||
|
||||
|
||||
@ -77,6 +97,7 @@ def test_initialize_loads_config_from_hermes_env_file(tmp_path, monkeypatch):
|
||||
for key in list(os.environ):
|
||||
if key.startswith("MEMORY_SYSTEM_"):
|
||||
monkeypatch.delenv(key, raising=False)
|
||||
monkeypatch.chdir(tmp_path)
|
||||
|
||||
class InitClient(FakeClient):
|
||||
def __init__(self, endpoint, api_key="", timeout=0):
|
||||
@ -183,7 +204,22 @@ def test_search_tool_uses_memory_system_api():
|
||||
result = json.loads(provider.handle_tool_call("memory_system_search", {"query": "coffee", "limit": 3}))
|
||||
|
||||
assert result["status"] == "success"
|
||||
assert result["items"][0]["content"] == "likes latte"
|
||||
assert result["items"] == [
|
||||
{
|
||||
"source_backend": "openviking",
|
||||
"score": 0.9,
|
||||
"text": "likes latte",
|
||||
"uri": "viking://user/user-1/memories/a",
|
||||
},
|
||||
{
|
||||
"source_backend": "everos",
|
||||
"memory_type": "profile",
|
||||
"text": "prefers warm coffee",
|
||||
},
|
||||
]
|
||||
assert "backends" not in result
|
||||
assert "vector" not in json.dumps(result)
|
||||
assert "original_data" not in json.dumps(result)
|
||||
assert provider._client.posts[-1] == (
|
||||
"/memory-system/search",
|
||||
{
|
||||
|
||||
@ -5,13 +5,34 @@ from memory_system_api.clients import EverOSMemorySystemClient, OpenVikingMemory
|
||||
|
||||
class FakeStore:
|
||||
def __init__(self):
|
||||
self.saved = {}
|
||||
self.users = {}
|
||||
self.accounts = {}
|
||||
self.sessions = []
|
||||
self.tasks = []
|
||||
|
||||
def get_user_key(self, user_id: str) -> str | None:
|
||||
return self.saved.get(user_id)
|
||||
return self.users.get(user_id)
|
||||
|
||||
def save_user_key(self, user_id: str, user_key: str) -> None:
|
||||
self.saved[user_id] = user_key
|
||||
self.users[user_id] = user_key
|
||||
|
||||
def get_account_key(self, account_id: str) -> str | None:
|
||||
return self.accounts.get(account_id)
|
||||
|
||||
def save_account_key(self, account_id: str, admin_user_id: str, account_key: str) -> None:
|
||||
self.accounts[account_id] = account_key
|
||||
|
||||
def account_key_matches(self, account_id: str, account_key: str) -> bool:
|
||||
return self.accounts.get(account_id) == account_key
|
||||
|
||||
def user_key_matches(self, user_id: str, user_key: str) -> bool:
|
||||
return self.users.get(user_id) == user_key
|
||||
|
||||
def save_session(self, user_id: str, session_id: str) -> None:
|
||||
self.sessions.append((user_id, session_id))
|
||||
|
||||
def save_task(self, user_id: str, session_id: str, task_id: str, archive_uri: str | None) -> None:
|
||||
self.tasks.append((user_id, session_id, task_id, archive_uri))
|
||||
|
||||
|
||||
class FakeResponse:
|
||||
@ -44,13 +65,52 @@ class FakeAsyncClient:
|
||||
self.calls.append(("post", self.api_key, self.headers, path, json))
|
||||
return self.responses.pop(0)
|
||||
|
||||
async def get(self, path: str) -> FakeResponse:
|
||||
self.calls.append(("get", self.api_key, self.headers, path, None))
|
||||
return self.responses.pop(0)
|
||||
|
||||
def test_openviking_uses_root_identity_when_account_already_exists():
|
||||
|
||||
def test_openviking_rejects_unknown_user_credentials():
|
||||
store = FakeStore()
|
||||
client = OpenVikingMemorySystemClient(store=store)
|
||||
|
||||
try:
|
||||
client.credential_for_user("tom", "missing-key")
|
||||
except PermissionError as exc:
|
||||
assert "Invalid user key" in str(exc)
|
||||
else:
|
||||
raise AssertionError("expected PermissionError")
|
||||
|
||||
|
||||
def test_openviking_accepts_matching_user_credentials():
|
||||
store = FakeStore()
|
||||
store.save_user_key("tom", "tom-key")
|
||||
client = OpenVikingMemorySystemClient(store=store)
|
||||
client.root_key = "root-key"
|
||||
|
||||
credential = client.credential_for_user("tom", "tom-key", agent_id="sess-1")
|
||||
|
||||
assert credential.api_key == "tom-key"
|
||||
assert credential.account_id == "admin"
|
||||
assert credential.user_id == "tom"
|
||||
assert credential.agent_id == "sess-1"
|
||||
|
||||
|
||||
def test_openviking_create_user_initializes_admin_workspace_first():
|
||||
store = FakeStore()
|
||||
client = OpenVikingMemorySystemClient(store=store)
|
||||
client.root_key = "root-key"
|
||||
calls = []
|
||||
responses = [FakeResponse(409, {"status": "error", "error": {"code": "CONFLICT"}})]
|
||||
responses = [
|
||||
FakeResponse(
|
||||
200,
|
||||
{"status": "ok", "result": {"account_id": "admin", "admin_user_id": "admin", "user_key": "admin-key"}},
|
||||
),
|
||||
FakeResponse(
|
||||
200,
|
||||
{"status": "ok", "result": {"account_id": "admin", "user_id": "userA", "user_key": "userA-key"}},
|
||||
),
|
||||
]
|
||||
client._client = lambda api_key, extra_headers=None: FakeAsyncClient( # type: ignore[method-assign]
|
||||
calls,
|
||||
responses,
|
||||
@ -58,15 +118,64 @@ def test_openviking_uses_root_identity_when_account_already_exists():
|
||||
extra_headers or {},
|
||||
)
|
||||
|
||||
credential = asyncio.run(client.ensure_user("tom"))
|
||||
result = asyncio.run(client.create_user("userA"))
|
||||
|
||||
assert credential.api_key == "root-key"
|
||||
assert credential.account_id == "tom"
|
||||
assert credential.user_id == "tom"
|
||||
assert store.saved == {}
|
||||
assert result == {"status": "ok", "result": {"account_id": "admin", "user_id": "userA", "user_key": "userA-key"}}
|
||||
assert store.accounts == {"admin": "admin-key"}
|
||||
assert store.users == {"admin": "admin-key", "userA": "userA-key"}
|
||||
assert calls == [
|
||||
(
|
||||
"post",
|
||||
"root-key",
|
||||
{},
|
||||
"/api/v1/admin/accounts",
|
||||
{"account_id": "admin", "admin_user_id": "admin"},
|
||||
),
|
||||
(
|
||||
"post",
|
||||
"root-key",
|
||||
{},
|
||||
"/api/v1/admin/accounts/admin/users",
|
||||
{"user_id": "userA", "role": "user"},
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
def test_openviking_root_identity_headers_are_sent_for_session_create():
|
||||
def test_openviking_create_user_reuses_existing_admin_workspace():
|
||||
store = FakeStore()
|
||||
store.save_account_key("admin", "admin", "admin-key")
|
||||
client = OpenVikingMemorySystemClient(store=store)
|
||||
client.root_key = "root-key"
|
||||
calls = []
|
||||
responses = [
|
||||
FakeResponse(
|
||||
200,
|
||||
{"status": "ok", "result": {"account_id": "admin", "user_id": "userB", "user_key": "userB-key"}},
|
||||
)
|
||||
]
|
||||
client._client = lambda api_key, extra_headers=None: FakeAsyncClient( # type: ignore[method-assign]
|
||||
calls,
|
||||
responses,
|
||||
api_key,
|
||||
extra_headers or {},
|
||||
)
|
||||
|
||||
result = asyncio.run(client.create_user("userB"))
|
||||
|
||||
assert result == {"status": "ok", "result": {"account_id": "admin", "user_id": "userB", "user_key": "userB-key"}}
|
||||
assert store.users == {"userB": "userB-key"}
|
||||
assert calls == [
|
||||
(
|
||||
"post",
|
||||
"root-key",
|
||||
{},
|
||||
"/api/v1/admin/accounts/admin/users",
|
||||
{"user_id": "userB", "role": "user"},
|
||||
)
|
||||
]
|
||||
|
||||
|
||||
def test_openviking_user_key_auth_is_used_for_session_create():
|
||||
client = OpenVikingMemorySystemClient(store=FakeStore())
|
||||
client.root_key = "root-key"
|
||||
calls = []
|
||||
@ -77,22 +186,121 @@ def test_openviking_root_identity_headers_are_sent_for_session_create():
|
||||
api_key,
|
||||
extra_headers or {},
|
||||
)
|
||||
credential = client.root_credential("tom")
|
||||
credential = client.user_credential("tom-key", "tom", agent_id="sess-2")
|
||||
|
||||
result = asyncio.run(client.ensure_session(credential, "sess-2"))
|
||||
|
||||
assert result == {"status": "ok", "result": {"session_id": "sess-2"}}
|
||||
assert client.store.sessions == [("tom", "sess-2")]
|
||||
assert calls == [
|
||||
(
|
||||
"post",
|
||||
"root-key",
|
||||
{"X-OpenViking-Account": "tom", "X-OpenViking-User": "tom"},
|
||||
"tom-key",
|
||||
{},
|
||||
"/api/v1/sessions",
|
||||
{"session_id": "sess-2"},
|
||||
)
|
||||
]
|
||||
|
||||
|
||||
def test_openviking_find_uses_current_identity_memory_scope():
|
||||
client = OpenVikingMemorySystemClient(store=FakeStore())
|
||||
calls = []
|
||||
responses = [FakeResponse(200, {"status": "ok", "result": {"memories": []}})]
|
||||
client._client = lambda api_key, extra_headers=None: FakeAsyncClient( # type: ignore[method-assign]
|
||||
calls,
|
||||
responses,
|
||||
api_key,
|
||||
extra_headers or {},
|
||||
)
|
||||
credential = client.user_credential("tom-key", "tom", agent_id="sess-1")
|
||||
|
||||
result = asyncio.run(client.find(credential, "咖啡", 5))
|
||||
|
||||
assert result == {"status": "ok", "result": {"memories": []}}
|
||||
assert calls == [
|
||||
(
|
||||
"post",
|
||||
"tom-key",
|
||||
{},
|
||||
"/api/v1/search/find",
|
||||
{"query": "咖啡", "target_uri": "viking://user/tom/memories/", "limit": 5},
|
||||
)
|
||||
]
|
||||
|
||||
|
||||
def test_openviking_search_uses_session_target_uri():
|
||||
client = OpenVikingMemorySystemClient(store=FakeStore())
|
||||
calls = []
|
||||
responses = [FakeResponse(200, {"status": "ok", "result": {"memories": []}})]
|
||||
client._client = lambda api_key, extra_headers=None: FakeAsyncClient( # type: ignore[method-assign]
|
||||
calls,
|
||||
responses,
|
||||
api_key,
|
||||
extra_headers or {},
|
||||
)
|
||||
credential = client.user_credential("tom-key", "tom", agent_id="sess-1")
|
||||
|
||||
result = asyncio.run(client.search(credential, "sess-1", "咖啡", 5))
|
||||
|
||||
assert result == {"status": "ok", "result": {"memories": []}}
|
||||
assert calls == [
|
||||
(
|
||||
"post",
|
||||
"tom-key",
|
||||
{},
|
||||
"/api/v1/search/search",
|
||||
{"query": "咖啡", "limit": 5, "session_id": "sess-1", "target_uri": "viking://user/tom/sess-1"},
|
||||
)
|
||||
]
|
||||
|
||||
|
||||
def test_openviking_commit_keeps_no_recent_live_messages():
|
||||
client = OpenVikingMemorySystemClient(store=FakeStore())
|
||||
calls = []
|
||||
responses = [
|
||||
FakeResponse(
|
||||
200,
|
||||
{
|
||||
"status": "ok",
|
||||
"result": {
|
||||
"status": "accepted",
|
||||
"task_id": "task-1",
|
||||
"archive_uri": "viking://session/tom/sess-1/history/archive_001",
|
||||
},
|
||||
},
|
||||
)
|
||||
]
|
||||
client._client = lambda api_key, extra_headers=None: FakeAsyncClient( # type: ignore[method-assign]
|
||||
calls,
|
||||
responses,
|
||||
api_key,
|
||||
extra_headers or {},
|
||||
)
|
||||
credential = client.user_credential("tom-key", "tom")
|
||||
|
||||
result = asyncio.run(client.commit_session(credential, "sess-1"))
|
||||
|
||||
assert result == {
|
||||
"status": "ok",
|
||||
"result": {
|
||||
"status": "accepted",
|
||||
"task_id": "task-1",
|
||||
"archive_uri": "viking://session/tom/sess-1/history/archive_001",
|
||||
},
|
||||
}
|
||||
assert client.store.tasks == [("tom", "sess-1", "task-1", "viking://session/tom/sess-1/history/archive_001")]
|
||||
assert calls == [
|
||||
(
|
||||
"post",
|
||||
"tom-key",
|
||||
{},
|
||||
"/api/v1/sessions/sess-1/commit",
|
||||
{"keep_recent_count": 0},
|
||||
)
|
||||
]
|
||||
|
||||
|
||||
def test_everos_assistant_payload_does_not_use_user_id_as_sender():
|
||||
client = EverOSMemorySystemClient()
|
||||
|
||||
|
||||
@ -2,6 +2,15 @@ def test_memory_system_server_exposes_routes():
|
||||
from memory_system_api.server import app
|
||||
|
||||
paths = {route.path for route in app.routes}
|
||||
assert "/memory-system/users" in paths
|
||||
assert "/memory-system/messages" in paths
|
||||
assert "/memory-system/search" in paths
|
||||
assert "/memory-system/users/{user_id}/profile" in paths
|
||||
|
||||
|
||||
def test_memory_system_messages_does_not_require_account_key_header():
|
||||
from memory_system_api.server import app
|
||||
|
||||
route = next(route for route in app.routes if getattr(route, "path", "") == "/memory-system/messages")
|
||||
|
||||
assert all(getattr(dependency.call, "__name__", "") != "account_key_header" for dependency in route.dependant.dependencies)
|
||||
|
||||
@ -9,8 +9,19 @@ class FakeOpenViking:
|
||||
self.fail_on_append = fail_on_append
|
||||
self.calls = []
|
||||
|
||||
async def ensure_user(self, user_id: str) -> str:
|
||||
self.calls.append(("ensure_user", user_id))
|
||||
async def create_user(self, user_id: str) -> dict:
|
||||
self.calls.append(("create_user", user_id))
|
||||
return {"account_id": "admin", "user_id": user_id, "user_key": f"{user_id}-key"}
|
||||
|
||||
def credential_for_user(
|
||||
self,
|
||||
user_id: str,
|
||||
user_key: str,
|
||||
agent_id: str | None = None,
|
||||
) -> str:
|
||||
self.calls.append(("credential_for_user", user_id, user_key, agent_id))
|
||||
if user_key != f"{user_id}-key":
|
||||
raise PermissionError("Invalid user key")
|
||||
return f"key-{user_id}"
|
||||
|
||||
async def ensure_session(self, user_key: str, session_id: str) -> dict:
|
||||
@ -23,8 +34,8 @@ class FakeOpenViking:
|
||||
raise RuntimeError("openviking append failed")
|
||||
return {"message_count": len([call for call in self.calls if call[0] == "append_message"])}
|
||||
|
||||
async def find(self, user_key: str, user_id: str, query: str, limit: int) -> dict:
|
||||
self.calls.append(("find", user_key, user_id, query, limit))
|
||||
async def find(self, user_key: str, query: str, limit: int) -> dict:
|
||||
self.calls.append(("find", user_key, query, limit))
|
||||
await asyncio.sleep(0.01)
|
||||
return {"items": [{"source": "openviking-find"}]}
|
||||
|
||||
@ -33,6 +44,10 @@ class FakeOpenViking:
|
||||
await asyncio.sleep(0.01)
|
||||
return {"items": [{"source": "openviking-search"}]}
|
||||
|
||||
async def commit_session(self, user_key: str, session_id: str) -> dict:
|
||||
self.calls.append(("commit_session", user_key, session_id))
|
||||
return {"status": "ok", "result": {"task_id": "task-1", "archive_uri": "archive-1"}}
|
||||
|
||||
|
||||
class FakeEverOS:
|
||||
def __init__(self, fail_on_append: bool = False):
|
||||
@ -50,6 +65,10 @@ class FakeEverOS:
|
||||
await asyncio.sleep(0.01)
|
||||
return {"items": [{"source": f"everos-{method}"}]}
|
||||
|
||||
async def flush(self, user_id: str, session_id: str) -> dict:
|
||||
self.calls.append(("flush", user_id, session_id))
|
||||
return {"status": "flushed"}
|
||||
|
||||
|
||||
class FakeEverOSWithVector(FakeEverOS):
|
||||
async def search(self, user_id: str, session_id: str | None, query: str, method: str, limit: int) -> dict:
|
||||
@ -70,6 +89,47 @@ class FakeEverOSWithVector(FakeEverOS):
|
||||
}
|
||||
|
||||
|
||||
class FakeEverOSVerbose(FakeEverOS):
|
||||
async def search(self, user_id: str, session_id: str | None, query: str, method: str, limit: int) -> dict:
|
||||
self.calls.append(("search", user_id, session_id, query, method, limit))
|
||||
return {
|
||||
"data": {
|
||||
"episodes": [
|
||||
{
|
||||
"id": "episode-1",
|
||||
"user_id": user_id,
|
||||
"session_id": session_id,
|
||||
"timestamp": "2026-05-22T07:50:51.750000Z",
|
||||
"summary": "userB 在对话中表示自己喜欢拿铁。",
|
||||
"subject": "UserB 表达对拿铁的喜好",
|
||||
"episode": "userB 在对话中表示自己喜欢拿铁。",
|
||||
"type": "Conversation",
|
||||
"parent_id": "parent-1",
|
||||
"score": 0.72,
|
||||
"atomic_facts": [],
|
||||
}
|
||||
],
|
||||
"profiles": [],
|
||||
"raw_messages": [],
|
||||
"query": {
|
||||
"text": query,
|
||||
"method": method,
|
||||
"filters_applied": {"user_id": user_id, "session_id": session_id},
|
||||
},
|
||||
"original_data": {
|
||||
"episodes": {
|
||||
"episode-1": {
|
||||
"id": "episode-1",
|
||||
"summary": "userB 在对话中表示自己喜欢拿铁。",
|
||||
"episode": "userB 在对话中表示自己喜欢拿铁。",
|
||||
"vector_model": "Qwen3-VL-Embedding-2B",
|
||||
}
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
def test_capture_includes_exception_type_when_message_is_empty():
|
||||
service = MemorySystemService(openviking=FakeOpenViking(), everos=FakeEverOS())
|
||||
|
||||
@ -85,20 +145,64 @@ def test_capture_includes_exception_type_when_message_is_empty():
|
||||
assert response.error == "EmptyError"
|
||||
|
||||
|
||||
def test_create_user_delegates_to_openviking_only():
|
||||
openviking = FakeOpenViking()
|
||||
everos = FakeEverOS()
|
||||
service = MemorySystemService(openviking=openviking, everos=everos)
|
||||
|
||||
response = asyncio.run(service.create_user("alice"))
|
||||
|
||||
assert response.status == "success"
|
||||
assert response.account == {"account_id": "admin", "user_id": "alice", "user_key": "alice-key"}
|
||||
assert openviking.calls == [("create_user", "alice")]
|
||||
assert everos.calls == []
|
||||
|
||||
|
||||
def test_search_removes_vectors_from_items_and_backend_results():
|
||||
service = MemorySystemService(openviking=FakeOpenViking(), everos=FakeEverOSWithVector())
|
||||
|
||||
response = asyncio.run(service.search(
|
||||
SearchRequest(user_id="tom", session_id="sess-1", query="咖啡偏好", use_llm=False, limit=5)
|
||||
SearchRequest(user_id="tom", user_key="tom-key", session_id="sess-1", query="咖啡偏好", use_llm=False, limit=5),
|
||||
))
|
||||
|
||||
assert response.items == [
|
||||
{"source_backend": "openviking", "source": "openviking-find"},
|
||||
{"source_backend": "everos", "id": "episode-1"},
|
||||
{"source_backend": "everos", "memory_type": "episode", "id": "episode-1"},
|
||||
]
|
||||
assert not _has_key(response.backends["everos"].result, "vector")
|
||||
|
||||
|
||||
def test_search_returns_compact_items_and_backend_diagnostics_without_duplicate_raw_payloads():
|
||||
service = MemorySystemService(openviking=FakeOpenViking(), everos=FakeEverOSVerbose())
|
||||
|
||||
response = asyncio.run(service.search(
|
||||
SearchRequest(user_id="tom", user_key="tom-key", session_id="sess-1", query="我喜欢喝什么?", use_llm=True),
|
||||
))
|
||||
|
||||
assert response.items == [
|
||||
{"source_backend": "openviking", "source": "openviking-search"},
|
||||
{
|
||||
"source_backend": "everos",
|
||||
"memory_type": "episode",
|
||||
"id": "episode-1",
|
||||
"user_id": "tom",
|
||||
"session_id": "sess-1",
|
||||
"timestamp": "2026-05-22T07:50:51.750000Z",
|
||||
"summary": "userB 在对话中表示自己喜欢拿铁。",
|
||||
"score": 0.72,
|
||||
},
|
||||
]
|
||||
assert response.backends["everos"].result == {
|
||||
"counts": {"episodes": 1, "profiles": 0, "raw_messages": 0},
|
||||
"query": {
|
||||
"text": "我喜欢喝什么?",
|
||||
"method": "agentic",
|
||||
"filters_applied": {"user_id": "tom", "session_id": "sess-1"},
|
||||
},
|
||||
}
|
||||
assert not _has_key(response.backends["everos"].result, "original_data")
|
||||
|
||||
|
||||
def _has_key(value, key: str) -> bool:
|
||||
if isinstance(value, dict):
|
||||
return key in value or any(_has_key(item, key) for item in value.values())
|
||||
@ -115,6 +219,7 @@ def test_ingest_splits_user_and_assistant_messages():
|
||||
response = asyncio.run(service.ingest_messages(
|
||||
MessageIngestRequest(
|
||||
user_id="tom",
|
||||
user_key="tom-key",
|
||||
session_id="sess-1",
|
||||
user_message="我喜欢拿铁",
|
||||
assistant_message="我记住了",
|
||||
@ -124,7 +229,7 @@ def test_ingest_splits_user_and_assistant_messages():
|
||||
assert response.status == "success"
|
||||
assert response.message_count == 2
|
||||
assert openviking.calls == [
|
||||
("ensure_user", "tom"),
|
||||
("credential_for_user", "tom", "tom-key", "sess-1"),
|
||||
("ensure_session", "key-tom", "sess-1"),
|
||||
("append_message", "key-tom", "sess-1", "user", "我喜欢拿铁"),
|
||||
("append_message", "key-tom", "sess-1", "assistant", "我记住了"),
|
||||
@ -139,7 +244,11 @@ def test_ingest_requires_at_least_one_message():
|
||||
service = MemorySystemService(openviking=FakeOpenViking(), everos=FakeEverOS())
|
||||
|
||||
try:
|
||||
asyncio.run(service.ingest_messages(MessageIngestRequest(user_id="tom", session_id="sess-1")))
|
||||
asyncio.run(
|
||||
service.ingest_messages(
|
||||
MessageIngestRequest(user_id="tom", user_key="tom-key", session_id="sess-1"),
|
||||
)
|
||||
)
|
||||
except ValueError as exc:
|
||||
assert "at least one message" in str(exc)
|
||||
else:
|
||||
@ -150,7 +259,7 @@ def test_ingest_returns_partial_success_when_one_backend_fails():
|
||||
service = MemorySystemService(openviking=FakeOpenViking(fail_on_append=True), everos=FakeEverOS())
|
||||
|
||||
response = asyncio.run(service.ingest_messages(
|
||||
MessageIngestRequest(user_id="tom", session_id="sess-1", user_message="hello")
|
||||
MessageIngestRequest(user_id="tom", user_key="tom-key", session_id="sess-1", user_message="hello"),
|
||||
))
|
||||
|
||||
assert response.status == "partial_success"
|
||||
@ -158,13 +267,28 @@ def test_ingest_returns_partial_success_when_one_backend_fails():
|
||||
assert response.backends["everos"].status == "success"
|
||||
|
||||
|
||||
def test_commit_uses_user_key_without_account_id():
|
||||
openviking = FakeOpenViking()
|
||||
everos = FakeEverOS()
|
||||
service = MemorySystemService(openviking=openviking, everos=everos)
|
||||
|
||||
response = asyncio.run(service.commit_session("tom", "tom-key", "sess-1"))
|
||||
|
||||
assert response.status == "success"
|
||||
assert openviking.calls == [
|
||||
("credential_for_user", "tom", "tom-key", "sess-1"),
|
||||
("commit_session", "key-tom", "sess-1"),
|
||||
]
|
||||
assert everos.calls == [("flush", "tom", "sess-1")]
|
||||
|
||||
|
||||
def test_search_uses_find_and_hybrid_without_llm():
|
||||
openviking = FakeOpenViking()
|
||||
everos = FakeEverOS()
|
||||
service = MemorySystemService(openviking=openviking, everos=everos)
|
||||
|
||||
response = asyncio.run(service.search(
|
||||
SearchRequest(user_id="tom", session_id="sess-1", query="咖啡偏好", use_llm=False, limit=5)
|
||||
SearchRequest(user_id="tom", user_key="tom-key", session_id="sess-1", query="咖啡偏好", use_llm=False, limit=5),
|
||||
))
|
||||
|
||||
assert response.status == "success"
|
||||
@ -172,7 +296,8 @@ def test_search_uses_find_and_hybrid_without_llm():
|
||||
{"source_backend": "openviking", "source": "openviking-find"},
|
||||
{"source_backend": "everos", "source": "everos-hybrid"},
|
||||
]
|
||||
assert ("find", "key-tom", "tom", "咖啡偏好", 5) in openviking.calls
|
||||
assert ("credential_for_user", "tom", "tom-key", "sess-1") in openviking.calls
|
||||
assert ("find", "key-tom", "咖啡偏好", 5) in openviking.calls
|
||||
assert ("search", "tom", "sess-1", "咖啡偏好", "hybrid", 5) in everos.calls
|
||||
|
||||
|
||||
@ -182,7 +307,7 @@ def test_search_uses_search_and_agentic_with_llm():
|
||||
service = MemorySystemService(openviking=openviking, everos=everos)
|
||||
|
||||
response = asyncio.run(service.search(
|
||||
SearchRequest(user_id="tom", session_id="sess-1", query="咖啡偏好", use_llm=True, limit=5)
|
||||
SearchRequest(user_id="tom", user_key="tom-key", session_id="sess-1", query="咖啡偏好", use_llm=True, limit=5),
|
||||
))
|
||||
|
||||
assert response.status == "success"
|
||||
@ -190,5 +315,6 @@ def test_search_uses_search_and_agentic_with_llm():
|
||||
{"source_backend": "openviking", "source": "openviking-search"},
|
||||
{"source_backend": "everos", "source": "everos-agentic"},
|
||||
]
|
||||
assert ("credential_for_user", "tom", "tom-key", "sess-1") in openviking.calls
|
||||
assert ("search", "key-tom", "sess-1", "咖啡偏好", 5) in openviking.calls
|
||||
assert ("search", "tom", "sess-1", "咖啡偏好", "agentic", 5) in everos.calls
|
||||
|
||||
32
tests/test_memory_system_store.py
Normal file
32
tests/test_memory_system_store.py
Normal file
@ -0,0 +1,32 @@
|
||||
from memory_system_api.store import OpenVikingUserKeyStore
|
||||
|
||||
|
||||
def test_store_persists_openviking_user_session_and_task_metadata(tmp_path):
|
||||
db_path = tmp_path / "memory.sqlite3"
|
||||
store = OpenVikingUserKeyStore(str(db_path))
|
||||
|
||||
store.save_user_key("userA", "userA-key")
|
||||
store.save_session("userA", "sessionA1")
|
||||
store.save_task(
|
||||
user_id="userA",
|
||||
session_id="sessionA1",
|
||||
task_id="task-1",
|
||||
archive_uri="viking://session/userA/sessionA1/history/archive_001",
|
||||
)
|
||||
|
||||
reopened = OpenVikingUserKeyStore(str(db_path))
|
||||
|
||||
assert reopened.get_user_key("userA") == "userA-key"
|
||||
assert reopened.user_key_matches("userA", "userA-key")
|
||||
assert reopened.get_session("userA", "sessionA1") == {
|
||||
"user_id": "userA",
|
||||
"session_id": "sessionA1",
|
||||
"latest_task_id": "task-1",
|
||||
"latest_archive_uri": "viking://session/userA/sessionA1/history/archive_001",
|
||||
}
|
||||
assert reopened.get_task("task-1") == {
|
||||
"task_id": "task-1",
|
||||
"user_id": "userA",
|
||||
"session_id": "sessionA1",
|
||||
"archive_uri": "viking://session/userA/sessionA1/history/archive_001",
|
||||
}
|
||||
Reference in New Issue
Block a user