Add generic memory gateway v1

This commit is contained in:
2026-05-05 16:18:31 +08:00
parent ba84b1ddb3
commit e65731a273
54 changed files with 4082 additions and 49 deletions

View File

@ -0,0 +1,53 @@
import asyncio
from memory_gateway.evermemos_service import ConsolidateRequest, consolidate_session
def test_evermemos_service_consolidates_session(monkeypatch, tmp_path):
monkeypatch.setattr(
"memory_gateway.obsidian_review.get_config",
lambda: type(
"Config",
(),
{
"obsidian": type(
"Obsidian",
(),
{"vault_path": str(tmp_path / "vault"), "review_dir": "Reviews/Queue"},
)()
},
)(),
)
payload = {
"session_id": "sess_service",
"context": {"user_id": "user_a", "agent_id": "agent_a", "workspace_id": "ws_a", "session_id": "sess_service"},
"episodes": [
{
"user_id": "user_a",
"agent_id": "agent_a",
"workspace_id": "ws_a",
"session_id": "sess_service",
"namespace": "session/sess_service/episodic",
"content": "结论EverMemOS 本地服务负责整理稳定长期记忆。",
"tags": ["decision"],
},
{
"user_id": "user_a",
"agent_id": "agent_a",
"workspace_id": "ws_a",
"session_id": "sess_service",
"namespace": "session/sess_service/episodic",
"content": "重要:高价值记忆应该进入 Obsidian review queue。",
"tags": ["review", "high-value"],
},
],
}
response = asyncio.run(consolidate_session(ConsolidateRequest.model_validate(payload)))
assert response["status"] == "ok"
result = response["result"]
assert result["episodes"] == 2
assert len(result["candidates"]) == 2
assert len(result["promoted"]) == 1
assert len(result["review_drafts"]) == 1

View File

@ -160,8 +160,9 @@ def test_mcp_rpc_lists_tools_with_api_key(monkeypatch):
assert response.status_code == 200
payload = response.json()
assert payload["jsonrpc"] == "2.0"
assert len(payload["result"]["tools"]) == 7
assert len(payload["result"]["tools"]) >= 7
assert any(tool["name"] == "commit_summary" for tool in payload["result"]["tools"])
assert any(tool["name"] == "memory_search" for tool in payload["result"]["tools"])
def test_search_passes_through_gateway(monkeypatch):

29
tests/test_v1_mcp.py Normal file
View File

@ -0,0 +1,29 @@
import asyncio
from memory_gateway.repositories import InMemoryRepository
from memory_gateway.services import MemoryGatewayService
def test_v1_mcp_tools_are_exposed_and_dispatch(monkeypatch):
import memory_gateway.server as server
service = MemoryGatewayService(InMemoryRepository())
monkeypatch.setattr(server, "v1_service", service)
tools = asyncio.run(server.list_tools())
assert any(tool.name == "memory_search" for tool in tools)
assert any(tool.name == "memory_commit_session" for tool in tools)
result = asyncio.run(
server.call_v1_memory_tool(
"memory_upsert",
{
"user_id": "user_a",
"content": "MCP 写入的 v1 memory",
"visibility": "private",
},
)
)
assert result["user_id"] == "user_a"
assert result["namespace"] == "user/user_a/long_term"

185
tests/test_v1_service.py Normal file
View File

@ -0,0 +1,185 @@
import asyncio
from memory_gateway.repositories import InMemoryRepository, SQLiteRepository
from memory_gateway.schemas import (
AccessContext,
CommitSessionRequest,
CreateUserRequest,
EpisodeAppendRequest,
MemorySearchRequest,
MemoryUpsertRequest,
Visibility,
)
from memory_gateway.services import MemoryGatewayService
from memory_gateway.types import Config, EverMemOSConfig, ObsidianConfig
def test_private_memory_is_isolated_by_user():
service = MemoryGatewayService(InMemoryRepository())
service.create_user(CreateUserRequest(user_id="user_a", display_name="A"))
service.create_user(CreateUserRequest(user_id="user_b", display_name="B"))
memory = service.upsert_memory(
MemoryUpsertRequest(
user_id="user_a",
content="用户 A 的私有偏好是中文输出",
visibility=Visibility.PRIVATE,
)
)
own_results = service.search_memory(MemorySearchRequest(user_id="user_a", query="中文"))
other_results = service.search_memory(MemorySearchRequest(user_id="user_b", query="中文"))
assert own_results["total"] == 1
assert own_results["results"][0]["memory"].id == memory.id
assert other_results["total"] == 0
def test_workspace_memory_requires_matching_workspace():
service = MemoryGatewayService(InMemoryRepository())
memory = service.upsert_memory(
MemoryUpsertRequest(
user_id="user_a",
workspace_id="ws_1",
content="workspace 共享的项目决策",
visibility=Visibility.WORKSPACE_SHARED,
)
)
visible = service.get_memory(memory.id, AccessContext(user_id="user_b", workspace_id="ws_1"))
assert visible.id == memory.id
hidden = service.search_memory(MemorySearchRequest(user_id="user_b", workspace_id="ws_2", query="项目决策"))
assert hidden["total"] == 0
def test_sqlite_repository_persists_memory(tmp_path):
db_path = tmp_path / "memory_gateway.sqlite3"
repo = SQLiteRepository(db_path)
service = MemoryGatewayService(repo)
service.create_user(CreateUserRequest(user_id="user_a", display_name="A"))
memory = service.upsert_memory(MemoryUpsertRequest(user_id="user_a", content="持久化 SQLite memory"))
reloaded_service = MemoryGatewayService(SQLiteRepository(db_path))
reloaded = reloaded_service.get_memory(memory.id, AccessContext(user_id="user_a"))
assert reloaded.content == "持久化 SQLite memory"
def test_commit_session_promotes_dedupes_and_creates_review_draft(monkeypatch, tmp_path):
monkeypatch.setattr(
"memory_gateway.services.get_config",
lambda: Config(evermemos=EverMemOSConfig(enabled=False)),
)
monkeypatch.setattr(
"memory_gateway.obsidian_review.get_config",
lambda: Config(obsidian=ObsidianConfig(vault_path=str(tmp_path / "vault"), review_dir="Reviews/Queue")),
)
service = MemoryGatewayService(InMemoryRepository())
service.append_episode(
EpisodeAppendRequest(
user_id="user_a",
session_id="sess_1",
content="结论:这个项目必须保留用户隔离和 namespace ACL。",
tags=["decision"],
)
)
service.append_episode(
EpisodeAppendRequest(
user_id="user_a",
session_id="sess_1",
content="重要:这条高价值记忆需要人工 review 后再进入长期记忆。",
tags=["review", "high-value"],
)
)
result = service.commit_session(
"sess_1",
CommitSessionRequest(
user_id="user_a",
session_id="sess_1",
min_importance=0.6,
),
)
assert len(result["promoted"]) == 1
assert result["evermemos_backend"] == "local-disabled"
assert len(result["review_drafts"]) == 1
assert (tmp_path / "vault" / "Reviews" / "Queue").exists()
def test_commit_session_uses_external_evermemos(monkeypatch):
monkeypatch.setattr(
"memory_gateway.services.get_config",
lambda: Config(evermemos=EverMemOSConfig(enabled=True, fallback_to_local=False)),
)
class FakeEverMemOSClient:
def consolidate_session(self, **kwargs):
return {
"episodes": 1,
"candidates": [],
"promoted": [
{
"content": "外部 EverMemOS 总结出的长期记忆",
"summary": "外部 EverMemOS 长期记忆",
"memory_type": "summary",
"tags": ["external-evermemos"],
}
],
"duplicates": [],
"conflicts": [],
"review_drafts": [],
}
def health(self):
return {"status": "ok"}
service = MemoryGatewayService(InMemoryRepository(), evermemos_client=FakeEverMemOSClient())
service.append_episode(
EpisodeAppendRequest(
user_id="user_a",
session_id="sess_external",
content="这条 episode 应该交给外部 EverMemOS。",
)
)
result = service.commit_session(
"sess_external",
CommitSessionRequest(user_id="user_a", session_id="sess_external"),
)
assert result["evermemos_backend"] == "external"
assert len(result["promoted"]) == 1
search = service.search_memory(MemorySearchRequest(user_id="user_a", query="外部 EverMemOS"))
assert search["total"] == 1
def test_search_fans_out_to_openviking_after_namespace_acl(monkeypatch):
service = MemoryGatewayService(InMemoryRepository())
class FakeSearchResult:
results = [{"uri": "viking://user/user_a/long_term/demo", "abstract": "OpenViking result", "score": 0.9}]
class FakeOpenVikingClient:
async def search(self, query, namespace=None, limit=None, uri=None):
assert namespace == "user/user_a/long_term"
return FakeSearchResult()
async def fake_get_openviking_client():
return FakeOpenVikingClient()
monkeypatch.setattr("memory_gateway.services.get_openviking_client", fake_get_openviking_client)
result = asyncio.run(
service.search_memory_with_openviking(
MemorySearchRequest(
user_id="user_a",
query="demo",
namespaces=["user/user_a/long_term", "user/user_b/long_term"],
)
)
)
assert result["openviking_total"] == 1
assert result["searched_namespaces"] == ["user/user_a/long_term"]