diff --git a/README.md b/README.md index 602f34b..5a8690a 100644 --- a/README.md +++ b/README.md @@ -151,6 +151,7 @@ http://127.0.0.1:1934/memory-system | `POST` | `/messages` | 写入一轮或半轮会话消息 | 需要 | | `POST` | `/sessions/{session_id}/commit` | 提交会话,触发 OpenViking commit 和 EverOS flush | 需要 | | `POST` | `/sessions/{session_id}/extract` | 立即触发 OpenViking extract | 需要 | +| `GET/POST` | `/sessions/{session_id}/context` | 查询 OpenViking 会话上下文,并用同一 query 搜索 EverOS 记忆 | 需要 | | `GET` | `/openviking/tasks/{task_id}` | 查询 OpenViking 后台任务状态 | 需要 | | `POST` | `/search` | 同时搜索 OpenViking 和 EverOS 记忆 | 需要 | | `GET` | `/users/{user_id}/profile` | 查询 EverOS profile | 需要 | @@ -284,6 +285,59 @@ curl -sS -X POST http://127.0.0.1:1934/memory-system/sessions/sessionA1/extract }' ``` +### `GET/POST /sessions/{session_id}/context` + +查询一次会话的当前上下文。网关会调用 OpenViking: + +```http +GET /api/v1/sessions/{session_id}/context +Authorization: Bearer +``` + +同时用同一个 `query` 调用 EverOS `/api/v1/memories/search`,返回相关 episodic/profile/raw message 记忆。适合在回答用户问题前,把“当前 session 工作记忆”和“EverOS 相关记忆”一起取回。 + +路径参数: + +| 参数 | 类型 | 说明 | +|---|---|---| +| `session_id` | string | 要查询的会话 ID | + +请求体: + +| 参数 | 类型 | 必需 | 说明 | +|---|---|---:|---| +| `user_id` | string | 是 | 用户 ID | +| `user_key` | string | 是 | `/users` 返回的 user key | +| `query` | string | 是 | 用于 EverOS search 的查询文本 | +| `limit` | int | 否 | EverOS 记忆返回条数,默认 10,范围 1 到 100 | + +POST 示例: + +```bash +curl -sS -X POST http://127.0.0.1:1934/memory-system/sessions/sessionA1/context \ + -H "Content-Type: application/json" \ + -d '{ + "user_id": "userA", + "user_key": "'"$USER_KEY"'", + "query": "我喜欢喝什么?", + "limit": 10 + }' +``` + +GET 示例: + +```bash +curl -sS "http://127.0.0.1:1934/memory-system/sessions/sessionA1/context?user_id=userA&user_key=$USER_KEY&query=我喜欢喝什么?&limit=10" +``` + +返回字段: + +| 字段 | 含义 | +|---|---| +| `context` | OpenViking `result`,包含 `latest_archive_overview`、`pre_archive_abstracts`、`messages`、`stats` | +| `items` | EverOS 搜索命中的精简记忆结果,含 `source_backend: "everos"` | +| `backends` | 两个后端的精简诊断信息,不重复返回完整 OpenViking context 或 EverOS `original_data` | + ### `GET /openviking/tasks/{task_id}` 查询 OpenViking 后台任务状态,例如 commit 返回的任务。 diff --git a/memory_system_api/api.py b/memory_system_api/api.py index 0c59a31..71b5318 100644 --- a/memory_system_api/api.py +++ b/memory_system_api/api.py @@ -4,7 +4,13 @@ from __future__ import annotations from fastapi import APIRouter, Depends, HTTPException, Query, status from .auth import verify_api_key -from .schemas import MessageIngestRequest, SearchRequest, SessionUserRequest, UserCreateRequest +from .schemas import ( + MessageIngestRequest, + SearchRequest, + SessionContextRequest, + SessionUserRequest, + UserCreateRequest, +) from .service import MemorySystemService @@ -70,6 +76,34 @@ async def extract_session( raise user_auth_error(exc) from exc +@router.post("/sessions/{session_id}/context") +async def get_session_context( + session_id: str, + request: SessionContextRequest, + service: MemorySystemService = Depends(get_service), +): + try: + return await service.get_session_context(session_id, request) + except PermissionError as exc: + raise user_auth_error(exc) from exc + + +@router.get("/sessions/{session_id}/context") +async def get_session_context_from_query( + session_id: str, + user_id: str = Query(min_length=1), + user_key: str = Query(min_length=1), + query: str = Query(min_length=1), + limit: int = Query(default=10, ge=1, le=100), + service: MemorySystemService = Depends(get_service), +): + try: + request = SessionContextRequest(user_id=user_id, user_key=user_key, query=query, limit=limit) + return await service.get_session_context(session_id, request) + 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, diff --git a/memory_system_api/clients.py b/memory_system_api/clients.py index aabbc06..5d191cc 100644 --- a/memory_system_api/clients.py +++ b/memory_system_api/clients.py @@ -180,6 +180,12 @@ class OpenVikingMemorySystemClient: response.raise_for_status() return response.json() + async def get_session_context(self, credential: OpenVikingCredential | str, session_id: str) -> dict[str, Any]: + async with self._credential_client(credential) as client: + response = await client.get(f"/api/v1/sessions/{session_id}/context") + response.raise_for_status() + return response.json() + def _credential_client(self, credential: OpenVikingCredential | str) -> httpx.AsyncClient: if isinstance(credential, str): return self._client(credential) diff --git a/memory_system_api/schemas.py b/memory_system_api/schemas.py index 84e79c1..bd2003b 100644 --- a/memory_system_api/schemas.py +++ b/memory_system_api/schemas.py @@ -33,6 +33,13 @@ class SearchRequest(BaseModel): limit: int = Field(default=10, ge=1, le=100) +class SessionContextRequest(BaseModel): + user_id: str = Field(min_length=1) + user_key: str = Field(min_length=1) + query: str = Field(min_length=1) + limit: int = Field(default=10, ge=1, le=100) + + class BackendStatus(BaseModel): status: OperationStatus result: Any = None @@ -71,6 +78,13 @@ class SearchResponse(BaseModel): backends: dict[str, BackendStatus] +class SessionContextResponse(BaseModel): + status: OperationStatus + context: dict[str, Any] | None = None + items: list[dict[str, Any]] = Field(default_factory=list) + backends: dict[str, BackendStatus] + + class ProfileResponse(BaseModel): status: OperationStatus profile: Any = None diff --git a/memory_system_api/service.py b/memory_system_api/service.py index fcbdeeb..365543a 100644 --- a/memory_system_api/service.py +++ b/memory_system_api/service.py @@ -15,6 +15,8 @@ from .schemas import ( ProfileResponse, SearchRequest, SearchResponse, + SessionContextRequest, + SessionContextResponse, ) @@ -124,6 +126,40 @@ class MemorySystemService: backends=compact_backends, ) + async def get_session_context(self, session_id: str, request: SessionContextRequest) -> SessionContextResponse: + credential = self.openviking.credential_for_user( + request.user_id, + request.user_key, + agent_id=session_id, + ) + + async def read_openviking_context() -> dict[str, Any]: + return await self.openviking.get_session_context(credential, session_id) + + async def search_everos() -> dict[str, Any]: + return await self.everos.search( + request.user_id, + session_id, + request.query, + "hybrid", + request.limit, + ) + + backends = await self._run_backends(openviking=read_openviking_context, everos=search_everos) + backends = self._remove_vectors_from_backends(backends) + context = self._context_from_openviking_result(backends["openviking"].result) + items = ( + self._items_from_backend_result("everos", backends["everos"].result)[: request.limit] + if backends["everos"].status == "success" + else [] + ) + return SessionContextResponse( + status=self._aggregate_status(backends), + context=context, + items=items, + backends=self._compact_session_context_backends(backends), + ) + async def get_profile(self, user_id: str) -> ProfileResponse: backends = {"everos": await self._capture(lambda: self.everos.get_profile(user_id))} profile = backends["everos"].result if backends["everos"].status == "success" else None @@ -260,6 +296,33 @@ class MemorySystemService: return result + def _context_from_openviking_result(self, result: Any) -> dict[str, Any] | None: + if not isinstance(result, dict): + return None + data = result.get("result") if isinstance(result.get("result"), dict) else result + return data if isinstance(data, dict) else None + + def _compact_session_context_backends(self, backends: dict[str, BackendStatus]) -> dict[str, BackendStatus]: + return { + name: backend.model_copy(update={"result": self._compact_session_context_backend_result(name, backend.result)}) + for name, backend in backends.items() + } + + def _compact_session_context_backend_result(self, backend_name: str, result: Any) -> Any: + if backend_name == "openviking": + data = self._context_from_openviking_result(result) + if data is None: + return result + compact = { + "status": result.get("status") if isinstance(result, dict) else None, + "estimatedTokens": data.get("estimatedTokens"), + "stats": data.get("stats"), + "has_latest_archive_overview": bool(data.get("latest_archive_overview")), + "message_count": len(data.get("messages") or []) if isinstance(data.get("messages"), list) else 0, + } + return {key: value for key, value in compact.items() if value is not None} + return self._compact_backend_result(backend_name, 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)}) diff --git a/skills/memory-system-api/SKILL.md b/skills/memory-system-api/SKILL.md index c7800c0..c46880c 100644 --- a/skills/memory-system-api/SKILL.md +++ b/skills/memory-system-api/SKILL.md @@ -59,6 +59,7 @@ Base path: `/memory-system` | `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/POST` | `/sessions/{session_id}/context` | Read OpenViking session context plus EverOS recall | 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 | @@ -127,6 +128,25 @@ curl -sS -X POST "$BASE/memory-system/search" \ }' ``` +Session context: + +```bash +curl -sS -X POST "$BASE/memory-system/sessions/sessionA1/context" \ + -H "Content-Type: application/json" \ + -d '{ + "user_id": "userA", + "user_key": "'"$USER_KEY"'", + "query": "我喜欢喝什么?", + "limit": 10 + }' +``` + +GET with query parameters is also supported: + +```bash +curl -sS "$BASE/memory-system/sessions/sessionA1/context?user_id=userA&user_key=$USER_KEY&query=我喜欢喝什么?&limit=10" +``` + ## Response Handling Top-level `status` is one of: @@ -137,6 +157,8 @@ Top-level `status` is one of: 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. +Session context responses include OpenViking context under `context`, EverOS recall under `items`, and compact backend diagnostics under `backends`. + ## Common Mistakes - Calling `/messages` before `/users`. diff --git a/skills/memory-system-api/references/api.md b/skills/memory-system-api/references/api.md index dab7f2e..32b3070 100644 --- a/skills/memory-system-api/references/api.md +++ b/skills/memory-system-api/references/api.md @@ -72,6 +72,68 @@ curl -s -X POST /memory-system/sessions//ext -d '{"user_id": "", "user_key": ""}' ``` +## Session Context + +Use this when the caller needs the OpenViking working-memory context for one session plus related EverOS recall for the same user/session. + +```bash +curl -s -X POST /memory-system/sessions//context \ + -H "Content-Type: application/json" \ + -d '{ + "user_id": "", + "user_key": "", + "query": "我喜欢喝什么?", + "limit": 10 + }' +``` + +Equivalent GET form: + +```bash +curl -s "/memory-system/sessions//context?user_id=&user_key=&query=我喜欢喝什么?&limit=10" +``` + +Response shape: + +```json +{ + "status": "success", + "context": { + "latest_archive_overview": "# Working Memory\n...", + "pre_archive_abstracts": [], + "messages": [], + "estimatedTokens": 342, + "stats": {"totalArchives": 3} + }, + "items": [ + { + "source_backend": "everos", + "memory_type": "episode", + "id": "episode-1", + "summary": "userB 在对话中表示自己喜欢拿铁。", + "score": 0.72 + } + ], + "backends": { + "openviking": { + "status": "success", + "result": { + "status": "ok", + "estimatedTokens": 342, + "has_latest_archive_overview": true, + "message_count": 0 + } + }, + "everos": { + "status": "success", + "result": { + "counts": {"episodes": 1, "profiles": 0, "raw_messages": 0} + } + } + } +} +``` + ## Search Without LLM planning: diff --git a/tests/test_memory_system_clients.py b/tests/test_memory_system_clients.py index 414d68a..a6859b9 100644 --- a/tests/test_memory_system_clients.py +++ b/tests/test_memory_system_clients.py @@ -255,6 +255,49 @@ def test_openviking_search_uses_session_target_uri(): ] +def test_openviking_get_session_context_uses_user_key_auth(): + client = OpenVikingMemorySystemClient(store=FakeStore()) + calls = [] + responses = [ + FakeResponse( + 200, + { + "status": "ok", + "result": { + "latest_archive_overview": "# Working Memory", + "messages": [], + }, + }, + ) + ] + 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.get_session_context(credential, "sess-1")) + + assert result == { + "status": "ok", + "result": { + "latest_archive_overview": "# Working Memory", + "messages": [], + }, + } + assert calls == [ + ( + "get", + "tom-key", + {}, + "/api/v1/sessions/sess-1/context", + None, + ) + ] + + def test_openviking_commit_keeps_no_recent_live_messages(): client = OpenVikingMemorySystemClient(store=FakeStore()) calls = [] diff --git a/tests/test_memory_system_server.py b/tests/test_memory_system_server.py index 4223c7b..ca746c6 100644 --- a/tests/test_memory_system_server.py +++ b/tests/test_memory_system_server.py @@ -4,6 +4,14 @@ def test_memory_system_server_exposes_routes(): paths = {route.path for route in app.routes} assert "/memory-system/users" in paths assert "/memory-system/messages" in paths + assert "/memory-system/sessions/{session_id}/context" in paths + context_methods = { + method + for route in app.routes + if getattr(route, "path", "") == "/memory-system/sessions/{session_id}/context" + for method in getattr(route, "methods", set()) + } + assert {"GET", "POST"} <= context_methods assert "/memory-system/search" in paths assert "/memory-system/users/{user_id}/profile" in paths diff --git a/tests/test_memory_system_service.py b/tests/test_memory_system_service.py index 70d228e..59604bc 100644 --- a/tests/test_memory_system_service.py +++ b/tests/test_memory_system_service.py @@ -1,6 +1,6 @@ import asyncio -from memory_system_api.schemas import MessageIngestRequest, SearchRequest +from memory_system_api.schemas import MessageIngestRequest, SearchRequest, SessionContextRequest from memory_system_api.service import MemorySystemService @@ -44,6 +44,19 @@ class FakeOpenViking: await asyncio.sleep(0.01) return {"items": [{"source": "openviking-search"}]} + async def get_session_context(self, user_key: str, session_id: str) -> dict: + self.calls.append(("get_session_context", user_key, session_id)) + return { + "status": "ok", + "result": { + "latest_archive_overview": "# Working Memory\nUser likes coffee.", + "pre_archive_abstracts": [], + "messages": [], + "estimatedTokens": 42, + "stats": {"totalArchives": 1}, + }, + } + 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"}} @@ -203,6 +216,43 @@ def test_search_returns_compact_items_and_backend_diagnostics_without_duplicate_ assert not _has_key(response.backends["everos"].result, "original_data") +def test_session_context_combines_openviking_context_and_everos_search_items(): + openviking = FakeOpenViking() + everos = FakeEverOSVerbose() + service = MemorySystemService(openviking=openviking, everos=everos) + + response = asyncio.run( + service.get_session_context( + "sess-1", + SessionContextRequest(user_id="tom", user_key="tom-key", query="我喜欢喝什么?", limit=5), + ) + ) + + assert response.status == "success" + assert response.context == { + "latest_archive_overview": "# Working Memory\nUser likes coffee.", + "pre_archive_abstracts": [], + "messages": [], + "estimatedTokens": 42, + "stats": {"totalArchives": 1}, + } + assert response.items == [ + { + "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 ("credential_for_user", "tom", "tom-key", "sess-1") in openviking.calls + assert ("get_session_context", "key-tom", "sess-1") in openviking.calls + assert ("search", "tom", "sess-1", "我喜欢喝什么?", "hybrid", 5) in everos.calls + + 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())