Compare commits

..

11 Commits

74 changed files with 7055 additions and 75611 deletions

27
.env.example Normal file
View File

@ -0,0 +1,27 @@
# Upstream memory service used by the gateway client.
MEMORY_GATEWAY_BACKEND_BASE_URL=http://127.0.0.1:1995
# Gateway-owned SQLite database. This does not point at upstream internal storage.
MEMORY_GATEWAY_DB_PATH=./data/memory_gateway.sqlite3
# Raw uploaded files are stored here for gateway-managed ingestion.
MEMORY_GATEWAY_STORAGE_DIR=./data/storage
# Number of resource session IDs sent per upstream search request.
MEMORY_GATEWAY_RESOURCE_SEARCH_BATCH_SIZE=50
# Max upload size in bytes. Default here is 25 MiB.
MEMORY_GATEWAY_MAX_UPLOAD_BYTES=26214400
# Comma-separated MIME allowlist. Prefix wildcards such as image/* are supported.
MEMORY_GATEWAY_ALLOWED_MIME_TYPES=image/*,audio/*,application/pdf,text/html,application/xhtml+xml,text/plain,text/markdown,text/csv,application/json,application/msword,application/vnd.openxmlformats-officedocument.wordprocessingml.document,application/vnd.ms-excel,application/vnd.openxmlformats-officedocument.spreadsheetml.sheet,application/vnd.ms-powerpoint,application/vnd.openxmlformats-officedocument.presentationml.presentation
# Upstream add/flush retry policy during resource ingestion.
MEMORY_GATEWAY_BACKEND_INGEST_ATTEMPTS=3
MEMORY_GATEWAY_BACKEND_RETRY_DELAY_SECONDS=0.25
MEMORY_GATEWAY_BACKEND_TIMEOUT_SECONDS=120
# API server settings used by python main.py.
MEMORY_GATEWAY_HOST=0.0.0.0
MEMORY_GATEWAY_PORT=8010
MEMORY_GATEWAY_RELOAD=false

33
.gitignore vendored
View File

@ -1,11 +1,18 @@
# Local runtime configuration # Local environment files
config.yaml
*.local.yaml
*.secret.yaml
.env .env
.env.* backend.env
*.env.local
# Python cache / test artifacts # Gateway runtime data
data/
storage/
*.sqlite
*.sqlite3
*.db
*.db-shm
*.db-wal
# Python caches
__pycache__/ __pycache__/
*.py[cod] *.py[cod]
.pytest_cache/ .pytest_cache/
@ -17,13 +24,13 @@ htmlcov/
# Virtual environments # Virtual environments
.venv/ .venv/
venv/ venv/
env/
# Local editor / agent metadata # Packaging output
.codex build/
.DS_Store dist/
*.egg-info/
# Runtime output # Logs and local process files
*.log *.log
*.tmp *.pid
*.sqlite3
obsidian-vault/Reviews/

168
AGENTS.md
View File

@ -1,168 +0,0 @@
# AGENTS.md
Guidance for coding agents working in this repository.
## Project Overview
Memory Gateway is a lightweight Python HTTP gateway that exposes one business-facing Memory System API over two memory backends:
- OpenViking handles sessions, session archives, extracted long-term memories, resources, and semantic search.
- EverOS handles profile and episodic-memory style recall.
The main package is `memory_system_api`. Treat it as a narrow gateway, not a replacement for either backend. Callers should interact with users, sessions, messages, resources, memories, search, and profile endpoints under `/memory-system`.
## Key Contract
Business endpoints are gated by `user_id + user_key`.
- Create a user first with `POST /memory-system/users`.
- Save `account.result.user_key` from the response.
- Pass `user_id` and `user_key` on later business calls.
- Do not ask callers to pass `account_id`.
- Do not ask callers to pass OpenViking root keys.
- `X-API-Key` is only for protecting the Memory Gateway server itself when `server.api_key` is configured.
OpenViking user calls use the OpenViking user key as `X-API-Key`. Admin calls, such as account creation, use the configured OpenViking root key internally.
## Important Paths
- `memory_system_api/api.py`: FastAPI routes under `/memory-system`.
- `memory_system_api/service.py`: orchestration across OpenViking and EverOS.
- `memory_system_api/clients.py`: async HTTP clients for backend APIs.
- `memory_system_api/schemas.py`: Pydantic request and response models.
- `memory_system_api/store.py`: SQLite persistence for user keys, sessions, tasks, and archive metadata.
- `memory_system_api/config.py`: YAML and environment-based configuration.
- `plugins/memory/memory_system/`: Hermes memory provider plugin that talks to this API.
- `skills/memory-system-api/`: local agent skill and reference docs for using this API.
- `eval/hermes_memory_eval/`: LoCoMo-style evaluation runner for Hermes memory behavior.
- `tests/`: unit tests for clients, service orchestration, server routes, store behavior, and plugin behavior.
## API Surface
Current Memory System endpoints include:
- `GET /memory-system/health`
- `POST /memory-system/users`
- `POST /memory-system/messages`
- `POST /memory-system/sessions/{session_id}/commit`
- `POST /memory-system/sessions/{session_id}/extract`
- `GET|POST /memory-system/sessions/{session_id}/context`
- `GET|POST /memory-system/openviking/tasks/{task_id}`
- `GET /memory-system/memories`
- `GET /memory-system/memories/content`
- `POST /memory-system/memories`
- `DELETE /memory-system/memories`
- `POST /memory-system/resources`
- `DELETE /memory-system/resources`
- `POST /memory-system/search`
- `GET|POST /memory-system/users/{user_id}/profile`
Memory writes are implemented through OpenViking `POST /api/v1/content/write` with `mode` set to `create`, `replace`, or `append`. Memory reads and deletes use `content/read`, `fs/ls`, and `fs`.
## Development Setup
The project uses Python 3.10+.
Common setup:
```bash
uv sync --extra dev
```
If `uv` is not installed on a fresh server:
```bash
curl -LsSf https://astral.sh/uv/install.sh | sh
export PATH="$HOME/.local/bin:$PATH"
uv sync --extra dev
```
In sandboxed environments, `uv` may need a writable cache directory:
```bash
uv --cache-dir /private/tmp/uv-cache sync --extra dev
uv --cache-dir /private/tmp/uv-cache run pytest -q
```
## Running The Server
Copy the example config before local runs:
```bash
cp config.example.yaml config.yaml
```
Start OpenViking and EverOS first, then run:
```bash
uv run memory-gateway --config config.yaml --host 0.0.0.0 --port 1934
```
The default Memory System base URL is:
```text
http://127.0.0.1:1934/memory-system
```
## Validation
Run focused tests for touched areas whenever possible:
```bash
uv run pytest -q tests/test_memory_system_clients.py
uv run pytest -q tests/test_memory_system_service.py
uv run pytest -q tests/test_memory_system_server.py
uv run pytest -q tests/test_memory_system_store.py
uv run pytest -q tests/test_hermes_memory_system_plugin.py
```
Run the full suite before handing off broad changes:
```bash
uv run pytest -q
uv run python -m compileall -q memory_system_api plugins eval tests
```
If dependencies cannot be installed, at minimum run:
```bash
python3 -m compileall -q memory_system_api plugins eval tests
```
Then report the missing `uv` or test dependency clearly.
## Coding Guidelines
- Keep changes scoped to the relevant API, service, client, schema, plugin, or docs surface.
- Prefer existing patterns in `api.py`, `service.py`, `clients.py`, and tests.
- Add or update tests before changing behavior.
- Preserve the `BackendStatus` response pattern: top-level status plus per-backend status, result, and error.
- Keep OpenViking-only operations from accidentally touching EverOS.
- Keep search responses compact. Do not return embedding vectors or large raw EverOS `original_data` blobs.
- Do not introduce in-memory identity caches; SQLite is the source of truth for user keys, sessions, tasks, and archive URIs.
- Do not commit local `config.yaml`, SQLite databases, real user keys, root keys, or API secrets.
- Use `rg` for searches and prefer small, targeted patches.
## Testing Patterns
The tests use lightweight fake clients and stores. When adding backend client behavior:
- Add client tests that assert exact HTTP path, headers, params, and JSON body.
- Add service tests that assert `credential_for_user` is called and the correct backend receives the operation.
- Add server route tests when new endpoints are exposed.
- For plugin changes, update `tests/test_hermes_memory_system_plugin.py`.
## Documentation Updates
When changing public behavior, update the relevant docs:
- `README.md` for user-facing setup and API details.
- `skills/memory-system-api/SKILL.md` for agent-facing usage.
- `skills/memory-system-api/references/api.md` when the longer API reference needs to match.
- `plugins/memory/memory_system/README.md` when Hermes plugin behavior changes.
## Safety Notes
- Never include real OpenViking root keys, user keys, Memory System API keys, or EverOS keys in examples.
- Prefer placeholders such as `<USER_KEY>`, `<MEMORY_SYSTEM_API_KEY>`, and `<OPENVIKING_ROOT_KEY>`.
- Keep destructive operations explicit. Deleting memories or resources maps to OpenViking `DELETE /api/v1/fs`; check `recursive` defaults and tests carefully.

1261
README.md

File diff suppressed because it is too large Load Diff

View File

@ -1,23 +0,0 @@
# Copy this file to config.yaml and replace placeholders with local values.
# Do not commit config.yaml because it may contain backend root keys.
server:
host: "127.0.0.1"
port: 1934
# Optional key that protects Memory Gateway itself. Leave empty for local dev.
api_key: ""
openviking:
# OpenViking HTTP server. The api_key must match server.root_api_key in ov.conf.
url: "http://127.0.0.1:1933"
api_key: "<OPENVIKING_ROOT_KEY>"
everos:
# EverOS HTTP server exposing /api/v1/memory/*.
url: "http://127.0.0.1:1995"
storage:
sqlite_path: "./memory_system_api.sqlite3"
logging:
level: "INFO"

7
core/__init__.py Normal file
View File

@ -0,0 +1,7 @@
"""Lightweight user resource memory gateway."""
from __future__ import annotations
__all__ = ["__version__"]
__version__ = "0.1.0"

598
core/api.py Normal file
View File

@ -0,0 +1,598 @@
from __future__ import annotations
import json
import logging
import time
from datetime import datetime, timezone
from typing import Any, Literal
from urllib.parse import parse_qsl, quote, urlsplit, urlunsplit
import httpx
from fastapi import APIRouter, FastAPI, File, Form, HTTPException, Request, UploadFile
from pydantic import ValidationError
from pydantic import BaseModel, Field, field_validator
from starlette.datastructures import UploadFile as StarletteUploadFile
from starlette.responses import Response
from .config import GatewayConfig
from .db import init_db
from .backend_client import BackendClient
from .repository import MemoryRepository
from .service import (
InvalidAttachment,
MemoryGatewayService,
UnsupportedContentType,
UploadTooLarge,
)
API_LOGGER = logging.getLogger("memory_gateway.api")
MAX_LOG_BODY_BYTES = 4096
REDACTED = "[REDACTED]"
SENSITIVE_FIELD_NAMES = {
"api_key",
"authorization",
"password",
"secret",
"token",
"user_key",
}
class SearchMemoriesRequest(BaseModel):
user_id: str = Field(min_length=1)
user_key: str = Field(min_length=1)
agent_id: str | None = Field(default=None, min_length=1)
conversation_id: str | None = None
query: str = Field(min_length=1)
scope: list[Literal["current_chat", "resources", "all_user_memory"]] = Field(
default_factory=lambda: ["current_chat", "resources"]
)
method: Literal["keyword", "vector", "hybrid", "agentic"] = "hybrid"
top_k: int = 8
radius: float | None = Field(default=None, ge=0, le=1)
include_profile: bool = True
enable_llm_rerank: bool = True
filters: dict[str, Any] | None = None
app_id: str = "default"
project_id: str = "default"
@field_validator("top_k")
@classmethod
def validate_top_k(cls, value: int) -> int:
if value != -1 and not 1 <= value <= 100:
raise ValueError("top_k must be -1 or in 1..100")
return value
class AddMemoryMessage(BaseModel):
sender_id: str = Field(min_length=1)
role: Literal["user", "assistant", "tool"]
timestamp: int = Field(gt=0)
content: str | list[dict[str, Any]]
class AddMemoryRequest(BaseModel):
user_id: str = Field(min_length=1)
user_key: str = Field(min_length=1)
session_id: str = Field(min_length=1)
messages: list[AddMemoryMessage] = Field(min_length=1)
app_id: str = "default"
project_id: str = "default"
class FlushMemoryRequest(BaseModel):
user_id: str = Field(min_length=1)
user_key: str = Field(min_length=1)
session_id: str = Field(min_length=1)
app_id: str = "default"
project_id: str = "default"
class ExternalResourceRequest(BaseModel):
user_id: str = Field(min_length=1)
user_key: str = Field(min_length=1)
app_id: str = "default"
project_id: str = "default"
filename: str = Field(min_length=1)
mime_type: str | None = None
content_type: str | None = None
size_bytes: int | None = Field(default=None, ge=0)
sha256: str | None = None
source_uri: str = Field(min_length=1)
ingest_uri: str | None = None
title: str | None = None
description: str | None = None
class MemoryOverrideRequest(BaseModel):
user_id: str = Field(min_length=1)
user_key: str = Field(min_length=1)
session_id: str = Field(min_length=1)
override_text: str = Field(min_length=1)
class MemoryDeleteRequest(BaseModel):
user_id: str = Field(min_length=1)
user_key: str = Field(min_length=1)
session_id: str = Field(min_length=1)
reason: str | None = None
class UserCreateRequest(BaseModel):
user_id: str = Field(min_length=1)
def _is_sensitive_field(key: str) -> bool:
lowered = key.lower()
return lowered in SENSITIVE_FIELD_NAMES or lowered.endswith("_token")
def _redact(value: Any, key: str | None = None) -> Any:
if key is not None and _is_sensitive_field(key):
return REDACTED
if isinstance(value, dict):
return {
item_key: _redact(item_value, item_key)
for item_key, item_value in value.items()
}
if isinstance(value, list):
return [_redact(item) for item in value]
return value
def _redacted_query_params(items: list[tuple[str, str]]) -> dict[str, Any]:
result: dict[str, Any] = {}
for key, value in items:
safe_value = REDACTED if _is_sensitive_field(key) else value
if key in result:
existing = result[key]
if isinstance(existing, list):
existing.append(safe_value)
else:
result[key] = [existing, safe_value]
else:
result[key] = safe_value
return result
def _redacted_url(url: str) -> str:
parts = urlsplit(url)
query = "&".join(
f"{quote(key, safe='')}="
f"{quote(REDACTED if _is_sensitive_field(key) else value, safe='[]')}"
for key, value in parse_qsl(parts.query, keep_blank_values=True)
)
return urlunsplit((parts.scheme, parts.netloc, parts.path, query, parts.fragment))
def _should_capture_request_body(
content_type: str | None,
content_length: int | None,
) -> bool:
normalized = (content_type or "").lower()
if normalized.startswith("multipart/"):
return False
return content_length is None or content_length <= MAX_LOG_BODY_BYTES
def _uncaptured_body_for_log(
content_type: str | None,
content_length: int | None,
) -> dict[str, Any]:
normalized = (content_type or "").lower()
result: dict[str, Any] = {
"content_type": normalized,
"size_bytes": content_length,
}
if not normalized.startswith("multipart/"):
result["truncated"] = True
return result
def _body_for_log(body: bytes, content_type: str | None) -> Any:
if not body:
return None
content_type = (content_type or "").lower()
if content_type.startswith("multipart/"):
return {"content_type": content_type, "size_bytes": len(body)}
if len(body) > MAX_LOG_BODY_BYTES:
return {
"truncated": True,
"size_bytes": len(body),
"content_type": content_type,
}
text = body.decode("utf-8", errors="replace")
if "application/json" in content_type:
try:
return _redact(json.loads(text))
except json.JSONDecodeError:
return text
if "application/x-www-form-urlencoded" in content_type:
return _redacted_query_params(parse_qsl(text, keep_blank_values=True))
if content_type.startswith("text/"):
return text
return {"content_type": content_type, "size_bytes": len(body)}
def _backend_http_error_detail(exc: httpx.HTTPStatusError) -> Any:
try:
return exc.response.json()
except ValueError:
return exc.response.text
def _form_text(form: Any, field: str, default: str | None = None) -> str:
value = form.get(field)
if value is None:
if default is not None:
return default
raise HTTPException(status_code=422, detail=f"missing form field: {field}")
if isinstance(value, StarletteUploadFile):
raise HTTPException(status_code=422, detail=f"form field must be text: {field}")
return str(value)
async def _form_json_text(form: Any, field: str) -> str:
value = form.get(field)
if value is None:
raise HTTPException(status_code=422, detail=f"missing form field: {field}")
if isinstance(value, StarletteUploadFile):
raw = await value.read()
return raw.decode("utf-8")
return str(value)
def _upload_files_from_form(form: Any) -> dict[str, UploadFile]:
files: dict[str, UploadFile] = {}
for key, value in form.multi_items():
if not isinstance(value, StarletteUploadFile):
continue
if key == "messages":
continue
if key in files:
raise HTTPException(
status_code=422,
detail=f"duplicate upload file field: {key}",
)
files[key] = value
return files
async def _multipart_messages(form: Any) -> list[dict[str, Any]]:
raw = await _form_json_text(form, "messages")
try:
parsed = json.loads(raw)
except json.JSONDecodeError as exc:
raise HTTPException(
status_code=400,
detail=f"invalid messages JSON: {exc.msg}",
) from exc
if not isinstance(parsed, list):
raise HTTPException(status_code=400, detail="messages must be a JSON array")
try:
return [AddMemoryMessage.model_validate(item).model_dump() for item in parsed]
except ValidationError as exc:
raise HTTPException(status_code=422, detail=exc.errors()) from exc
def create_app(
*,
config: GatewayConfig | None = None,
backend_client: Any | None = None,
) -> FastAPI:
cfg = config or GatewayConfig.from_env()
init_db(cfg.database_path)
repository = MemoryRepository(cfg.database_path)
client = backend_client or BackendClient(
cfg.backend_base_url,
timeout=cfg.backend_timeout_seconds,
)
service = MemoryGatewayService(cfg, repository, client)
app = FastAPI(title="memory-gateway", version="0.1.0")
app.state.config = cfg
app.state.repository = repository
app.state.backend_client = client
app.state.gateway_service = service
router = APIRouter()
@app.middleware("http")
async def log_api_request(request: Request, call_next: Any) -> Response:
request_time = datetime.now(timezone.utc).isoformat()
started = time.perf_counter()
request_content_type = request.headers.get("content-type")
raw_content_length = request.headers.get("content-length")
try:
request_content_length = (
int(raw_content_length) if raw_content_length is not None else None
)
except ValueError:
request_content_length = None
capture_request_body = _should_capture_request_body(
request_content_type,
request_content_length,
)
request_body = await request.body() if capture_request_body else None
response_body = b""
status_code = 500
error: str | None = None
try:
response = await call_next(request)
status_code = response.status_code
async for chunk in response.body_iterator:
response_body += chunk
logged_response = Response(
content=response_body,
status_code=response.status_code,
headers=dict(response.headers),
media_type=response.media_type,
background=response.background,
)
except Exception as exc:
error = str(exc)
raise
finally:
duration_ms = round((time.perf_counter() - started) * 1000, 3)
query_items = list(request.query_params.multi_items())
output: dict[str, Any] = {"status_code": status_code}
if error is not None:
output["error"] = error
else:
output["body"] = _body_for_log(
response_body,
response.headers.get("content-type"),
)
event = {
"request_time": request_time,
"duration_ms": duration_ms,
"method": request.method,
"path": request.url.path,
"url": _redacted_url(str(request.url)),
"client": request.client.host if request.client else None,
"input": {
"query_params": _redacted_query_params(query_items),
"body": (
_body_for_log(request_body, request_content_type)
if request_body is not None
else _uncaptured_body_for_log(
request_content_type,
request_content_length,
)
),
},
"output": output,
}
API_LOGGER.info(json.dumps(event, ensure_ascii=False, default=str))
return logged_response
def require_user(user_id: str, user_key: str) -> None:
if not service.authenticate_user(user_id, user_key):
raise HTTPException(status_code=401, detail="invalid user credentials")
@router.get("/health")
async def health() -> dict[str, Any]:
try:
backend_health = await client.health_check()
except Exception as exc:
return {
"status": "degraded",
"api": {"status": "ok"},
"backend": {
"status": "unavailable",
"base_url": cfg.backend_base_url,
"error": str(exc),
},
}
return {
"status": "ok",
"api": {"status": "ok"},
"backend": {
"status": "ok",
"base_url": cfg.backend_base_url,
"data": backend_health,
},
}
@router.post("/users")
async def create_user(request: UserCreateRequest) -> dict[str, Any]:
return service.create_user(request.user_id)
@router.post("/resources")
async def upload_resource(
user_id: str = Form(...),
user_key: str = Form(...),
app_id: str = Form("default"),
project_id: str = Form("default"),
title: str | None = Form(None),
description: str | None = Form(None),
file: UploadFile = File(...),
) -> dict[str, Any]:
require_user(user_id, user_key)
try:
return await service.upload_resource(
user_id=user_id,
app_id=app_id,
project_id=project_id,
file=file,
title=title,
description=description,
)
except UploadTooLarge as exc:
raise HTTPException(status_code=413, detail=str(exc)) from exc
except UnsupportedContentType as exc:
raise HTTPException(status_code=415, detail=str(exc)) from exc
@router.get("/resources")
async def list_resources(
user_id: str,
user_key: str,
) -> dict[str, Any]:
require_user(user_id, user_key)
return {"resources": service.list_resources(user_id)}
@router.get("/resources/{resource_id}")
async def get_resource(
resource_id: str,
user_id: str,
user_key: str,
) -> dict[str, Any]:
require_user(user_id, user_key)
resource = service.get_resource_detail(resource_id, user_id)
if resource is None:
return {"resources": []}
return {"resources": [resource]}
@router.post("/resources/external")
async def register_external_resource(
request: ExternalResourceRequest,
) -> dict[str, Any]:
require_user(request.user_id, request.user_key)
return await service.register_external_resource(
user_id=request.user_id,
app_id=request.app_id,
project_id=request.project_id,
filename=request.filename,
mime_type=request.mime_type,
content_type=request.content_type,
size_bytes=request.size_bytes,
sha256=request.sha256,
source_uri=request.source_uri,
ingest_uri=request.ingest_uri,
title=request.title,
description=request.description,
)
@router.delete("/resources/{resource_id}")
async def delete_resource(
resource_id: str,
user_id: str,
user_key: str,
) -> dict[str, Any]:
require_user(user_id, user_key)
resource = service.delete_resource(resource_id, user_id)
if resource is None:
raise HTTPException(status_code=404, detail="resource not found")
return resource
@router.post("/memories/search")
async def search_memories(
request: SearchMemoriesRequest,
) -> dict[str, Any]:
require_user(request.user_id, request.user_key)
return await service.search_memories(
user_id=request.user_id,
agent_id=request.agent_id,
query=request.query,
conversation_id=request.conversation_id,
scope=request.scope,
method=request.method,
top_k=request.top_k,
radius=request.radius,
include_profile=request.include_profile,
enable_llm_rerank=request.enable_llm_rerank,
filters=request.filters,
app_id=request.app_id,
project_id=request.project_id,
)
@router.post("/memories/add")
async def add_memory(
request: AddMemoryRequest,
) -> dict[str, Any]:
require_user(request.user_id, request.user_key)
try:
return await service.add_memory(
user_id=request.user_id,
session_id=request.session_id,
app_id=request.app_id,
project_id=request.project_id,
messages=[message.model_dump() for message in request.messages],
)
except httpx.HTTPStatusError as exc:
raise HTTPException(
status_code=exc.response.status_code,
detail=_backend_http_error_detail(exc),
) from exc
except UploadTooLarge as exc:
raise HTTPException(status_code=413, detail=str(exc)) from exc
except InvalidAttachment as exc:
raise HTTPException(status_code=422, detail=str(exc)) from exc
@router.post("/memories/add/multipart")
async def add_memory_multipart(request: Request) -> dict[str, Any]:
form = await request.form()
user_id = _form_text(form, "user_id")
user_key = _form_text(form, "user_key")
require_user(user_id, user_key)
try:
return await service.add_memory_with_uploads(
user_id=user_id,
session_id=_form_text(form, "session_id"),
app_id=_form_text(form, "app_id", "default"),
project_id=_form_text(form, "project_id", "default"),
messages=await _multipart_messages(form),
upload_files=_upload_files_from_form(form),
)
except httpx.HTTPStatusError as exc:
raise HTTPException(
status_code=exc.response.status_code,
detail=_backend_http_error_detail(exc),
) from exc
except UploadTooLarge as exc:
raise HTTPException(status_code=413, detail=str(exc)) from exc
except UnsupportedContentType as exc:
raise HTTPException(status_code=415, detail=str(exc)) from exc
except InvalidAttachment as exc:
raise HTTPException(status_code=422, detail=str(exc)) from exc
@router.post("/memories/flush")
async def flush_memory(
request: FlushMemoryRequest,
) -> dict[str, Any]:
require_user(request.user_id, request.user_key)
return await service.flush_memory(
session_id=request.session_id,
app_id=request.app_id,
project_id=request.project_id,
)
@router.patch("/memories/{memory_id}")
async def patch_memory(
memory_id: str,
request: MemoryOverrideRequest,
) -> dict[str, Any]:
require_user(request.user_id, request.user_key)
try:
service.assert_memory_session_owned(request.user_id, request.session_id)
except PermissionError as exc:
raise HTTPException(status_code=403, detail=str(exc)) from exc
return service.upsert_override(
user_id=request.user_id,
memory_id=memory_id,
session_id=request.session_id,
override_text=request.override_text,
)
@router.delete("/memories/{memory_id}")
async def delete_memory(
memory_id: str,
request: MemoryDeleteRequest,
) -> dict[str, Any]:
require_user(request.user_id, request.user_key)
try:
service.assert_memory_session_owned(request.user_id, request.session_id)
except PermissionError as exc:
raise HTTPException(status_code=403, detail=str(exc)) from exc
return service.delete_memory(
user_id=request.user_id,
memory_id=memory_id,
session_id=request.session_id,
reason=request.reason,
)
app.include_router(router)
return app

50
core/backend_client.py Normal file
View File

@ -0,0 +1,50 @@
from __future__ import annotations
from typing import Any
import httpx
class BackendClient:
def __init__(self, base_url: str, timeout: float = 120.0) -> None:
self.base_url = base_url.rstrip("/")
self.timeout = timeout
async def add_memory(self, payload: dict[str, Any]) -> dict[str, Any]:
return await self._post("/api/v1/memory/add", payload)
async def flush_memory(
self,
session_id: str,
app_id: str,
project_id: str,
) -> dict[str, Any]:
return await self._post(
"/api/v1/memory/flush",
{
"session_id": session_id,
"app_id": app_id,
"project_id": project_id,
},
)
async def search_memory(self, payload: dict[str, Any]) -> dict[str, Any]:
return await self._post("/api/v1/memory/search", payload)
async def health_check(self) -> dict[str, Any]:
async with httpx.AsyncClient(
base_url=self.base_url,
timeout=self.timeout,
) as client:
response = await client.get("/health")
response.raise_for_status()
return response.json()
async def _post(self, path: str, payload: dict[str, Any]) -> dict[str, Any]:
async with httpx.AsyncClient(
base_url=self.base_url,
timeout=self.timeout,
) as client:
response = await client.post(path, json=payload)
response.raise_for_status()
return response.json()

83
core/config.py Normal file
View File

@ -0,0 +1,83 @@
from __future__ import annotations
import os
from dataclasses import dataclass
from pathlib import Path
_PROJECT_ROOT = Path(__file__).resolve().parents[1]
_DEFAULT_ALLOWED_MIME_TYPES = (
"image/*",
"audio/*",
"application/pdf",
"text/html",
"application/xhtml+xml",
"text/plain",
"text/markdown",
"text/csv",
"application/json",
"application/msword",
"application/vnd.openxmlformats-officedocument.wordprocessingml.document",
"application/vnd.ms-excel",
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
"application/vnd.ms-powerpoint",
"application/vnd.openxmlformats-officedocument.presentationml.presentation",
)
@dataclass(frozen=True)
class GatewayConfig:
backend_base_url: str = "http://127.0.0.1:1995"
database_path: Path = _PROJECT_ROOT / "data" / "memory_gateway.sqlite3"
storage_dir: Path = _PROJECT_ROOT / "data" / "storage"
resource_search_batch_size: int = 50
max_upload_bytes: int = 25 * 1024 * 1024
allowed_mime_types: tuple[str, ...] = _DEFAULT_ALLOWED_MIME_TYPES
backend_ingest_attempts: int = 3
backend_retry_delay_seconds: float = 0.25
backend_timeout_seconds: float = 120.0
@classmethod
def from_env(cls) -> GatewayConfig:
allowed_mime_types = tuple(
item.strip()
for item in os.environ.get(
"MEMORY_GATEWAY_ALLOWED_MIME_TYPES",
",".join(_DEFAULT_ALLOWED_MIME_TYPES),
).split(",")
if item.strip()
)
return cls(
backend_base_url=os.environ.get(
"MEMORY_GATEWAY_BACKEND_BASE_URL",
"http://127.0.0.1:1995",
).rstrip("/"),
database_path=Path(
os.environ.get(
"MEMORY_GATEWAY_DB_PATH",
str(_PROJECT_ROOT / "data" / "memory_gateway.sqlite3"),
)
),
storage_dir=Path(
os.environ.get(
"MEMORY_GATEWAY_STORAGE_DIR",
str(_PROJECT_ROOT / "data" / "storage"),
)
),
resource_search_batch_size=int(
os.environ.get("MEMORY_GATEWAY_RESOURCE_SEARCH_BATCH_SIZE", "50")
),
max_upload_bytes=int(
os.environ.get("MEMORY_GATEWAY_MAX_UPLOAD_BYTES", str(25 * 1024 * 1024))
),
allowed_mime_types=allowed_mime_types,
backend_ingest_attempts=int(
os.environ.get("MEMORY_GATEWAY_BACKEND_INGEST_ATTEMPTS", "3")
),
backend_retry_delay_seconds=float(
os.environ.get("MEMORY_GATEWAY_BACKEND_RETRY_DELAY_SECONDS", "0.25")
),
backend_timeout_seconds=float(
os.environ.get("MEMORY_GATEWAY_BACKEND_TIMEOUT_SECONDS", "120")
),
)

145
core/db.py Normal file
View File

@ -0,0 +1,145 @@
from __future__ import annotations
import sqlite3
from pathlib import Path
SCHEMA = """
CREATE TABLE IF NOT EXISTS users (
id TEXT PRIMARY KEY,
user_key TEXT NOT NULL,
created_at TIMESTAMP NOT NULL,
updated_at TIMESTAMP NOT NULL
);
CREATE TABLE IF NOT EXISTS user_resources (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL,
app_id TEXT NOT NULL DEFAULT 'default',
project_id TEXT NOT NULL DEFAULT 'default',
session_id TEXT NOT NULL,
original_filename TEXT,
mime_type TEXT,
content_type TEXT NOT NULL,
uri TEXT NOT NULL,
uri_public BOOLEAN NOT NULL DEFAULT FALSE,
sha256 TEXT,
size_bytes INTEGER,
title TEXT,
description TEXT,
status TEXT NOT NULL DEFAULT 'pending',
error_message TEXT,
created_at TIMESTAMP NOT NULL,
updated_at TIMESTAMP NOT NULL,
deleted_at TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_user_resources_user_scope
ON user_resources (user_id, app_id, project_id, status, deleted_at);
CREATE INDEX IF NOT EXISTS idx_user_resources_session_id
ON user_resources (session_id);
CREATE INDEX IF NOT EXISTS idx_user_resources_user_id
ON user_resources (user_id);
CREATE TABLE IF NOT EXISTS memory_attachments (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL,
app_id TEXT NOT NULL DEFAULT 'default',
project_id TEXT NOT NULL DEFAULT 'default',
session_id TEXT NOT NULL,
resource_id TEXT,
content_type TEXT NOT NULL,
name TEXT NOT NULL,
internal_uri TEXT NOT NULL,
source TEXT NOT NULL,
sha256 TEXT,
created_at TIMESTAMP NOT NULL,
deleted_at TIMESTAMP
);
CREATE UNIQUE INDEX IF NOT EXISTS idx_memory_attachments_unique_uri
ON memory_attachments (user_id, session_id, internal_uri);
CREATE INDEX IF NOT EXISTS idx_memory_attachments_user_session
ON memory_attachments (user_id, session_id, deleted_at);
CREATE INDEX IF NOT EXISTS idx_memory_attachments_resource
ON memory_attachments (resource_id, deleted_at);
INSERT OR IGNORE INTO memory_attachments (
id,
user_id,
app_id,
project_id,
session_id,
resource_id,
content_type,
name,
internal_uri,
source,
sha256,
created_at,
deleted_at
)
SELECT
'a_resource_' || id,
user_id,
app_id,
project_id,
session_id,
id,
content_type,
COALESCE(original_filename, id),
uri,
'resource_upload',
sha256,
created_at,
deleted_at
FROM user_resources;
CREATE TABLE IF NOT EXISTS memory_tombstones (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL,
memory_id TEXT,
session_id TEXT,
reason TEXT,
created_at TIMESTAMP NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_memory_tombstones_user_memory
ON memory_tombstones (user_id, memory_id);
CREATE INDEX IF NOT EXISTS idx_memory_tombstones_user_session
ON memory_tombstones (user_id, session_id);
CREATE TABLE IF NOT EXISTS memory_overrides (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL,
memory_id TEXT,
session_id TEXT,
override_text TEXT NOT NULL,
is_active BOOLEAN NOT NULL DEFAULT TRUE,
created_at TIMESTAMP NOT NULL,
updated_at TIMESTAMP NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_memory_overrides_user_memory_active
ON memory_overrides (user_id, memory_id, is_active);
"""
def connect(db_path: Path) -> sqlite3.Connection:
conn = sqlite3.connect(db_path)
conn.row_factory = sqlite3.Row
conn.execute("PRAGMA journal_mode=WAL")
conn.execute("PRAGMA foreign_keys=ON")
conn.execute("PRAGMA busy_timeout=5000")
return conn
def init_db(db_path: Path) -> None:
db_path.parent.mkdir(parents=True, exist_ok=True)
with connect(db_path) as conn:
conn.executescript(SCHEMA)

375
core/repository.py Normal file
View File

@ -0,0 +1,375 @@
from __future__ import annotations
import uuid
from datetime import datetime, timezone
from pathlib import Path
from typing import Any
from .db import connect
def utc_now() -> str:
return datetime.now(timezone.utc).isoformat()
def _row_to_dict(row: Any | None) -> dict[str, Any] | None:
if row is None:
return None
return dict(row)
class MemoryRepository:
def __init__(self, db_path: Path) -> None:
self.db_path = db_path
def create_user(self, user_id: str, user_key: str) -> dict[str, Any]:
existing = self.get_user(user_id)
if existing is not None:
return existing
now = utc_now()
with connect(self.db_path) as conn:
conn.execute(
"""
INSERT INTO users (id, user_key, created_at, updated_at)
VALUES (?, ?, ?, ?)
""",
(user_id, user_key, now, now),
)
conn.commit()
user = self.get_user(user_id)
if user is None:
raise RuntimeError("created user could not be read back")
return user
def get_user(self, user_id: str) -> dict[str, Any] | None:
with connect(self.db_path) as conn:
row = conn.execute(
"SELECT * FROM users WHERE id = ?",
(user_id,),
).fetchone()
return _row_to_dict(row)
def create_resource(self, **values: Any) -> dict[str, Any]:
now = utc_now()
payload = {
"created_at": now,
"updated_at": now,
"deleted_at": None,
**values,
}
columns = ", ".join(payload)
placeholders = ", ".join(f":{key}" for key in payload)
with connect(self.db_path) as conn:
conn.execute(
f"INSERT INTO user_resources ({columns}) VALUES ({placeholders})",
payload,
)
conn.commit()
resource = self.get_resource(str(payload["id"]))
if resource is None:
raise RuntimeError("created resource could not be read back")
return resource
def update_resource_status(
self,
resource_id: str,
status: str,
error_message: str | None = None,
) -> dict[str, Any] | None:
with connect(self.db_path) as conn:
conn.execute(
"""
UPDATE user_resources
SET status = ?, error_message = ?, updated_at = ?
WHERE id = ?
""",
(status, error_message, utc_now(), resource_id),
)
conn.commit()
return self.get_resource(resource_id)
def soft_delete_resource(
self,
resource_id: str,
user_id: str | None = None,
) -> dict[str, Any] | None:
now = utc_now()
where = "id = ? AND deleted_at IS NULL"
params: tuple[Any, ...] = (now, now, resource_id)
attachment_where = "resource_id = ? AND deleted_at IS NULL"
attachment_params: tuple[Any, ...] = (now, resource_id)
if user_id is not None:
where += " AND user_id = ?"
params = (now, now, resource_id, user_id)
attachment_where += " AND user_id = ?"
attachment_params = (now, resource_id, user_id)
with connect(self.db_path) as conn:
conn.execute(
f"""
UPDATE user_resources
SET status = 'deleted', deleted_at = ?, updated_at = ?
WHERE {where}
""",
params,
)
conn.execute(
f"""
UPDATE memory_attachments
SET deleted_at = ?
WHERE {attachment_where}
""",
attachment_params,
)
conn.commit()
return self.get_resource(resource_id)
def get_resource(self, resource_id: str) -> dict[str, Any] | None:
with connect(self.db_path) as conn:
row = conn.execute(
"SELECT * FROM user_resources WHERE id = ?",
(resource_id,),
).fetchone()
return _row_to_dict(row)
def get_resource_for_user(
self,
resource_id: str,
user_id: str,
) -> dict[str, Any] | None:
with connect(self.db_path) as conn:
row = conn.execute(
"""
SELECT * FROM user_resources
WHERE id = ? AND user_id = ? AND deleted_at IS NULL
""",
(resource_id, user_id),
).fetchone()
return _row_to_dict(row)
def get_resource_by_session(self, session_id: str) -> dict[str, Any] | None:
with connect(self.db_path) as conn:
row = conn.execute(
"SELECT * FROM user_resources WHERE session_id = ?",
(session_id,),
).fetchone()
return _row_to_dict(row)
def get_resource_by_session_for_user(
self,
session_id: str,
user_id: str,
) -> dict[str, Any] | None:
with connect(self.db_path) as conn:
row = conn.execute(
"""
SELECT * FROM user_resources
WHERE session_id = ? AND user_id = ? AND deleted_at IS NULL
""",
(session_id, user_id),
).fetchone()
return _row_to_dict(row)
def find_active_resource_by_sha256(
self,
*,
user_id: str,
app_id: str,
project_id: str,
sha256: str,
) -> dict[str, Any] | None:
with connect(self.db_path) as conn:
row = conn.execute(
"""
SELECT * FROM user_resources
WHERE user_id = ?
AND app_id = ?
AND project_id = ?
AND sha256 = ?
AND deleted_at IS NULL
AND status IN ('ingesting', 'extracted')
ORDER BY created_at ASC
LIMIT 1
""",
(user_id, app_id, project_id, sha256),
).fetchone()
return _row_to_dict(row)
def list_resources(self, user_id: str) -> list[dict[str, Any]]:
with connect(self.db_path) as conn:
rows = conn.execute(
"""
SELECT * FROM user_resources
WHERE user_id = ? AND deleted_at IS NULL
ORDER BY created_at DESC
""",
(user_id,),
).fetchall()
return [dict(row) for row in rows]
def list_extracted_resources(
self,
user_id: str,
app_id: str,
project_id: str,
) -> list[dict[str, Any]]:
with connect(self.db_path) as conn:
rows = conn.execute(
"""
SELECT * FROM user_resources
WHERE user_id = ?
AND app_id = ?
AND project_id = ?
AND deleted_at IS NULL
AND status = 'extracted'
ORDER BY created_at DESC
""",
(user_id, app_id, project_id),
).fetchall()
return [dict(row) for row in rows]
def create_attachment(self, **values: Any) -> dict[str, Any]:
attachment_id = str(values.get("id") or f"a_{uuid.uuid4().hex}")
payload = {
"id": attachment_id,
"created_at": utc_now(),
"deleted_at": None,
**values,
}
with connect(self.db_path) as conn:
conn.execute(
"""
INSERT OR IGNORE INTO memory_attachments (
id, user_id, app_id, project_id, session_id, resource_id,
content_type, name, internal_uri, source, sha256,
created_at, deleted_at
) VALUES (
:id, :user_id, :app_id, :project_id, :session_id, :resource_id,
:content_type, :name, :internal_uri, :source, :sha256,
:created_at, :deleted_at
)
""",
payload,
)
row = conn.execute(
"""
SELECT * FROM memory_attachments
WHERE user_id = ? AND session_id = ? AND internal_uri = ?
""",
(
payload["user_id"],
payload["session_id"],
payload["internal_uri"],
),
).fetchone()
conn.commit()
attachment = _row_to_dict(row)
if attachment is None:
raise RuntimeError("created attachment could not be read back")
return attachment
def list_attachments_for_session(
self,
user_id: str,
session_id: str,
) -> list[dict[str, Any]]:
with connect(self.db_path) as conn:
rows = conn.execute(
"""
SELECT * FROM memory_attachments
WHERE user_id = ? AND session_id = ? AND deleted_at IS NULL
ORDER BY created_at ASC, id ASC
""",
(user_id, session_id),
).fetchall()
return [dict(row) for row in rows]
def add_tombstone(
self,
user_id: str,
memory_id: str | None,
session_id: str | None,
reason: str | None,
) -> dict[str, Any]:
tombstone_id = f"t_{uuid.uuid4().hex}"
with connect(self.db_path) as conn:
conn.execute(
"""
INSERT INTO memory_tombstones
(id, user_id, memory_id, session_id, reason, created_at)
VALUES (?, ?, ?, ?, ?, ?)
""",
(tombstone_id, user_id, memory_id, session_id, reason, utc_now()),
)
conn.commit()
return {"id": tombstone_id}
def get_tombstones(self, user_id: str) -> list[dict[str, Any]]:
with connect(self.db_path) as conn:
rows = conn.execute(
"SELECT * FROM memory_tombstones WHERE user_id = ?",
(user_id,),
).fetchall()
return [dict(row) for row in rows]
def upsert_override(
self,
user_id: str,
memory_id: str,
session_id: str | None,
override_text: str,
) -> dict[str, Any]:
now = utc_now()
with connect(self.db_path) as conn:
row = conn.execute(
"""
SELECT id FROM memory_overrides
WHERE user_id = ? AND memory_id = ? AND is_active = TRUE
ORDER BY created_at DESC
LIMIT 1
""",
(user_id, memory_id),
).fetchone()
if row:
override_id = row["id"]
conn.execute(
"""
UPDATE memory_overrides
SET session_id = ?, override_text = ?, updated_at = ?
WHERE id = ?
""",
(session_id, override_text, now, override_id),
)
else:
override_id = f"o_{uuid.uuid4().hex}"
conn.execute(
"""
INSERT INTO memory_overrides
(
id, user_id, memory_id, session_id, override_text,
is_active, created_at, updated_at
)
VALUES (?, ?, ?, ?, ?, TRUE, ?, ?)
""",
(
override_id,
user_id,
memory_id,
session_id,
override_text,
now,
now,
),
)
conn.commit()
return {"id": override_id}
def get_active_overrides(self, user_id: str) -> list[dict[str, Any]]:
with connect(self.db_path) as conn:
rows = conn.execute(
"""
SELECT * FROM memory_overrides
WHERE user_id = ? AND is_active = TRUE
""",
(user_id,),
).fetchall()
return [dict(row) for row in rows]

1182
core/service.py Normal file

File diff suppressed because it is too large Load Diff

View File

@ -1,77 +0,0 @@
输入user_id和session_id插入memories
curl -X POST "http://localhost:1995/api/v1/memories" \
-H "Content-Type: application/json" \
-d '{
"user_id": "user_001",
"session_id": "default",
"messages": [
{
"message_id": "msg_007",
"timestamp": 1778724000000,
"role": "user",
"content": "我喜欢喝拿铁,不喜欢美式。"
}
]
}'
{"data":{"request_id":"4535506c-26b6-4741-be62-3723db7a552c","message_count":1,"status":"accumulated","message":"Messages accepted"}
status=accumulated 表示先缓存了等待边界检测status=extracted 才表示已经触发记忆提取。
如果想强制触发总结/提取:调用 flush
curl -X POST "http://localhost:1995/api/v1/memories/flush" -H "Content-Type: application/json" -d '{
"user_id": "user_001",
"session_id": "default"
}'
{"data":{"request_id":"cc3e24be-9127-41aa-aefe-2ee80eacd054","status":"extracted","message":"Flush completed"}}
curl -X POST "http://localhost:1995/api/v1/memories/search" \
-H "Content-Type: application/json" \
-d '{
"query": "Tom 喜欢喝什么咖啡?",
"method": "hybrid",
"memory_types": ["episodic_memory", "profile"],
"filters": {
"user_id": "user_001"
},
"top_k": 10,
"include_original_data": true
}'
{"data":{"episodes":[],"profiles":[{"id":"6a058e72e0fcbba549ae94d8","user_id":"user_001","group_id":"gen_solo_669f08bf6134","profile_data":{"item_type":"explicit_info","embed_text":"饮食偏好: 喜欢喝拿铁,不喜欢美式咖啡"},"scenario":"solo","memcell_count":1,"score":0.7263925671577454}],"raw_messages":[],"agent_memory":null,"query":{"text":"Tom 喜欢喝什么咖啡?","method":"hybrid","filters_applied":{"user_id":"user_001"}},"original_data":{"episodes":{},"profiles":{}}}}
按用户/群组/时间拉取记忆:
curl -X POST "http://localhost:1995/api/v1/memories/get" \
-H "Content-Type: application/json" \
-d '{
"memory_type": "episodic_memory",
"filters": {
"user_id": "user_001"
},
"page": 1,
"page_size": 20,
"rank_by": "timestamp",
"rank_order": "desc"
}'
{"data":{"episodes":[{"id":"6a058e5de0fcbba549ae94d6","user_id":"user_001","group_id":"gen_solo_669f08bf6134","session_id":"default","timestamp":"2026-05-14T02:00:00Z","participants":["user_001"],"sender_ids":["user_001"],"summary":"2026年5月14日星期四凌晨02:00 UTC汤姆明确表达了自己的咖啡偏好。他陈述喜爱饮用拿铁咖啡同时明确表示不偏好美式咖啡。","subject":"汤姆于2026年5月14日凌晨表达咖啡口味偏好","episode":"2026年5月14日星期四凌晨02:00 UTC汤姆明确表达了自己的咖啡偏好。他陈述喜爱饮用拿铁咖啡同时明确表示不偏好美式咖啡。","type":"Conversation","parent_type":"memcell","parent_id":"6a058e46e0fcbba549ae94d3"}],"profiles":[],"agent_cases":[],"agent_skills":[],"total_count":1,"count":1}}
查 profile 画像:
(OpenViking) tom@tom:~$ curl -X POST "http://localhost:1995/api/v1/memories/get" \
-H "Content-Type: application/json" \
-d '{
"memory_type": "profile",
"filters": {
"user_id": "user_001"
},
"page": 1,
"page_size": 20
}'
{"data":{"episodes":[],"profiles":[{"id":"6a058e71e0fcbba549ae94d7","user_id":"user_001","group_id":"gen_solo_669f08bf6134","profile_data":{"id":null,"memory_type":"profile","user_id":"user_001","user_name":null,"timestamp":"2026-05-14T08:57:00.574433+00:00","group_id":"gen_solo_669f08bf6134","explicit_info":[{"category":"饮食偏好","description":"喜欢喝拿铁,不喜欢美式咖啡","evidence":"2026年5月14日用户明确表示“我喜欢喝拿铁不喜欢美式。”","sources":["6a058e46e0fcbba549ae94d3"]}],"implicit_traits":[],"last_updated":"2026-05-14T08:57:21.222404+00:00","processed_episode_ids":["6a058e46e0fcbba549ae94d3"]},"scenario":"solo","memcell_count":1}],"agent_cases":[],"agent_skills":[],"total_count":1,"count":1}}

View File

@ -1,71 +0,0 @@
创建用户
curl -sS -X POST "http://localhost:1934/memory-system/users" \
-H "Content-Type: application/json" \
-d '{"user_id":"userC"}'
{"status":"success","account":{"status":"ok","result":{"account_id":"userC_account","admin_user_id":"userC","isolate_user_scope_by_agent":false,"isolate_agent_scope_by_user":false,"user_key":"dXNlckNfYWNjb3VudA.dXNlckM.ZDFlMGI2OWI0NzZkZmZiMGExOWFlNGQ2N2JjYzMxNTg4NzVjZmNhN2Q4MTYwYmU1NGE4YWZjZTdjYzliMDI3NQ"},"error":null,"telemetry":null,"profile":null},"backends":{"openviking":{"status":"success","result":{"status":"ok","result":{"account_id":"userC_account","admin_user_id":"userC","isolate_user_scope_by_agent":false,"isolate_agent_scope_by_user":false,"user_key":"dXNlckNfYWNjb3VudA.dXNlckM.ZDFlMGI2OWI0NzZkZmZiMGExOWFlNGQ2N2JjYzMxNTg4NzVjZmNhN2Q4MTYwYmU1NGE4YWZjZTdjYzliMDI3NQ"},"error":null,"telemetry":null,"profile":null},"error":null}}}
插入信息
curl -sS -X POST "http://localhost:1934/memory-system/messages" \
-H "Content-Type: application/json" \
-d '{
"user_id": "userC",
"user_key": "dXNlckNfYWNjb3VudA.dXNlckM.ZDFlMGI2OWI0NzZkZmZiMGExOWFlNGQ2N2JjYzMxNTg4NzVjZmNhN2Q4MTYwYmU1NGE4YWZjZTdjYzliMDI3NQ",
"session_id": "sessionC1",
"user_message": "我喜欢拿铁。",
"assistant_message": "好的,我记住了。"
}'
存储记忆
curl -sS -X POST "http://localhost:1934/memory-system/sessions/sessionC1/commit" \
-H "Content-Type: application/json" \
-d '{
"user_id": "userC",
"user_key": "dXNlckNfYWNjb3VudA.dXNlckM.ZDFlMGI2OWI0NzZkZmZiMGExOWFlNGQ2N2JjYzMxNTg4NzVjZmNhN2Q4MTYwYmU1NGE4YWZjZTdjYzliMDI3NQ"
}'
全局搜索
curl -sS -X POST "http://localhost:1934/memory-system/search" \
-H "Content-Type: application/json" \
-d '{
"user_id": "userC",
"user_key": "dXNlckNfYWNjb3VudA.dXNlckM.ZDFlMGI2OWI0NzZkZmZiMGExOWFlNGQ2N2JjYzMxNTg4NzVjZmNhN2Q4MTYwYmU1NGE4YWZjZTdjYzliMDI3NQ",
"session_id": "sessionC1",
"query": "大语言模型应用是什么",
"limit": 10,
"level": 2,
"score_threshold": 0.8
}' | jq .
curl -s -X POST http://localhost:1933/api/v1/search/search \
-H "Content-Type: application/json" \
-H "Authorization: Bearer 3f7a4b4faae1e2d49583a0e45d9ba5b51f3d0f545d97c9b6c4f19171f717e8af" \
-d '{
"query": "大语言模型应用是什么",
"level": 2
}' | jq .
会话查询
curl -sS -X POST "http://localhost:1934/memory-system/sessions/sessionB1/context" \
-H "Content-Type: application/json" \
-d '{
"user_id": "userB",
"user_key": "1e5f24acba77017e7506e6df9d668aebc0ddc91c4ed9af77c6d8da5e9d4ed6c7",
"query": "我喜欢喝什么?",
"limit": 10
}' | jq .
用户画像查询
curl -sS -X POST "http://localhost:1934/memory-system/users/userB/profile" \
-H "Content-Type: application/json" \
-d '{
"user_key": "1e5f24acba77017e7506e6df9d668aebc0ddc91c4ed9af77c6d8da5e9d4ed6c7",
"query": "我想喝东西",
"limit": 10,
"level": 2
}' | jq .
上传文件

View File

@ -1,390 +0,0 @@
# 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 | `X-API-Key: $ROOT_KEY` |
| 普通用户 API例如 session/message/search | `X-API-Key: $USER_A_KEY` |
---
## 1. 创建用户隔离工作区 / account
Admin API 用于多租户管理。Memory Gateway 为每个业务用户直接创建一个 admin account不再调用 `/api/v1/admin/accounts/admin/users`
```bash
curl -X POST "$OV_HOST/api/v1/admin/accounts" \
-H "X-API-Key: $ROOT_KEY" \
-H "Content-Type: application/json" \
-d '{
"account_id": "userA_account",
"admin_user_id": "userA"
}'
```
典型返回:
```json
{
"status": "ok",
"result": {
"account_id": "userA_account",
"admin_user_id": "userA",
"user_key": "<userA-user-key>"
},
"error": null,
"telemetry": null
}
```
---
## 2. 创建更多用户
### 2.1 创建 userA
```bash
curl -X POST "$OV_HOST/api/v1/admin/accounts" \
-H "X-API-Key: $ROOT_KEY" \
-H "Content-Type: application/json" \
-d '{
"account_id": "userA_account",
"admin_user_id": "userA"
}'
```
返回后保存:
```bash
export USER_A_KEY="<userA-user-key>"
```
### 2.2 创建 userB
```bash
curl -X POST "$OV_HOST/api/v1/admin/accounts" \
-H "X-API-Key: $ROOT_KEY" \
-H "Content-Type: application/json" \
-d '{
"account_id": "userB_account",
"admin_user_id": "userB"
}'
```
返回后保存:
```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 "X-API-Key: $USER_A_KEY" \
-H "Content-Type: application/json" \
-d '{
"session_id": "sessionA1"
}'
```
典型返回:
```json
{
"status": "ok",
"result": {
"session_id": "sessionA1",
"user": {
"account_id": "userA_account",
"user_id": "userA",
"agent_id": "default"
}
},
"error": null,
"telemetry": null
}
```
### 3.2 创建 userB 的 sessionB1
```bash
curl -X POST "$OV_HOST/api/v1/sessions" \
-H "X-API-Key: $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 "X-API-Key: $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 "X-API-Key: $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 "X-API-Key: $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 "X-API-Key: $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 "X-API-Key: $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 "X-API-Key: $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 "X-API-Key: $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 "X-API-Key: $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 "X-API-Key: $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`

View File

@ -1,215 +0,0 @@
创建admin工作区
curl -X POST http://127.0.0.1:1933/api/v1/admin/accounts -H "X-API-Key: your-secret-root-key" -H "Content-Type: application/json" -d '{"account_id": "userB_account", "admin_user_id": "userB"}'
{"status":"ok","result":{"account_id":"userA_account","admin_user_id":"userA","isolate_user_scope_by_agent":false,"isolate_agent_scope_by_user":false,"user_key":"dXNlckFfYWNjb3VudA.dXNlckE.OGU4NzczZmQ2ZDExNjNhMWI4OTg2MWZkZTk5OTcyODlhNmM2ZTZjNDNmOGJkMWRiZDk2M2QyNTdhYTZmMTFlYQ"},"error":null,"telemetry":null,"profile":null}
{"status":"ok","result":{"account_id":"userB_account","admin_user_id":"userB","isolate_user_scope_by_agent":false,"isolate_agent_scope_by_user":false,"user_key":"dXNlckJfYWNjb3VudA.dXNlckI.YzZiNDZjMjJiZWMwNTM1OTBiOGEwMzAyOTFhZGMxZWQ4MTJhZDNhMmM5ZjJjZGYxMDI1YTkxZDVlMWY2M2M5MA"},"error":null,"telemetry":null,"profile":null}
创建用户
curl -X POST http://127.0.0.1:1933/api/v1/admin/accounts/admin/users -H "X-API-Key: your-secret-root-key" -H "Content-Type: application/json" -d '{"user_id": "userA", "role": "user"}'
userA
{"status":"ok","result":{"account_id":"admin","user_id":"userA","user_key":"3f7a4b4faae1e2d49583a0e45d9ba5b51f3d0f545d97c9b6c4f19171f717e8af"},"error":null,"telemetry":null}
userB
{"status":"ok","result":{"account_id":"admin","user_id":"userB","user_key":"3a017f01d1f9cddeec2b4832b4b3cb60b004ff27ec76505d72b24104412015c8"},"error":null,"telemetry":null}
创建session
userA
curl -X POST http://127.0.0.1:1933/api/v1/sessions \
-H "X-API-Key: dXNlckFfYWNjb3VudA.dXNlckE.OGU4NzczZmQ2ZDExNjNhMWI4OTg2MWZkZTk5OTcyODlhNmM2ZTZjNDNmOGJkMWRiZDk2M2QyNTdhYTZmMTFlYQ" \
-H "Content-Type: application/json" \
-d '{"session_id":"sessionA1"}'
A1
{"status":"ok","result":{"session_id":"238a22c1-a32a-4a3a-a174-2112d476173b","user":{"account_id":"admin","user_id":"userA","agent_id":"default"}},"error":null,"telemetry":null}
A2
{"status":"ok","result":{"session_id":"d20cede5-30ba-4d98-81f9-d74ee83bb071","user":{"account_id":"admin","user_id":"userA","agent_id":"default"}},"error":null,"telemetry":null}
B1
{"status":"ok","result":{"session_id":"654248bf-c36f-4a61-9fe8-de8f207d5227","user":{"account_id":"admin","user_id":"userB","agent_id":"default"}},"error":null,"telemetry":null}
B2
{"status":"ok","result":{"session_id":"c567601c-28e8-4b3d-be10-c95c909b374e","user":{"account_id":"admin","user_id":"userB","agent_id":"default"}},"error":null,"telemetry":null}
对话session插入massage
userA A1
curl -X POST http://localhost:1933/api/v1/sessions/sessionA1/messages \
-H "X-API-Key: dXNlckFfYWNjb3VudA.dXNlckE.OGU4NzczZmQ2ZDExNjNhMWI4OTg2MWZkZTk5OTcyODlhNmM2ZTZjNDNmOGJkMWRiZDk2M2QyNTdhYTZmMTFlYQ" \
-H "Content-Type: application/json" \
-d '{
"role": "user",
"content": "我喜欢用 Python 写数据分析脚本。"
}'
{"status":"ok","result":{"session_id":"sessionA1","message_count":1},"error":null,"telemetry":null}
curl -X POST http://localhost:1933/api/v1/sessions/sessionA1/messages \
-H "X-API-Key: dXNlckFfYWNjb3VudA.dXNlckE.OGU4NzczZmQ2ZDExNjNhMWI4OTg2MWZkZTk5OTcyODlhNmM2ZTZjNDNmOGJkMWRiZDk2M2QyNTdhYTZmMTFlYQ" \
-H "Content-Type: application/json" \
-d '{
"role": "assistant",
"content": "好的,我会记住你偏好 Python 数据分析。"
}'
{"status":"ok","result":{"session_id":"sessionA1","message_count":2},"error":null,"telemetry":null}
userB B1
curl -X POST http://localhost:1933/api/v1/sessions/sessionB1/messages \
-H "Authorization: Bearer 1e5f24acba77017e7506e6df9d668aebc0ddc91c4ed9af77c6d8da5e9d4ed6c7" \
-H "Content-Type: application/json" \
-d '{
"role": "user",
"content": "我还想喝咖啡,咖啡有力气"
}'
{"status":"ok","result":{"session_id":"sessionB1","message_count":1},"error":null,"telemetry":null}
curl -X POST http://localhost:1933/api/v1/sessions/sessionB1/messages \
-H "Authorization: Bearer 1e5f24acba77017e7506e6df9d668aebc0ddc91c4ed9af77c6d8da5e9d4ed6c7" \
-H "Content-Type: application/json" \
-d '{
"role": "assistant",
"content": "黑咖啡品味很浓,很有力气。"
}'
{"status":"ok","result":{"session_id":"sessionB1","message_count":2},"error":null,"telemetry":null}
对话session插入memory
userA A1
curl -X POST http://localhost:1933/api/v1/sessions/sessionA1/commit \
-H "X-API-Key: dXNlckFfYWNjb3VudA.dXNlckE.OGU4NzczZmQ2ZDExNjNhMWI4OTg2MWZkZTk5OTcyODlhNmM2ZTZjNDNmOGJkMWRiZDk2M2QyNTdhYTZmMTFlYQ"
{"status":"ok","result":{"session_id":"sessionA1","status":"accepted","task_id":"1b93fcde-0944-42ad-a819-4241faf0048f","archive_uri":"viking://session/sessionA1/history/archive_001","archived":true,"trace_id":""}}
userB B1
curl -X POST http://localhost:1933/api/v1/sessions/sessionB1/commit \
-H "Authorization: Bearer 1e5f24acba77017e7506e6df9d668aebc0ddc91c4ed9af77c6d8da5e9d4ed6c7" \
-H "Content-Type: application/json" \
-d '{"keep_recent_count":0}'
{"status":"ok","result":{"session_id":"sessionB1","status":"accepted","task_id":"cd98a696-ac6f-4f0c-8187-6e824f5ebcbc","archive_uri":"viking://session/userB/sessionB1/history/archive_001","archived":true}}
查询插入结果
userA A1
curl http://localhost:1933/api/v1/tasks/1b93fcde-0944-42ad-a819-4241faf0048f \
-H "X-API-Key: dXNlckFfYWNjb3VudA.dXNlckE.OGU4NzczZmQ2ZDExNjNhMWI4OTg2MWZkZTk5OTcyODlhNmM2ZTZjNDNmOGJkMWRiZDk2M2QyNTdhYTZmMTFlYQ"
{"status":"ok","result":{"task_id":"1b93fcde-0944-42ad-a819-4241faf0048f","task_type":"session_commit","status":"completed","created_at":1779855851.700087,"updated_at":1779855866.0683796,"resource_id":"sessionA1","result":{"session_id":"sessionA1","archive_uri":"viking://session/sessionA1/history/archive_001","memories_extracted":{"memory_write":1},"session_skills_extracted":0,"session_skill_uris":[],"active_count_updated":0,"token_usage":{"llm":{"prompt_tokens":7883,"completion_tokens":437,"total_tokens":8320},"embedding":{"total_tokens":49},"total":{"total_tokens":8369}}},"error":null,"created_at_iso":"2026-05-27T04:24:11.700087+00:00","updated_at_iso":"2026-05-27T04:24:26.068380+00:00"},"error":null,"telemetry":null,"profile":null}
userB B1
curl http://localhost:1933/api/v1/tasks/cd98a696-ac6f-4f0c-8187-6e824f5ebcbc \
-H "Authorization: Bearer 3a017f01d1f9cddeec2b4832b4b3cb60b004ff27ec76505d72b24104412015c8"
{"status":"ok","result":{"task_id":"cd98a696-ac6f-4f0c-8187-6e824f5ebcbc","task_type":"session_commit","status":"running","created_at":1779418133.2990522,"updated_at":1779418133.2993288,"resource_id":"sessionB1","result":null,"error":null},"error":null,"telemetry":null}
向量搜索用户memory
curl -X POST http://localhost:1933/api/v1/search/find \
-H "Content-Type: application/json" \
-H "X-API-Key: dXNlckFfYWNjb3VudA.dXNlckE.OGU4NzczZmQ2ZDExNjNhMWI4OTg2MWZkZTk5OTcyODlhNmM2ZTZjNDNmOGJkMWRiZDk2M2QyNTdhYTZmMTFlYQ" \
-d '{
"query": "我之前说了什么",
"limit": 3
}' | jq .
{"status":"ok","result":{"memories":[{"context_type":"memory","uri":"viking://user/userA/memories/preferences/mem_d49a95a2-8491-40bb-b0d3-4cf4c16d3de8.md","level":2,"score":0.7411142547036441,"category":"","match_reason":"","relations":[],"abstract":"Python 数据分析:偏好使用 Python 编写数据分析脚本","overview":null},{"context_type":"memory","uri":"viking://user/userA/memories/preferences/.abstract.md","level":0,"score":0.6752108627461267,"category":"","match_reason":"","relations":[],"abstract":"This directory contains a single user preference document that captures a specific behavioral attribute regarding tool choice for data analysis. The document is a lightweight, non-technical profile note intended to record that the user prefers writing d...","overview":null},{"context_type":"memory","uri":"viking://user/userA/memories/.overview.md","level":1,"score":0.6262621398392979,"category":"","match_reason":"","relations":[],"abstract":"User's long-term memory storage. Contains memory types like preferences, entities, events, managed hierarchically by type.","overview":null}],"resources":[],"skills":[],"total":3}}
LLM搜索用户session memory
curl -s -X POST http://localhost:1933/api/v1/search/search \
-H "Content-Type: application/json" \
-H "X-API-Key: dXNlckFfYWNjb3VudA.dXNlckE.OGU4NzczZmQ2ZDExNjNhMWI4OTg2MWZkZTk5OTcyODlhNmM2ZTZjNDNmOGJkMWRiZDk2M2QyNTdhYTZmMTFlYQ" \
-d '{
"query": "我正在做什么",
"limit": 10,
"level": 2
}' | jq .
{
"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
}
]
}
}
}
curl -X GET "http://localhost:1933/api/v1/sessions/sessionA1" \
-H "X-API-Key: dXNlckFfYWNjb3VudA.dXNlckE.OGU4NzczZmQ2ZDExNjNhMWI4OTg2MWZkZTk5OTcyODlhNmM2ZTZjNDNmOGJkMWRiZDk2M2QyNTdhYTZmMTFlYQ"
组装 session 上下文
curl -X GET "http://localhost:1933/api/v1/sessions/{session_id}/context" \
-H "X-API-Key: user key"
curl -X GET "http://localhost:1933/api/v1/sessions/sessionA1/context" \
-H "X-API-Key: dXNlckFfYWNjb3VudA.dXNlckE.OGU4NzczZmQ2ZDExNjNhMWI4OTg2MWZkZTk5OTcyODlhNmM2ZTZjNDNmOGJkMWRiZDk2M2QyNTdhYTZmMTFlYQ" | jq .
获取指定 archive
curl -X GET "http://localhost:1933/api/v1/sessions/sessionA1/archives/archive_001" \
-H "X-API-Key: dXNlckFfYWNjb3VudA.dXNlckE.OGU4NzczZmQ2ZDExNjNhMWI4OTg2MWZkZTk5OTcyODlhNmM2ZTZjNDNmOGJkMWRiZDk2M2QyNTdhYTZmMTFlYQ" | jq .
curl -sG "http://localhost:1933/api/v1/fs/ls" \
--data-urlencode "uri=viking://user/alice1/memories/preferences/" \
--data-urlencode "recursive=true" \
-H "X-API-Key: 1d1a4d61838f67d808230b19ed1b6b4ce647f073ea33ee005ed3b9b24f35b978" | jq
上传临时文件
curl -s -X POST http://localhost:1933/api/v1/resources/temp_upload \
-H "X-API-Key: dXNlckFfYWNjb3VudA.dXNlckE.OGU4NzczZmQ2ZDExNjNhMWI4OTg2MWZkZTk5OTcyODlhNmM2ZTZjNDNmOGJkMWRiZDk2M2QyNTdhYTZmMTFlYQ" \
-F "file=@/home/tom/memory-gateway/tests/大语言模型应用.pdf" \
| jq .
{
"status": "ok",
"result": {
"temp_file_id": "upload_89b818fa75114b35a5b2c55263dee8ff.pdf"
}
}
上传图片
curl -X POST http://localhost:1933/api/v1/resources \
-H "X-API-Key: dXNlckFfYWNjb3VudA.dXNlckE.OGU4NzczZmQ2ZDExNjNhMWI4OTg2MWZkZTk5OTcyODlhNmM2ZTZjNDNmOGJkMWRiZDk2M2QyNTdhYTZmMTFlYQ" \
-H "Content-Type: application/json" \
-d '{
"temp_file_id": "upload_89b818fa75114b35a5b2c55263dee8ff.pdf",
"to": "viking://resources/userA/files/大语言模型应用.pdf",
"reason": "userA 上传的文件",
"wait": true,
"directly_upload_media": true
}'
curl -X POST http://localhost:1933/api/v1/resources \
-H "X-API-Key: dXNlckFfYWNjb3VudA.dXNlckE.OGU4NzczZmQ2ZDExNjNhMWI4OTg2MWZkZTk5OTcyODlhNmM2ZTZjNDNmOGJkMWRiZDk2M2QyNTdhYTZmMTFlYQ" \
-H "Content-Type: application/json" \
-d '{
"temp_file_id": "upload_89b818fa75114b35a5b2c55263dee8ff.pdf",
"to": "viking://resources/userA/files/大语言模型应用.pdf",
"reason": "userA 上传的文件",
"wait": true,
"directly_upload_media": true
}'

View File

@ -0,0 +1,45 @@
# Memory Gateway Agent Skill Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Create a reusable AI-agent skill that safely operates the Memory Gateway API through a deterministic Python CLI.
**Architecture:** Keep procedural guidance in `SKILL.md`, detailed endpoint schemas in `references/api.md`, and all HTTP/multipart behavior in one standard-library CLI. Read credentials from environment variables or explicit flags and never persist secrets in the skill.
**Tech Stack:** Agent Skills format, Python 3 standard library, pytest, Memory Gateway HTTP API.
---
### Task 1: Scaffold the skill
**Files:**
- Create: `skill/memory-gateway-agent/SKILL.md`
- Create: `skill/memory-gateway-agent/agents/openai.yaml`
- Create: `skill/memory-gateway-agent/scripts/memory_gateway.py`
- Create: `skill/memory-gateway-agent/references/api.md`
- [x] Initialize the standard skill structure with `init_skill.py`.
- [x] Remove generated placeholders and keep only required resources.
### Task 2: Implement and test the CLI
**Files:**
- Create: `tests/test_memory_gateway_skill.py`
- Modify: `skill/memory-gateway-agent/scripts/memory_gateway.py`
- [x] Write failing tests for environment credentials, JSON requests, multipart uploads, and HTTP errors.
- [x] Run the focused tests and confirm they fail for missing implementation.
- [x] Implement the standard-library CLI with commands for health, users, resources, search, add/flush, override, and delete.
- [x] Run the focused tests and confirm they pass.
### Task 3: Author and validate the skill
**Files:**
- Modify: `skill/memory-gateway-agent/SKILL.md`
- Modify: `skill/memory-gateway-agent/references/api.md`
- Modify: `skill/memory-gateway-agent/agents/openai.yaml`
- [x] Document the agent workflow, authentication rules, ownership checks, and safe handling of secrets.
- [x] Document endpoint parameters and CLI examples in the API reference.
- [x] Generate UI metadata with the official skill-creator script.
- [x] Run `quick_validate.py`, CLI `--help`, focused tests, and the full project test suite.

View File

@ -0,0 +1,62 @@
# Upstream Brand Neutralization Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Remove the upstream product identity from the current Memory Gateway files without changing the upstream memory HTTP protocol.
**Architecture:** Replace product-specific names with `backend` terminology at configuration, client, service, API, test, and documentation boundaries. Enforce the result with a repository-level regression test that scans both file names and text content.
**Tech Stack:** Python 3, FastAPI, pytest, Markdown, environment configuration.
---
### Task 1: Add the neutral-brand regression test
**Files:**
- Create: `tests/test_branding.py`
- [x] Add a test that constructs the forbidden token from separate string fragments.
- [x] Scan non-generated project file names and UTF-8 text contents.
- [x] Run the test and verify it fails against the existing product-specific names.
### Task 2: Rename runtime boundaries
**Files:**
- Rename: `core/backend_client.py` to `core/backend_client.py`
- Modify: `core/api.py`
- Modify: `core/config.py`
- Modify: `core/service.py`
- Modify: `core/__init__.py`
- Modify: `.env.example`
- Modify: `.gitignore`
- Delete: `backend.env.example`
- [x] Rename the client class, dependency attributes, retry helpers, and configuration fields to `backend` terminology.
- [x] Rename environment variables to `MEMORY_GATEWAY_BACKEND_*`.
- [x] Rename health and direct add/flush response fields to `backend`.
- [x] Preserve all `/api/v1/memory/*` paths.
### Task 3: Rename tests and public documentation
**Files:**
- Rename: `tests/test_backend_integration.py` to `tests/test_backend_integration.py`
- Modify: `tests/test_gateway.py`
- Modify: `tests/test_command.md`
- Modify: `README.md`
- Modify: `pyproject.toml`
- Modify: `skill/memory-gateway-agent/SKILL.md`
- Modify: `skill/memory-gateway-agent/references/api.md`
- [x] Rename fixtures, tests, environment flags, examples, and expected JSON fields.
- [x] Describe the dependency only as an upstream memory service.
- [x] Update integration commands and package metadata.
### Task 4: Verify the current working tree
**Files:**
- Modify: `docs/superpowers/plans/2026-06-12-upstream-brand-neutralization.md`
- [x] Remove generated bytecode caches.
- [x] Run the branding regression test.
- [x] Run the full test suite and Python compilation.
- [x] Run a final case-insensitive content and file-name scan, excluding `.git` history.

View File

@ -0,0 +1,57 @@
# Memory Attachment Path Mapping Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Persist attachment-to-session mappings for resource and direct memory ingestion, then return filename-matched real URIs from memory search results.
**Architecture:** Add one SQLite attachment table and repository methods. Register resource files directly, materialize base64 memory attachments under Gateway storage, and enrich normalized search results by matching attachment names against recursive raw string values.
**Tech Stack:** Python 3.10+, FastAPI, SQLite, Pydantic, pytest, httpx.
---
### Task 1: Attachment persistence
**Files:**
- Modify: `core/db.py`
- Modify: `core/repository.py`
- Modify: `tests/test_gateway.py`
- [x] Write failing tests proving attachment records can be created, listed by user/session, deduplicated, and soft-deleted with resources.
- [x] Run focused tests and verify failure because the table and methods do not exist.
- [x] Add `memory_attachments`, indexes, resource backfill SQL, and focused repository methods.
- [x] Run focused tests and verify they pass.
### Task 2: Register attachments during ingestion
**Files:**
- Modify: `core/api.py`
- Modify: `core/service.py`
- Modify: `tests/test_gateway.py`
- [x] Write failing tests for `/resources`, `/memories/add` URI items, and `/memories/add` base64 items.
- [x] Run focused tests and verify missing mappings and files.
- [x] Register resource mappings, pass authenticated `user_id` into add service, materialize base64 files, and persist successful add mappings.
- [x] Run focused tests and verify they pass.
### Task 3: Enrich search results
**Files:**
- Modify: `core/service.py`
- Modify: `tests/test_gateway.py`
- [x] Write failing tests for filename match, no match, base64-key exclusion, and cross-user isolation.
- [x] Run focused tests and verify `attachments` is absent.
- [x] Recursively collect raw strings excluding base64 and return deduplicated matching attachments.
- [x] Run focused tests and verify they pass.
### Task 4: Documentation and regression
**Files:**
- Modify: `README.md`
- Modify: `tests/test_command.md`
- [x] Document attachment persistence, historical backfill limits, matching behavior, and response shape.
- [x] Update the search response example with `attachments`.
- [x] Run `git diff --check`, compile checks, and the complete pytest suite.
- [x] Review the final diff for user isolation and unintended URI exposure outside search.

View File

@ -0,0 +1,118 @@
# Memory Search Upstream Options Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Extend `POST /memories/search` with all upstream search options while preserving Gateway authentication, scopes, resource isolation, tombstones, and overrides.
**Architecture:** Extend the existing Pydantic request model and pass the validated values through `MemoryGatewayService`. Keep scope orchestration intact, combine caller filters with scope-generated session filters using `AND`, and tag normalized results according to their upstream response array.
**Tech Stack:** Python 3.10+, FastAPI, Pydantic v2, pytest, pytest-asyncio, httpx ASGI transport.
---
### Task 1: Search request options and defaults
**Files:**
- Modify: `tests/test_gateway.py`
- Modify: `core/api.py`
- Modify: `core/service.py`
- [x] **Step 1: Write failing tests for defaults, custom options, and validation**
Add API tests that assert a default search sends `method="hybrid"`, `include_profile=true`, and `enable_llm_rerank=true`; a custom request forwards `agent_id`, `keyword`, `radius`, `top_k=-1`, and both false flags; and invalid `method`, `radius`, and `top_k=0` return HTTP 422.
- [x] **Step 2: Run tests and verify expected failures**
Run:
```bash
uv run pytest tests/test_gateway.py -k 'search_forwards_default_upstream_options or search_forwards_all_upstream_options or search_rejects_invalid_upstream_options' -q
```
Expected: assertions fail because the request model and service do not yet accept or forward the new fields.
- [x] **Step 3: Implement request fields and payload forwarding**
Extend `SearchMemoriesRequest` with:
```python
agent_id: str | None = Field(default=None, min_length=1)
method: Literal["keyword", "vector", "hybrid", "agentic"] = "hybrid"
radius: float | None = Field(default=None, ge=0, le=1)
include_profile: bool = True
enable_llm_rerank: bool = True
filters: dict[str, Any] | None = None
```
Validate `top_k` as `-1` or `1..100`, pass all values to the service, and make `_search_payload` select exactly one upstream owner key (`agent_id` when present, otherwise `user_id`).
- [x] **Step 4: Run focused tests and verify they pass**
Run the command from Step 2. Expected: all selected tests pass.
### Task 2: Filter composition and result memory types
**Files:**
- Modify: `tests/test_gateway.py`
- Modify: `core/service.py`
- [x] **Step 1: Write failing tests for filter composition and result types**
Add a resource-scope test asserting caller filters and `session_id in [...]` are combined as:
```python
{"AND": [caller_filters, {"session_id": {"in": [session_id]}}]}
```
Extend the fake backend to return all response arrays and assert normalized results have `memory_type` values `episode`, `profile`, `agent_case`, `agent_skill`, and `unprocessed_message`.
- [x] **Step 2: Run tests and verify expected failures**
Run:
```bash
uv run pytest tests/test_gateway.py -k 'search_combines_custom_and_scope_filters or search_labels_all_memory_types' -q
```
Expected: failures because caller filters are not composed and normalized results have no `memory_type`.
- [x] **Step 3: Implement composition and typed normalization**
Add a small `_combine_filters` helper that returns either condition directly, returns `None` when both are absent, or returns `{"AND": [custom, scope]}` when both exist. Iterate an explicit mapping from response array name to memory type in `_extract_results` and include the mapped value in every normalized result.
- [x] **Step 4: Run focused tests and verify they pass**
Run the command from Step 2. Expected: both tests pass.
### Task 3: Documentation and regression verification
**Files:**
- Modify: `README.md`
- Verify: `tests/test_gateway.py`
- Verify: `tests/test_memory_gateway_skill.py`
- [x] **Step 1: Update the Chinese API documentation**
Document `agent_id`, `method`, `radius`, `include_profile`, `enable_llm_rerank`, `filters`, the `top_k=-1` rule, filter composition, and the `memory_type` response field. Update the curl and JSON examples with the new defaults.
- [x] **Step 2: Run formatting and full tests**
Run:
```bash
git diff --check
uv run pytest -q
```
Expected: no whitespace errors and all tests pass.
- [x] **Step 3: Review the final diff**
Run:
```bash
git diff --stat
git diff -- core/api.py core/service.py tests/test_gateway.py README.md
```
Expected: changes are limited to the approved search compatibility scope and documentation.

View File

@ -0,0 +1,23 @@
# Upstream Brand Neutralization Design
## Goal
Remove the upstream product name from the current Memory Gateway working tree while preserving the upstream HTTP protocol and application behavior.
## Scope
- Rename the upstream client module, class, configuration fields, environment variables, state attributes, response fields, tests, and integration test file to neutral `backend` terminology.
- Rewrite README, Skill documentation, examples, package metadata, and test records to describe an "upstream memory service".
- Remove the upstream-specific environment example because its variable names identify the product.
- Preserve `/api/v1/memory/add`, `/api/v1/memory/flush`, and `/api/v1/memory/search` paths.
- Do not rewrite Git history.
## Compatibility
This is an intentional configuration and response-schema rename. Deployments must move to `MEMORY_GATEWAY_BACKEND_*` variables, and health/add/flush consumers must read the `backend` field. No legacy aliases are retained because they would defeat the neutralization requirement.
## Verification
- Add an automated repository scan that rejects the forbidden upstream token in current files and file names.
- Run the full unit suite and compilation checks.
- Run a final case-insensitive repository scan excluding `.git`, virtual environments, runtime data, and generated bytecode.

View File

@ -0,0 +1,101 @@
# Memory 附件真实路径映射设计
## 目标
`/resources``/memories/add` 两种摄入方式都保存附件与 session 的映射。
`/memories/search` 返回结果时,根据结果 `session_id` 查询当前用户附件,并且只有
当附件完整文件名出现在结果 `raw` 的字符串字段中时,才返回该附件真实 URI。
## 数据模型
新增 SQLite 表 `memory_attachments`
- `id TEXT PRIMARY KEY`
- `user_id TEXT NOT NULL`
- `app_id TEXT NOT NULL DEFAULT 'default'`
- `project_id TEXT NOT NULL DEFAULT 'default'`
- `session_id TEXT NOT NULL`
- `resource_id TEXT`
- `content_type TEXT NOT NULL`
- `name TEXT NOT NULL`
- `internal_uri TEXT NOT NULL`
- `source TEXT NOT NULL`
- `sha256 TEXT`
- `created_at TIMESTAMP NOT NULL`
- `deleted_at TIMESTAMP`
`(user_id, session_id, internal_uri)` 建立唯一索引,避免幂等上传产生重复映射;
`(user_id, session_id, deleted_at)` 建立查询索引。
数据库初始化时,将现有未删除 `user_resources` 回填为附件映射。历史
`/memories/add` 请求没有保存在 Gateway 数据库中,因此无法自动回填。
## 摄入规则
### `/resources`
资源记录创建后,为保存的真实 `file://` URI 创建附件映射:
- `session_id` 使用 `resource:{user_id}:{resource_id}`
- `resource_id` 指向资源;
- `source``resource_upload`
- `content_type`、文件名、SHA256 复用资源元数据。
重复资源上传时确保已有资源对应的附件映射存在。
### `/memories/add`
API 将已鉴权的 `user_id` 一并传给 service。逐条检查 message 的 content item
- 只有字符串 content 或纯文本 item 时不创建附件;
-`uri` 时记录该 URI`source=memory_add_uri`
- 没有 `uri` 但有 `base64` 时,解码并保存到
`storage/{user_id}/memory_attachments/{attachment_id}/{safe_name}`,记录生成的
`file://` URI`source=memory_add_base64`
- 同时存在 `uri``base64` 时优先使用 `uri`,不重复落盘;
- 文件名优先使用 `name`,否则从 URI 路径或 `ext` 生成安全名称。
上游 add 调用失败时,删除本次 base64 生成的文件,不写入映射。调用成功后写入
附件映射。上游请求体保持原样,不修改现有 add 行为。
## 搜索匹配规则
对每条标准化搜索结果:
1. 根据已鉴权 `user_id` 和结果 `session_id` 查询未删除附件;
2. 递归遍历 `raw` 中 dict、list 的字符串值;
3. 跳过键名为 `base64` 的值,避免扫描大块编码数据;
4. 使用附件完整文件名做不区分大小写的子串匹配;
5. 仅命中的附件进入 `attachments`,按 `internal_uri` 去重;
6. 没有 session 或没有命中时返回 `attachments: []`
响应附件格式:
```json
{
"type": "image",
"name": "simple-multimodal-image.png",
"internal_uri": "file:///home/tom/memory-gateway/tests/simple-multimodal-image.png"
}
```
episode 是 session 级记忆,因此只能在同一 session 的附件中按文件名匹配,不能
证明具体附件是向量召回的直接来源。
## 删除与隔离
- 所有附件查询必须同时匹配 `user_id``session_id`
- 删除 `/resources` 时,对应附件映射设置 `deleted_at`
- 真实路径按用户明确要求直接出现在搜索结果中;
- 不改变资源列表和详情现有的 `resource://` 对外 URI。
## 测试
- 资源上传创建附件映射;
- 资源搜索仅在 raw 出现文件名时返回真实 URI
- raw 不含文件名时返回空附件数组;
- `/memories/add` 的 URI content 创建映射;
- `/memories/add` 的 base64 content 落盘并创建映射;
- 不扫描 raw 中的 base64 字段;
- 不返回其他用户同 session 的附件;
- 现有测试继续通过。

View File

@ -0,0 +1,145 @@
# Memory Search 上游参数增量兼容设计
## 目标
扩展 Memory Gateway 的 `POST /memories/search`,在保留现有用户鉴权、
`scope` 搜索编排、资源隔离、软删除和覆盖修改能力的前提下,支持上游
搜索接口的全部请求选项。
本次只修改 `/memories/search`,不新增 `/memories/get`,也不提供上游路径的
线协议兼容接口。
## 请求模型
保留现有字段:
- `user_id`:必填,始终用于 Gateway 用户鉴权和本地数据隔离。
- `user_key`:必填,用于 Gateway 用户鉴权。
- `conversation_id`:可选,供 `current_chat` scope 生成 session 过滤条件。
- `query`:必填。
- `scope`:保留 `current_chat``resources``all_user_memory`
- `app_id``project_id`:默认 `default`
新增或扩展字段:
- `agent_id`:可选。存在时,上游搜索使用 `agent_id`;不存在时使用
Gateway 鉴权用户的 `user_id`。请求中不会同时向上游发送两种 owner ID。
- `method`:支持 `keyword``vector``hybrid``agentic`,默认 `hybrid`
- `top_k`:支持 `-1``1..100`,保留 Gateway 默认值 `8`
- `radius`:可选,范围 `0..1`
- `include_profile`:布尔值,默认 `true`
- `enable_llm_rerank`:布尔值,默认 `true`
- `filters`:可选对象,支持上游开放字段过滤 DSL包括嵌套 `AND``OR`
`agent_id` 只改变上游记忆 owner不替代 Gateway 的 `user_id/user_key` 鉴权。
这可以防止调用者绕过 Gateway 用户体系,同时允许同一已认证用户查询被授权
使用的 agent memory。当前版本不新增 agent 权限表,因此仅校验 Gateway 用户
凭据,不声明 agent 的独立所有权关系。
## 搜索编排与过滤器合并
每一次上游 search 调用都必须透传:
- owner`agent_id``user_id`,二选一;
- `query`
- `method`
- `top_k`
- `radius`,仅在请求提供时发送;
- `include_profile`
- `enable_llm_rerank`
- `app_id`
- `project_id`
- 合并后的 `filters`
现有 scope 继续生成内部 session 条件:
- `current_chat``session_id = chat:{conversation_id}`
- `resources`:按批次生成 `session_id in [...]`
- `all_user_memory`:不生成 session 条件。
当请求同时提供自定义 `filters` 和 scope session 条件时,使用以下结构合并:
```json
{
"AND": [
{"自定义过滤条件": "..."},
{"session_id": "scope 生成的条件"}
]
}
```
仅存在其中一个条件时直接使用该条件;两者都不存在时不发送 `filters`
Gateway 不解析或重写自定义过滤 DSL 的内部字段,由上游执行完整校验。
`agent_id` 与所有 scope 均可组合。对于没有对应数据的组合,上游自然返回空数组;
Gateway 不额外禁止这些组合,以保持接口简单并完整透传搜索能力。
## 响应标准化
继续返回 Gateway 的统一结构:
```json
{
"results": []
}
```
每个结果新增 `memory_type`
| 上游数组 | `memory_type` |
|---|---|
| `episodes` | `episode` |
| `profiles` | `profile` |
| `agent_cases` | `agent_case` |
| `agent_skills` | `agent_skill` |
| `unprocessed_messages` | `unprocessed_message` |
其余字段保持现状:`id``session_id``text``score``source_scope`
`resource_id``resource_uri``raw`
profile 和 agent skill 等没有 `session_id` 的结果允许返回 `null`。资源映射只对
能匹配当前用户资源 session 的结果生效,不泄露其他用户的内部资源 URI。
所有类型的结果继续按现有顺序执行:
1. 合并各 scope 的结果;
2. 应用当前用户的 memory tombstone
3. 按 memory ID 应用当前用户的 active override
4. 返回统一结果。
## 错误处理
- 不合法的 `method``top_k``radius` 由 Gateway 请求模型返回 HTTP 422。
- 上游过滤 DSL 错误和其他 HTTP 错误继续由现有 client 行为向外传播。
- 不改变当前 `current_chat` 缺少 `conversation_id` 时跳过该 scope 的行为。
- 不为 `agent_id` 引入新的数据库表或权限模型。
## 代码改动
- `core/api.py`
- 扩展 `SearchMemoriesRequest`
- 将新增参数传给 service。
- `core/service.py`
- 扩展 `search_memories``_search_payload`
- 合并自定义 filters 与 scope filters。
- 标准化结果时增加 `memory_type`
- `tests/test_gateway.py`
- 验证默认参数透传。
- 验证全部自定义搜索选项透传。
- 验证 agent owner 与用户鉴权身份分离。
- 验证 filters 与 scope 条件使用 `AND` 合并。
- 验证五类结果的 `memory_type`
- `README.md`
- 更新 `/memories/search` 参数和响应说明。
## 验收标准
1. 未提供新字段时,上游收到 `method=hybrid``include_profile=true`
`enable_llm_rerank=true`
2. 所有上游搜索选项均能通过 Gateway 请求并原样传递。
3. `top_k=-1` 被接受,`top_k=0` 和范围外值被拒绝。
4. 自定义 filters 不会覆盖 scope 的资源或聊天 session 隔离条件。
5. 设置 `agent_id` 后,上游只收到 `agent_id`Gateway 仍使用
`user_id/user_key` 完成鉴权。
6. 每个搜索结果包含准确的 `memory_type`
7. 现有 tombstone、override、资源 URI 隔离测试继续通过。

View File

@ -1 +0,0 @@
"""Evaluation utilities."""

View File

@ -1,139 +0,0 @@
# Hermes Memory Evaluation
This is a small LoCoMo-style memory evaluation runner for Hermes Agent.
It follows the same shape as `openclaw-eval`: ingest historical conversations, ask QA questions with the same user id, then use an LLM judge to score the answers.
## 1. Configure Hermes Memory
Install or copy the `memory_system` Hermes plugin, then put Memory System settings in `/home/tom/.hermes/memory_system.env`:
```dotenv
MEMORY_SYSTEM_ENDPOINT=http://127.0.0.1:1934
MEMORY_SYSTEM_USER_ID=default
MEMORY_SYSTEM_SEARCH_USE_LLM=false
MEMORY_SYSTEM_COMMIT_EVERY_TURNS=1
MEMORY_SYSTEM_COMMIT_INTERVAL_SECONDS=0
```
The eval runner overrides `MEMORY_SYSTEM_USER_ID` per LoCoMo sample, so one sample maps to one memory user.
## 2. Prepare Config
Copy and edit:
```bash
cp eval/hermes_memory_eval/config.example.yaml eval/hermes_memory_eval/config.yaml
```
For a stable eval, keep:
```yaml
memory:
commit_every_turns: 1
commit_interval_seconds: 0
```
## 3. Ingest Conversations
Before ingest, verify the eval Hermes home can see the plugin:
```bash
HERMES_HOME=/home/tom/memory-gateway/eval/hermes_memory_eval/hermes_home hermes memory status
```
The status must show `memory_system` as installed and active.
Run a small smoke test first:
```bash
python eval/hermes_memory_eval/run_eval.py ingest /path/to/locomo10_small.json \
--config eval/hermes_memory_eval/config.yaml \
--sample 0 \
--sessions 1-2 \
--output output/hermes_ingest.jsonl
```
This sends each selected session to:
```bash
hermes chat -Q --source memory-eval -q "<formatted session>"
```
## 4. Ask QA Questions
Use the same sample and user mapping:
```bash
python eval/hermes_memory_eval/run_eval.py qa /path/to/locomo10_small.json \
--config eval/hermes_memory_eval/config.yaml \
--sample 0 \
--count 10 \
--output output/hermes_qa.jsonl
```
Each QA runs in a fresh Hermes CLI call, so the answer should come from persistent memory rather than the prior short-term chat context.
The default QA prompt explicitly asks Hermes to call `memory_system_search` before answering.
If Memory System API does not log `POST /memory-system/search`, inspect the session JSON to confirm whether the model made a tool call.
## 5. Judge Answers
Use the `judge` section in `config.yaml`:
```yaml
judge:
base_url: "https://api.openai.com/v1"
api_key_env: "OPENAI_API_KEY"
model: "gpt-4o-mini"
parallel: 4
timeout_seconds: 120
```
Then run:
```bash
OPENAI_API_KEY=sk-... python eval/hermes_memory_eval/judge.py output/hermes_qa.jsonl \
--config eval/hermes_memory_eval/config.yaml \
--output output/hermes_grades.json
```
For Ark/Doubao-style endpoints:
```yaml
judge:
base_url: "https://ark.cn-beijing.volces.com/api/v3"
api_key_env: "ARK_API_KEY"
model: "doubao-seed-2-0-pro-260215"
```
```bash
ARK_API_KEY=... python eval/hermes_memory_eval/judge.py output/hermes_qa.jsonl \
--config eval/hermes_memory_eval/config.yaml \
--output output/hermes_grades.json
```
## Recommended Comparisons
Run the same dataset in these modes:
- no external memory
- `MEMORY_SYSTEM_SEARCH_USE_LLM=false`
- `MEMORY_SYSTEM_SEARCH_USE_LLM=true`
Compare final QA score and inspect failed examples. If search recall is high but QA accuracy is low, Hermes is not using retrieved memory well. If search recall is low, the issue is likely write/extract/search quality.
## Current Small Dataset Result
On `locomo10_small.json` sample `conv-26`, the current smoke test results are:
| Mode | Score | Category 1 | Category 2 | Category 3 | Category 4 |
| --- | ---: | ---: | ---: | ---: | ---: |
| Memory System enabled | 5/35 (14.29%) | 0/5 (0.00%) | 1/9 (11.11%) | 1/2 (50.00%) | 3/19 (15.79%) |
| No external memory | 0/35 (0.00%) | 0/5 (0.00%) | 0/9 (0.00%) | 0/2 (0.00%) | 0/19 (0.00%) |
This means the Memory System path is contributing signal over the no-memory baseline, but the absolute score is still low. The main follow-up is to inspect failed QA examples and separate retrieval failure from answer-use failure:
- If `POST /memory-system/search` does not appear during QA, Hermes did not call the memory tool.
- If search results do not contain the evidence/gold answer, the write/extract/search path needs improvement.
- If search results contain the evidence but the answer is wrong, Hermes is not using retrieved memory effectively.
For future runs, keep a fresh `user_prefix` per mode so OpenViking/EverOS memory from prior runs does not contaminate results.

View File

@ -1,2 +0,0 @@
"""Hermes memory evaluation helpers."""

View File

@ -1,25 +0,0 @@
hermes:
command: "hermes"
timeout_seconds: 600
quiet: true
source: "memory-eval"
extra_args: []
memory:
env_file: "/home/tom/.hermes/memory_system.env"
endpoint: "http://127.0.0.1:1934"
api_key: ""
user_prefix: "locomo-"
search_use_llm: false
commit_every_turns: 1
commit_interval_seconds: 0
qa:
prompt_template: "请先使用 memory_system_search 查询长期记忆,再根据检索到的记忆回答问题。如果记忆中没有答案,请直接说不知道,不要编造。\n\n问题{question}"
judge:
base_url: "https://api.openai.com/v1"
api_key_env: "OPENAI_API_KEY"
model: "gpt-4o-mini"
parallel: 4
timeout_seconds: 120

View File

@ -1,25 +0,0 @@
hermes:
command: "hermes"
timeout_seconds: 600
quiet: true
source: "memory-eval"
extra_args: []
memory:
env_file: "/home/tom/memory-gateway/eval/hermes_memory_eval/hermes_home/memory_system.env"
endpoint: "http://127.0.0.1:1934"
api_key: ""
user_prefix: "locomo-full-nomemory-20260520-"
search_use_llm: false
commit_every_turns: 1
commit_interval_seconds: 0
qa:
prompt_template: "{question}"
judge:
base_url: "https://oai.bwgdi.com/v1"
model: "Qwen3.6-35B"
api_key: "sk-4BxeAtnQCRv3x1xwRcmTJg"
parallel: 4
timeout_seconds: 120

File diff suppressed because one or more lines are too long

View File

@ -1,852 +0,0 @@
[
{
"sample_id": "conv-26",
"conversation": {
"speaker_a": "Caroline",
"speaker_b": "Melanie",
"session_1_date_time": "1:56 pm on 8 May, 2023",
"session_1": [
{
"speaker": "Caroline",
"dia_id": "D1:1",
"text": "Hey Mel! Good to see you! How have you been?"
},
{
"speaker": "Melanie",
"dia_id": "D1:2",
"text": "Hey Caroline! Good to see you! I'm swamped with the kids & work. What's up with you? Anything new?"
},
{
"speaker": "Caroline",
"dia_id": "D1:3",
"text": "I went to a LGBTQ support group yesterday and it was so powerful."
},
{
"speaker": "Melanie",
"dia_id": "D1:4",
"text": "Wow, that's cool, Caroline! What happened that was so awesome? Did you hear any inspiring stories?"
},
{
"speaker": "Caroline",
"img_url": [
"https://i.redd.it/l7hozpetnhlb1.jpg"
],
"blip_caption": "a photo of a dog walking past a wall with a painting of a woman",
"query": "transgender pride flag mural",
"dia_id": "D1:5",
"text": "The transgender stories were so inspiring! I was so happy and thankful for all the support."
},
{
"speaker": "Melanie",
"dia_id": "D1:6",
"text": "Wow, love that painting! So cool you found such a helpful group. What's it done for you?"
},
{
"speaker": "Caroline",
"dia_id": "D1:7",
"text": "The support group has made me feel accepted and given me courage to embrace myself."
},
{
"speaker": "Melanie",
"dia_id": "D1:8",
"text": "That's really cool. You've got guts. What now?"
},
{
"speaker": "Caroline",
"dia_id": "D1:9",
"text": "Gonna continue my edu and check out career options, which is pretty exciting!"
},
{
"speaker": "Melanie",
"dia_id": "D1:10",
"text": "Wow, Caroline! What kinda jobs are you thinkin' of? Anything that stands out?"
},
{
"speaker": "Caroline",
"dia_id": "D1:11",
"text": "I'm keen on counseling or working in mental health - I'd love to support those with similar issues."
},
{
"speaker": "Melanie",
"img_url": [
"http://candicealexander.com/cdn/shop/products/IMG_7269_a49d5af8-c76c-4ecd-ae20-48c08cb11dec.jpg"
],
"blip_caption": "a photo of a painting of a sunset over a lake",
"query": "painting sunrise",
"dia_id": "D1:12",
"text": "You'd be a great counselor! Your empathy and understanding will really help the people you work with. By the way, take a look at this."
},
{
"speaker": "Caroline",
"dia_id": "D1:13",
"text": "Thanks, Melanie! That's really sweet. Is this your own painting?"
},
{
"speaker": "Melanie",
"dia_id": "D1:14",
"text": "Yeah, I painted that lake sunrise last year! It's special to me."
},
{
"speaker": "Caroline",
"dia_id": "D1:15",
"text": "Wow, Melanie! The colors really blend nicely. Painting looks like a great outlet for expressing yourself."
},
{
"speaker": "Melanie",
"dia_id": "D1:16",
"text": "Thanks, Caroline! Painting's a fun way to express my feelings and get creative. It's a great way to relax after a long day."
},
{
"speaker": "Caroline",
"dia_id": "D1:17",
"text": "Totally agree, Mel. Relaxing and expressing ourselves is key. Well, I'm off to go do some research."
},
{
"speaker": "Melanie",
"dia_id": "D1:18",
"text": "Yep, Caroline. Taking care of ourselves is vital. I'm off to go swimming with the kids. Talk to you soon!"
}
],
"session_2_date_time": "1:14 pm on 25 May, 2023",
"session_2": [
{
"speaker": "Melanie",
"dia_id": "D2:1",
"text": "Hey Caroline, since we last chatted, I've had a lot of things happening to me. I ran a charity race for mental health last Saturday \u2013 it was really rewarding. Really made me think about taking care of our minds."
},
{
"speaker": "Caroline",
"dia_id": "D2:2",
"text": "That charity race sounds great, Mel! Making a difference & raising awareness for mental health is super rewarding - I'm really proud of you for taking part!"
},
{
"speaker": "Melanie",
"dia_id": "D2:3",
"text": "Thanks, Caroline! The event was really thought-provoking. I'm starting to realize that self-care is really important. It's a journey for me, but when I look after myself, I'm able to better look after my family."
},
{
"speaker": "Caroline",
"dia_id": "D2:4",
"text": "I totally agree, Melanie. Taking care of ourselves is so important - even if it's not always easy. Great that you're prioritizing self-care."
},
{
"speaker": "Melanie",
"dia_id": "D2:5",
"text": "Yeah, it's tough. So I'm carving out some me-time each day - running, reading, or playing my violin - which refreshes me and helps me stay present for my fam!"
},
{
"speaker": "Caroline",
"dia_id": "D2:6",
"text": "That's great, Mel! Taking time for yourself is so important. You're doing an awesome job looking after yourself and your family!"
},
{
"speaker": "Melanie",
"dia_id": "D2:7",
"text": "Thanks, Caroline. It's still a work in progress, but I'm doing my best. My kids are so excited about summer break! We're thinking about going camping next month. Any fun plans for the summer?"
},
{
"speaker": "Caroline",
"dia_id": "D2:8",
"text": "Researching adoption agencies \u2014 it's been a dream to have a family and give a loving home to kids who need it."
},
{
"speaker": "Melanie",
"dia_id": "D2:9",
"text": "Wow, Caroline! That's awesome! Taking in kids in need - you're so kind. Your future family is gonna be so lucky to have you!"
},
{
"speaker": "Caroline",
"img_url": [
"https://live.staticflickr.com/3437/3935231341_b2955b00dd_b.jpg"
],
"blip_caption": "a photography of a sign for a new arrival and an information and domestic building",
"query": "adoption agency brochure",
"dia_id": "D2:10",
"re-download": true,
"text": "Thanks, Mel! My goal is to give kids a loving home. I'm truly grateful for all the support I've got from friends and mentors. Now the hard work starts to turn my dream into a reality. And here's one of the adoption agencies I'm looking into. It's a lot to take in, but I'm feeling hopeful and optimistic."
},
{
"speaker": "Melanie",
"dia_id": "D2:11",
"text": "Wow, that agency looks great! What made you pick it?"
},
{
"speaker": "Caroline",
"dia_id": "D2:12",
"text": "I chose them 'cause they help LGBTQ+ folks with adoption. Their inclusivity and support really spoke to me."
},
{
"speaker": "Melanie",
"dia_id": "D2:13",
"text": "That's great, Caroline! Loving the inclusivity and support. Anything you're excited for in the adoption process?"
},
{
"speaker": "Caroline",
"dia_id": "D2:14",
"text": "I'm thrilled to make a family for kids who need one. It'll be tough as a single parent, but I'm up for the challenge!"
},
{
"speaker": "Melanie",
"dia_id": "D2:15",
"text": "You're doing something amazing! Creating a family for those kids is so lovely. You'll be an awesome mom! Good luck!"
},
{
"speaker": "Caroline",
"dia_id": "D2:16",
"text": "Thanks, Melanie! Your kind words really mean a lot. I'll do my best to make sure these kids have a safe and loving home."
},
{
"speaker": "Melanie",
"dia_id": "D2:17",
"text": "No doubts, Caroline. You have such a caring heart - they'll get all the love and stability they need! Excited for this new chapter!"
}
],
"session_3_date_time": "7:55 pm on 9 June, 2023",
"session_3": [
{
"speaker": "Caroline",
"dia_id": "D3:1",
"text": "Hey Melanie! How's it going? I wanted to tell you about my school event last week. It was awesome! I talked about my transgender journey and encouraged students to get involved in the LGBTQ community. It was great to see their reactions. It made me reflect on how far I've come since I started transitioning three years ago."
},
{
"speaker": "Melanie",
"dia_id": "D3:2",
"text": "Hey Caroline! Great to hear from you. Sounds like your event was amazing! I'm so proud of you for spreading awareness and getting others involved in the LGBTQ community. You've come a long way since your transition - keep on inspiring people with your strength and courage!"
},
{
"speaker": "Caroline",
"dia_id": "D3:3",
"text": "Thanks, Mel! Your backing really means a lot. I felt super powerful giving my talk. I shared my own journey, the struggles I had and how much I've developed since coming out. It was wonderful to see how the audience related to what I said and how it inspired them to be better allies. Conversations about gender identity and inclusion are so necessary and I'm thankful for being able to give a voice to the trans community."
},
{
"speaker": "Melanie",
"dia_id": "D3:4",
"text": "Wow, Caroline, you're doing an awesome job of inspiring others with your journey. It's great to be part of it and see how you're positively affecting so many. Talking about inclusivity and acceptance is crucial, and you're so brave to speak up for the trans community. Keep up the great work!"
},
{
"speaker": "Caroline",
"dia_id": "D3:5",
"text": "Thanks Mel! Your kind words mean a lot. Sharing our experiences isn't always easy, but I feel it's important to help promote understanding and acceptance. I've been blessed with loads of love and support throughout this journey, and I want to pass it on to others. By sharing our stories, we can build a strong, supportive community of hope."
},
{
"speaker": "Melanie",
"dia_id": "D3:6",
"text": "Yeah, Caroline! It takes courage to talk about our own stories. But it's in these vulnerable moments that we bond and understand each other. We all have our different paths, but if we share them, we show people that they're not alone. Our stories can be so inspiring and encouraging to others who are facing the same challenges. Thank you for using your voice to create love, acceptance, and hope. You're doing amazing!"
},
{
"speaker": "Caroline",
"dia_id": "D3:7",
"text": "Your words mean a lot to me. I'm grateful for the chance to share my story and give others hope. We all have unique paths, and by working together we can build a more inclusive and understanding world. I'm going to keep using my voice to make a change and lift others up. And you're part of that!"
},
{
"speaker": "Melanie",
"dia_id": "D3:8",
"text": "Thanks, Caroline, for letting me join your journey. I'm so proud to be part of the difference you're making. Let's keep motivating and helping each other out as we journey through life. We can make a real impact together!"
},
{
"speaker": "Caroline",
"dia_id": "D3:9",
"text": "Yeah Mel, let's spread love and understanding! Thanks for the support and encouragement. We can tackle life's challenges together! We got this!"
},
{
"speaker": "Melanie",
"dia_id": "D3:10",
"text": "Yes, Caroline! We can do it. Your courage is inspiring. I want to be couragous for my family- they motivate me and give me love. What motivates you?"
},
{
"speaker": "Caroline",
"img_url": [
"https://fox2now.com/wp-content/uploads/sites/14/2023/08/that-tall-family.jpg"
],
"blip_caption": "a photo of a family posing for a picture in a yard",
"query": "group of friends and family",
"dia_id": "D3:11",
"text": "Thanks, Mel! My friends, family and mentors are my rocks \u2013 they motivate me and give me the strength to push on. Here's a pic from when we met up last week!"
},
{
"speaker": "Melanie",
"dia_id": "D3:12",
"text": "Wow, that photo is great! How long have you had such a great support system?"
},
{
"speaker": "Caroline",
"dia_id": "D3:13",
"text": "Yeah, I'm really lucky to have them. They've been there through everything, I've known these friends for 4 years, since I moved from my home country. Their love and help have been so important especially after that tough breakup. I'm super thankful. Who supports you, Mel?"
},
{
"speaker": "Melanie",
"img_url": [
"https://mrswebersneighborhood.com/wp-content/uploads/2022/07/Cedar-Falls-Hocking-Hills.jpg"
],
"blip_caption": "a photo of a man and a little girl standing in front of a waterfall",
"query": "husband kids hiking nature",
"dia_id": "D3:14",
"text": "I'm lucky to have my husband and kids; they keep me motivated."
},
{
"speaker": "Caroline",
"dia_id": "D3:15",
"text": "Wow, what an amazing family pic! How long have you been married?"
},
{
"speaker": "Melanie",
"img_url": [
"https://i.redd.it/8o28nfllf3eb1.jpg"
],
"blip_caption": "a photo of a bride in a wedding dress holding a bouquet",
"query": "wedding day",
"dia_id": "D3:16",
"text": "5 years already! Time flies- feels like just yesterday I put this dress on! Thanks, Caroline!"
},
{
"speaker": "Caroline",
"dia_id": "D3:17",
"text": "Congrats, Melanie! You both looked so great on your wedding day! Wishing you many happy years together!"
},
{
"speaker": "Melanie",
"img_url": [
"http://shirleyswardrobe.com/wp-content/uploads/2017/07/LF-Picnic-6.jpg"
],
"blip_caption": "a photo of a man and woman sitting on a blanket eating food",
"query": "family picnic park laughing",
"dia_id": "D3:18",
"text": "Thanks, Caroline! Appreciate your kind words. Looking forward to more happy years. Our family and moments make it all worth it."
},
{
"speaker": "Caroline",
"dia_id": "D3:19",
"text": "Looks like you had a great day! How was it? You all look so happy!"
},
{
"speaker": "Melanie",
"dia_id": "D3:20",
"text": "It so fun! We played games, ate good food, and just hung out together. Family moments make life awesome."
},
{
"speaker": "Caroline",
"dia_id": "D3:21",
"text": "Sounds great, Mel! Glad you had a great time. Cherish the moments - they're the best!"
},
{
"speaker": "Melanie",
"dia_id": "D3:22",
"text": "Absolutely, Caroline! I cherish time with family. It's when I really feel alive and happy."
},
{
"speaker": "Caroline",
"dia_id": "D3:23",
"text": "I 100% agree, Mel. Hanging with loved ones is amazing and brings so much happiness. Those moments really make me thankful. Family is everything."
}
],
"session_4_date_time": "10:37 am on 27 June, 2023",
"session_4": [
{
"speaker": "Caroline",
"img_url": [
"https://i.redd.it/67uas3gnmz7b1.jpg"
],
"blip_caption": "a photo of a person holding a necklace with a cross and a heart",
"query": "pendant transgender symbol",
"dia_id": "D4:1",
"text": "Hey Melanie! Long time no talk! A lot's been going on in my life! Take a look at this."
},
{
"speaker": "Melanie",
"dia_id": "D4:2",
"text": "Hey, Caroline! Nice to hear from you! Love the necklace, any special meaning to it?"
},
{
"speaker": "Caroline",
"dia_id": "D4:3",
"text": "Thanks, Melanie! This necklace is super special to me - a gift from my grandma in my home country, Sweden. She gave it to me when I was young, and it stands for love, faith and strength. It's like a reminder of my roots and all the love and support I get from my family."
},
{
"speaker": "Melanie",
"blip_caption": "a photo of a stack of bowls with different designs on them",
"dia_id": "D4:4",
"text": "That's gorgeous, Caroline! It's awesome what items can mean so much to us, right? Got any other objects that you treasure, like that necklace?"
},
{
"speaker": "Caroline",
"dia_id": "D4:5",
"text": "Yep, Melanie! I've got some other stuff with sentimental value, like my hand-painted bowl. A friend made it for my 18th birthday ten years ago. The pattern and colors are awesome-- it reminds me of art and self-expression."
},
{
"speaker": "Melanie",
"dia_id": "D4:6",
"text": "That sounds great, Caroline! It's awesome having stuff around that make us think of good connections and times. Actually, I just took my fam camping in the mountains last week - it was a really nice time together!"
},
{
"speaker": "Caroline",
"dia_id": "D4:7",
"text": "Sounds great, Mel. Glad you made some new family mems. How was it? Anything fun?"
},
{
"speaker": "Melanie",
"dia_id": "D4:8",
"text": "It was an awesome time, Caroline! We explored nature, roasted marshmallows around the campfire and even went on a hike. The view from the top was amazing! The 2 younger kids love nature. It was so special having these moments together as a family - I'll never forget it!"
},
{
"speaker": "Caroline",
"dia_id": "D4:9",
"text": "That's awesome, Melanie! Family moments like that are so special. Glad y'all had such a great time."
},
{
"speaker": "Melanie",
"dia_id": "D4:10",
"text": "Thanks, Caroline! Family time matters to me. What's up with you lately?"
},
{
"speaker": "Caroline",
"blip_caption": "a photo of a book shelf with many books on it",
"dia_id": "D4:11",
"text": "Lately, I've been looking into counseling and mental health as a career. I want to help people who have gone through the same things as me."
},
{
"speaker": "Melanie",
"dia_id": "D4:12",
"text": "Sounds great! What kind of counseling and mental health services do you want to persue?"
},
{
"speaker": "Caroline",
"dia_id": "D4:13",
"text": "I'm still figuring out the details, but I'm thinking of working with trans people, helping them accept themselves and supporting their mental health. Last Friday, I went to an LGBTQ+ counseling workshop and it was really enlightening. They talked about different therapeutic methods and how to best work with trans people. Seeing how passionate these pros were about making a safe space for people like me was amazing."
},
{
"speaker": "Melanie",
"dia_id": "D4:14",
"text": "Woah, Caroline, it sounds like you're doing some impressive work. It's inspiring to see your dedication to helping others. What motivated you to pursue counseling?"
},
{
"speaker": "Caroline",
"dia_id": "D4:15",
"text": "Thanks, Melanie. It really mattered. My own journey and the support I got made a huge difference. Now I want to help people go through it too. I saw how counseling and support groups improved my life, so I started caring more about mental health and understanding myself. Now I'm passionate about creating a safe, inviting place for people to grow."
},
{
"speaker": "Melanie",
"dia_id": "D4:16",
"text": "Wow, Caroline! You've gained so much from your own experience. Your passion and hard work to help others is awesome. Keep it up, you're making a big impact!"
},
{
"speaker": "Caroline",
"dia_id": "D4:17",
"text": "Thanks, Melanie! Your kind words mean a lot."
},
{
"speaker": "Melanie",
"blip_caption": "a photo of a book shelf filled with books in a room",
"dia_id": "D4:18",
"text": "Congrats Caroline! Good on you for going after what you really care about."
}
]
},
"qa": [
{
"question": "What did Caroline realize after her charity race?",
"evidence": [
"D2:3"
],
"category": 5,
"answer": "self-care is important"
},
{
"question": "When did Caroline go to the LGBTQ support group?",
"answer": "7 May 2023",
"evidence": [
"D1:3"
],
"category": 2
},
{
"question": "When did Melanie paint a sunrise?",
"answer": 2022,
"evidence": [
"D1:12"
],
"category": 2
},
{
"question": "What fields would Caroline be likely to pursue in her educaton?",
"answer": "Psychology, counseling certification",
"evidence": [
"D1:9",
"D1:11"
],
"category": 3
},
{
"question": "What did Caroline research?",
"answer": "Adoption agencies",
"evidence": [
"D2:8"
],
"category": 1
},
{
"question": "What is Caroline's identity?",
"answer": "Transgender woman",
"evidence": [
"D1:5"
],
"category": 1
},
{
"question": "When did Melanie run a charity race?",
"answer": "The sunday before 25 May 2023",
"evidence": [
"D2:1"
],
"category": 2
},
{
"question": "When is Melanie planning on going camping?",
"answer": "June 2023",
"evidence": [
"D2:7"
],
"category": 2
},
{
"question": "What is Caroline's relationship status?",
"answer": "Single",
"evidence": [
"D3:13",
"D2:14"
],
"category": 1
},
{
"question": "When did Caroline give a speech at a school?",
"answer": "The week before 9 June 2023",
"evidence": [
"D3:1"
],
"category": 2
},
{
"question": "When did Caroline meet up with her friends, family, and mentors?",
"answer": "The week before 9 June 2023",
"evidence": [
"D3:11"
],
"category": 2
},
{
"question": "How long has Caroline had her current group of friends for?",
"answer": "4 years",
"evidence": [
"D3:13"
],
"category": 2
},
{
"question": "Where did Caroline move from 4 years ago?",
"answer": "Sweden",
"evidence": [
"D3:13",
"D4:3"
],
"category": 1
},
{
"question": "How long ago was Caroline's 18th birthday?",
"answer": "10 years ago",
"evidence": [
"D4:5"
],
"category": 2
},
{
"question": "What career path has Caroline decided to persue?",
"answer": "counseling or mental health for Transgender people",
"evidence": [
"D4:13",
"D1:11"
],
"category": 1
},
{
"question": "Would Caroline still want to pursue counseling as a career if she hadn't received support growing up?",
"answer": "Likely no",
"evidence": [
"D4:15",
"D3:5"
],
"category": 3
},
{
"question": "When did Melanie go camping in June?",
"answer": "The week before 27 June 2023",
"evidence": [
"D4:8"
],
"category": 2
},
{
"question": "What did the charity race raise awareness for?",
"answer": "mental health",
"evidence": [
"D2:2"
],
"category": 4
},
{
"question": "What did Melanie realize after the charity race?",
"answer": "self-care is important",
"evidence": [
"D2:3"
],
"category": 4
},
{
"question": "How does Melanie prioritize self-care?",
"answer": "by carving out some me-time each day for activities like running, reading, or playing the violin",
"evidence": [
"D2:5"
],
"category": 4
},
{
"question": "What are Caroline's plans for the summer?",
"answer": "researching adoption agencies",
"evidence": [
"D2:8"
],
"category": 4
},
{
"question": "What type of individuals does the adoption agency Caroline is considering support?",
"answer": "LGBTQ+ individuals",
"evidence": [
"D2:12"
],
"category": 4
},
{
"question": "Why did Caroline choose the adoption agency?",
"answer": "because of their inclusivity and support for LGBTQ+ individuals",
"evidence": [
"D2:12"
],
"category": 4
},
{
"question": "What is Caroline excited about in the adoption process?",
"answer": "creating a family for kids who need one",
"evidence": [
"D2:14"
],
"category": 4
},
{
"question": "What does Melanie think about Caroline's decision to adopt?",
"answer": "she thinks Caroline is doing something amazing and will be an awesome mom",
"evidence": [
"D2:15"
],
"category": 4
},
{
"question": "How long have Mel and her husband been married?",
"answer": "Mel and her husband have been married for 5 years.",
"evidence": [
"D3:16"
],
"category": 4
},
{
"question": "What does Caroline's necklace symbolize?",
"answer": "love, faith, and strength",
"evidence": [
"D4:3"
],
"category": 4
},
{
"question": "What country is Caroline's grandma from?",
"answer": "Sweden",
"evidence": [
"D4:3"
],
"category": 4
},
{
"question": "What was grandma's gift to Caroline?",
"answer": "necklace",
"evidence": [
"D4:3"
],
"category": 4
},
{
"question": "What is Melanie's hand-painted bowl a reminder of?",
"answer": "art and self-expression",
"evidence": [
"D4:5"
],
"category": 4
},
{
"question": "What did Melanie and her family do while camping?",
"answer": "explored nature, roasted marshmallows, and went on a hike",
"evidence": [
"D4:8"
],
"category": 4
},
{
"question": "What kind of counseling and mental health services is Caroline interested in pursuing?",
"answer": "working with trans people, helping them accept themselves and supporting their mental health",
"evidence": [
"D4:13"
],
"category": 4
},
{
"question": "What workshop did Caroline attend recently?",
"answer": "LGBTQ+ counseling workshop",
"evidence": [
"D4:13"
],
"category": 4
},
{
"question": "What was discussed in the LGBTQ+ counseling workshop?",
"answer": "therapeutic methods and how to best work with trans people",
"evidence": [
"D4:13"
],
"category": 4
},
{
"question": "What motivated Caroline to pursue counseling?",
"answer": "her own journey and the support she received, and how counseling improved her life",
"evidence": [
"D4:15"
],
"category": 4
},
{
"question": "What kind of place does Caroline want to create for people?",
"answer": "a safe and inviting place for people to grow",
"evidence": [
"D4:15"
],
"category": 4
},
{
"question": "What are Melanie's plans for the summer with respect to adoption?",
"evidence": [
"D2:8"
],
"category": 5,
"answer": "researching adoption agencies"
},
{
"question": "What type of individuals does the adoption agency Melanie is considering support?",
"evidence": [
"D2:12"
],
"category": 5,
"answer": "LGBTQ+ individuals"
},
{
"question": "Why did Melanie choose the adoption agency?",
"evidence": [
"D2:12"
],
"category": 5,
"answer": "because of their inclusivity and support for LGBTQ+ individuals"
},
{
"question": "What is Melanie excited about in her adoption process?",
"evidence": [
"D2:14"
],
"category": 5,
"answer": "creating a family for kids who need one"
},
{
"question": "What does Melanie's necklace symbolize?",
"evidence": [
"D4:3"
],
"category": 5,
"answer": "love, faith, and strength"
},
{
"question": "What country is Melanie's grandma from?",
"evidence": [
"D4:3"
],
"category": 5,
"answer": "Sweden"
},
{
"question": "What was grandma's gift to Melanie?",
"evidence": [
"D4:3"
],
"category": 5,
"answer": "necklace"
},
{
"question": "What was grandpa's gift to Caroline?",
"evidence": [
"D4:3"
],
"category": 5,
"answer": "necklace"
},
{
"question": "What is Caroline's hand-painted bowl a reminder of?",
"evidence": [
"D4:5"
],
"category": 5,
"answer": "art and self-expression"
},
{
"question": "What did Caroline and her family do while camping?",
"evidence": [
"D4:8"
],
"category": 5,
"answer": "explored nature, roasted marshmallows, and went on a hike"
},
{
"question": "What kind of counseling and mental health services is Melanie interested in pursuing?",
"evidence": [
"D4:13"
],
"category": 5,
"answer": "working with trans people, helping them accept themselves and supporting their mental health"
},
{
"question": "What kind of counseling workshop did Melanie attend recently?",
"evidence": [
"D4:13"
],
"category": 5,
"answer": "LGBTQ+ counseling workshop"
},
{
"question": "What motivated Melanie to pursue counseling?",
"evidence": [
"D4:15"
],
"category": 5,
"answer": "her own journey and the support she received, and how counseling improved her life"
},
{
"question": "What kind of place does Melanie want to create for people?",
"evidence": [
"D4:15"
],
"category": 5,
"answer": "a safe and inviting place for people to grow"
}
]
}
]

View File

@ -1,51 +0,0 @@
"""Hermes CLI client used by the memory evaluation runner."""
from __future__ import annotations
import os
import subprocess
from dataclasses import dataclass, field
from typing import Mapping
@dataclass(frozen=True)
class HermesClientConfig:
command: str = "hermes"
timeout_seconds: int = 600
quiet: bool = True
source: str = "memory-eval"
extra_args: list[str] = field(default_factory=list)
class HermesClient:
def __init__(self, config: HermesClientConfig):
self._config = config
def chat(self, message: str, *, user_id: str, env: Mapping[str, str] | None = None) -> str:
command = [self._config.command, "chat"]
if self._config.quiet:
command.append("-Q")
if self._config.source:
command.extend(["--source", self._config.source])
command.extend(self._config.extra_args)
command.extend(["-q", message])
process_env = os.environ.copy()
process_env["MEMORY_SYSTEM_USER_ID"] = user_id
if env:
process_env.update({key: str(value) for key, value in env.items() if value is not None})
result = subprocess.run(
command,
capture_output=True,
check=False,
env=process_env,
text=True,
timeout=self._config.timeout_seconds,
)
if result.returncode != 0:
stderr = result.stderr.strip()
stdout = result.stdout.strip()
detail = stderr or stdout or f"exit code {result.returncode}"
raise RuntimeError(f"Hermes command failed: {detail}")
return result.stdout.strip()

View File

@ -1,188 +0,0 @@
"""LLM judge for Hermes memory QA outputs."""
from __future__ import annotations
import argparse
import asyncio
import json
import os
from pathlib import Path
from typing import Any
import httpx
import yaml
def load_answers(path: str | Path) -> list[dict[str, Any]]:
input_path = Path(path)
if input_path.suffix == ".jsonl":
with input_path.open("r", encoding="utf-8") as file:
return [json.loads(line) for line in file if line.strip()]
with input_path.open("r", encoding="utf-8") as file:
data = json.load(file)
if isinstance(data, dict):
return data.get("results", data.get("grades", []))
if isinstance(data, list):
return data
raise ValueError("answers file must be JSON list, JSONL, or object with results")
def load_config(path: str | Path | None) -> dict[str, Any]:
if not path:
return {}
config_path = Path(path)
if not config_path.exists():
return {}
with config_path.open("r", encoding="utf-8") as file:
return yaml.safe_load(file) or {}
def resolve_judge_config(args: argparse.Namespace) -> dict[str, Any]:
config = load_config(args.config)
judge = config.get("judge", {})
base_url = args.base_url or judge.get("base_url") or os.environ.get("OPENAI_BASE_URL") or "https://api.openai.com/v1"
model = args.model or judge.get("model") or "gpt-4o-mini"
api_key_env = args.api_key_env or judge.get("api_key_env") or "OPENAI_API_KEY"
api_key = args.api_key or judge.get("api_key") or os.environ.get(api_key_env, "")
parallel = args.parallel if args.parallel is not None else int(judge.get("parallel", 4))
timeout_seconds = args.timeout_seconds if args.timeout_seconds is not None else int(judge.get("timeout_seconds", 120))
return {
"base_url": str(base_url),
"model": str(model),
"api_key": str(api_key),
"api_key_env": str(api_key_env),
"parallel": int(parallel),
"timeout_seconds": int(timeout_seconds),
}
def judge_prompt(question: str, expected: str, response: str) -> list[dict[str, str]]:
return [
{
"role": "system",
"content": "You are an expert grader for long-term memory QA. Return JSON only.",
},
{
"role": "user",
"content": (
"Decide whether the generated answer matches the gold answer.\n"
"Be generous: count it correct if it refers to the same fact, topic, person, place, or date.\n"
"Return exactly JSON: {\"is_correct\":\"CORRECT\" or \"WRONG\", \"reasoning\":\"short reason\"}.\n\n"
f"Question: {question}\n"
f"Gold answer: {expected}\n"
f"Generated answer: {response}"
),
},
]
async def grade_one(
client: httpx.AsyncClient,
*,
base_url: str,
api_key: str,
model: str,
item: dict[str, Any],
) -> dict[str, Any]:
payload = {
"model": model,
"temperature": 0,
"messages": judge_prompt(item["question"], item["expected"], item["response"]),
}
response = await client.post(
f"{base_url.rstrip('/')}/chat/completions",
headers={"Authorization": f"Bearer {api_key}", "Content-Type": "application/json"},
json=payload,
)
response.raise_for_status()
content = response.json()["choices"][0]["message"]["content"]
parsed = json.loads(content)
label = str(parsed.get("is_correct", parsed.get("label", "WRONG"))).strip().lower()
return {
**item,
"grade": label == "correct",
"judge_reasoning": parsed.get("reasoning", ""),
}
async def grade_answers(
answers: list[dict[str, Any]],
*,
base_url: str,
api_key: str,
model: str,
timeout_seconds: int = 120,
parallel: int = 4,
) -> list[dict[str, Any]]:
limits = httpx.Limits(max_connections=max(1, parallel))
async with httpx.AsyncClient(timeout=timeout_seconds, limits=limits) as client:
semaphore = asyncio.Semaphore(max(1, parallel))
async def _grade(item: dict[str, Any]) -> dict[str, Any]:
async with semaphore:
return await grade_one(client, base_url=base_url, api_key=api_key, model=model, item=item)
return await asyncio.gather(*[_grade(item) for item in answers])
def summarize(grades: list[dict[str, Any]]) -> dict[str, Any]:
correct = sum(1 for item in grades if item.get("grade"))
total = len(grades)
categories: dict[str, dict[str, int]] = {}
for item in grades:
category = str(item.get("category", "unknown"))
categories.setdefault(category, {"correct": 0, "total": 0})
categories[category]["total"] += 1
if item.get("grade"):
categories[category]["correct"] += 1
return {
"score": correct / total if total else 0.0,
"correct": correct,
"total": total,
"categories": categories,
}
def main() -> None:
parser = argparse.ArgumentParser(description="Judge Hermes memory QA answers")
parser.add_argument("input", help="QA JSONL or JSON file")
parser.add_argument("--config", default="eval/hermes_memory_eval/config.yaml")
parser.add_argument("--output", default=None)
parser.add_argument("--base-url", default=None)
parser.add_argument("--api-key", default=None)
parser.add_argument("--api-key-env", default=None)
parser.add_argument("--model", default=None)
parser.add_argument("--parallel", type=int, default=None)
parser.add_argument("--timeout-seconds", type=int, default=None)
args = parser.parse_args()
judge_config = resolve_judge_config(args)
if not judge_config["api_key"]:
raise SystemExit(f"missing --api-key or {judge_config['api_key_env']}")
answers = load_answers(args.input)
grades = asyncio.run(
grade_answers(
answers,
base_url=judge_config["base_url"],
api_key=judge_config["api_key"],
model=judge_config["model"],
parallel=judge_config["parallel"],
timeout_seconds=judge_config["timeout_seconds"],
)
)
summary = summarize(grades)
print(f"score: {summary['correct']}/{summary['total']} ({summary['score']:.2%})")
for category, stats in sorted(summary["categories"].items()):
total = stats["total"]
score = stats["correct"] / total if total else 0.0
print(f"category {category}: {stats['correct']}/{total} ({score:.2%})")
if args.output:
output = {"summary": summary, "grades": grades}
Path(args.output).parent.mkdir(parents=True, exist_ok=True)
with Path(args.output).open("w", encoding="utf-8") as file:
json.dump(output, file, indent=2, ensure_ascii=False)
if __name__ == "__main__":
main()

View File

@ -1,118 +0,0 @@
"""LoCoMo dataset parsing and formatting for Hermes memory evaluation."""
from __future__ import annotations
import json
from dataclasses import dataclass
from pathlib import Path
from typing import Any
@dataclass(frozen=True)
class LocomoSession:
sample_id: str
session_key: str
date_time: str
message: str
@dataclass(frozen=True)
class LocomoQA:
sample_id: str
question: str
expected: str
category: str
evidence: list[Any]
def load_samples(path: str | Path, sample_index: int | None = None) -> list[dict[str, Any]]:
with Path(path).open("r", encoding="utf-8") as file:
data = json.load(file)
if not isinstance(data, list):
raise ValueError("LoCoMo input must be a JSON list")
if sample_index is None:
return data
if sample_index < 0 or sample_index >= len(data):
raise ValueError(f"sample index {sample_index} out of range 0-{len(data) - 1}")
return [data[sample_index]]
def parse_session_range(value: str | None) -> tuple[int, int] | None:
if not value:
return None
if "-" in value:
start, end = value.split("-", 1)
return int(start), int(end)
number = int(value)
return number, number
def format_message(message: dict[str, Any]) -> str:
speaker = message.get("speaker", "unknown")
text = message.get("text", "")
line = f"{speaker}: {text}"
image_urls = message.get("img_url", [])
if isinstance(image_urls, str):
image_urls = [image_urls]
caption = message.get("blip_caption", "")
for url in image_urls:
suffix = f": {caption}" if caption else ""
line += f"\n{url}{suffix}"
if caption and not image_urls:
line += f"\n({caption})"
return line
def build_sessions(
sample: dict[str, Any],
session_range: tuple[int, int] | None = None,
tail: str = "请记住以上历史对话,只回复 OK。",
) -> list[LocomoSession]:
conversation = sample["conversation"]
session_keys = sorted(
[key for key in conversation if key.startswith("session_") and not key.endswith("_date_time")],
key=lambda key: int(key.split("_")[1]),
)
sessions: list[LocomoSession] = []
for session_key in session_keys:
session_number = int(session_key.split("_")[1])
if session_range:
start, end = session_range
if session_number < start or session_number > end:
continue
date_time = conversation.get(f"{session_key}_date_time", "")
parts = [f"[group chat conversation: {date_time}]"]
parts.extend(format_message(message) for message in conversation[session_key])
if tail:
parts.append(tail)
sessions.append(
LocomoSession(
sample_id=str(sample["sample_id"]),
session_key=session_key,
date_time=date_time,
message="\n\n".join(parts),
)
)
return sessions
def build_qas(sample: dict[str, Any], *, include_category_5: bool = False) -> list[LocomoQA]:
qas: list[LocomoQA] = []
for qa in sample.get("qa", []):
category = str(qa.get("category", ""))
if category == "5" and not include_category_5:
continue
qas.append(
LocomoQA(
sample_id=str(sample["sample_id"]),
question=str(qa["question"]),
expected=str(qa["answer"]),
category=category,
evidence=qa.get("evidence", []),
)
)
return qas
def sample_user_id(prefix: str, sample: dict[str, Any]) -> str:
return f"{prefix}{sample['sample_id']}"

View File

@ -1,186 +0,0 @@
"""Run Hermes memory evaluation using LoCoMo-style datasets."""
from __future__ import annotations
import argparse
import json
import os
import sys
from pathlib import Path
from typing import Any
import yaml
if __package__ in {None, ""}:
sys.path.insert(0, str(Path(__file__).resolve().parents[2]))
from eval.hermes_memory_eval.hermes_client import HermesClient, HermesClientConfig
from eval.hermes_memory_eval.locomo import (
build_qas,
build_sessions,
load_samples,
parse_session_range,
sample_user_id,
)
def load_config(path: str | Path) -> dict[str, Any]:
with Path(path).open("r", encoding="utf-8") as file:
return yaml.safe_load(file) or {}
def memory_env(config: dict[str, Any]) -> dict[str, str]:
memory = config.get("memory", {})
env: dict[str, str] = {}
mappings = {
"env_file": "MEMORY_SYSTEM_ENV_FILE",
"endpoint": "MEMORY_SYSTEM_ENDPOINT",
"api_key": "MEMORY_SYSTEM_API_KEY",
"search_use_llm": "MEMORY_SYSTEM_SEARCH_USE_LLM",
"commit_every_turns": "MEMORY_SYSTEM_COMMIT_EVERY_TURNS",
"commit_interval_seconds": "MEMORY_SYSTEM_COMMIT_INTERVAL_SECONDS",
}
for key, env_key in mappings.items():
value = memory.get(key)
if value is not None:
env[env_key] = str(value)
return env
def build_client(config: dict[str, Any]) -> HermesClient:
hermes = config.get("hermes", {})
return HermesClient(
HermesClientConfig(
command=str(hermes.get("command", "hermes")),
timeout_seconds=int(hermes.get("timeout_seconds", 600)),
quiet=bool(hermes.get("quiet", True)),
source=str(hermes.get("source", "memory-eval")),
extra_args=[str(arg) for arg in hermes.get("extra_args", [])],
)
)
def qa_prompt(config: dict[str, Any], question: str) -> str:
qa_config = config.get("qa", {})
template = str(
qa_config.get(
"prompt_template",
(
"请先使用 memory_system_search 查询长期记忆,再根据检索到的记忆回答问题。"
"如果记忆中没有答案,请直接说不知道,不要编造。\n\n问题:{question}"
),
)
)
return template.format(question=question)
def write_jsonl(path: str | Path, records: list[dict[str, Any]]) -> None:
output_path = Path(path)
output_path.parent.mkdir(parents=True, exist_ok=True)
with output_path.open("w", encoding="utf-8") as file:
for record in records:
file.write(json.dumps(record, ensure_ascii=False) + "\n")
def run_ingest(args: argparse.Namespace) -> None:
config = load_config(args.config)
client = build_client(config)
env = memory_env(config)
samples = load_samples(args.input, args.sample)
session_range = parse_session_range(args.sessions)
user_prefix = str(config.get("memory", {}).get("user_prefix", "locomo-"))
records: list[dict[str, Any]] = []
for sample in samples:
user_id = args.user or sample_user_id(user_prefix, sample)
sessions = build_sessions(sample, session_range=session_range, tail=args.tail)
print(f"=== Sample {sample['sample_id']} user={user_id} sessions={len(sessions)} ===", file=sys.stderr)
for session in sessions:
try:
response = client.chat(session.message, user_id=user_id, env=env)
status = "success"
except Exception as exc:
response = str(exc)
status = "failed"
print(f"[{session.sample_id}/{session.session_key}] {status}", file=sys.stderr)
records.append(
{
"mode": "ingest",
"status": status,
"sample_id": session.sample_id,
"session": session.session_key,
"date_time": session.date_time,
"user_id": user_id,
"response": response,
}
)
if args.output:
write_jsonl(args.output, records)
print(f"written: {args.output}", file=sys.stderr)
def run_qa(args: argparse.Namespace) -> None:
config = load_config(args.config)
client = build_client(config)
env = memory_env(config)
samples = load_samples(args.input, args.sample)
user_prefix = str(config.get("memory", {}).get("user_prefix", "locomo-"))
records: list[dict[str, Any]] = []
for sample in samples:
user_id = args.user or sample_user_id(user_prefix, sample)
qas = build_qas(sample, include_category_5=args.include_category_5)
if args.count is not None:
qas = qas[: args.count]
print(f"=== Sample {sample['sample_id']} user={user_id} qa={len(qas)} ===", file=sys.stderr)
for index, qa in enumerate(qas, start=1):
try:
response = client.chat(qa_prompt(config, qa.question), user_id=user_id, env=env)
status = "success"
except Exception as exc:
response = str(exc)
status = "failed"
print(f"[{qa.sample_id}] Q{index}/{len(qas)} {status}", file=sys.stderr)
records.append(
{
"mode": "qa",
"status": status,
"sample_id": qa.sample_id,
"user_id": user_id,
"qi": index,
"question": qa.question,
"expected": qa.expected,
"response": response,
"category": qa.category,
"evidence": qa.evidence,
}
)
if args.output:
write_jsonl(args.output, records)
print(f"written: {args.output}", file=sys.stderr)
def main() -> None:
parser = argparse.ArgumentParser(description="Evaluate Hermes memory with LoCoMo-style datasets")
parser.add_argument("mode", choices=["ingest", "qa"])
parser.add_argument("input", help="Path to LoCoMo JSON dataset")
parser.add_argument("--config", default="eval/hermes_memory_eval/config.example.yaml")
parser.add_argument("--output", default=None)
parser.add_argument("--sample", type=int, default=None)
parser.add_argument("--user", default=None)
parser.add_argument("--sessions", default=None, help="Ingest session range, for example 1-4")
parser.add_argument("--tail", default="请记住以上历史对话,只回复 OK。")
parser.add_argument("--count", type=int, default=None)
parser.add_argument("--include-category-5", action="store_true")
args = parser.parse_args()
if args.mode == "ingest":
run_ingest(args)
else:
run_qa(args)
if __name__ == "__main__":
main()

View File

@ -1,77 +0,0 @@
# Copy this file to ./EverOS/methods/EverCore/.env.
# Do not commit the copied .env file because it contains provider keys.
# Required by EverCore for memory extraction.
LLM_PROVIDER=openai
LLM_MODEL=<LLM_MODEL_NAME>
LLM_BASE_URL=<LLM_BASE_URL>
LLM_API_KEY=<LLM_API_KEY>
LLM_TEMPERATURE=0.3
LLM_MAX_TOKENS=1000000
# Required by EverCore for embedding and rerank.
VECTORIZE_PROVIDER=vllm
VECTORIZE_API_KEY=<EMBEDDING_API_KEY>
VECTORIZE_BASE_URL=<EMBEDDING_BASE_URL>
VECTORIZE_MODEL=Qwen3-VL-Embedding-2B
VECTORIZE_FALLBACK_PROVIDER=none
VECTORIZE_TIMEOUT=30
VECTORIZE_MAX_RETRIES=3
VECTORIZE_BATCH_SIZE=10
VECTORIZE_MAX_CONCURRENT=5
VECTORIZE_ENCODING_FORMAT=float
VECTORIZE_DIMENSIONS=1024
RERANK_PROVIDER=vllm
RERANK_API_KEY=<RERANK_API_KEY>
RERANK_BASE_URL=<RERANK_BASE_URL>
RERANK_MODEL=Qwen3-VL-Reranker-2B
# EverCore API server.
API_BASE_URL=http://localhost:1995
LOG_LEVEL=INFO
ENV=dev
PYTHONASYNCIODEBUG=1
MEMORY_LANGUAGE=en
# Docker compose default dependencies.
# ===================
# Redis Configuration
# ===================
TENANT_SINGLE_TENANT_ID=t_tom
REDIS_HOST=localhost
REDIS_PORT=6379
REDIS_DB=8
REDIS_SSL=false
# ===================
# MongoDB Configuration
# ===================
MONGODB_HOST=localhost
MONGODB_PORT=27017
MONGODB_USERNAME=admin
MONGODB_PASSWORD=memsys123
MONGODB_DATABASE=memsys
MONGODB_URI_PARAMS=socketTimeoutMS=15000&authSource=admin
# ===================
# Elasticsearch Configuration
# ===================
ES_HOSTS=http://localhost:19200
ES_USERNAME=
ES_PASSWORD=
ES_VERIFY_CERTS=false
SELF_ES_INDEX_NS=memsys
# ===================
# Milvus Vector Database Configuration
# ===================
MILVUS_HOST=localhost
MILVUS_PORT=19530
SELF_MILVUS_COLLECTION_NS=memsys

22
main.py Normal file
View File

@ -0,0 +1,22 @@
from __future__ import annotations
import os
import uvicorn
from dotenv import load_dotenv
from core.api import create_app
load_dotenv()
app = create_app()
if __name__ == "__main__":
uvicorn.run(
"main:app",
host=os.environ.get("MEMORY_GATEWAY_HOST", "127.0.0.1"),
port=int(os.environ.get("MEMORY_GATEWAY_PORT", "8010")),
reload=os.environ.get("MEMORY_GATEWAY_RELOAD", "false").lower() == "true",
)

View File

@ -1 +0,0 @@
"""Lightweight Memory System API package."""

View File

@ -1,265 +0,0 @@
"""FastAPI router for the lightweight Memory System API."""
from __future__ import annotations
from fastapi import APIRouter, Depends, HTTPException, Query, status
from .auth import verify_api_key
from .schemas import (
MemoryWriteRequest,
MessageIngestRequest,
ProfileRequest,
ResourceUploadRequest,
SearchRequest,
SessionContextRequest,
SessionUserRequest,
TaskStatusRequest,
UserCreateRequest,
)
from .service import MemorySystemService
router = APIRouter(
prefix="/memory-system",
tags=["memory-system"],
dependencies=[Depends(verify_api_key)],
)
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),
):
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("/resources")
async def upload_resource(
request: ResourceUploadRequest,
service: MemorySystemService = Depends(get_service),
):
try:
return await service.upload_resource(request)
except PermissionError as exc:
raise user_auth_error(exc) from exc
@router.delete("/resources")
async def delete_resource(
user_id: str = Query(min_length=1),
user_key: str = Query(min_length=1),
uri: str = Query(min_length=1),
recursive: bool = Query(default=True),
service: MemorySystemService = Depends(get_service),
):
try:
return await service.delete_resource(user_id, user_key, uri, recursive=recursive)
except PermissionError as exc:
raise user_auth_error(exc) from exc
@router.get("/memories")
async def list_memories(
user_id: str = Query(min_length=1),
user_key: str = Query(min_length=1),
uri: str = Query(default="viking://user/memories", min_length=1),
recursive: bool = Query(default=True),
service: MemorySystemService = Depends(get_service),
):
try:
return await service.list_memories(user_id, user_key, uri=uri, recursive=recursive)
except PermissionError as exc:
raise user_auth_error(exc) from exc
@router.get("/memories/content")
async def read_memory(
user_id: str = Query(min_length=1),
user_key: str = Query(min_length=1),
uri: str = Query(min_length=1),
service: MemorySystemService = Depends(get_service),
):
try:
return await service.read_memory(user_id, user_key, uri)
except PermissionError as exc:
raise user_auth_error(exc) from exc
@router.post("/memories")
async def write_memory(
request: MemoryWriteRequest,
service: MemorySystemService = Depends(get_service),
):
try:
return await service.write_memory(request)
except PermissionError as exc:
raise user_auth_error(exc) from exc
@router.delete("/memories")
async def delete_memory(
user_id: str = Query(min_length=1),
user_key: str = Query(min_length=1),
uri: str = Query(min_length=1),
recursive: bool = Query(default=False),
service: MemorySystemService = Depends(get_service),
):
try:
return await service.delete_memory(user_id, user_key, uri, recursive=recursive)
except PermissionError as exc:
raise user_auth_error(exc) from exc
@router.post("/sessions/{session_id}/commit")
async def commit_session(
session_id: str,
request: SessionUserRequest,
service: MemorySystemService = Depends(get_service),
):
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")
async def extract_session(
session_id: str,
request: SessionUserRequest,
service: MemorySystemService = Depends(get_service),
):
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.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,
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),
):
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("/openviking/tasks/{task_id}")
async def get_openviking_task_from_body(
task_id: str,
request: TaskStatusRequest,
service: MemorySystemService = Depends(get_service),
):
try:
return await service.get_openviking_task(
request.user_id,
request.user_key,
task_id,
session_id=request.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),
):
try:
return await service.search(request)
except PermissionError as exc:
raise user_auth_error(exc) from exc
@router.post("/users/{user_id}/profile")
async def get_profile_from_body(
user_id: str,
request: ProfileRequest,
service: MemorySystemService = Depends(get_service),
):
try:
return await service.get_profile(
user_id,
request.user_key,
query=request.query,
limit=request.limit,
level=request.level,
)
except PermissionError as exc:
raise user_auth_error(exc) from exc
@router.get("/users/{user_id}/profile")
async def get_profile(
user_id: str,
user_key: str = Query(min_length=1),
query: str = Query(default="用户画像", min_length=1),
limit: int = Query(default=10, ge=1, le=100),
level: int = Query(default=2, ge=0),
service: MemorySystemService = Depends(get_service),
):
try:
return await service.get_profile(user_id, user_key, query=query, limit=limit, level=level)
except PermissionError as exc:
raise user_auth_error(exc) from exc

View File

@ -1,12 +0,0 @@
"""API key auth for Memory System API."""
from __future__ import annotations
from fastapi import Header, HTTPException, status
from .config import get_config
def verify_api_key(x_api_key: str | None = Header(default=None)) -> None:
expected = get_config().server.api_key
if expected and x_api_key != expected:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid API key")

View File

@ -1,484 +0,0 @@
"""Async clients for OpenViking and EverOS used by the lightweight API."""
from __future__ import annotations
from dataclasses import dataclass
from datetime import datetime, timezone
from pathlib import Path
from typing import Any
import httpx
from .config import get_config
from .store import ADMIN_ACCOUNT_ID, ADMIN_USER_ID, OpenVikingUserKeyStore
@dataclass(frozen=True)
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:
def __init__(self, store: OpenVikingUserKeyStore | None = None) -> None:
config = get_config()
self.base_url = config.openviking.url.rstrip("/")
self.root_key = config.openviking.api_key or "your-secret-root-key"
self.timeout = config.openviking.timeout
self.verify_ssl = config.openviking.verify_ssl
self.store = store or OpenVikingUserKeyStore(config.storage.sqlite_path)
async def health(self) -> dict[str, Any]:
async with self._client(self.root_key) as client:
response = await client.get("/health")
response.raise_for_status()
return response.json()
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=self._create_account_payload(account_id, admin_user_id),
)
if response.status_code == 409:
return response.json()
response.raise_for_status()
data = response.json()
user_key = self._extract_user_key(data)
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, account_id=account_id)
return data
async def create_user(self, user_id: str, role: str = "user") -> dict[str, Any]:
existing = self.store.get_user_key(user_id)
account_id = self._account_id_for_user(user_id)
if existing:
return {
"status": "ok",
"result": {
"account_id": account_id,
"admin_user_id": user_id,
"user_id": user_id,
"user_key": existing,
},
}
return await self.create_account(account_id, user_id)
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=self._account_id_for_user(user_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(
self, credential: OpenVikingCredential | str, session_id: str, role: str, content: str
) -> dict[str, Any]:
async with self._credential_client(credential) as client:
response = await client.post(
f"/api/v1/sessions/{session_id}/messages",
json={"role": role, "content": content},
)
response.raise_for_status()
return response.json()
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",
json={"keep_recent_count": 0},
)
response.raise_for_status()
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:
response = await client.post(f"/api/v1/sessions/{session_id}/extract")
response.raise_for_status()
return response.json()
async def get_task(self, credential: OpenVikingCredential | str, task_id: str) -> dict[str, Any]:
async with self._credential_client(credential) as client:
response = await client.get(f"/api/v1/tasks/{task_id}")
response.raise_for_status()
return response.json()
async def upload_temp_file(self, credential: OpenVikingCredential | str, path: str | Path) -> dict[str, Any]:
file_path = Path(path)
async with self._credential_client(credential, json_content_type=False) as client:
with file_path.open("rb") as file_obj:
response = await client.post(
"/api/v1/resources/temp_upload",
files={"file": (file_path.name, file_obj)},
)
response.raise_for_status()
return response.json()
async def add_resource(
self,
credential: OpenVikingCredential | str,
*,
to: str,
reason: str | None = None,
wait: bool = True,
directly_upload_media: bool = True,
path: str | None = None,
temp_file_id: str | None = None,
) -> dict[str, Any]:
payload: dict[str, Any] = {
"to": to,
"wait": wait,
"directly_upload_media": directly_upload_media,
}
if reason is not None:
payload["reason"] = reason
if temp_file_id is not None:
payload["temp_file_id"] = temp_file_id
else:
payload["path"] = path
async with self._credential_client(credential) as client:
response = await client.post("/api/v1/resources", json=payload)
response.raise_for_status()
return response.json()
async def delete_resource(
self,
credential: OpenVikingCredential | str,
uri: str,
recursive: bool = True,
) -> dict[str, Any]:
async with self._credential_client(credential) as client:
response = await client.delete(
"/api/v1/fs",
params={"uri": uri, "recursive": str(recursive).lower()},
)
response.raise_for_status()
return response.json()
async def list_memories(
self,
credential: OpenVikingCredential | str,
uri: str = "viking://user/memories",
recursive: bool = True,
) -> dict[str, Any]:
async with self._credential_client(credential) as client:
response = await client.get(
"/api/v1/fs/ls",
params={"uri": uri, "recursive": str(recursive).lower()},
)
response.raise_for_status()
return response.json()
async def read_memory(self, credential: OpenVikingCredential | str, uri: str) -> dict[str, Any]:
async with self._credential_client(credential) as client:
response = await client.get("/api/v1/content/read", params={"uri": uri})
response.raise_for_status()
return response.json()
async def write_memory(
self,
credential: OpenVikingCredential | str,
*,
uri: str,
content: str,
mode: str = "create",
wait: bool = True,
) -> dict[str, Any]:
async with self._credential_client(credential) as client:
response = await client.post(
"/api/v1/content/write",
json={
"uri": uri,
"content": content,
"mode": mode,
"wait": wait,
},
)
response.raise_for_status()
return response.json()
async def delete_memory(
self,
credential: OpenVikingCredential | str,
uri: str,
recursive: bool = False,
) -> dict[str, Any]:
async with self._credential_client(credential) as client:
response = await client.delete(
"/api/v1/fs",
params={"uri": uri, "recursive": str(recursive).lower()},
)
response.raise_for_status()
return response.json()
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": target_uri,
"limit": limit,
},
)
response.raise_for_status()
return response.json()
async def search(
self,
credential: OpenVikingCredential | str,
query: str,
limit: int,
level: int = 2,
score_threshold: float = 0.8,
target_uri: str = "viking://user/memories",
) -> dict[str, Any]:
payload: dict[str, Any] = {
"query": query,
"target_uri": target_uri,
"limit": limit,
"level": level,
"score_threshold": score_threshold,
}
async with self._credential_client(credential) as client:
response = await client.post("/api/v1/search/search", json=payload)
response.raise_for_status()
return response.json()
async def search_profile_memories(
self,
credential: OpenVikingCredential | str,
query: str,
limit: int,
level: int,
) -> dict[str, Any]:
async with self._credential_client(credential) as client:
response = await client.post(
"/api/v1/search/search",
json={
"query": query,
"limit": limit,
"level": level,
"target_uri": "viking://user/memories",
},
)
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,
json_content_type: bool = True,
) -> httpx.AsyncClient:
if isinstance(credential, str):
return self._client(credential, json_content_type=json_content_type)
if credential.user_key_auth:
return self._client(credential.api_key, json_content_type=json_content_type)
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, json_content_type=json_content_type)
def _client(
self,
api_key: str,
extra_headers: dict[str, str] | None = None,
json_content_type: bool = True,
) -> httpx.AsyncClient:
headers = {"X-API-Key": api_key}
if json_content_type:
headers["Content-Type"] = "application/json"
if extra_headers:
headers.update(extra_headers)
return httpx.AsyncClient(
base_url=self.base_url,
headers=headers,
timeout=self.timeout,
verify=self.verify_ssl,
)
def _extract_user_key(self, data: dict[str, Any]) -> str | None:
result = data.get("result") if isinstance(data.get("result"), dict) else data
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 _account_id_for_user(self, user_id: str) -> str:
return f"{user_id}_account"
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:
config = get_config()
self.base_url = config.everos.url.rstrip("/")
self.api_key = config.everos.api_key
self.timeout = config.everos.timeout
self.verify_ssl = config.everos.verify_ssl
self.health_path = config.everos.health_path
async def health(self) -> dict[str, Any]:
async with self._client() as client:
response = await client.get(self.health_path)
response.raise_for_status()
return response.json()
async def append_message(self, user_id: str, session_id: str, role: str, content: str) -> dict[str, Any]:
async with self._client() as client:
response = await client.post(
"/api/v1/memory/add",
json=self.build_message_payload(user_id=user_id, session_id=session_id, role=role, content=content),
)
response.raise_for_status()
return response.json()
def build_message_payload(self, user_id: str, session_id: str, role: str, content: str) -> dict[str, Any]:
everos_role = "assistant" if role == "assistant" else "user"
sender_id = "assistant" if everos_role == "assistant" else user_id
timestamp = int(datetime.now(timezone.utc).timestamp() * 1000)
return {
"session_id": session_id,
"messages": [
{
"message_id": f"msg_{timestamp}",
"timestamp": timestamp,
"sender_id": sender_id,
"sender_name": sender_id,
"role": everos_role,
"content": content,
}
],
}
async def flush(self, user_id: str, session_id: str) -> dict[str, Any]:
async with self._client() as client:
response = await client.post(
"/api/v1/memory/flush",
json={"session_id": session_id},
)
response.raise_for_status()
return response.json()
async def search(self, user_id: str, session_id: str | None, query: str, method: str, limit: int) -> dict[str, Any]:
filters: dict[str, Any] = {}
if session_id:
filters["session_id"] = session_id
payload: dict[str, Any] = {
"user_id": user_id,
"query": query,
"method": method,
"top_k": limit,
"include_profile": True,
}
if filters:
payload["filters"] = filters
async with self._client() as client:
response = await client.post(
"/api/v1/memory/search",
json=payload,
)
response.raise_for_status()
return response.json()
async def get_profile(self, user_id: str) -> dict[str, Any]:
async with self._client() as client:
response = await client.post(
"/api/v1/memory/get",
json={
"user_id": user_id,
"memory_type": "profile",
"page": 1,
"page_size": 20,
},
)
response.raise_for_status()
return response.json()
def _client(self) -> httpx.AsyncClient:
headers = {"Content-Type": "application/json"}
if self.api_key:
headers["X-API-Key"] = self.api_key
headers["Authorization"] = f"Bearer {self.api_key}"
return httpx.AsyncClient(
base_url=self.base_url,
headers=headers,
timeout=self.timeout,
verify=self.verify_ssl,
)

View File

@ -1,101 +0,0 @@
"""Configuration loading for Memory System API."""
from __future__ import annotations
import os
from pathlib import Path
from typing import Any, Literal
import yaml
from pydantic import BaseModel, Field
class ServerConfig(BaseModel):
host: str = "127.0.0.1"
port: int = 1934
api_key: str = ""
class OpenVikingConfig(BaseModel):
url: str = "http://127.0.0.1:1933"
api_key: str = ""
timeout: int = 30
verify_ssl: bool = True
class EverOSConfig(BaseModel):
url: str = "http://127.0.0.1:1995"
api_key: str = ""
timeout: int = 180
verify_ssl: bool = True
health_path: str = "/health"
class StorageConfig(BaseModel):
sqlite_path: str = "/home/tom/memory-gateway/memory_system_api.sqlite3"
class LoggingConfig(BaseModel):
level: str = "INFO"
format: str = "%(asctime)s - %(name)s - %(levelname)s - %(message)s"
class Config(BaseModel):
server: ServerConfig = Field(default_factory=ServerConfig)
openviking: OpenVikingConfig = Field(default_factory=OpenVikingConfig)
everos: EverOSConfig = Field(default_factory=EverOSConfig)
storage: StorageConfig = Field(default_factory=StorageConfig)
logging: LoggingConfig = Field(default_factory=LoggingConfig)
_config: Config | None = None
def load_config(config_path: str | None = None) -> Config:
path = Path(config_path or os.environ.get("MEMORY_SYSTEM_CONFIG", "config.yaml"))
if not path.exists():
return _apply_env_overrides(Config())
with path.open("r", encoding="utf-8") as handle:
data = yaml.safe_load(handle) or {}
config = Config(
server=ServerConfig(**data.get("server", {})),
openviking=OpenVikingConfig(**data.get("openviking", {})),
everos=EverOSConfig(**data.get("everos", {})),
storage=StorageConfig(**data.get("storage", {})),
logging=LoggingConfig(**data.get("logging", {})),
)
return _apply_env_overrides(config)
def get_config() -> Config:
global _config
if _config is None:
_config = load_config()
return _config
def set_config(config: Config) -> None:
global _config
_config = config
def _apply_env_overrides(config: Config) -> Config:
updates: dict[str, dict[str, Any]] = {
"server": _env_updates("MEMORY_SYSTEM_SERVER", {"API_KEY": "api_key", "HOST": "host", "PORT": "port"}),
"openviking": _env_updates("OPENVIKING", {"URL": "url", "BASE_URL": "url", "API_KEY": "api_key", "TIMEOUT": "timeout"}),
"everos": _env_updates("EVEROS", {"URL": "url", "BASE_URL": "url", "API_KEY": "api_key", "TIMEOUT": "timeout"}),
"storage": _env_updates("MEMORY_SYSTEM_STORAGE", {"SQLITE_PATH": "sqlite_path"}),
}
for section, values in updates.items():
if values:
setattr(config, section, getattr(config, section).model_copy(update=values))
return config
def _env_updates(prefix: str, mapping: dict[str, str]) -> dict[str, Any]:
values: dict[str, Any] = {}
for env_name, field_name in mapping.items():
raw = os.environ.get(f"{prefix}_{env_name}")
if raw is None:
continue
values[field_name] = int(raw) if field_name in {"port", "timeout"} else raw
return values

View File

@ -1,138 +0,0 @@
"""Schemas for the lightweight Memory System API."""
from __future__ import annotations
from typing import Any, Literal
from pydantic import BaseModel, Field
OperationStatus = Literal["success", "partial_success", "failed"]
MemoryWriteMode = Literal["create", "replace", "append"]
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
timestamp: int | None = None
metadata: dict[str, Any] = Field(default_factory=dict)
class SessionUserRequest(BaseModel):
user_id: str = Field(min_length=1)
user_key: str = Field(min_length=1)
class TaskStatusRequest(SessionUserRequest):
session_id: str | None = Field(default=None, 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
limit: int = Field(default=10, ge=1, le=100)
level: int = Field(default=2, ge=0)
score_threshold: float = Field(default=0.8, ge=0, le=1)
target_uri: str = Field(default="viking://user/memories", min_length=1)
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 ProfileRequest(BaseModel):
user_key: str = Field(min_length=1)
query: str = Field(default="用户画像", min_length=1)
limit: int = Field(default=10, ge=1, le=100)
level: int = Field(default=2, ge=0)
class MemoryWriteRequest(BaseModel):
user_id: str = Field(min_length=1)
user_key: str = Field(min_length=1)
uri: str = Field(min_length=1)
content: str
mode: MemoryWriteMode = "create"
wait: bool = True
class ResourceUploadRequest(BaseModel):
user_id: str = Field(min_length=1)
user_key: str = Field(min_length=1)
path: str = Field(min_length=1)
to: str = Field(min_length=1)
reason: str | None = None
wait: bool = True
directly_upload_media: bool = True
class BackendStatus(BaseModel):
status: OperationStatus
result: Any = None
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
backends: dict[str, BackendStatus]
class CommitResponse(BaseModel):
status: OperationStatus
backends: dict[str, BackendStatus]
class ExtractResponse(BaseModel):
status: OperationStatus
backends: dict[str, BackendStatus]
class SearchResponse(BaseModel):
status: OperationStatus
items: list[dict[str, Any]] = Field(default_factory=list)
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
items: list[dict[str, Any]] = Field(default_factory=list)
backends: dict[str, BackendStatus]
class MemoryOperationResponse(BaseModel):
status: OperationStatus
memory: Any = None
backends: dict[str, BackendStatus]
class ResourceMutationResponse(BaseModel):
status: OperationStatus
resource: Any = None
backends: dict[str, BackendStatus]

View File

@ -1,101 +0,0 @@
"""Standalone FastAPI server for Memory System API."""
from __future__ import annotations
import logging
from fastapi import FastAPI, Request, Response
from fastapi.middleware.cors import CORSMiddleware
from .api import router
from .config import Config, load_config, set_config
request_logger = logging.getLogger("memory_system_api.requests")
app = FastAPI(title="Memory System API", version="0.1.0")
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
@app.middleware("http")
async def log_request_and_response(request: Request, call_next):
request_body = await request.body()
request_logger.info(
"request %s %s body=%s",
request.method,
_path_with_query(request),
_body_for_log(request_body),
)
async def receive():
return {"type": "http.request", "body": request_body, "more_body": False}
response = await call_next(Request(request.scope, receive))
response_body = b""
async for chunk in response.body_iterator:
response_body += chunk
request_logger.info(
"response %s %s status=%s body=%s",
request.method,
_path_with_query(request),
response.status_code,
_body_for_log(response_body),
)
return Response(
content=response_body,
status_code=response.status_code,
headers=dict(response.headers),
media_type=response.media_type,
background=response.background,
)
app.include_router(router)
def create_app(config: Config | None = None) -> FastAPI:
if config:
set_config(config)
return app
def _path_with_query(request: Request) -> str:
query = request.url.query
return f"{request.url.path}?{query}" if query else request.url.path
def _body_for_log(body: bytes) -> str:
if not body:
return ""
return body.decode("utf-8", errors="replace")
def main() -> None:
import argparse
import uvicorn
parser = argparse.ArgumentParser(description="Memory System API")
parser.add_argument("--config", default="config.yaml", help="Config file path")
parser.add_argument("--host", default=None, help="Bind host")
parser.add_argument("--port", type=int, default=None, help="Bind port")
args = parser.parse_args()
config = load_config(args.config)
if args.host:
config.server.host = args.host
if args.port:
config.server.port = args.port
set_config(config)
logging.basicConfig(level=config.logging.level.upper(), format=config.logging.format)
uvicorn.run(app, host=config.server.host, port=config.server.port, log_level=config.logging.level.lower())
if __name__ == "__main__":
main()

View File

@ -1,504 +0,0 @@
"""Orchestration for the lightweight Memory System API."""
from __future__ import annotations
import asyncio
from typing import Any, Awaitable, Callable
from urllib.parse import urlparse
from .clients import EverOSMemorySystemClient, OpenVikingMemorySystemClient
from .schemas import (
AccountResponse,
BackendStatus,
CommitResponse,
ExtractResponse,
MemoryOperationResponse,
MemoryWriteRequest,
MessageIngestRequest,
MessageIngestResponse,
ProfileResponse,
ResourceMutationResponse,
ResourceUploadRequest,
SearchRequest,
SearchResponse,
SessionContextRequest,
SessionContextResponse,
)
class MemorySystemService:
def __init__(self, openviking: Any | None = None, everos: Any | None = None) -> None:
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 upload_resource(self, request: ResourceUploadRequest) -> ResourceMutationResponse:
credential = self.openviking.credential_for_user(request.user_id, request.user_key)
async def upload_openviking() -> dict[str, Any]:
if self._is_remote_url(request.path):
return await self.openviking.add_resource(
credential,
path=request.path,
to=request.to,
reason=request.reason,
wait=request.wait,
directly_upload_media=request.directly_upload_media,
)
temp_upload = await self.openviking.upload_temp_file(credential, request.path)
temp_file_id = self._temp_file_id_from_result(temp_upload)
return await self.openviking.add_resource(
credential,
temp_file_id=temp_file_id,
to=request.to,
reason=request.reason,
wait=request.wait,
directly_upload_media=request.directly_upload_media,
)
backends = {"openviking": await self._capture(upload_openviking)}
resource = backends["openviking"].result if backends["openviking"].status == "success" else None
return ResourceMutationResponse(status=self._aggregate_status(backends), resource=resource, backends=backends)
async def delete_resource(
self,
user_id: str,
user_key: str,
uri: str,
recursive: bool = True,
) -> ResourceMutationResponse:
credential = self.openviking.credential_for_user(user_id, user_key)
backends = {
"openviking": await self._capture(lambda: self.openviking.delete_resource(credential, uri, recursive)),
}
resource = backends["openviking"].result if backends["openviking"].status == "success" else None
return ResourceMutationResponse(status=self._aggregate_status(backends), resource=resource, backends=backends)
async def list_memories(
self,
user_id: str,
user_key: str,
uri: str = "viking://user/memories",
recursive: bool = True,
) -> MemoryOperationResponse:
credential = self.openviking.credential_for_user(user_id, user_key)
backends = {
"openviking": await self._capture(lambda: self.openviking.list_memories(credential, uri, recursive)),
}
memory = backends["openviking"].result if backends["openviking"].status == "success" else None
return MemoryOperationResponse(status=self._aggregate_status(backends), memory=memory, backends=backends)
async def read_memory(
self,
user_id: str,
user_key: str,
uri: str,
) -> MemoryOperationResponse:
credential = self.openviking.credential_for_user(user_id, user_key)
backends = {
"openviking": await self._capture(lambda: self.openviking.read_memory(credential, uri)),
}
memory = backends["openviking"].result if backends["openviking"].status == "success" else None
return MemoryOperationResponse(status=self._aggregate_status(backends), memory=memory, backends=backends)
async def write_memory(self, request: MemoryWriteRequest) -> MemoryOperationResponse:
credential = self.openviking.credential_for_user(request.user_id, request.user_key)
backends = {
"openviking": await self._capture(
lambda: self.openviking.write_memory(
credential,
uri=request.uri,
content=request.content,
mode=request.mode,
wait=request.wait,
)
),
}
memory = backends["openviking"].result if backends["openviking"].status == "success" else None
return MemoryOperationResponse(status=self._aggregate_status(backends), memory=memory, backends=backends)
async def delete_memory(
self,
user_id: str,
user_key: str,
uri: str,
recursive: bool = False,
) -> MemoryOperationResponse:
credential = self.openviking.credential_for_user(user_id, user_key)
backends = {
"openviking": await self._capture(lambda: self.openviking.delete_memory(credential, uri, recursive)),
}
memory = backends["openviking"].result if backends["openviking"].status == "success" else None
return MemoryOperationResponse(status=self._aggregate_status(backends), memory=memory, 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 = 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]]:
results = []
for message in messages:
results.append(
await self.openviking.append_message(credential, request.session_id, message["role"], message["content"])
)
return results
async def write_everos() -> list[dict[str, Any]]:
results = []
for message in messages:
results.append(
await self.everos.append_message(request.user_id, request.session_id, message["role"], message["content"])
)
return results
backends = await self._run_backends(openviking=write_openviking, everos=write_everos)
return MessageIngestResponse(
status=self._aggregate_status(backends),
message_count=len(messages),
backends=backends,
)
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(credential, session_id)
async def flush_everos() -> dict[str, Any]:
return await self.everos.flush(user_id, session_id)
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, 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(credential, session_id)),
}
return ExtractResponse(status=self._aggregate_status(backends), backends=backends)
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:
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]:
return await self.openviking.search(
credential,
request.query,
request.limit,
request.level,
request.score_threshold,
request.target_uri,
)
async def search_everos() -> dict[str, Any]:
return await self.everos.search(
request.user_id,
request.session_id,
request.query,
everos_method,
request.limit,
)
backends = await self._run_backends(openviking=search_openviking, everos=search_everos)
backends = self._remove_vectors_from_backends(backends)
items = self._merge_search_items(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_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,
user_key: str,
query: str = "用户画像",
limit: int = 10,
level: int = 2,
) -> ProfileResponse:
credential = self.openviking.credential_for_user(user_id, user_key)
backends = await self._run_backends(
everos=lambda: self.everos.get_profile(user_id),
openviking=lambda: self.openviking.search_profile_memories(credential, query, limit, level),
)
backends = self._remove_vectors_from_backends(backends)
profile = backends["everos"].result if backends["everos"].status == "success" else None
items = (
self._items_from_backend_result("openviking", backends["openviking"].result)[:limit]
if backends["openviking"].status == "success"
else []
)
return ProfileResponse(
status=self._aggregate_status(backends),
profile=profile,
items=items,
backends=self._compact_profile_backends(backends),
)
async def health(self) -> dict[str, Any]:
backends = await self._run_backends(openviking=self.openviking.health, everos=self.everos.health)
return {"status": self._aggregate_status(backends), "backends": backends}
def _messages_from_request(self, request: MessageIngestRequest) -> list[dict[str, str]]:
messages = []
if request.user_message:
messages.append({"role": "user", "content": request.user_message})
if request.assistant_message:
messages.append({"role": "assistant", "content": request.assistant_message})
return messages
def _is_remote_url(self, path: str) -> bool:
return urlparse(path).scheme in {"http", "https"}
def _temp_file_id_from_result(self, result: Any) -> str:
data = result.get("result") if isinstance(result, dict) and isinstance(result.get("result"), dict) else result
temp_file_id = data.get("temp_file_id") if isinstance(data, dict) else None
if not temp_file_id:
raise ValueError("OpenViking temp upload response missing temp_file_id")
return str(temp_file_id)
async def _run_backends(self, **calls: Callable[[], Awaitable[Any]]) -> dict[str, BackendStatus]:
names = list(calls)
results = await asyncio.gather(*(self._capture(calls[name]) for name in names))
return dict(zip(names, results))
async def _capture(self, call: Callable[[], Awaitable[Any]]) -> BackendStatus:
try:
return BackendStatus(status="success", result=await call())
except Exception as exc: # noqa: BLE001
message = str(exc)
error = f"{type(exc).__name__}: {message}" if message else type(exc).__name__
return BackendStatus(status="failed", error=error)
def _aggregate_status(self, backends: dict[str, BackendStatus]) -> str:
statuses = {backend.status for backend in backends.values()}
if statuses == {"success"}:
return "success"
if "success" in statuses:
return "partial_success"
return "failed"
def _merge_search_items(self, backends: dict[str, BackendStatus]) -> list[dict[str, Any]]:
items: list[dict[str, Any]] = []
for backend_name, backend in backends.items():
if backend.status != "success":
continue
items.extend(self._items_from_backend_result(backend_name, backend.result))
return items
def _items_from_backend_result(self, backend_name: str, result: Any) -> list[dict[str, Any]]:
if isinstance(result, dict) and isinstance(result.get("items"), list):
return [self._with_backend(backend_name, item) for item in result["items"] if isinstance(item, dict)]
data = result.get("data") if isinstance(result, dict) and isinstance(result.get("data"), dict) else result
if not isinstance(data, dict):
return []
if isinstance(data.get("result"), dict):
data = data["result"]
raw_items: list[dict[str, Any]] = []
for key in ("memories", "resources", "episodes", "profiles", "raw_messages"):
values = data.get(key)
if isinstance(values, list):
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]:
if "source_backend" in item:
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 _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 _compact_profile_backends(self, backends: dict[str, BackendStatus]) -> dict[str, BackendStatus]:
return {
name: backend.model_copy(update={"result": self._compact_profile_backend_result(name, backend.result)})
for name, backend in backends.items()
}
def _compact_profile_backend_result(self, backend_name: str, result: Any) -> Any:
if backend_name == "openviking":
return self._compact_backend_result("openviking", result)
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] = {}
for key in ("total_count", "count"):
if key in data:
compact[key] = data[key]
compact["counts"] = {
key: len(data.get(key) or [])
for key in ("episodes", "profiles", "agent_cases", "agent_skills")
if isinstance(data.get(key), list)
}
return compact
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)})
for name, backend in backends.items()
}
def _remove_vectors(self, value: Any) -> Any:
if isinstance(value, dict):
return {key: self._remove_vectors(item) for key, item in value.items() if key != "vector"}
if isinstance(value, list):
return [self._remove_vectors(item) for item in value]
return value

View File

@ -1,223 +0,0 @@
"""Small SQLite store for OpenViking user keys."""
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, account_id: str = ADMIN_ACCOUNT_ID) -> None:
now = datetime.now(timezone.utc).isoformat()
with self._connect() as conn:
conn.execute(
"""
INSERT INTO memory_system_openviking_users (user_id, account_id, user_key, created_at, updated_at)
VALUES (?, ?, ?, ?, ?)
ON CONFLICT(user_id) DO UPDATE SET
user_key = excluded.user_key,
updated_at = excluded.updated_at
""",
(user_id, 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 (
user_id TEXT PRIMARY KEY,
account_id TEXT NOT NULL,
user_key TEXT NOT NULL,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
)
"""
)
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}"

View File

@ -1,44 +0,0 @@
{
"server": {
"host": "0.0.0.0",
"port": 1933,
"auth_mode": "api_key",
"root_api_key": "<OPENVIKING_ROOT_KEY>",
"cors_origins": ["*"]
},
"storage": {
"workspace": "/Users/tom/projects/openviking_workspace",
"agfs": {
"backend": "local"
},
"vectordb": {
"name": "context",
"backend": "local"
}
},
"memory": {
"version": "v2",
"agent_scope_mode": "user+agent"
},
"embedding": {
"dense": {
"provider": "<EMBEDDING_PROVIDER>",
"api_base": "<EMBEDDING_API_BASE>",
"api_key": "<EMBEDDING_API_KEY>",
"model": "<EMBEDDING_MODEL>",
"dimension": 1024
}
},
"vlm": {
"provider": "<VLM_PROVIDER>",
"api_base": "<VLM_API_BASE>",
"api_key": "<VLM_API_KEY>",
"model": "<VLM_MODEL>"
},
"rerank": {
"provider": "<RERANK_PROVIDER>",
"api_base": "<RERANK_API_BASE>",
"api_key": "<RERANK_API_KEY>",
"model": "<RERANK_MODEL>"
}
}

View File

@ -1,62 +0,0 @@
# Hermes Memory System Plugin
This Hermes memory provider talks to the Memory System API instead of calling OpenViking or EverOS directly.
It stores completed Hermes turns through:
- `POST /memory-system/messages`
- `POST /memory-system/sessions/{session_id}/commit` on session end
- `POST /memory-system/search` for recall
- `GET /memory-system/users/{user_id}/profile` for user profile reads
- `GET/POST/DELETE /memory-system/memories` for direct memory URI management
## Configure
Put these values in the Hermes profile env file, usually `~/.hermes/.env`:
```dotenv
MEMORY_SYSTEM_ENDPOINT=http://127.0.0.1:1934
MEMORY_SYSTEM_USER_ID=default
MEMORY_SYSTEM_USER_KEY=
MEMORY_SYSTEM_API_KEY=
MEMORY_SYSTEM_SEARCH_USE_LLM=false
MEMORY_SYSTEM_COMMIT_EVERY_TURNS=5
MEMORY_SYSTEM_COMMIT_INTERVAL_SECONDS=300
MEMORY_SYSTEM_TIMEOUT_SECONDS=180
```
You can also keep a separate file and point to it with `MEMORY_SYSTEM_ENV_FILE`.
Real environment variables still override file values.
`MEMORY_SYSTEM_USER_KEY` is the key returned by `POST /memory-system/users`.
If it is omitted, the plugin calls `/memory-system/users` during initialization and uses the returned key for the current process.
Then select this provider in Hermes memory config:
```yaml
memory:
provider: memory_system
```
## Tools
- `memory_system_search`: search OpenViking and EverOS via Memory System API.
- `memory_system_profile`: read the EverOS profile memory for the active user.
- `memory_system_remember`: explicitly write an important memory and commit the session.
- `memory_system_memory_list`: list OpenViking memory URIs under `viking://user/memories`.
- `memory_system_memory_read`: read one memory URI.
- `memory_system_memory_write`: create, replace, or append one memory URI.
- `memory_system_memory_delete`: delete one memory URI, non-recursive by default.
Use the direct memory URI tools only when the model needs to inspect or edit a specific `viking://user/memories/...` item. Normal conversation recall should still use `memory_system_search`, and normal explicit remembering should still use `memory_system_remember`.
The plugin commits after 5 new turns or 300 seconds by default, whichever comes first.
Set either value to `0` to disable that trigger. Session end still commits any new turns that were not already committed.
`MEMORY_SYSTEM_TIMEOUT_SECONDS` should be long enough for commit/search calls that wait on EverOS LLM extraction or rerank services. The default is 180 seconds.
If commit returns `partial_success`, the plugin logs the response and does not mark the pending turns as committed, so a later periodic commit or session-end commit can retry EverOS flush.
Search responses from current Memory System API versions do not include raw `vector` fields. The API strips those large embedding arrays before returning merged results or backend debug payloads.
The plugin is intentionally thin. User identity, session identity, backend writes, OpenViking commit, and EverOS flush stay owned by Memory System API.

View File

@ -1,827 +0,0 @@
"""Hermes memory provider for Memory System API.
Memory System API wraps OpenViking session memory and EverOS user profile
memory behind one small HTTP surface. This plugin keeps Hermes integration
thin: completed turns are written to the API, session end triggers commit,
and tools expose search/profile/explicit remember operations.
"""
from __future__ import annotations
import json
import logging
import os
import threading
import time
from pathlib import Path
from typing import Any, Dict, List, Optional
from urllib.parse import urlencode
from agent.memory_provider import MemoryProvider
from tools.registry import tool_error
logger = logging.getLogger(__name__)
_DEFAULT_ENDPOINT = "http://127.0.0.1:1934"
_DEFAULT_TIMEOUT = 180.0
_CONFIG_KEYS = {
"MEMORY_SYSTEM_ENDPOINT",
"MEMORY_SYSTEM_API_KEY",
"MEMORY_SYSTEM_USER_ID",
"MEMORY_SYSTEM_USER_KEY",
"MEMORY_SYSTEM_SEARCH_USE_LLM",
"MEMORY_SYSTEM_COMMIT_EVERY_TURNS",
"MEMORY_SYSTEM_COMMIT_INTERVAL_SECONDS",
"MEMORY_SYSTEM_TIMEOUT_SECONDS",
}
def _get_httpx():
try:
import httpx
return httpx
except ImportError:
return None
def _env_bool(name: str, default: bool = False) -> bool:
return _bool_value(_memory_system_config().get(name), default)
def _bool_value(value: Optional[str], default: bool = False) -> bool:
if value is None:
return default
return value.strip().lower() in {"1", "true", "yes", "on"}
def _env_int(name: str, default: int) -> int:
return _int_value(_memory_system_config().get(name), default)
def _int_value(value: Optional[str], default: int) -> int:
if value is None or value.strip() == "":
return default
try:
return int(value)
except ValueError:
return default
def _env_float(name: str, default: float) -> float:
return _float_value(_memory_system_config().get(name), default)
def _float_value(value: Optional[str], default: float) -> float:
if value is None or value.strip() == "":
return default
try:
return float(value)
except ValueError:
return default
def _parse_env_file(path: Path) -> Dict[str, str]:
values: Dict[str, str] = {}
try:
lines = path.read_text(encoding="utf-8").splitlines()
except OSError:
return values
for line in lines:
stripped = line.strip()
if not stripped or stripped.startswith("#"):
continue
if stripped.startswith("export "):
stripped = stripped[len("export ") :].strip()
if "=" not in stripped:
continue
key, value = stripped.split("=", 1)
key = key.strip()
if key not in _CONFIG_KEYS:
continue
value = value.strip().strip('"').strip("'")
values[key] = value
return values
def _memory_system_config(hermes_home: str = "") -> Dict[str, str]:
candidates: List[Path] = []
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")
env_hermes_home = os.environ.get("HERMES_HOME", "")
if env_hermes_home:
candidates.append(Path(env_hermes_home).expanduser() / ".env")
candidates.append(Path(env_hermes_home).expanduser() / "memory_system.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()
for path in candidates:
resolved = path.expanduser()
if resolved in seen:
continue
seen.add(resolved)
config.update(_parse_env_file(resolved))
for key in _CONFIG_KEYS:
if key in os.environ:
config[key] = os.environ[key]
return config
class _MemorySystemClient:
"""Small sync HTTP client for Memory System API."""
def __init__(self, endpoint: str, api_key: str = "", timeout: float = _DEFAULT_TIMEOUT):
self._endpoint = endpoint.rstrip("/")
self._api_key = api_key
self._timeout = timeout
self._httpx = _get_httpx()
if self._httpx is None:
raise ImportError("httpx is required for memory_system: pip install httpx")
def _headers(self) -> Dict[str, str]:
headers = {"Content-Type": "application/json"}
if self._api_key:
headers["X-API-Key"] = self._api_key
return headers
def _url(self, path: str) -> str:
return f"{self._endpoint}{path}"
def get(self, path: str) -> Dict[str, Any]:
response = self._httpx.get(self._url(path), headers=self._headers(), timeout=self._timeout)
response.raise_for_status()
return response.json()
def delete(self, path: str) -> Dict[str, Any]:
response = self._httpx.delete(self._url(path), headers=self._headers(), timeout=self._timeout)
response.raise_for_status()
return response.json()
def post(self, path: str, payload: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
response = self._httpx.post(
self._url(path),
json=payload or {},
headers=self._headers(),
timeout=self._timeout,
)
response.raise_for_status()
return response.json()
def health(self) -> bool:
try:
response = self._httpx.get(
self._url("/memory-system/health"),
headers=self._headers(),
timeout=3.0,
)
return response.status_code == 200
except Exception:
return False
SEARCH_SCHEMA = {
"name": "memory_system_search",
"description": (
"Search persistent memory through Memory System API. "
"By default this uses fast hybrid search; set use_llm=true for agentic search."
),
"parameters": {
"type": "object",
"properties": {
"query": {"type": "string", "description": "Search query."},
"use_llm": {
"type": "boolean",
"description": "Use agentic LLM search instead of hybrid search.",
},
"limit": {"type": "integer", "description": "Maximum results, default 10."},
"session_id": {
"type": "string",
"description": "Optional session override. Defaults to the active Hermes session.",
},
},
"required": ["query"],
},
}
PROFILE_SCHEMA = {
"name": "memory_system_profile",
"description": "Read the current user's profile memory from Memory System API.",
"parameters": {
"type": "object",
"properties": {
"user_id": {
"type": "string",
"description": "Optional user override. Defaults to the active Hermes user.",
},
"user_key": {
"type": "string",
"description": "Required only when user_id overrides the active Hermes user.",
},
},
},
}
REMEMBER_SCHEMA = {
"name": "memory_system_remember",
"description": (
"Store an important memory through Memory System API and commit the active session. "
"Use this when information should be remembered beyond the current conversation."
),
"parameters": {
"type": "object",
"properties": {
"content": {"type": "string", "description": "Memory text to store."},
"session_id": {
"type": "string",
"description": "Optional session override. Defaults to the active Hermes session.",
},
},
"required": ["content"],
},
}
MEMORY_LIST_SCHEMA = {
"name": "memory_system_memory_list",
"description": "List OpenViking memory URIs through Memory System API.",
"parameters": {
"type": "object",
"properties": {
"uri": {
"type": "string",
"description": "Memory root URI. Defaults to viking://user/memories.",
},
"recursive": {
"type": "boolean",
"description": "Whether to list recursively. Defaults to true.",
},
},
},
}
MEMORY_READ_SCHEMA = {
"name": "memory_system_memory_read",
"description": "Read one OpenViking memory URI through Memory System API.",
"parameters": {
"type": "object",
"properties": {
"uri": {"type": "string", "description": "Memory URI to read."},
},
"required": ["uri"],
},
}
MEMORY_WRITE_SCHEMA = {
"name": "memory_system_memory_write",
"description": "Create, replace, or append an OpenViking memory URI through Memory System API.",
"parameters": {
"type": "object",
"properties": {
"uri": {"type": "string", "description": "Target memory URI."},
"content": {"type": "string", "description": "Memory content to write."},
"mode": {
"type": "string",
"enum": ["create", "replace", "append"],
"description": "Write mode. Use replace to modify an existing memory.",
},
"wait": {
"type": "boolean",
"description": "Wait for Memory System processing/indexing. Defaults to true.",
},
},
"required": ["uri", "content"],
},
}
MEMORY_DELETE_SCHEMA = {
"name": "memory_system_memory_delete",
"description": "Delete one OpenViking memory URI through Memory System API.",
"parameters": {
"type": "object",
"properties": {
"uri": {"type": "string", "description": "Memory URI to delete."},
"recursive": {
"type": "boolean",
"description": "Delete recursively. Defaults to false; use true only for directory-like memories.",
},
},
"required": ["uri"],
},
}
class MemorySystemMemoryProvider(MemoryProvider):
"""Hermes MemoryProvider backed by Memory System API."""
@property
def name(self) -> str:
return "memory_system"
def __init__(self) -> None:
config = _memory_system_config()
self._endpoint = config.get("MEMORY_SYSTEM_ENDPOINT", _DEFAULT_ENDPOINT)
self._api_key = config.get("MEMORY_SYSTEM_API_KEY", "")
self._user_id = config.get("MEMORY_SYSTEM_USER_ID", "default")
self._user_key = config.get("MEMORY_SYSTEM_USER_KEY", "")
self._session_id = ""
self._client: Optional[_MemorySystemClient] = None
self._sync_thread: Optional[threading.Thread] = None
self._prefetch_thread: Optional[threading.Thread] = None
self._prefetch_lock = threading.Lock()
self._prefetch_result = ""
self._turn_count = 0
self._last_commit_turn = 0
self._last_commit_time = time.monotonic()
self._commit_every_turns = _env_int("MEMORY_SYSTEM_COMMIT_EVERY_TURNS", 5)
self._commit_interval_seconds = _env_int("MEMORY_SYSTEM_COMMIT_INTERVAL_SECONDS", 300)
self._timeout = _env_float("MEMORY_SYSTEM_TIMEOUT_SECONDS", _DEFAULT_TIMEOUT)
self._default_use_llm = _env_bool("MEMORY_SYSTEM_SEARCH_USE_LLM", False)
def is_available(self) -> bool:
return bool(_memory_system_config().get("MEMORY_SYSTEM_ENDPOINT")) and _get_httpx() is not None
def get_config_schema(self) -> List[Dict[str, Any]]:
return [
{
"key": "endpoint",
"description": "Memory System API endpoint.",
"required": True,
"default": _DEFAULT_ENDPOINT,
"env_var": "MEMORY_SYSTEM_ENDPOINT",
},
{
"key": "api_key",
"description": "Memory System API key, if server.api_key is configured.",
"secret": True,
"required": False,
"env_var": "MEMORY_SYSTEM_API_KEY",
},
{
"key": "user_id",
"description": "Default Memory System user id for this Hermes profile.",
"required": False,
"default": "default",
"env_var": "MEMORY_SYSTEM_USER_ID",
},
{
"key": "user_key",
"description": (
"Memory System user key returned by /memory-system/users. "
"If omitted, the plugin creates or looks up the configured user on initialization."
),
"secret": True,
"required": False,
"env_var": "MEMORY_SYSTEM_USER_KEY",
},
{
"key": "commit_every_turns",
"description": "Commit after this many new turns. Set 0 to disable turn-based commits.",
"required": False,
"default": 5,
"env_var": "MEMORY_SYSTEM_COMMIT_EVERY_TURNS",
},
{
"key": "commit_interval_seconds",
"description": "Commit after this many seconds if new turns exist. Set 0 to disable time-based commits.",
"required": False,
"default": 300,
"env_var": "MEMORY_SYSTEM_COMMIT_INTERVAL_SECONDS",
},
{
"key": "timeout_seconds",
"description": "HTTP timeout for Memory System API requests. Commit may wait for EverOS LLM extraction.",
"required": False,
"default": _DEFAULT_TIMEOUT,
"env_var": "MEMORY_SYSTEM_TIMEOUT_SECONDS",
},
]
def initialize(self, session_id: str, **kwargs) -> None:
config = _memory_system_config(str(kwargs.get("hermes_home") or ""))
self._endpoint = config.get("MEMORY_SYSTEM_ENDPOINT", _DEFAULT_ENDPOINT)
self._api_key = config.get("MEMORY_SYSTEM_API_KEY", "")
self._user_id = (
config.get("MEMORY_SYSTEM_USER_ID")
or kwargs.get("user_id")
or kwargs.get("agent_identity")
or "default"
)
self._user_key = config.get("MEMORY_SYSTEM_USER_KEY") or kwargs.get("user_key") or ""
self._session_id = session_id
self._default_use_llm = _bool_value(config.get("MEMORY_SYSTEM_SEARCH_USE_LLM"), False)
self._commit_every_turns = _int_value(config.get("MEMORY_SYSTEM_COMMIT_EVERY_TURNS"), 5)
self._commit_interval_seconds = _int_value(
config.get("MEMORY_SYSTEM_COMMIT_INTERVAL_SECONDS"), 300
)
self._timeout = _float_value(config.get("MEMORY_SYSTEM_TIMEOUT_SECONDS"), _DEFAULT_TIMEOUT)
self._last_commit_turn = 0
self._last_commit_time = time.monotonic()
try:
client = _MemorySystemClient(self._endpoint, self._api_key, self._timeout)
if not client.health():
logger.warning("Memory System API health check failed: %s", self._endpoint)
return
self._client = client
self._ensure_user_key()
except Exception as exc:
logger.warning("Memory System API initialization failed: %s", exc)
self._client = None
def system_prompt_block(self) -> str:
if not self._client:
return ""
return (
"# Memory System\n"
"Persistent memory is active. Use memory_system_search for recall, "
"memory_system_profile for user profile, and memory_system_remember "
"for important information that should be stored. Use memory_system_memory_* "
"tools only when you need to list, read, edit, or delete specific memory URIs."
)
def prefetch(self, query: str, *, session_id: str = "") -> str:
if self._prefetch_thread and self._prefetch_thread.is_alive():
self._prefetch_thread.join(timeout=3.0)
with self._prefetch_lock:
result = self._prefetch_result
self._prefetch_result = ""
if not result:
return ""
return f"## Memory System Context\n{result}"
def queue_prefetch(self, query: str, *, session_id: str = "") -> None:
if not self._client or not query:
return
def _run() -> None:
try:
response = self._client.post(
"/memory-system/search",
{
"user_id": self._user_id,
"user_key": self._user_key,
"session_id": session_id or self._session_id,
"query": query,
"use_llm": self._default_use_llm,
"limit": 5,
},
)
formatted = self._format_items(response.get("items", []), limit=5)
if formatted:
with self._prefetch_lock:
self._prefetch_result = formatted
except Exception as exc:
logger.debug("Memory System prefetch failed: %s", exc)
self._prefetch_thread = threading.Thread(
target=_run, daemon=True, name="memory-system-prefetch"
)
self._prefetch_thread.start()
def sync_turn(self, user_content: str, assistant_content: str, *, session_id: str = "") -> None:
if not self._client:
return
self._turn_count += 1
def _sync() -> None:
try:
payload = self._message_payload(
session_id=session_id or self._session_id,
user_message=user_content,
assistant_message=assistant_content,
)
self._client.post("/memory-system/messages", payload)
if self._should_commit_now():
self._commit_session(session_id or self._session_id)
except Exception as exc:
logger.debug("Memory System sync_turn failed: %s", exc)
if self._sync_thread and self._sync_thread.is_alive():
self._sync_thread.join(timeout=5.0)
self._sync_thread = threading.Thread(target=_sync, daemon=True, name="memory-system-sync")
self._sync_thread.start()
def on_session_end(self, messages: List[Dict[str, Any]]) -> None:
if not self._client:
return
if self._sync_thread and self._sync_thread.is_alive():
self._sync_thread.join(timeout=10.0)
if self._turn_count == 0 or self._last_commit_turn >= self._turn_count:
return
try:
self._commit_session(self._session_id)
except Exception as exc:
logger.warning("Memory System session commit failed: %s", exc)
def on_memory_write(self, action: str, target: str, content: str) -> None:
if action != "add" or not content:
return
self._remember(content, session_id=self._session_id, commit=False)
def get_tool_schemas(self) -> List[Dict[str, Any]]:
return [
SEARCH_SCHEMA,
PROFILE_SCHEMA,
REMEMBER_SCHEMA,
MEMORY_LIST_SCHEMA,
MEMORY_READ_SCHEMA,
MEMORY_WRITE_SCHEMA,
MEMORY_DELETE_SCHEMA,
]
def handle_tool_call(self, tool_name: str, args: Dict[str, Any], **kwargs) -> str:
if not self._client:
return tool_error("Memory System API is not connected")
try:
if tool_name == "memory_system_search":
return self._tool_search(args)
if tool_name == "memory_system_profile":
return self._tool_profile(args)
if tool_name == "memory_system_remember":
return self._tool_remember(args)
if tool_name == "memory_system_memory_list":
return self._tool_memory_list(args)
if tool_name == "memory_system_memory_read":
return self._tool_memory_read(args)
if tool_name == "memory_system_memory_write":
return self._tool_memory_write(args)
if tool_name == "memory_system_memory_delete":
return self._tool_memory_delete(args)
return tool_error(f"Unknown tool: {tool_name}")
except Exception as exc:
return tool_error(str(exc))
def shutdown(self) -> None:
for thread in (self._sync_thread, self._prefetch_thread):
if thread and thread.is_alive():
thread.join(timeout=5.0)
def _message_payload(
self,
*,
session_id: str,
user_message: str = "",
assistant_message: str = "",
) -> Dict[str, Any]:
payload: Dict[str, Any] = {
"user_id": self._user_id,
"user_key": self._user_key,
"session_id": session_id,
"metadata": {"source": "hermes", "provider": "memory_system"},
}
if user_message:
payload["user_message"] = user_message[:4000]
if assistant_message:
payload["assistant_message"] = assistant_message[:4000]
return payload
def _format_items(self, items: List[Dict[str, Any]], *, limit: int) -> str:
parts = []
for item in items[:limit]:
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)):
prefix = f"{prefix} {score:.2f}"
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
new_turns = self._turn_count - self._last_commit_turn
if self._commit_every_turns > 0 and new_turns >= self._commit_every_turns:
return True
elapsed = time.monotonic() - self._last_commit_time
return self._commit_interval_seconds > 0 and elapsed >= self._commit_interval_seconds
def _commit_session(self, session_id: str) -> Dict[str, Any]:
response = self._client.post(
f"/memory-system/sessions/{session_id}/commit",
self._user_payload(),
)
if response.get("status") == "success":
self._last_commit_turn = self._turn_count
self._last_commit_time = time.monotonic()
else:
logger.warning("Memory System commit did not fully succeed: %s", response)
return response
def _tool_search(self, args: Dict[str, Any]) -> str:
query = str(args.get("query", "")).strip()
if not query:
return tool_error("query is required")
limit = int(args.get("limit") or 10)
limit = max(1, min(limit, 100))
payload = {
"user_id": self._user_id,
"user_key": self._user_key,
"session_id": args.get("session_id") or self._session_id,
"query": query,
"use_llm": bool(args.get("use_llm", self._default_use_llm)),
"limit": limit,
}
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()
if not user_id:
return tool_error("user_id is required")
user_key = (
self._user_key
if user_id == self._user_id
else str(args.get("user_key") or "").strip()
)
if not user_key:
return tool_error("user_key is required for profile reads")
path = f"/memory-system/users/{user_id}/profile?{urlencode({'user_key': user_key})}"
return json.dumps(
self._client.get(path),
ensure_ascii=False,
)
def _tool_remember(self, args: Dict[str, Any]) -> str:
content = str(args.get("content", "")).strip()
if not content:
return tool_error("content is required")
session_id = str(args.get("session_id") or self._session_id).strip()
return json.dumps(self._remember(content, session_id=session_id, commit=True), ensure_ascii=False)
def _remember(self, content: str, *, session_id: str, commit: bool) -> Dict[str, Any]:
if not self._client:
return {"error": "Memory System API is not connected"}
response = self._client.post(
"/memory-system/messages",
self._message_payload(session_id=session_id, user_message=content),
)
if commit:
commit_response = self._client.post(
f"/memory-system/sessions/{session_id}/commit",
self._user_payload(),
)
return {"status": response.get("status"), "write": response, "commit": commit_response}
return response
def _ensure_user_key(self) -> None:
if self._user_key:
return
if not self._client:
return
response = self._client.post("/memory-system/users", {"user_id": self._user_id})
result = (
response.get("account", {}).get("result", {})
if isinstance(response.get("account"), dict)
else {}
)
user_key = result.get("user_key")
if not user_key:
raise ValueError("Memory System user creation response missing account.result.user_key")
self._user_key = str(user_key)
def _user_payload(self) -> Dict[str, str]:
if not self._user_key:
raise ValueError("Memory System user_key is required")
return {"user_id": self._user_id, "user_key": self._user_key}
def _query_path(self, path: str, params: Dict[str, Any]) -> str:
return f"{path}?{urlencode(params)}"
def _tool_memory_list(self, args: Dict[str, Any]) -> str:
uri = str(args.get("uri") or "viking://user/memories").strip()
recursive = self._arg_bool(args.get("recursive"), True)
path = self._query_path(
"/memory-system/memories",
{
"user_id": self._user_id,
"user_key": self._user_key,
"uri": uri,
"recursive": str(recursive).lower(),
},
)
return json.dumps(self._client.get(path), ensure_ascii=False)
def _tool_memory_read(self, args: Dict[str, Any]) -> str:
uri = str(args.get("uri", "")).strip()
if not uri:
return tool_error("uri is required")
path = self._query_path(
"/memory-system/memories/content",
{"user_id": self._user_id, "user_key": self._user_key, "uri": uri},
)
return json.dumps(self._client.get(path), ensure_ascii=False)
def _tool_memory_write(self, args: Dict[str, Any]) -> str:
uri = str(args.get("uri", "")).strip()
content = str(args.get("content", ""))
if not uri:
return tool_error("uri is required")
if not content:
return tool_error("content is required")
mode = str(args.get("mode") or "create").strip().lower()
if mode not in {"create", "replace", "append"}:
return tool_error("mode must be create, replace, or append")
payload = {
"user_id": self._user_id,
"user_key": self._user_key,
"uri": uri,
"content": content,
"mode": mode,
"wait": self._arg_bool(args.get("wait"), True),
}
return json.dumps(self._client.post("/memory-system/memories", payload), ensure_ascii=False)
def _tool_memory_delete(self, args: Dict[str, Any]) -> str:
uri = str(args.get("uri", "")).strip()
if not uri:
return tool_error("uri is required")
recursive = self._arg_bool(args.get("recursive"), False)
path = self._query_path(
"/memory-system/memories",
{
"user_id": self._user_id,
"user_key": self._user_key,
"uri": uri,
"recursive": str(recursive).lower(),
},
)
return json.dumps(self._client.delete(path), ensure_ascii=False)
def _arg_bool(self, value: Any, default: bool) -> bool:
if isinstance(value, bool):
return value
if value is None:
return default
return _bool_value(str(value), default)
def register(ctx) -> None:
ctx.register_memory_provider(MemorySystemMemoryProvider())

View File

@ -1,8 +0,0 @@
MEMORY_SYSTEM_ENDPOINT=http://127.0.0.1:1934
MEMORY_SYSTEM_USER_ID=default
MEMORY_SYSTEM_USER_KEY=
MEMORY_SYSTEM_API_KEY=
MEMORY_SYSTEM_SEARCH_USE_LLM=false
MEMORY_SYSTEM_COMMIT_EVERY_TURNS=5
MEMORY_SYSTEM_COMMIT_INTERVAL_SECONDS=300
MEMORY_SYSTEM_TIMEOUT_SECONDS=180

View File

@ -1,9 +0,0 @@
name: memory_system
version: 0.1.2
description: "Memory System API provider for Hermes, combining OpenViking session memory and EverOS user profile memory."
pip_dependencies:
- httpx
requires_env:
- MEMORY_SYSTEM_ENDPOINT
hooks:
- on_session_end

View File

@ -1,32 +1,35 @@
[project] [project]
name = "memory-system-api" name = "memory-gateway"
version = "0.1.0" version = "0.1.0"
description = "Lightweight Memory System API for OpenViking session memory and EverOS user profiles" description = "Lightweight user resource memory gateway"
readme = "README.md"
requires-python = ">=3.10" requires-python = ">=3.10"
dependencies = [ dependencies = [
"fastapi>=0.109.0", "fastapi>=0.104.0",
"httpx>=0.26.0", "httpx>=0.25.0",
"pydantic>=2.5.0", "pydantic>=2.7.1",
"pyyaml>=6.0", "python-dotenv>=1.0.1",
"uvicorn>=0.27.0", "python-multipart>=0.0.9",
] "uvicorn[standard]>=0.24.0",
[project.scripts]
memory-gateway = "memory_system_api.server:main"
[project.optional-dependencies]
dev = [
"pytest>=8.0.0",
"ruff>=0.1.0",
] ]
[build-system] [build-system]
requires = ["hatchling"] requires = ["setuptools>=68"]
build-backend = "hatchling.build" build-backend = "setuptools.build_meta"
[tool.hatch.build.targets.wheel] [tool.setuptools.packages.find]
packages = ["memory_system_api"] where = ["."]
include = ["core*"]
[tool.ruff] [dependency-groups]
target-version = "py310" dev = [
"pytest>=8.0.0",
"pytest-asyncio>=0.23.0",
]
[tool.pytest.ini_options]
testpaths = ["tests"]
python_files = ["test_*.py"]
asyncio_mode = "auto"
markers = [
"integration: tests that call a real upstream memory service",
]

View File

@ -0,0 +1,64 @@
---
name: memory-gateway-agent
description: Use when an AI agent needs to create or authenticate a Memory Gateway user, upload and manage user resources, add or flush chat memories, search scoped memory, or apply user-approved memory corrections and deletions through the Memory Gateway HTTP API.
---
# Memory Gateway Agent
Use the bundled CLI instead of constructing HTTP requests manually. It produces JSON output and uses only the Python standard library.
## Configure
Set these variables before authenticated operations:
```bash
export MEMORY_GATEWAY_BASE_URL="http://127.0.0.1:8010"
export MEMORY_GATEWAY_USER_ID="u_123"
export MEMORY_GATEWAY_USER_KEY="uk_xxx"
SKILL_DIR="/path/to/memory-gateway-agent"
CLI="python $SKILL_DIR/scripts/memory_gateway.py"
```
Do not write a real `user_key` into source files, prompts, logs, or committed documentation. Command-line flags may override environment variables, but environment variables are preferred because process arguments may be observable.
## Workflow
1. Run `$CLI health` when connectivity or upstream memory service availability is uncertain.
2. Use an existing `user_id` and `user_key`. Run `create-user` only when the user explicitly needs a new Gateway identity.
3. Choose the operation:
- Upload durable user files with `upload-resource`.
- Add conversational or multimodal messages with `add-memory`, then call `flush-memory`.
- Search with the narrowest useful scope.
- List or inspect resources before deleting them.
4. Treat JSON output as the source of truth. Preserve returned `resource_id`, memory `id`, and `session_id` exactly.
5. For override or deletion, use the memory `id` and `session_id` returned by search. Never invent IDs or apply changes across users.
## Common Commands
```bash
$CLI health
$CLI list-resources
$CLI upload-resource ./contract.pdf --title "Contract"
$CLI search "What are the payment terms?" --scope resources --top-k 8
$CLI search "What did we discuss?" --scope current_chat --conversation-id c_456
$CLI override-memory mem_abc --session-id resource:u_123:r_xxx --text "Corrected text"
$CLI delete-memory mem_abc --session-id resource:u_123:r_xxx --reason "User requested deletion"
```
For chat ingestion, put the Gateway `messages` array in a JSON file:
```bash
$CLI add-memory --session-id chat:c_456 --messages /tmp/messages.json
$CLI flush-memory --session-id chat:c_456
```
Read [references/api.md](references/api.md) when choosing scopes, constructing multimodal messages, interpreting errors, or using less common commands.
## Safety Rules
- Do not expose internal file paths. Return the Gateway's `resource://{user_id}/{resource_id}` URI to users.
- Do not claim ingestion succeeded unless the upload status is `extracted` or flush reports success.
- Treat `health.status = degraded` as Gateway available but upstream memory service unavailable.
- Resource deletion is soft deletion in Gateway search scope and removes the Gateway upload copy; it does not delete upstream memory service internal indexes.
- Memory override and deletion require an owned `resource:{user_id}:{resource_id}` or `memory_edit:{user_id}` session.
- Ask for confirmation before destructive deletion unless the user's current request explicitly instructs deletion.

View File

@ -0,0 +1,4 @@
interface:
display_name: "Memory Gateway Agent"
short_description: "Operate Memory Gateway resources and user memories"
default_prompt: "Use $memory-gateway-agent to store, search, edit, or delete user memory through Memory Gateway."

View File

@ -0,0 +1,260 @@
# Memory Gateway API Reference
## Contents
- Configuration
- Session IDs
- CLI commands
- Message format
- Search scopes
- Errors and result handling
## Configuration
The CLI reads:
| Variable | Default | Purpose |
|---|---|---|
| `MEMORY_GATEWAY_BASE_URL` | `http://127.0.0.1:8010` | Gateway URL |
| `MEMORY_GATEWAY_USER_ID` | none | Authenticated user ID |
| `MEMORY_GATEWAY_USER_KEY` | none | Authenticated user key |
| `MEMORY_GATEWAY_TIMEOUT_SECONDS` | `120` | HTTP timeout |
Global CLI flags `--base-url`, `--user-id`, `--user-key`, and `--timeout` override these values. Put global flags before the subcommand.
## Session IDs
| Memory source | Format |
|---|---|
| Chat | `chat:{conversation_id}` |
| Uploaded resource | `resource:{user_id}:{resource_id}` |
| Manual correction | `memory_edit:{user_id}` |
Use the exact `session_id` returned by the API whenever possible.
## CLI Commands
Assume:
```bash
CLI="python skill/memory-gateway-agent/scripts/memory_gateway.py"
```
### Health
```bash
$CLI health
```
No credentials required. HTTP 200 may contain `"status": "degraded"` when upstream memory service is unavailable.
### Create User
```bash
$CLI create-user u_123
```
Returns a randomly generated `user_key`. Store it securely. Repeating the same `user_id` returns the existing key.
### Upload Resource
```bash
$CLI upload-resource ./document.pdf \
--app-id default \
--project-id default \
--title "Document title" \
--description "Optional description"
```
Requires credentials. Supported resources depend on the server MIME allowlist. Success returns:
```json
{
"resource_id": "r_xxx",
"session_id": "resource:u_123:r_xxx",
"uri": "resource://u_123/r_xxx",
"status": "extracted"
}
```
`failed` means the record exists but upstream memory service ingestion failed. Identical active content for the same user/app/project may return the existing resource.
### List, Get, and Delete Resources
```bash
$CLI list-resources
$CLI get-resource r_xxx
$CLI delete-resource r_xxx
```
Missing or foreign resource details return `{"resources": []}`. Delete excludes the resource from future `resources` searches.
### Search
```bash
$CLI search "payment terms" --scope resources --top-k 8
$CLI search "previous discussion" \
--scope current_chat \
--conversation-id c_456
$CLI search "known preferences" --scope all_user_memory
```
Repeat `--scope` to combine scopes:
```bash
$CLI search "query" --scope current_chat --scope resources \
--conversation-id c_456
```
When no scope is provided, the CLI searches `resources` only unless a conversation ID is supplied; with a conversation ID it searches `current_chat` and `resources`. Explicit `current_chat` scope requires `--conversation-id`.
Each result includes normalized `id`, `session_id`, `text`, `source_scope`, and optional resource metadata. The `raw` field preserves the upstream memory service response.
### Add and Flush Memory
Create `/tmp/messages.json` containing an array:
```json
[
{
"sender_id": "u_123",
"role": "user",
"timestamp": 1781172177000,
"content": [
{"type": "text", "text": "Remember this note"}
]
}
]
```
Then run:
```bash
$CLI add-memory --session-id chat:c_456 --messages /tmp/messages.json
$CLI flush-memory --session-id chat:c_456
```
`--messages` accepts either a JSON array string or a path to a JSON file. Always flush after all messages for the session have been added.
For local binary files that cannot be converted to base64 by the caller, use the
multipart API directly. Put an `upload_id` in the content item and send a file
field with the same name:
```bash
curl -X POST "$MEMORY_GATEWAY_BASE_URL/memories/add/multipart" \
-F user_id="$MEMORY_GATEWAY_USER_ID" \
-F user_key="$MEMORY_GATEWAY_USER_KEY" \
-F session_id=chat:c_456 \
-F app_id=default \
-F project_id=default \
-F 'messages=[
{
"sender_id": "u_123",
"role": "user",
"timestamp": 1781172177000,
"content": [
{"type": "text", "text": "Remember this image"},
{
"type": "image",
"upload_id": "image_1",
"name": "image.png",
"ext": "png"
}
]
}
]' \
-F 'image_1=@./image.png;type=image/png'
```
The multipart endpoint appends messages to the provided chat session. It stores
the uploaded file under Gateway storage, forwards text/base64 content to the
upstream memory service, and records an attachment mapping. Call `flush-memory`
afterward when the session should be extracted and indexed. This differs from
`upload-resource`, which creates an independent `resource:{user_id}:{resource_id}`
session and automatically performs add plus flush for resource searches.
### Override and Delete Memory
Use IDs from a search result:
```bash
$CLI override-memory mem_abc \
--session-id resource:u_123:r_xxx \
--text "Corrected memory text"
$CLI delete-memory mem_abc \
--session-id resource:u_123:r_xxx \
--reason "User requested deletion"
```
These operations write Gateway overrides or tombstones. They do not modify upstream memory service files directly. The server rejects sessions not owned by the authenticated user.
## Message Format
Each message requires:
| Field | Type | Notes |
|---|---|---|
| `sender_id` | string | Usually the current user ID |
| `role` | string | `user`, `assistant`, or `tool` |
| `timestamp` | integer | Unix milliseconds, greater than zero |
| `content` | string or array | Text or upstream memory service content items |
Common content items:
```json
{"type": "text", "text": "Plain text"}
```
```json
{
"type": "image",
"base64": "BASE64_DATA",
"ext": "png",
"name": "image.png"
}
```
```json
{
"type": "audio",
"base64": "BASE64_DATA",
"ext": "wav",
"name": "audio.wav"
}
```
Prefer base64 for local binary files. A `file://` URI is only usable when upstream memory service can access the same filesystem path.
If that shared path guarantee is not true, use `/memories/add/multipart`,
`upload-resource`, or `/resources/external`.
## Search Scopes
| Scope | Behavior |
|---|---|
| `current_chat` | Searches `chat:{conversation_id}`; provide `--conversation-id` |
| `resources` | Searches extracted, non-deleted resources belonging to the user/app/project |
| `all_user_memory` | Searches user memory without a session filter |
Use the narrowest scope that answers the request. This reduces unrelated results and prevents accidental cross-context use.
## Errors and Result Handling
The CLI exits nonzero and writes JSON to stderr:
```json
{"error": "Memory Gateway returned HTTP 401: invalid user credentials"}
```
Common statuses:
| Status | Meaning |
|---|---|
| `401` | Invalid `user_id` or `user_key` |
| `403` | Memory session is not owned by the user |
| `404` | Resource not found for deletion |
| `413` | Upload exceeds server size limit |
| `415` | MIME type is not allowed |
| `422` | Request fields are invalid or missing |
Do not retry `401`, `403`, `413`, `415`, or `422` without changing the request. Retry connectivity or transient server failures only when appropriate for the caller's workflow.

View File

@ -0,0 +1,466 @@
#!/usr/bin/env python3
from __future__ import annotations
import argparse
import json
import mimetypes
import os
import sys
import uuid
from pathlib import Path
from typing import Any
from urllib.error import HTTPError, URLError
from urllib.parse import urlencode
from urllib.request import Request, urlopen
class GatewayError(RuntimeError):
pass
class Settings:
def __init__(
self,
base_url: str,
user_id: str | None,
user_key: str | None,
timeout: float = 120.0,
) -> None:
self.base_url = base_url.rstrip("/")
self.user_id = user_id
self.user_key = user_key
self.timeout = timeout
@classmethod
def from_env(cls) -> Settings:
return cls(
base_url=os.environ.get(
"MEMORY_GATEWAY_BASE_URL",
"http://127.0.0.1:8010",
),
user_id=os.environ.get("MEMORY_GATEWAY_USER_ID"),
user_key=os.environ.get("MEMORY_GATEWAY_USER_KEY"),
timeout=float(os.environ.get("MEMORY_GATEWAY_TIMEOUT_SECONDS", "120")),
)
class MemoryGatewayClient:
def __init__(
self,
base_url: str,
*,
user_id: str | None = None,
user_key: str | None = None,
timeout: float = 120.0,
) -> None:
self.base_url = base_url.rstrip("/")
self.user_id = user_id
self.user_key = user_key
self.timeout = timeout
def _credentials(self) -> dict[str, str]:
if not self.user_id or not self.user_key:
raise GatewayError(
"user credentials are required; set MEMORY_GATEWAY_USER_ID and "
"MEMORY_GATEWAY_USER_KEY or pass --user-id and --user-key"
)
return {"user_id": self.user_id, "user_key": self.user_key}
def _request(
self,
method: str,
path: str,
*,
query: dict[str, Any] | None = None,
json_body: dict[str, Any] | None = None,
body: bytes | None = None,
headers: dict[str, str] | None = None,
) -> dict[str, Any]:
url = f"{self.base_url}{path}"
if query:
url = f"{url}?{urlencode(query, doseq=True)}"
request_headers = dict(headers or {})
request_body = body
if json_body is not None:
request_body = json.dumps(json_body, ensure_ascii=False).encode("utf-8")
request_headers["Content-Type"] = "application/json"
request = Request(
url,
data=request_body,
headers=request_headers,
method=method,
)
try:
with urlopen(request, timeout=self.timeout) as response:
raw = response.read()
except HTTPError as exc:
raw = exc.read()
detail = _error_detail(raw, exc.reason)
raise GatewayError(f"Memory Gateway returned HTTP {exc.code}: {detail}") from exc
except URLError as exc:
raise GatewayError(f"cannot connect to Memory Gateway: {exc.reason}") from exc
if not raw:
return {}
try:
value = json.loads(raw.decode("utf-8"))
except (UnicodeDecodeError, json.JSONDecodeError) as exc:
raise GatewayError("Memory Gateway returned a non-JSON response") from exc
if not isinstance(value, dict):
raise GatewayError("Memory Gateway returned an unexpected JSON response")
return value
def health(self) -> dict[str, Any]:
return self._request("GET", "/health")
def create_user(self, user_id: str) -> dict[str, Any]:
return self._request("POST", "/users", json_body={"user_id": user_id})
def upload_resource(
self,
file_path: Path,
*,
app_id: str = "default",
project_id: str = "default",
title: str | None = None,
description: str | None = None,
) -> dict[str, Any]:
if not file_path.is_file():
raise GatewayError(f"upload file does not exist: {file_path}")
fields: dict[str, str] = {
**self._credentials(),
"app_id": app_id,
"project_id": project_id,
}
if title is not None:
fields["title"] = title
if description is not None:
fields["description"] = description
boundary = f"memory-gateway-{uuid.uuid4().hex}"
mime_type = mimetypes.guess_type(file_path.name)[0] or "application/octet-stream"
body = _multipart_body(
boundary,
fields,
field_name="file",
file_path=file_path,
mime_type=mime_type,
)
return self._request(
"POST",
"/resources",
body=body,
headers={"Content-Type": f"multipart/form-data; boundary={boundary}"},
)
def list_resources(self) -> dict[str, Any]:
return self._request("GET", "/resources", query=self._credentials())
def get_resource(self, resource_id: str) -> dict[str, Any]:
return self._request(
"GET",
f"/resources/{resource_id}",
query=self._credentials(),
)
def delete_resource(self, resource_id: str) -> dict[str, Any]:
return self._request(
"DELETE",
f"/resources/{resource_id}",
query=self._credentials(),
)
def search(
self,
query: str,
*,
conversation_id: str | None = None,
scopes: list[str] | None = None,
top_k: int = 8,
app_id: str = "default",
project_id: str = "default",
) -> dict[str, Any]:
selected_scopes = scopes or (
["current_chat", "resources"] if conversation_id else ["resources"]
)
if "current_chat" in selected_scopes and not conversation_id:
raise GatewayError(
"conversation_id is required when search scope includes current_chat"
)
payload: dict[str, Any] = {
**self._credentials(),
"query": query,
"scope": selected_scopes,
"top_k": top_k,
"app_id": app_id,
"project_id": project_id,
}
if conversation_id is not None:
payload["conversation_id"] = conversation_id
return self._request("POST", "/memories/search", json_body=payload)
def add_memory(
self,
session_id: str,
messages: list[dict[str, Any]],
*,
app_id: str = "default",
project_id: str = "default",
) -> dict[str, Any]:
return self._request(
"POST",
"/memories/add",
json_body={
**self._credentials(),
"session_id": session_id,
"messages": messages,
"app_id": app_id,
"project_id": project_id,
},
)
def flush_memory(
self,
session_id: str,
*,
app_id: str = "default",
project_id: str = "default",
) -> dict[str, Any]:
return self._request(
"POST",
"/memories/flush",
json_body={
**self._credentials(),
"session_id": session_id,
"app_id": app_id,
"project_id": project_id,
},
)
def override_memory(
self,
memory_id: str,
session_id: str,
override_text: str,
) -> dict[str, Any]:
return self._request(
"PATCH",
f"/memories/{memory_id}",
json_body={
**self._credentials(),
"session_id": session_id,
"override_text": override_text,
},
)
def delete_memory(
self,
memory_id: str,
session_id: str,
*,
reason: str | None = None,
) -> dict[str, Any]:
payload: dict[str, Any] = {
**self._credentials(),
"session_id": session_id,
}
if reason is not None:
payload["reason"] = reason
return self._request(
"DELETE",
f"/memories/{memory_id}",
json_body=payload,
)
def _error_detail(raw: bytes, fallback: Any) -> str:
try:
body = json.loads(raw.decode("utf-8"))
except (UnicodeDecodeError, json.JSONDecodeError):
return str(fallback)
if isinstance(body, dict) and body.get("detail"):
return str(body["detail"])
return str(fallback)
def _multipart_body(
boundary: str,
fields: dict[str, str],
*,
field_name: str,
file_path: Path,
mime_type: str,
) -> bytes:
marker = boundary.encode("ascii")
chunks: list[bytes] = []
for name, value in fields.items():
chunks.extend(
[
b"--" + marker + b"\r\n",
f'Content-Disposition: form-data; name="{name}"\r\n\r\n'.encode(),
value.encode("utf-8"),
b"\r\n",
]
)
chunks.extend(
[
b"--" + marker + b"\r\n",
(
f'Content-Disposition: form-data; name="{field_name}"; '
f'filename="{file_path.name}"\r\n'
).encode(),
f"Content-Type: {mime_type}\r\n\r\n".encode(),
file_path.read_bytes(),
b"\r\n--" + marker + b"--\r\n",
]
)
return b"".join(chunks)
def _load_json_array(value: str) -> list[dict[str, Any]]:
source = Path(value)
text = source.read_text(encoding="utf-8") if source.is_file() else value
try:
parsed = json.loads(text)
except json.JSONDecodeError as exc:
raise GatewayError(f"invalid messages JSON: {exc}") from exc
if not isinstance(parsed, list) or not all(isinstance(item, dict) for item in parsed):
raise GatewayError("messages JSON must be an array of objects")
return parsed
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(description="Memory Gateway agent CLI")
parser.add_argument("--base-url")
parser.add_argument("--user-id")
parser.add_argument("--user-key")
parser.add_argument("--timeout", type=float)
subparsers = parser.add_subparsers(dest="command", required=True)
subparsers.add_parser("health")
create_user = subparsers.add_parser("create-user")
create_user.add_argument("user_id")
upload = subparsers.add_parser("upload-resource")
upload.add_argument("file", type=Path)
_add_scope_arguments(upload)
upload.add_argument("--title")
upload.add_argument("--description")
subparsers.add_parser("list-resources")
get_resource = subparsers.add_parser("get-resource")
get_resource.add_argument("resource_id")
delete_resource = subparsers.add_parser("delete-resource")
delete_resource.add_argument("resource_id")
search = subparsers.add_parser("search")
search.add_argument("query")
search.add_argument("--conversation-id")
search.add_argument(
"--scope",
action="append",
choices=["current_chat", "resources", "all_user_memory"],
)
search.add_argument("--top-k", type=int, default=8)
_add_scope_arguments(search)
add = subparsers.add_parser("add-memory")
add.add_argument("--session-id", required=True)
add.add_argument(
"--messages",
required=True,
help="JSON array or path to a JSON file containing messages",
)
_add_scope_arguments(add)
flush = subparsers.add_parser("flush-memory")
flush.add_argument("--session-id", required=True)
_add_scope_arguments(flush)
override = subparsers.add_parser("override-memory")
override.add_argument("memory_id")
override.add_argument("--session-id", required=True)
override.add_argument("--text", required=True)
delete_memory = subparsers.add_parser("delete-memory")
delete_memory.add_argument("memory_id")
delete_memory.add_argument("--session-id", required=True)
delete_memory.add_argument("--reason")
return parser
def _add_scope_arguments(parser: argparse.ArgumentParser) -> None:
parser.add_argument("--app-id", default="default")
parser.add_argument("--project-id", default="default")
def main(argv: list[str] | None = None) -> int:
settings = Settings.from_env()
args = build_parser().parse_args(argv)
client = MemoryGatewayClient(
args.base_url or settings.base_url,
user_id=args.user_id or settings.user_id,
user_key=args.user_key or settings.user_key,
timeout=args.timeout or settings.timeout,
)
try:
result = _run_command(client, args)
except GatewayError as exc:
print(json.dumps({"error": str(exc)}, ensure_ascii=False), file=sys.stderr)
return 1
print(json.dumps(result, ensure_ascii=False, indent=2))
return 0
def _run_command(client: MemoryGatewayClient, args: argparse.Namespace) -> dict[str, Any]:
if args.command == "health":
return client.health()
if args.command == "create-user":
return client.create_user(args.user_id)
if args.command == "upload-resource":
return client.upload_resource(
args.file,
app_id=args.app_id,
project_id=args.project_id,
title=args.title,
description=args.description,
)
if args.command == "list-resources":
return client.list_resources()
if args.command == "get-resource":
return client.get_resource(args.resource_id)
if args.command == "delete-resource":
return client.delete_resource(args.resource_id)
if args.command == "search":
return client.search(
args.query,
conversation_id=args.conversation_id,
scopes=args.scope,
top_k=args.top_k,
app_id=args.app_id,
project_id=args.project_id,
)
if args.command == "add-memory":
return client.add_memory(
args.session_id,
_load_json_array(args.messages),
app_id=args.app_id,
project_id=args.project_id,
)
if args.command == "flush-memory":
return client.flush_memory(
args.session_id,
app_id=args.app_id,
project_id=args.project_id,
)
if args.command == "override-memory":
return client.override_memory(args.memory_id, args.session_id, args.text)
if args.command == "delete-memory":
return client.delete_memory(
args.memory_id,
args.session_id,
reason=args.reason,
)
raise GatewayError(f"unsupported command: {args.command}")
if __name__ == "__main__":
raise SystemExit(main())

View File

@ -1,233 +0,0 @@
---
name: memory-system-api
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, managing memory URIs, searching memories, reading profiles, or debugging OpenViking/EverOS backend results."
---
# Memory System API
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.
## Current Contract
The API is user-gated:
- 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.
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.
`X-API-Key` is separate. It protects the Memory System API itself only when `server.api_key` is configured.
## 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 creates one isolated OpenViking account per business user:
```json
{"account_id": "<user_id>_account", "admin_user_id": "<user_id>"}
```
The gateway does not call `/api/v1/admin/accounts/admin/users`. User creation goes through `/api/v1/admin/accounts`, stores the returned `user_key`, and later business calls use that user key directly.
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/memories
viking://user/memories/...
viking://user/<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/POST` | `/sessions/{session_id}/context` | Read OpenViking session context plus EverOS recall | Yes |
| `GET` | `/openviking/tasks/{task_id}` | Poll OpenViking task status | Yes |
| `GET` | `/memories` | List OpenViking memory URIs under a memory root | Yes |
| `GET` | `/memories/content` | Read one OpenViking memory URI | Yes |
| `POST` | `/memories` | Create, replace, or append an OpenViking memory via `content/write` | Yes |
| `DELETE` | `/memories` | Delete an OpenViking memory URI via `fs` | Yes |
| `POST` | `/resources` | Upload local file or remote URL to OpenViking resources | Yes |
| `DELETE` | `/resources` | Delete OpenViking resource URI via `fs` | 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
curl -sS -X POST "$BASE/memory-system/users" \
-H "Content-Type: application/json" \
-d '{"user_id":"userA"}'
```
Write messages:
```bash
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": "好的。"
}'
```
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"'"}'
```
Search:
```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
}'
```
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"
```
List memories:
```bash
curl -sS -G "$BASE/memory-system/memories" \
--data-urlencode "user_id=userA" \
--data-urlencode "user_key=$USER_KEY" \
--data-urlencode "uri=viking://user/memories" \
--data-urlencode "recursive=true"
```
Read memory:
```bash
curl -sS -G "$BASE/memory-system/memories/content" \
--data-urlencode "user_id=userA" \
--data-urlencode "user_key=$USER_KEY" \
--data-urlencode "uri=viking://user/memories/preferences/python.md"
```
Create, replace, or append memory:
```bash
curl -sS -X POST "$BASE/memory-system/memories" \
-H "Content-Type: application/json" \
-d '{
"user_id": "userA",
"user_key": "'"$USER_KEY"'",
"uri": "viking://user/memories/preferences/python.md",
"content": "# Python 偏好\n\n用户偏好使用 Python 做数据分析。",
"mode": "replace",
"wait": true
}'
```
Delete memory:
```bash
curl -sS -X DELETE -G "$BASE/memory-system/memories" \
--data-urlencode "user_id=userA" \
--data-urlencode "user_key=$USER_KEY" \
--data-urlencode "uri=viking://user/memories/preferences/python.md" \
--data-urlencode "recursive=false"
```
For directory-like or composite memories, retry deletion with `recursive=true` only after OpenViking reports that recursive deletion is required.
## Response Handling
Top-level `status` is one of:
- `success`: all attempted backends succeeded.
- `partial_success`: at least one backend succeeded and one failed.
- `failed`: all attempted backends failed.
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`.
- Omitting `user_key` on business calls.
- Sending `account_id` to business APIs.
- Confusing `X-API-Key` with `user_key`.
- Assuming memory updates use a PATCH endpoint; use `POST /memory-system/memories` with `mode=replace` or `mode=append`.
- Deleting memories recursively by default; use `recursive=false` unless the target is a directory or composite resource.
- 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 API behavior or this skill:
```bash
PYTHONPATH=/home/tom/memory-gateway pytest -q
python -m compileall -q memory_system_api plugins eval tests
```

View File

@ -1,4 +0,0 @@
interface:
display_name: "Memory System API"
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 session messages with user_id and user_key, commit or extract sessions, manage memory URIs, search memory, and read user profiles."

View File

@ -1,345 +0,0 @@
# Memory System API Reference
Use the deployed service URL supplied by the user, runtime, or configuration:
```text
<MEMORY_SYSTEM_BASE_URL>
```
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>"
```
## Health
```bash
curl -s <MEMORY_SYSTEM_BASE_URL>/memory-system/health
```
## Create User
Create the business user before calling any business endpoint:
```bash
curl -s -X POST <MEMORY_SYSTEM_BASE_URL>/memory-system/users \
-H "Content-Type: application/json" \
-d '{"user_id": "<USER_ID>"}'
```
Save the returned `account.result.user_key` and pass it as `user_key` on later calls:
```json
{
"status": "success",
"account": {
"status": "ok",
"result": {
"account_id": "<USER_ID>_account",
"admin_user_id": "<USER_ID>",
"user_key": "<USER_KEY>"
}
}
}
```
Do not send `account_id` to business endpoints.
## Write Messages
```bash
curl -s -X POST <MEMORY_SYSTEM_BASE_URL>/memory-system/messages \
-H "Content-Type: application/json" \
-d '{
"user_id": "<USER_ID>",
"user_key": "<USER_KEY>",
"session_id": "<SESSION_ID>",
"user_message": "我喜欢喝拿铁,不喜欢美式。",
"assistant_message": "好的,我会记住你的咖啡偏好。"
}'
```
`user_message` and `assistant_message` are optional independently, but at least one must be present.
## Commit Session
```bash
curl -s -X POST <MEMORY_SYSTEM_BASE_URL>/memory-system/sessions/<SESSION_ID>/commit \
-H "Content-Type: application/json" \
-d '{"user_id": "<USER_ID>", "user_key": "<USER_KEY>"}'
```
Use the returned OpenViking task ID, if present:
```bash
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:
```json
{
"status": "partial_success",
"backends": {
"openviking": {"status": "success", "result": {"status": "ok"}},
"everos": {"status": "failed", "error": "ReadTimeout"}
}
}
```
Wait for EverOS or its upstream LLM/rerank service to recover, then call the same commit endpoint again.
## Immediate Extract
```bash
curl -s -X POST <MEMORY_SYSTEM_BASE_URL>/memory-system/sessions/<SESSION_ID>/extract \
-H "Content-Type: application/json" \
-d '{"user_id": "<USER_ID>", "user_key": "<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_BASE_URL>/memory-system/sessions/<SESSION_ID>/context \
-H "Content-Type: application/json" \
-d '{
"user_id": "<USER_ID>",
"user_key": "<USER_KEY>",
"query": "我喜欢喝什么?",
"limit": 10
}'
```
Equivalent GET form:
```bash
curl -s "<MEMORY_SYSTEM_BASE_URL>/memory-system/sessions/<SESSION_ID>/context?user_id=<USER_ID>&user_key=<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}
}
}
}
}
```
## Manage Memories
Memory management operates on OpenViking memory URIs, usually under `viking://user/memories`. There is no separate PATCH endpoint. Update existing memory by writing directly to its URI.
### List Memory URIs
```bash
curl -sS --get "<MEMORY_SYSTEM_BASE_URL>/memory-system/memories" \
--data-urlencode "user_id=<USER_ID>" \
--data-urlencode "user_key=<USER_KEY>" \
--data-urlencode "uri=viking://user/memories" \
--data-urlencode "recursive=true"
```
This maps to OpenViking `GET /api/v1/fs/ls`.
### Read Memory Content
```bash
curl -sS --get "<MEMORY_SYSTEM_BASE_URL>/memory-system/memories/content" \
--data-urlencode "user_id=<USER_ID>" \
--data-urlencode "user_key=<USER_KEY>" \
--data-urlencode "uri=viking://user/memories/preferences/python.md"
```
This maps to OpenViking `GET /api/v1/content/read`.
### Create, Replace, Or Append Memory
```bash
curl -sS -X POST "<MEMORY_SYSTEM_BASE_URL>/memory-system/memories" \
-H "Content-Type: application/json" \
-d '{
"user_id": "<USER_ID>",
"user_key": "<USER_KEY>",
"uri": "viking://user/<USER_ID>/memories/preferences/饮食偏好.md",
"content": "用户喜欢喝茶",
"mode": "create",
"wait": true
}'
```
For preference memories, write directly under `memories/preferences/`; do not add an extra `/user` path segment.
`mode` supports:
- `create`: create a new memory URI.
- `replace`: fully replace an existing memory.
- `append`: append content to an existing memory.
This maps to OpenViking `POST /api/v1/content/write`. OpenViking refreshes semantic and vector indexes after writing.
### Delete Memory
```bash
curl -sS -X DELETE --get "<MEMORY_SYSTEM_BASE_URL>/memory-system/memories" \
--data-urlencode "user_id=<USER_ID>" \
--data-urlencode "user_key=<USER_KEY>" \
--data-urlencode "uri=viking://user/memories/preferences/python.md" \
--data-urlencode "recursive=false"
```
Default to `recursive=false`. If OpenViking reports that the URI is a directory or composite resource, retry with `recursive=true`.
## Search
Without LLM planning:
```bash
curl -s -X POST <MEMORY_SYSTEM_BASE_URL>/memory-system/search \
-H "Content-Type: application/json" \
-d '{
"user_id": "<USER_ID>",
"user_key": "<USER_KEY>",
"session_id": "<SESSION_ID>",
"query": "我喜欢喝什么咖啡?",
"use_llm": false,
"limit": 10,
"level": 2,
"score_threshold": 0.8,
"target_uri": "viking://user/memories"
}'
```
With LLM planning:
```bash
curl -s -X POST <MEMORY_SYSTEM_BASE_URL>/memory-system/search \
-H "Content-Type: application/json" \
-d '{
"user_id": "<USER_ID>",
"user_key": "<USER_KEY>",
"session_id": "<SESSION_ID>",
"query": "我的偏好是什么?",
"use_llm": true,
"limit": 10,
"level": 2,
"score_threshold": 0.8,
"target_uri": "viking://user/memories"
}'
```
Raw API search responses include merged `items` plus compact backend diagnostics. Fields named `vector` are stripped recursively before the API returns JSON. The API calls current EverOS `POST /api/v1/memory/search` with top-level `user_id`, `query`, `method`, `top_k`, `include_profile`, and optional `filters.session_id`; it does not return full EverOS episode/profile payloads inside `backends`. OpenViking search uses `/api/v1/search/search`; `target_uri`, `level`, and `score_threshold` are optional request fields.
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},
"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 -sS --get "<MEMORY_SYSTEM_BASE_URL>/memory-system/users/<USER_ID>/profile" \
--data-urlencode "user_key=<USER_KEY>" \
--data-urlencode "query=我想喝东西" \
--data-urlencode "limit=10" \
--data-urlencode "level=2"
```
This combines EverOS profile with OpenViking memory recall. The OpenViking request uses `/api/v1/search/search` with `target_uri: viking://user/memories`, `limit`, and `level`.
## Response Interpretation
Inspect backend status:
```json
{
"status": "partial_success",
"backends": {
"openviking": {"status": "success", "result": {}},
"everos": {"status": "failed", "error": "..."}
}
}
```
Use backend-specific errors for debugging.
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.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 548 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 259 B

BIN
tests/simple-tone.wav Normal file

Binary file not shown.

1
tests/test.md Normal file
View File

@ -0,0 +1 @@
这是测试文件

View File

@ -0,0 +1,80 @@
from __future__ import annotations
import os
from pathlib import Path
from uuid import uuid4
import httpx
import pytest
from core.api import create_app
from core.config import GatewayConfig
from core.backend_client import BackendClient
pytestmark = pytest.mark.integration
def _integration_enabled() -> bool:
return os.environ.get("RUN_BACKEND_INTEGRATION") == "1"
def _ingest_integration_enabled() -> bool:
return os.environ.get("RUN_BACKEND_INGEST_INTEGRATION") == "1"
def _backend_base_url() -> str:
return os.environ.get("MEMORY_GATEWAY_BACKEND_BASE_URL", "http://127.0.0.1:1995")
@pytest.mark.skipif(
not _integration_enabled(),
reason="set RUN_BACKEND_INTEGRATION=1 to run against an upstream memory service",
)
@pytest.mark.asyncio
async def test_real_backend_health_check() -> None:
client = BackendClient(_backend_base_url(), timeout=10)
health = await client.health_check()
assert isinstance(health, dict)
@pytest.mark.skipif(
not _ingest_integration_enabled(),
reason=(
"set RUN_BACKEND_INGEST_INTEGRATION=1 to run upstream add/flush ingestion"
),
)
@pytest.mark.asyncio
async def test_gateway_uploads_text_resource_to_real_backend(tmp_path: Path) -> None:
config = GatewayConfig(
backend_base_url=_backend_base_url(),
database_path=tmp_path / "gateway.sqlite3",
storage_dir=tmp_path / "storage",
backend_ingest_attempts=1,
backend_timeout_seconds=30,
)
app = create_app(config=config)
transport = httpx.ASGITransport(app=app)
user_id = f"it_{uuid4().hex}"
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
created_user = await client.post("/users", json={"user_id": user_id})
assert created_user.status_code == 200, created_user.text
user_key = created_user.json()["user_key"]
uploaded = await client.post(
"/resources",
data={"user_id": user_id, "user_key": user_key},
files={
"file": (
"integration.txt",
b"upstream memory service integration",
"text/plain",
)
},
)
assert uploaded.status_code == 200, uploaded.text
assert uploaded.json()["status"] == "extracted"

35
tests/test_branding.py Normal file
View File

@ -0,0 +1,35 @@
from __future__ import annotations
from pathlib import Path
PROJECT_ROOT = Path(__file__).resolve().parents[1]
FORBIDDEN_TOKEN = "ever" + "os"
SKIPPED_PARTS = {
".git",
".pytest_cache",
".venv",
"__pycache__",
"data",
}
def test_current_project_does_not_expose_upstream_product_name() -> None:
matches: list[str] = []
for path in PROJECT_ROOT.rglob("*"):
relative = path.relative_to(PROJECT_ROOT)
if any(part in SKIPPED_PARTS for part in relative.parts):
continue
if FORBIDDEN_TOKEN in path.name.lower():
matches.append(f"filename: {relative}")
if not path.is_file():
continue
try:
text = path.read_text(encoding="utf-8")
except UnicodeDecodeError:
continue
for line_number, line in enumerate(text.splitlines(), start=1):
if FORBIDDEN_TOKEN in line.lower():
matches.append(f"content: {relative}:{line_number}")
assert matches == []

325
tests/test_command.md Normal file
View File

@ -0,0 +1,325 @@
# Memory Gateway API curl examples
This file keeps only the concrete API curl shapes and short notes. Replace
`<USER_KEY>` with the key returned by `POST /users`.
Base URL used in the live deployment test:
```text
http://127.0.0.1:8010
```
Test files:
```text
tests/simple-multimodal-image.png
tests/simple-tone.wav
```
## 1. Health
```bash
curl -sS http://127.0.0.1:8010/health
```
Expected shape:
```json
{
"status": "ok",
"api": {"status": "ok"},
"backend": {
"status": "ok",
"base_url": "http://0.0.0.0:1995",
"data": {"status": "ok"}
}
}
```
## 2. Create user
```bash
curl -sS -X POST http://127.0.0.1:8010/users \
-H 'Content-Type: application/json' \
-d '{"user_id":"gateway_demo_user"}'
```
Expected shape:
```json
{
"user_id": "gateway_demo_user",
"user_key": "uk_REDACTED",
"created_at": "2026-06-22T06:54:35.823262+00:00"
}
```
Use the returned `user_key` in later requests.
## 3. Add chat memory with multipart files
Use this when files belong to a chat/session message and the client should not
or cannot convert the files to base64.
`upload_id` rules:
- `upload_id` is defined by the caller.
- Gateway does not generate it.
- Gateway does not require a format such as `user_id_filetype_number`.
- It only needs to be non-empty, unique inside the request, and equal to the
multipart file field name.
- Good simple values are `image_1`, `image_2`, `audio_1`, `doc_1`.
In the `messages` JSON, `upload_id: "image_1"` points to this file field:
```bash
-F 'image_1=@tests/simple-multimodal-image.png;type=image/png'
```
Request:
```bash
curl -sS -X POST http://127.0.0.1:8010/memories/add/multipart \
-F 'user_id=gateway_demo_user' \
-F 'user_key=<USER_KEY>' \
-F 'session_id=chat:gateway_demo_conversation' \
-F 'app_id=default' \
-F 'project_id=default' \
-F 'messages=[
{
"sender_id": "gateway_demo_user",
"role": "user",
"timestamp": 1782111275810,
"content": [
{
"type": "text",
"text": "请记住这次上传:图片里有左上红色方块、右上蓝色圆形、底部绿色横条;音频是一段短促测试音。"
},
{
"type": "image",
"upload_id": "image_1",
"name": "simple-multimodal-image.png",
"ext": "png"
},
{
"type": "audio",
"upload_id": "audio_1",
"name": "simple-tone.wav",
"ext": "wav"
}
]
}
]' \
-F 'image_1=@tests/simple-multimodal-image.png;type=image/png' \
-F 'audio_1=@tests/simple-tone.wav;type=audio/wav'
```
Expected shape:
```json
{
"session_id": "chat:gateway_demo_conversation",
"backend": {
"request_id": "0d6451f4077040e4af207cc6b034ea34",
"data": {
"message_count": 1,
"status": "accumulated"
}
}
}
```
Gateway stores the uploaded files and forwards upstream-compatible `base64` or
`text` content. The client does not send `file://` and does not send base64.
Common errors:
- Missing file field for an `upload_id`: `422`
- Duplicate `upload_id`: `422`
- Extra uploaded file field not referenced by `messages`: `422`
- Unsupported MIME type: `415`
- File too large: `413`
## 4. Flush chat session
`/memories/add/multipart` only appends messages. Call flush when the session
should be extracted and indexed.
```bash
curl -sS -X POST http://127.0.0.1:8010/memories/flush \
-H 'Content-Type: application/json' \
-d '{
"user_id": "gateway_demo_user",
"user_key": "<USER_KEY>",
"session_id": "chat:gateway_demo_conversation",
"app_id": "default",
"project_id": "default"
}'
```
Expected shape:
```json
{
"session_id": "chat:gateway_demo_conversation",
"backend": {
"request_id": "4df5415115a34f109c564abd2f9012c6",
"data": {"status": "extracted"}
}
}
```
## 5. Search chat session
```bash
curl -sS -X POST http://127.0.0.1:8010/memories/search \
-H 'Content-Type: application/json' \
-d '{
"user_id": "gateway_demo_user",
"user_key": "<USER_KEY>",
"conversation_id": "gateway_demo_conversation",
"query": "图片里的蓝色圆形在哪里?底部是什么颜色的横条?",
"scope": ["current_chat"],
"top_k": 5,
"app_id": "default",
"project_id": "default"
}'
```
Expected result excerpt:
```json
{
"results": [
{
"session_id": "chat:gateway_demo_conversation",
"source_scope": "current_chat",
"text": "The image contained a red square, a blue circle, and a green horizontal rectangle.",
"attachments": [
{
"type": "image",
"name": "simple-multimodal-image.png",
"internal_uri": "file:///home/tom/memory-gateway/data/storage/..."
},
{
"type": "audio",
"name": "simple-tone.wav",
"internal_uri": "file:///home/tom/memory-gateway/data/storage/..."
}
]
}
]
}
```
## 6. Upload an independent resource
Use `/resources` when the file is an independent resource, not just an
attachment inside one chat message.
```bash
curl -sS -X POST http://127.0.0.1:8010/resources \
-F 'user_id=gateway_demo_user' \
-F 'user_key=<USER_KEY>' \
-F 'app_id=default' \
-F 'project_id=default' \
-F 'title=Gateway demo image resource' \
-F 'description=Demo upload for simple multimodal image' \
-F 'file=@tests/simple-multimodal-image.png;type=image/png'
```
Expected shape:
```json
{
"resource_id": "r_1678eacf3e8c49f9a8863454c5b35e68",
"session_id": "resource:gateway_demo_user:r_1678eacf3e8c49f9a8863454c5b35e68",
"uri": "resource://gateway_demo_user/r_1678eacf3e8c49f9a8863454c5b35e68",
"status": "extracted"
}
```
Unlike `/memories/add/multipart`, `/resources` automatically calls upstream add
and flush.
## 7. List resources
```bash
curl -sS \
'http://127.0.0.1:8010/resources?user_id=gateway_demo_user&user_key=<USER_KEY>'
```
Expected shape:
```json
{
"resources": [
{
"resource_id": "r_1678eacf3e8c49f9a8863454c5b35e68",
"filename": "simple-multimodal-image.png",
"content_type": "image",
"mime_type": "image/png",
"uri": "resource://gateway_demo_user/r_1678eacf3e8c49f9a8863454c5b35e68",
"session_id": "resource:gateway_demo_user:r_1678eacf3e8c49f9a8863454c5b35e68",
"status": "extracted"
}
]
}
```
## 8. Search resources
```bash
curl -sS -X POST http://127.0.0.1:8010/memories/search \
-H 'Content-Type: application/json' \
-d '{
"user_id": "gateway_demo_user",
"user_key": "<USER_KEY>",
"query": "这张资源图片里有哪些几何图形和颜色?",
"scope": ["resources"],
"top_k": 5,
"app_id": "default",
"project_id": "default"
}'
```
Expected result excerpt:
```json
{
"results": [
{
"source_scope": "resources",
"resource_id": "r_1678eacf3e8c49f9a8863454c5b35e68",
"resource_uri": "resource://gateway_demo_user/r_1678eacf3e8c49f9a8863454c5b35e68",
"text": "The image displayed a red square, a blue circle, and a green rectangle.",
"attachments": [
{
"type": "image",
"name": "simple-multimodal-image.png",
"internal_uri": "file:///home/tom/memory-gateway/data/storage/..."
}
]
}
]
}
```
## Multipart vs resources
Use `/memories/add/multipart` when the upload belongs to a chat/session message:
- caller supplies `session_id`, usually `chat:{conversation_id}`;
- caller defines `upload_id` values in `messages`;
- caller uploads files as form fields with names matching `upload_id`;
- Gateway only calls upstream add;
- caller should call `/memories/flush`;
- search normally uses `current_chat` or `all_user_memory`.
Use `/resources` when the upload is an independent resource:
- Gateway creates `resource_id`;
- Gateway creates `session_id = resource:{user_id}:{resource_id}`;
- Gateway writes `user_resources`;
- Gateway automatically calls upstream add and flush;
- search normally uses `resources`.

1604
tests/test_gateway.py Normal file

File diff suppressed because it is too large Load Diff

View File

@ -1,417 +0,0 @@
import importlib.util
import json
import os
import sys
from pathlib import Path
HERMES_ROOT = Path("/home/tom/hermes-agent")
PLUGIN_PATH = Path(__file__).resolve().parents[1] / "plugins" / "memory" / "memory_system" / "__init__.py"
def load_plugin_module():
if str(HERMES_ROOT) not in sys.path:
sys.path.insert(0, str(HERMES_ROOT))
spec = importlib.util.spec_from_file_location("memory_system_plugin", PLUGIN_PATH)
module = importlib.util.module_from_spec(spec)
assert spec.loader is not None
spec.loader.exec_module(module)
return module
class FakeClient:
def __init__(self, commit_status="success"):
self.posts = []
self.gets = []
self.deletes = []
self.commit_status = commit_status
def post(self, path, payload=None):
self.posts.append((path, payload or {}))
if path == "/memory-system/users":
return {
"status": "success",
"account": {
"status": "ok",
"result": {
"account_id": f"{payload['user_id']}_account",
"admin_user_id": payload["user_id"],
"user_key": f"{payload['user_id']}-key",
},
},
}
if path == "/memory-system/search":
return {
"status": "success",
"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}
if path == "/memory-system/memories":
return {"status": "success", "memory": {"status": "ok", "result": {"uri": payload.get("uri")}}}
return {"status": "success"}
def get(self, path):
self.gets.append(path)
if path.startswith("/memory-system/memories/content"):
return {"status": "success", "memory": {"status": "ok", "result": {"content": "# Python"}}}
if path.startswith("/memory-system/memories"):
return {"status": "success", "memory": {"status": "ok", "result": {"children": []}}}
return {"status": "success", "profile": {"coffee": "latte"}}
def delete(self, path):
self.deletes.append(path)
return {"status": "success", "memory": {"status": "ok", "result": {"estimated_deleted_count": 1}}}
def make_provider():
module = load_plugin_module()
provider = module.MemorySystemMemoryProvider()
provider._client = FakeClient()
provider._endpoint = "http://127.0.0.1:1934"
provider._user_id = "user-1"
provider._user_key = "user-1-key"
provider._session_id = "session-1"
provider._commit_every_turns = 0
provider._commit_interval_seconds = 0
return provider
def wait_for_sync(provider):
thread = provider._sync_thread
if thread and thread.is_alive():
thread.join(timeout=2.0)
def test_initialize_loads_config_from_hermes_env_file(tmp_path, monkeypatch):
module = load_plugin_module()
hermes_home = tmp_path / ".hermes"
hermes_home.mkdir()
env_file = hermes_home / ".env"
env_file.write_text(
"\n".join(
[
"MEMORY_SYSTEM_ENDPOINT=http://127.0.0.1:1934",
"MEMORY_SYSTEM_USER_ID=file-user",
"MEMORY_SYSTEM_USER_KEY=file-user-key",
"MEMORY_SYSTEM_COMMIT_EVERY_TURNS=3",
"MEMORY_SYSTEM_COMMIT_INTERVAL_SECONDS=60",
"MEMORY_SYSTEM_TIMEOUT_SECONDS=123",
]
),
encoding="utf-8",
)
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):
super().__init__()
self.endpoint = endpoint
self.api_key = api_key
self.timeout = timeout
def health(self):
return True
monkeypatch.setattr(module, "_MemorySystemClient", InitClient)
provider = module.MemorySystemMemoryProvider()
provider.initialize("session-1", hermes_home=str(hermes_home))
assert provider._endpoint == "http://127.0.0.1:1934"
assert provider._user_id == "file-user"
assert provider._user_key == "file-user-key"
assert provider._commit_every_turns == 3
assert provider._commit_interval_seconds == 60
assert provider._timeout == 123
assert provider._client.endpoint == "http://127.0.0.1:1934"
assert provider._client.timeout == 123
def test_sync_turn_posts_user_and_assistant_messages():
provider = make_provider()
provider.sync_turn("hello", "hi there")
wait_for_sync(provider)
assert provider._client.posts == [
(
"/memory-system/messages",
{
"user_id": "user-1",
"user_key": "user-1-key",
"session_id": "session-1",
"user_message": "hello",
"assistant_message": "hi there",
"metadata": {"source": "hermes", "provider": "memory_system"},
},
)
]
def test_on_session_end_commits_after_turn_sync():
provider = make_provider()
provider.sync_turn("hello", "hi there")
provider.on_session_end([])
assert provider._client.posts[-1] == (
"/memory-system/sessions/session-1/commit",
{"user_id": "user-1", "user_key": "user-1-key"},
)
def test_sync_turn_commits_every_configured_turns():
provider = make_provider()
provider._commit_every_turns = 2
provider._commit_interval_seconds = 0
provider.sync_turn("turn 1", "reply 1")
wait_for_sync(provider)
provider.sync_turn("turn 2", "reply 2")
wait_for_sync(provider)
assert provider._client.posts[-1] == (
"/memory-system/sessions/session-1/commit",
{"user_id": "user-1", "user_key": "user-1-key"},
)
assert provider._last_commit_turn == 2
def test_partial_commit_does_not_mark_turns_committed():
provider = make_provider()
provider._client = FakeClient(commit_status="partial_success")
provider._turn_count = 2
response = provider._commit_session("session-1")
assert response["status"] == "partial_success"
assert provider._last_commit_turn == 0
def test_on_session_end_skips_when_periodic_commit_is_current():
provider = make_provider()
provider._commit_every_turns = 1
provider._commit_interval_seconds = 0
provider.sync_turn("hello", "hi there")
wait_for_sync(provider)
provider.on_session_end([])
commit_posts = [
post for post in provider._client.posts if post[0] == "/memory-system/sessions/session-1/commit"
]
assert len(commit_posts) == 1
def test_search_tool_uses_memory_system_api():
provider = make_provider()
result = json.loads(provider.handle_tool_call("memory_system_search", {"query": "coffee", "limit": 3}))
assert result["status"] == "success"
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",
{
"user_id": "user-1",
"user_key": "user-1-key",
"session_id": "session-1",
"query": "coffee",
"use_llm": False,
"limit": 3,
},
)
def test_profile_tool_reads_user_profile():
provider = make_provider()
result = json.loads(provider.handle_tool_call("memory_system_profile", {}))
assert result["profile"] == {"coffee": "latte"}
assert provider._client.gets == ["/memory-system/users/user-1/profile?user_key=user-1-key"]
def test_remember_tool_writes_and_commits():
provider = make_provider()
result = json.loads(provider.handle_tool_call("memory_system_remember", {"content": "likes latte"}))
assert result["status"] == "success"
assert provider._client.posts == [
(
"/memory-system/messages",
{
"user_id": "user-1",
"user_key": "user-1-key",
"session_id": "session-1",
"user_message": "likes latte",
"metadata": {"source": "hermes", "provider": "memory_system"},
},
),
(
"/memory-system/sessions/session-1/commit",
{"user_id": "user-1", "user_key": "user-1-key"},
),
]
def test_initialize_creates_user_when_user_key_is_not_configured(tmp_path, monkeypatch):
module = load_plugin_module()
for key in list(os.environ):
if key.startswith("MEMORY_SYSTEM_"):
monkeypatch.delenv(key, raising=False)
monkeypatch.setenv("MEMORY_SYSTEM_ENDPOINT", "http://127.0.0.1:1934")
monkeypatch.setenv("MEMORY_SYSTEM_USER_ID", "new-user")
monkeypatch.chdir(tmp_path)
class InitClient(FakeClient):
def __init__(self, endpoint, api_key="", timeout=0):
super().__init__()
self.endpoint = endpoint
def health(self):
return True
monkeypatch.setattr(module, "_MemorySystemClient", InitClient)
provider = module.MemorySystemMemoryProvider()
provider.initialize("session-1")
assert provider._user_key == "new-user-key"
assert provider._client.posts == [("/memory-system/users", {"user_id": "new-user"})]
def test_memory_tool_schemas_include_memory_management_tools():
provider = make_provider()
tool_names = {schema["name"] for schema in provider.get_tool_schemas()}
assert {
"memory_system_memory_list",
"memory_system_memory_read",
"memory_system_memory_write",
"memory_system_memory_delete",
} <= tool_names
def test_memory_list_tool_calls_memories_endpoint():
provider = make_provider()
result = json.loads(provider.handle_tool_call("memory_system_memory_list", {"recursive": True}))
assert result["status"] == "success"
assert provider._client.gets == [
"/memory-system/memories?user_id=user-1&user_key=user-1-key&uri=viking%3A%2F%2Fuser%2Fmemories&recursive=true"
]
def test_memory_read_tool_calls_memory_content_endpoint():
provider = make_provider()
result = json.loads(provider.handle_tool_call(
"memory_system_memory_read",
{"uri": "viking://user/memories/preferences/python.md"},
))
assert result["status"] == "success"
assert provider._client.gets == [
"/memory-system/memories/content?user_id=user-1&user_key=user-1-key&uri=viking%3A%2F%2Fuser%2Fmemories%2Fpreferences%2Fpython.md"
]
def test_memory_write_tool_posts_memory_payload():
provider = make_provider()
result = json.loads(provider.handle_tool_call(
"memory_system_memory_write",
{
"uri": "viking://user/memories/preferences/python.md",
"content": "# Python",
"mode": "replace",
"wait": True,
},
))
assert result["status"] == "success"
assert provider._client.posts == [
(
"/memory-system/memories",
{
"user_id": "user-1",
"user_key": "user-1-key",
"uri": "viking://user/memories/preferences/python.md",
"content": "# Python",
"mode": "replace",
"wait": True,
},
)
]
def test_memory_delete_tool_deletes_memory_uri_non_recursive_by_default():
provider = make_provider()
result = json.loads(provider.handle_tool_call(
"memory_system_memory_delete",
{"uri": "viking://user/memories/preferences/python.md"},
))
assert result["status"] == "success"
assert provider._client.deletes == [
"/memory-system/memories?user_id=user-1&user_key=user-1-key&uri=viking%3A%2F%2Fuser%2Fmemories%2Fpreferences%2Fpython.md&recursive=false"
]
def test_register_adds_provider():
module = load_plugin_module()
class Ctx:
def __init__(self):
self.providers = []
def register_memory_provider(self, provider):
self.providers.append(provider)
ctx = Ctx()
module.register(ctx)
assert len(ctx.providers) == 1
assert ctx.providers[0].name == "memory_system"

View File

@ -0,0 +1,204 @@
from __future__ import annotations
import importlib.util
import json
from pathlib import Path
from urllib.error import HTTPError
import pytest
SCRIPT_PATH = (
Path(__file__).parents[1]
/ "skill"
/ "memory-gateway-agent"
/ "scripts"
/ "memory_gateway.py"
)
def load_cli():
spec = importlib.util.spec_from_file_location("memory_gateway_skill_cli", SCRIPT_PATH)
assert spec is not None and spec.loader is not None
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
return module
class FakeResponse:
def __init__(self, body: dict[str, object], status: int = 200) -> None:
self.body = json.dumps(body).encode()
self.status = status
def __enter__(self):
return self
def __exit__(self, *args: object) -> None:
return None
def read(self) -> bytes:
return self.body
def close(self) -> None:
return None
def test_settings_read_gateway_credentials_from_environment(
monkeypatch: pytest.MonkeyPatch,
) -> None:
cli = load_cli()
monkeypatch.setenv("MEMORY_GATEWAY_BASE_URL", "http://gateway.test/")
monkeypatch.setenv("MEMORY_GATEWAY_USER_ID", "u_agent")
monkeypatch.setenv("MEMORY_GATEWAY_USER_KEY", "uk_secret")
settings = cli.Settings.from_env()
assert settings.base_url == "http://gateway.test"
assert settings.user_id == "u_agent"
assert settings.user_key == "uk_secret"
def test_json_request_sends_authenticated_payload(
monkeypatch: pytest.MonkeyPatch,
) -> None:
cli = load_cli()
captured: dict[str, object] = {}
def fake_urlopen(request, timeout):
captured["url"] = request.full_url
captured["method"] = request.method
captured["body"] = json.loads(request.data)
captured["content_type"] = request.headers["Content-type"]
captured["timeout"] = timeout
return FakeResponse({"results": []})
monkeypatch.setattr(cli, "urlopen", fake_urlopen)
client = cli.MemoryGatewayClient(
"http://gateway.test",
user_id="u_agent",
user_key="uk_secret",
timeout=9,
)
result = client.search("contract", scopes=["resources"], top_k=5)
assert result == {"results": []}
assert captured == {
"url": "http://gateway.test/memories/search",
"method": "POST",
"body": {
"user_id": "u_agent",
"user_key": "uk_secret",
"query": "contract",
"scope": ["resources"],
"top_k": 5,
"app_id": "default",
"project_id": "default",
},
"content_type": "application/json",
"timeout": 9,
}
def test_upload_builds_multipart_request_without_exposing_file_uri(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
cli = load_cli()
upload = tmp_path / "note.txt"
upload.write_text("remember this", encoding="utf-8")
captured: dict[str, object] = {}
def fake_urlopen(request, timeout):
captured["url"] = request.full_url
captured["method"] = request.method
captured["body"] = request.data
captured["content_type"] = request.headers["Content-type"]
return FakeResponse(
{
"resource_id": "r_1",
"uri": "resource://u_agent/r_1",
"status": "extracted",
}
)
monkeypatch.setattr(cli, "urlopen", fake_urlopen)
client = cli.MemoryGatewayClient(
"http://gateway.test",
user_id="u_agent",
user_key="uk_secret",
)
result = client.upload_resource(upload, title="Agent note")
body = captured["body"]
assert isinstance(body, bytes)
assert captured["url"] == "http://gateway.test/resources"
assert captured["method"] == "POST"
assert str(captured["content_type"]).startswith("multipart/form-data; boundary=")
assert b'name="user_id"' in body
assert b"u_agent" in body
assert b'name="file"; filename="note.txt"' in body
assert b"remember this" in body
assert b"file://" not in body
assert result["uri"] == "resource://u_agent/r_1"
def test_http_error_raises_gateway_error_without_leaking_user_key(
monkeypatch: pytest.MonkeyPatch,
) -> None:
cli = load_cli()
def fake_urlopen(request, timeout):
raise HTTPError(
request.full_url,
401,
"Unauthorized",
hdrs=None,
fp=FakeResponse({"detail": "invalid user credentials"}),
)
monkeypatch.setattr(cli, "urlopen", fake_urlopen)
client = cli.MemoryGatewayClient(
"http://gateway.test",
user_id="u_agent",
user_key="uk_super_secret",
)
with pytest.raises(cli.GatewayError) as exc_info:
client.list_resources()
message = str(exc_info.value)
assert "401" in message
assert "invalid user credentials" in message
assert "uk_super_secret" not in message
def test_load_messages_accepts_large_inline_json() -> None:
cli = load_cli()
value = json.dumps(
[
{
"sender_id": "u_agent",
"role": "user",
"timestamp": 1234567890123,
"content": "x" * 5000,
}
]
)
messages = cli._load_json_array(value)
assert messages[0]["content"] == "x" * 5000
def test_search_requires_conversation_id_for_current_chat_scope() -> None:
cli = load_cli()
client = cli.MemoryGatewayClient(
"http://gateway.test",
user_id="u_agent",
user_key="uk_secret",
)
with pytest.raises(cli.GatewayError, match="conversation_id"):
client.search("what did we discuss", scopes=["current_chat"])

View File

@ -1,848 +0,0 @@
import asyncio
from memory_system_api.clients import EverOSMemorySystemClient, OpenVikingMemorySystemClient
class FakeStore:
def __init__(self):
self.users = {}
self.accounts = {}
self.sessions = []
self.tasks = []
def get_user_key(self, user_id: str) -> str | None:
return self.users.get(user_id)
def save_user_key(self, user_id: str, user_key: str, account_id: str = "admin") -> None:
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:
def __init__(self, status_code: int, data: dict):
self.status_code = status_code
self._data = data
def json(self) -> dict:
return self._data
def raise_for_status(self) -> None:
if self.status_code >= 400:
raise AssertionError(f"unexpected status {self.status_code}")
class FakeAsyncClient:
def __init__(self, calls: list, responses: list[FakeResponse], api_key: str, headers: dict):
self.calls = calls
self.responses = responses
self.api_key = api_key
self.headers = headers
async def __aenter__(self):
return self
async def __aexit__(self, exc_type, exc, tb):
return False
async def post(self, path: str, json: dict | None = None, files: dict | None = None) -> FakeResponse:
if files and "file" in files:
uploaded = files["file"]
files = {"file": uploaded[0] if isinstance(uploaded, tuple) else uploaded}
self.calls.append(("post", self.api_key, self.headers, path, json, files))
return self.responses.pop(0)
async def get(self, path: str, params: dict | None = None) -> FakeResponse:
self.calls.append(("get", self.api_key, self.headers, path, params))
return self.responses.pop(0)
async def delete(self, path: str, params: dict | None = None) -> FakeResponse:
self.calls.append(("delete", self.api_key, self.headers, path, params))
return self.responses.pop(0)
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 == "tom_account"
assert credential.user_id == "tom"
assert credential.agent_id == "sess-1"
def test_openviking_client_uses_x_api_key_for_user_keys():
client = OpenVikingMemorySystemClient(store=FakeStore())
client.root_key = "root-key"
http_client = client._client("tom-key")
try:
assert http_client.headers["X-API-Key"] == "tom-key"
assert "Authorization" not in http_client.headers
finally:
asyncio.run(http_client.aclose())
def test_openviking_create_user_creates_isolated_admin_account():
store = FakeStore()
client = OpenVikingMemorySystemClient(store=store)
client.root_key = "root-key"
calls = []
responses = [
FakeResponse(
200,
{
"status": "ok",
"result": {
"account_id": "userA_account",
"admin_user_id": "userA",
"user_key": "userA-key",
},
},
),
]
client._client = lambda api_key, extra_headers=None, json_content_type=True: FakeAsyncClient( # type: ignore[method-assign]
calls,
responses,
api_key,
extra_headers or {},
)
result = asyncio.run(client.create_user("userA"))
assert result == {
"status": "ok",
"result": {
"account_id": "userA_account",
"admin_user_id": "userA",
"user_key": "userA-key",
},
}
assert store.accounts == {"userA_account": "userA-key"}
assert store.users == {"userA": "userA-key"}
assert calls == [
(
"post",
"root-key",
{},
"/api/v1/admin/accounts",
{"account_id": "userA_account", "admin_user_id": "userA"},
None,
),
]
def test_openviking_create_user_creates_account_even_when_admin_workspace_exists():
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": "userB_account",
"admin_user_id": "userB",
"user_key": "userB-key",
},
},
)
]
client._client = lambda api_key, extra_headers=None, json_content_type=True: 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": "userB_account",
"admin_user_id": "userB",
"user_key": "userB-key",
},
}
assert store.accounts == {"admin": "admin-key", "userB_account": "userB-key"}
assert store.users == {"userB": "userB-key"}
assert calls == [
(
"post",
"root-key",
{},
"/api/v1/admin/accounts",
{"account_id": "userB_account", "admin_user_id": "userB"},
None,
)
]
def test_openviking_user_key_auth_is_used_for_session_create():
client = OpenVikingMemorySystemClient(store=FakeStore())
client.root_key = "root-key"
calls = []
responses = [FakeResponse(200, {"status": "ok", "result": {"session_id": "sess-2"}})]
client._client = lambda api_key, extra_headers=None, json_content_type=True: FakeAsyncClient( # type: ignore[method-assign]
calls,
responses,
api_key,
extra_headers or {},
)
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",
"tom-key",
{},
"/api/v1/sessions",
{"session_id": "sess-2"},
None,
)
]
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, json_content_type=True: 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},
None,
)
]
def test_openviking_search_uses_fixed_user_memory_target_with_level_and_score_threshold():
client = OpenVikingMemorySystemClient(store=FakeStore())
calls = []
responses = [FakeResponse(200, {"status": "ok", "result": {"memories": []}})]
client._client = lambda api_key, extra_headers=None, json_content_type=True: 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, "咖啡", 5, level=3, score_threshold=0.7))
assert result == {"status": "ok", "result": {"memories": []}}
assert calls == [
(
"post",
"tom-key",
{},
"/api/v1/search/search",
{
"query": "咖啡",
"target_uri": "viking://user/memories",
"limit": 5,
"level": 3,
"score_threshold": 0.7,
},
None,
)
]
def test_openviking_search_accepts_custom_target_uri():
client = OpenVikingMemorySystemClient(store=FakeStore())
calls = []
responses = [FakeResponse(200, {"status": "ok", "result": {"memories": []}})]
client._client = lambda api_key, extra_headers=None, json_content_type=True: 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,
"咖啡",
5,
level=3,
score_threshold=0.7,
target_uri="viking://user/custom/memories",
))
assert result == {"status": "ok", "result": {"memories": []}}
assert calls == [
(
"post",
"tom-key",
{},
"/api/v1/search/search",
{
"query": "咖啡",
"target_uri": "viking://user/custom/memories",
"limit": 5,
"level": 3,
"score_threshold": 0.7,
},
None,
)
]
def test_openviking_profile_search_uses_user_memory_target_and_level():
client = OpenVikingMemorySystemClient(store=FakeStore())
calls = []
responses = [FakeResponse(200, {"status": "ok", "result": {"memories": []}})]
client._client = lambda api_key, extra_headers=None, json_content_type=True: FakeAsyncClient( # type: ignore[method-assign]
calls,
responses,
api_key,
extra_headers or {},
)
credential = client.user_credential("tom-key", "tom")
result = asyncio.run(client.search_profile_memories(credential, "我想喝东西", 10, 2))
assert result == {"status": "ok", "result": {"memories": []}}
assert calls == [
(
"post",
"tom-key",
{},
"/api/v1/search/search",
{
"query": "我想喝东西",
"limit": 10,
"level": 2,
"target_uri": "viking://user/memories",
},
None,
)
]
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, json_content_type=True: 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 = []
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, json_content_type=True: 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},
None,
)
]
def test_openviking_upload_temp_file_posts_multipart(tmp_path):
path = tmp_path / "report.pdf"
path.write_bytes(b"pdf-bytes")
client = OpenVikingMemorySystemClient(store=FakeStore())
calls = []
responses = [
FakeResponse(
200,
{"status": "ok", "result": {"temp_file_id": "upload_report.pdf"}},
)
]
client._client = lambda api_key, extra_headers=None, json_content_type=True: FakeAsyncClient( # type: ignore[method-assign]
calls,
responses,
api_key,
extra_headers or {},
)
credential = client.user_credential("tom-key", "tom")
result = asyncio.run(client.upload_temp_file(credential, path))
assert result == {"status": "ok", "result": {"temp_file_id": "upload_report.pdf"}}
assert calls == [
(
"post",
"tom-key",
{},
"/api/v1/resources/temp_upload",
None,
{"file": "report.pdf"},
)
]
def test_openviking_add_resource_posts_url_payload():
client = OpenVikingMemorySystemClient(store=FakeStore())
calls = []
responses = [FakeResponse(200, {"status": "ok", "result": {"uri": "viking://resources/tom/images/photo.png"}})]
client._client = lambda api_key, extra_headers=None, json_content_type=True: FakeAsyncClient( # type: ignore[method-assign]
calls,
responses,
api_key,
extra_headers or {},
)
credential = client.user_credential("tom-key", "tom")
result = asyncio.run(
client.add_resource(
credential,
path="https://example.com/photo.png",
to="viking://resources/tom/images/photo.png",
reason="上传远程图片",
wait=True,
directly_upload_media=True,
)
)
assert result == {"status": "ok", "result": {"uri": "viking://resources/tom/images/photo.png"}}
assert calls == [
(
"post",
"tom-key",
{},
"/api/v1/resources",
{
"path": "https://example.com/photo.png",
"to": "viking://resources/tom/images/photo.png",
"reason": "上传远程图片",
"wait": True,
"directly_upload_media": True,
},
None,
)
]
def test_openviking_delete_resource_sends_uri_and_recursive_flag():
client = OpenVikingMemorySystemClient(store=FakeStore())
calls = []
responses = [FakeResponse(200, {"status": "ok", "result": {"estimated_deleted_count": 4}})]
client._client = lambda api_key, extra_headers=None, json_content_type=True: FakeAsyncClient( # type: ignore[method-assign]
calls,
responses,
api_key,
extra_headers or {},
)
credential = client.user_credential("tom-key", "tom")
result = asyncio.run(
client.delete_resource(
credential,
uri="viking://resources/tom/files/report.pdf",
recursive=True,
)
)
assert result == {"status": "ok", "result": {"estimated_deleted_count": 4}}
assert calls == [
(
"delete",
"tom-key",
{},
"/api/v1/fs",
{"uri": "viking://resources/tom/files/report.pdf", "recursive": "true"},
)
]
def test_openviking_list_memories_calls_fs_ls_with_recursive_flag():
client = OpenVikingMemorySystemClient(store=FakeStore())
calls = []
responses = [FakeResponse(200, {"status": "ok", "result": {"children": []}})]
client._client = lambda api_key, extra_headers=None, json_content_type=True: FakeAsyncClient( # type: ignore[method-assign]
calls,
responses,
api_key,
extra_headers or {},
)
credential = client.user_credential("tom-key", "tom")
result = asyncio.run(
client.list_memories(
credential,
uri="viking://user/memories",
recursive=True,
)
)
assert result == {"status": "ok", "result": {"children": []}}
assert calls == [
(
"get",
"tom-key",
{},
"/api/v1/fs/ls",
{"uri": "viking://user/memories", "recursive": "true"},
)
]
def test_openviking_read_memory_calls_content_read():
client = OpenVikingMemorySystemClient(store=FakeStore())
calls = []
responses = [FakeResponse(200, {"status": "ok", "result": {"content": "# Python"}})]
client._client = lambda api_key, extra_headers=None, json_content_type=True: FakeAsyncClient( # type: ignore[method-assign]
calls,
responses,
api_key,
extra_headers or {},
)
credential = client.user_credential("tom-key", "tom")
result = asyncio.run(client.read_memory(credential, "viking://user/memories/preferences/python.md"))
assert result == {"status": "ok", "result": {"content": "# Python"}}
assert calls == [
(
"get",
"tom-key",
{},
"/api/v1/content/read",
{"uri": "viking://user/memories/preferences/python.md"},
)
]
def test_openviking_write_memory_posts_content_write_mode_and_wait():
client = OpenVikingMemorySystemClient(store=FakeStore())
calls = []
responses = [FakeResponse(200, {"status": "ok", "result": {"uri": "viking://user/memories/profile.md"}})]
client._client = lambda api_key, extra_headers=None, json_content_type=True: FakeAsyncClient( # type: ignore[method-assign]
calls,
responses,
api_key,
extra_headers or {},
)
credential = client.user_credential("tom-key", "tom")
result = asyncio.run(
client.write_memory(
credential,
uri="viking://user/memories/profile.md",
content="# Profile\n\nLikes Python.",
mode="replace",
wait=True,
)
)
assert result == {"status": "ok", "result": {"uri": "viking://user/memories/profile.md"}}
assert calls == [
(
"post",
"tom-key",
{},
"/api/v1/content/write",
{
"uri": "viking://user/memories/profile.md",
"content": "# Profile\n\nLikes Python.",
"mode": "replace",
"wait": True,
},
None,
)
]
def test_openviking_delete_memory_defaults_non_recursive():
client = OpenVikingMemorySystemClient(store=FakeStore())
calls = []
responses = [FakeResponse(200, {"status": "ok", "result": {"estimated_deleted_count": 1}})]
client._client = lambda api_key, extra_headers=None, json_content_type=True: FakeAsyncClient( # type: ignore[method-assign]
calls,
responses,
api_key,
extra_headers or {},
)
credential = client.user_credential("tom-key", "tom")
result = asyncio.run(
client.delete_memory(
credential,
uri="viking://user/memories/preferences/python.md",
)
)
assert result == {"status": "ok", "result": {"estimated_deleted_count": 1}}
assert calls == [
(
"delete",
"tom-key",
{},
"/api/v1/fs",
{"uri": "viking://user/memories/preferences/python.md", "recursive": "false"},
)
]
def test_everos_assistant_payload_does_not_use_user_id_as_sender():
client = EverOSMemorySystemClient()
payload = client.build_message_payload(
user_id="tom",
session_id="sess-1",
role="assistant",
content="我记住了",
)
message = payload["messages"][0]
assert message["role"] == "assistant"
assert message["sender_id"] != "tom"
assert message["sender_name"] != "tom"
def test_everos_user_payload_uses_user_id_as_sender():
client = EverOSMemorySystemClient()
payload = client.build_message_payload(
user_id="tom",
session_id="sess-1",
role="user",
content="我喜欢拿铁",
)
message = payload["messages"][0]
assert message["role"] == "user"
assert message["sender_id"] == "tom"
assert message["sender_name"] == "tom"
def test_everos_append_message_posts_current_memory_add_contract():
client = EverOSMemorySystemClient()
calls = []
responses = [FakeResponse(200, {"request_id": "req-1", "data": {"status": "accumulated", "message_count": 1}})]
client._client = lambda: FakeAsyncClient( # type: ignore[method-assign]
calls,
responses,
client.api_key or "",
{},
)
result = asyncio.run(client.append_message("tom", "sess-1", "user", "我喜欢拿铁"))
assert result == {"request_id": "req-1", "data": {"status": "accumulated", "message_count": 1}}
assert calls[0][0] == "post"
assert calls[0][3] == "/api/v1/memory/add"
payload = calls[0][4]
assert payload["session_id"] == "sess-1"
assert "user_id" not in payload
assert payload["messages"][0]["sender_id"] == "tom"
assert payload["messages"][0]["role"] == "user"
assert payload["messages"][0]["content"] == "我喜欢拿铁"
def test_everos_flush_posts_current_memory_flush_contract():
client = EverOSMemorySystemClient()
calls = []
responses = [FakeResponse(200, {"request_id": "req-1", "data": {"status": "extracted"}})]
client._client = lambda: FakeAsyncClient( # type: ignore[method-assign]
calls,
responses,
client.api_key or "",
{},
)
result = asyncio.run(client.flush("tom", "sess-1"))
assert result == {"request_id": "req-1", "data": {"status": "extracted"}}
assert calls == [
(
"post",
client.api_key or "",
{},
"/api/v1/memory/flush",
{"session_id": "sess-1"},
None,
)
]
def test_everos_search_posts_current_memory_search_contract():
client = EverOSMemorySystemClient()
calls = []
responses = [FakeResponse(200, {"request_id": "req-1", "data": {"episodes": []}})]
client._client = lambda: FakeAsyncClient( # type: ignore[method-assign]
calls,
responses,
client.api_key or "",
{},
)
result = asyncio.run(client.search("tom", "sess-1", "牛奶在哪里", "hybrid", 7))
assert result == {"request_id": "req-1", "data": {"episodes": []}}
assert calls == [
(
"post",
client.api_key or "",
{},
"/api/v1/memory/search",
{
"user_id": "tom",
"query": "牛奶在哪里",
"method": "hybrid",
"top_k": 7,
"include_profile": True,
"filters": {"session_id": "sess-1"},
},
None,
)
]
def test_everos_get_profile_posts_current_memory_get_contract():
client = EverOSMemorySystemClient()
calls = []
responses = [FakeResponse(200, {"request_id": "req-1", "data": {"profiles": []}})]
client._client = lambda: FakeAsyncClient( # type: ignore[method-assign]
calls,
responses,
client.api_key or "",
{},
)
result = asyncio.run(client.get_profile("tom"))
assert result == {"request_id": "req-1", "data": {"profiles": []}}
assert calls == [
(
"post",
client.api_key or "",
{},
"/api/v1/memory/get",
{
"user_id": "tom",
"memory_type": "profile",
"page": 1,
"page_size": 20,
},
None,
)
]

View File

@ -1,93 +0,0 @@
import logging
from fastapi.testclient import TestClient
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/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/resources" in paths
assert "/memory-system/memories" in paths
assert "/memory-system/memories/content" in paths
assert "/memory-system/users/{user_id}/profile" in paths
task_methods = {
method
for route in app.routes
if getattr(route, "path", "") == "/memory-system/openviking/tasks/{task_id}"
for method in getattr(route, "methods", set())
}
profile_methods = {
method
for route in app.routes
if getattr(route, "path", "") == "/memory-system/users/{user_id}/profile"
for method in getattr(route, "methods", set())
}
assert {"GET", "POST"} <= task_methods
assert {"GET", "POST"} <= profile_methods
resource_methods = {
method
for route in app.routes
if getattr(route, "path", "") == "/memory-system/resources"
for method in getattr(route, "methods", set())
}
assert {"DELETE", "POST"} <= resource_methods
memory_methods = {
method
for route in app.routes
if getattr(route, "path", "") == "/memory-system/memories"
for method in getattr(route, "methods", set())
}
memory_content_methods = {
method
for route in app.routes
if getattr(route, "path", "") == "/memory-system/memories/content"
for method in getattr(route, "methods", set())
}
assert {"DELETE", "GET", "POST"} <= memory_methods
assert {"GET"} <= memory_content_methods
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)
def test_memory_system_logs_request_and_response_bodies(caplog):
from memory_system_api.api import get_service
from memory_system_api.server import app
class FakeService:
async def create_user(self, user_id: str):
return {"status": "success", "account": {"user_id": user_id}}
app.dependency_overrides[get_service] = lambda: FakeService()
try:
with caplog.at_level(logging.INFO, logger="memory_system_api.requests"):
response = TestClient(app).post("/memory-system/users", json={"user_id": "userA"})
finally:
app.dependency_overrides.clear()
assert response.status_code == 200
assert response.json() == {"status": "success", "account": {"user_id": "userA"}}
assert any("request POST /memory-system/users body={\"user_id\":\"userA\"}" in record.message for record in caplog.records)
assert any(
"response POST /memory-system/users status=200 body={\"status\":\"success\",\"account\":{\"user_id\":\"userA\"}}"
in record.message
for record in caplog.records
)

View File

@ -1,725 +0,0 @@
import asyncio
from memory_system_api.schemas import (
MemoryWriteRequest,
MessageIngestRequest,
ResourceUploadRequest,
SearchRequest,
SessionContextRequest,
)
from memory_system_api.service import MemorySystemService
class FakeOpenViking:
def __init__(self, fail_on_append: bool = False):
self.fail_on_append = fail_on_append
self.calls = []
async def create_user(self, user_id: str) -> dict:
self.calls.append(("create_user", user_id))
return {"account_id": f"{user_id}_account", "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:
self.calls.append(("ensure_session", user_key, session_id))
return {"session_id": session_id}
async def append_message(self, user_key: str, session_id: str, role: str, content: str) -> dict:
self.calls.append(("append_message", user_key, session_id, role, content))
if self.fail_on_append:
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, query: str, limit: int) -> dict:
self.calls.append(("find", user_key, query, limit))
await asyncio.sleep(0.01)
return {"items": [{"source": "openviking-find"}]}
async def search(
self,
user_key: str,
query: str,
limit: int,
level: int = 2,
score_threshold: float = 0.8,
target_uri: str = "viking://user/memories",
) -> dict:
self.calls.append(("search", user_key, query, limit, level, score_threshold, target_uri))
await asyncio.sleep(0.01)
return {"items": [{"source": "openviking-search"}]}
async def search_profile_memories(self, user_key: str, query: str, limit: int, level: int) -> dict:
self.calls.append(("search_profile_memories", user_key, query, limit, level))
return {
"status": "ok",
"result": {
"memories": [
{
"context_type": "memory",
"uri": "viking://user/tom/memories/preferences/coffee.md",
"level": 2,
"score": 0.91,
"abstract": "用户喜欢喝咖啡。",
}
],
"resources": [],
"skills": [],
"total": 1,
},
}
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"}}
async def upload_temp_file(self, user_key: str, path) -> dict:
self.calls.append(("upload_temp_file", user_key, str(path)))
return {"status": "ok", "result": {"temp_file_id": "upload_report.pdf"}}
async def add_resource(
self,
user_key: str,
*,
to: str,
reason: str | None,
wait: bool,
directly_upload_media: bool,
path: str | None = None,
temp_file_id: str | None = None,
) -> dict:
self.calls.append((
"add_resource",
user_key,
path,
temp_file_id,
to,
reason,
wait,
directly_upload_media,
))
return {"status": "ok", "result": {"uri": to}}
async def delete_resource(self, user_key: str, uri: str, recursive: bool = True) -> dict:
self.calls.append(("delete_resource", user_key, uri, recursive))
return {"status": "ok", "result": {"uri": uri, "estimated_deleted_count": 4}}
async def list_memories(self, user_key: str, uri: str, recursive: bool = True) -> dict:
self.calls.append(("list_memories", user_key, uri, recursive))
return {"status": "ok", "result": {"children": [{"uri": "viking://user/memories/profile.md"}]}}
async def read_memory(self, user_key: str, uri: str) -> dict:
self.calls.append(("read_memory", user_key, uri))
return {"status": "ok", "result": {"uri": uri, "content": "# Profile"}}
async def write_memory(self, user_key: str, uri: str, content: str, mode: str, wait: bool = True) -> dict:
self.calls.append(("write_memory", user_key, uri, content, mode, wait))
return {"status": "ok", "result": {"uri": uri, "mode": mode}}
async def delete_memory(self, user_key: str, uri: str, recursive: bool = False) -> dict:
self.calls.append(("delete_memory", user_key, uri, recursive))
return {"status": "ok", "result": {"uri": uri, "estimated_deleted_count": 1}}
class FakeEverOS:
def __init__(self, fail_on_append: bool = False):
self.fail_on_append = fail_on_append
self.calls = []
async def append_message(self, user_id: str, session_id: str, role: str, content: str) -> dict:
self.calls.append(("append_message", user_id, session_id, role, content))
if self.fail_on_append:
raise RuntimeError("everos append failed")
return {"status": "accumulated"}
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))
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"}
async def get_profile(self, user_id: str) -> dict:
self.calls.append(("get_profile", user_id))
return {
"data": {
"episodes": [],
"profiles": [
{
"id": "profile-1",
"user_id": user_id,
"profile_data": {"summary": "喜欢咖啡"},
}
],
"agent_cases": [],
"agent_skills": [],
"total_count": 1,
"count": 1,
}
}
class FakeEverOSWithVector(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", "vector": [0.1, 0.2]}],
"original_data": {
"episodes": {
"episode-1": {
"summary": "喜欢拿铁",
"vector": [0.1, 0.2],
"nested": {"vector": [0.3]},
}
}
},
}
}
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())
class EmptyError(Exception):
pass
async def fail():
raise EmptyError()
response = asyncio.run(service._capture(fail))
assert response.status == "failed"
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": "alice_account", "admin_user_id": "alice", "user_key": "alice-key"}
assert openviking.calls == [("create_user", "alice")]
assert everos.calls == []
def test_upload_resource_with_url_delegates_directly_to_openviking_add_resource():
openviking = FakeOpenViking()
service = MemorySystemService(openviking=openviking, everos=FakeEverOS())
response = asyncio.run(service.upload_resource(ResourceUploadRequest(
user_id="tom",
user_key="tom-key",
path="https://example.com/images/photo.png",
to="viking://resources/tom/images/photo.png",
reason="上传远程图片",
)))
assert response.status == "success"
assert response.resource == {"status": "ok", "result": {"uri": "viking://resources/tom/images/photo.png"}}
assert openviking.calls == [
("credential_for_user", "tom", "tom-key", None),
(
"add_resource",
"key-tom",
"https://example.com/images/photo.png",
None,
"viking://resources/tom/images/photo.png",
"上传远程图片",
True,
True,
),
]
def test_upload_resource_with_local_path_uploads_temp_file_first(tmp_path):
path = tmp_path / "report.pdf"
path.write_bytes(b"pdf-bytes")
openviking = FakeOpenViking()
service = MemorySystemService(openviking=openviking, everos=FakeEverOS())
response = asyncio.run(service.upload_resource(ResourceUploadRequest(
user_id="tom",
user_key="tom-key",
path=str(path),
to="viking://resources/tom/files/report.pdf",
reason="上传本地文件",
)))
assert response.status == "success"
assert response.resource == {"status": "ok", "result": {"uri": "viking://resources/tom/files/report.pdf"}}
assert openviking.calls == [
("credential_for_user", "tom", "tom-key", None),
("upload_temp_file", "key-tom", str(path)),
(
"add_resource",
"key-tom",
None,
"upload_report.pdf",
"viking://resources/tom/files/report.pdf",
"上传本地文件",
True,
True,
),
]
def test_delete_resource_delegates_to_openviking_only():
openviking = FakeOpenViking()
everos = FakeEverOS()
service = MemorySystemService(openviking=openviking, everos=everos)
response = asyncio.run(service.delete_resource(
user_id="tom",
user_key="tom-key",
uri="viking://resources/tom/files/report.pdf",
recursive=True,
))
assert response.status == "success"
assert response.resource == {
"status": "ok",
"result": {"uri": "viking://resources/tom/files/report.pdf", "estimated_deleted_count": 4},
}
assert openviking.calls == [
("credential_for_user", "tom", "tom-key", None),
("delete_resource", "key-tom", "viking://resources/tom/files/report.pdf", True),
]
assert everos.calls == []
def test_list_memories_delegates_to_openviking_only():
openviking = FakeOpenViking()
everos = FakeEverOS()
service = MemorySystemService(openviking=openviking, everos=everos)
response = asyncio.run(service.list_memories(
user_id="tom",
user_key="tom-key",
uri="viking://user/memories",
recursive=True,
))
assert response.status == "success"
assert response.memory == {"status": "ok", "result": {"children": [{"uri": "viking://user/memories/profile.md"}]}}
assert openviking.calls == [
("credential_for_user", "tom", "tom-key", None),
("list_memories", "key-tom", "viking://user/memories", True),
]
assert everos.calls == []
def test_read_memory_delegates_to_openviking_only():
openviking = FakeOpenViking()
everos = FakeEverOS()
service = MemorySystemService(openviking=openviking, everos=everos)
response = asyncio.run(service.read_memory(
user_id="tom",
user_key="tom-key",
uri="viking://user/memories/profile.md",
))
assert response.status == "success"
assert response.memory == {"status": "ok", "result": {"uri": "viking://user/memories/profile.md", "content": "# Profile"}}
assert openviking.calls == [
("credential_for_user", "tom", "tom-key", None),
("read_memory", "key-tom", "viking://user/memories/profile.md"),
]
assert everos.calls == []
def test_write_memory_delegates_to_openviking_content_write_only():
openviking = FakeOpenViking()
everos = FakeEverOS()
service = MemorySystemService(openviking=openviking, everos=everos)
response = asyncio.run(service.write_memory(MemoryWriteRequest(
user_id="tom",
user_key="tom-key",
uri="viking://user/memories/profile.md",
content="# Profile\n\nLikes Python.",
mode="replace",
wait=True,
)))
assert response.status == "success"
assert response.memory == {"status": "ok", "result": {"uri": "viking://user/memories/profile.md", "mode": "replace"}}
assert openviking.calls == [
("credential_for_user", "tom", "tom-key", None),
("write_memory", "key-tom", "viking://user/memories/profile.md", "# Profile\n\nLikes Python.", "replace", True),
]
assert everos.calls == []
def test_write_memory_supports_openviking_create_mode():
openviking = FakeOpenViking()
everos = FakeEverOS()
service = MemorySystemService(openviking=openviking, everos=everos)
response = asyncio.run(service.write_memory(MemoryWriteRequest(
user_id="tom",
user_key="tom-key",
uri="viking://user/tom/memories/preferences/饮食偏好.md",
content="用户喜欢喝茶",
mode="create",
wait=True,
)))
assert response.status == "success"
assert response.memory == {"status": "ok", "result": {"uri": "viking://user/tom/memories/preferences/饮食偏好.md", "mode": "create"}}
assert openviking.calls == [
("credential_for_user", "tom", "tom-key", None),
("write_memory", "key-tom", "viking://user/tom/memories/preferences/饮食偏好.md", "用户喜欢喝茶", "create", True),
]
assert everos.calls == []
def test_delete_memory_delegates_to_openviking_only_and_defaults_non_recursive():
openviking = FakeOpenViking()
everos = FakeEverOS()
service = MemorySystemService(openviking=openviking, everos=everos)
response = asyncio.run(service.delete_memory(
user_id="tom",
user_key="tom-key",
uri="viking://user/memories/preferences/python.md",
))
assert response.status == "success"
assert response.memory == {
"status": "ok",
"result": {"uri": "viking://user/memories/preferences/python.md", "estimated_deleted_count": 1},
}
assert openviking.calls == [
("credential_for_user", "tom", "tom-key", None),
("delete_memory", "key-tom", "viking://user/memories/preferences/python.md", False),
]
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", user_key="tom-key", session_id="sess-1", query="咖啡偏好", use_llm=False, limit=5),
))
assert response.items == [
{"source_backend": "openviking", "source": "openviking-search"},
{"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 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 test_profile_combines_everos_profile_and_openviking_memory_search():
openviking = FakeOpenViking()
everos = FakeEverOS()
service = MemorySystemService(openviking=openviking, everos=everos)
response = asyncio.run(service.get_profile("tom", "tom-key", query="我想喝东西", limit=10, level=2))
assert response.status == "success"
assert response.profile == {
"data": {
"episodes": [],
"profiles": [
{
"id": "profile-1",
"user_id": "tom",
"profile_data": {"summary": "喜欢咖啡"},
}
],
"agent_cases": [],
"agent_skills": [],
"total_count": 1,
"count": 1,
}
}
assert response.items == [
{
"source_backend": "openviking",
"context_type": "memory",
"uri": "viking://user/tom/memories/preferences/coffee.md",
"level": 2,
"score": 0.91,
"abstract": "用户喜欢喝咖啡。",
}
]
assert response.backends["everos"].result == {
"total_count": 1,
"count": 1,
"counts": {
"episodes": 0,
"profiles": 1,
"agent_cases": 0,
"agent_skills": 0,
},
}
assert "profiles" not in response.backends["everos"].result
assert ("credential_for_user", "tom", "tom-key", None) in openviking.calls
assert ("search_profile_memories", "key-tom", "我想喝东西", 10, 2) in openviking.calls
assert everos.calls == [("get_profile", "tom")]
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())
if isinstance(value, list):
return any(_has_key(item, key) for item in value)
return False
def test_ingest_splits_user_and_assistant_messages():
openviking = FakeOpenViking()
everos = FakeEverOS()
service = MemorySystemService(openviking=openviking, everos=everos)
response = asyncio.run(service.ingest_messages(
MessageIngestRequest(
user_id="tom",
user_key="tom-key",
session_id="sess-1",
user_message="我喜欢拿铁",
assistant_message="我记住了",
)
))
assert response.status == "success"
assert response.message_count == 2
assert openviking.calls == [
("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", "我记住了"),
]
assert everos.calls == [
("append_message", "tom", "sess-1", "user", "我喜欢拿铁"),
("append_message", "tom", "sess-1", "assistant", "我记住了"),
]
def test_ingest_requires_at_least_one_message():
service = MemorySystemService(openviking=FakeOpenViking(), everos=FakeEverOS())
try:
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:
raise AssertionError("expected ValueError")
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", user_key="tom-key", session_id="sess-1", user_message="hello"),
))
assert response.status == "partial_success"
assert response.backends["openviking"].status == "failed"
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_openviking_search_and_hybrid_without_llm():
openviking = FakeOpenViking()
everos = FakeEverOS()
service = MemorySystemService(openviking=openviking, everos=everos)
response = asyncio.run(service.search(
SearchRequest(
user_id="tom",
user_key="tom-key",
session_id="sess-1",
query="咖啡偏好",
use_llm=False,
limit=5,
level=3,
score_threshold=0.7,
target_uri="viking://user/custom/memories",
),
))
assert response.status == "success"
assert response.items == [
{"source_backend": "openviking", "source": "openviking-search"},
{"source_backend": "everos", "source": "everos-hybrid"},
]
assert ("credential_for_user", "tom", "tom-key", "sess-1") in openviking.calls
assert ("search", "key-tom", "咖啡偏好", 5, 3, 0.7, "viking://user/custom/memories") in openviking.calls
assert ("search", "tom", "sess-1", "咖啡偏好", "hybrid", 5) in everos.calls
def test_search_uses_search_and_agentic_with_llm():
openviking = FakeOpenViking()
everos = FakeEverOS()
service = MemorySystemService(openviking=openviking, everos=everos)
response = asyncio.run(service.search(
SearchRequest(user_id="tom", user_key="tom-key", session_id="sess-1", query="咖啡偏好", use_llm=True, limit=5),
))
assert response.status == "success"
assert response.items == [
{"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", "咖啡偏好", 5, 2, 0.8, "viking://user/memories") in openviking.calls
assert ("search", "tom", "sess-1", "咖啡偏好", "agentic", 5) in everos.calls

View File

@ -1,32 +0,0 @@
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",
}

Binary file not shown.

405
uv.lock generated
View File

@ -34,6 +34,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/da/42/e921fccf5015463e32a3cf6ee7f980a6ed0f395ceeaa45060b61d86486c2/anyio-4.13.0-py3-none-any.whl", hash = "sha256:08b310f9e24a9594186fd75b4f73f4a4152069e3853f1ed8bfbf58369f4ad708", size = 114353, upload-time = "2026-03-24T12:59:08.246Z" }, { url = "https://files.pythonhosted.org/packages/da/42/e921fccf5015463e32a3cf6ee7f980a6ed0f395ceeaa45060b61d86486c2/anyio-4.13.0-py3-none-any.whl", hash = "sha256:08b310f9e24a9594186fd75b4f73f4a4152069e3853f1ed8bfbf58369f4ad708", size = 114353, upload-time = "2026-03-24T12:59:08.246Z" },
] ]
[[package]]
name = "backports-asyncio-runner"
version = "1.2.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/8e/ff/70dca7d7cb1cbc0edb2c6cc0c38b65cba36cccc491eca64cabd5fe7f8670/backports_asyncio_runner-1.2.0.tar.gz", hash = "sha256:a5aa7b2b7d8f8bfcaa2b57313f70792df84e32a2a746f585213373f900b42162", size = 69893, upload-time = "2025-07-02T02:27:15.685Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/a0/59/76ab57e3fe74484f48a53f8e337171b4a2349e506eabe136d7e01d059086/backports_asyncio_runner-1.2.0-py3-none-any.whl", hash = "sha256:0da0a936a8aeb554eccb426dc55af3ba63bcdc69fa1a600b5bb305413a4477b5", size = 12313, upload-time = "2025-07-02T02:27:14.263Z" },
]
[[package]] [[package]]
name = "certifi" name = "certifi"
version = "2026.5.20" version = "2026.5.20"
@ -114,6 +123,56 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" }, { url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" },
] ]
[[package]]
name = "httptools"
version = "0.8.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/43/e5/d471fcb0e14523fe1c3f4ba58ca52480e7bd70ad7109a3846bc75892f7fb/httptools-0.8.0.tar.gz", hash = "sha256:6b2a32f18d97e16e90827d7a819ffa8dbd8cc245fc4e1fa9d1095b54ef4bd999", size = 271342, upload-time = "2026-05-25T22:17:48.841Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/40/b9/be66eb0decd730d89b9c94f930e4b8d87787b05724bb84af98bfd825f72c/httptools-0.8.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:bf3b6f807c8541503cecfbb8a8dffb385640d0d96102f3d112aa8740f9b7c826", size = 208805, upload-time = "2026-05-25T22:16:50.434Z" },
{ url = "https://files.pythonhosted.org/packages/9d/f7/b4d41eaae2869d31356bc4bbf546f44fae83ff298af0a043ca0625b06773/httptools-0.8.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:da684f2e1aa2ee9bdcb083f3f3a68c5956750b375bc5df864d3a5f0c42a40b77", size = 113527, upload-time = "2026-05-25T22:16:51.672Z" },
{ url = "https://files.pythonhosted.org/packages/e6/e4/77487e14fc7be47180fd0eb4267c7486d0cc59b74031839a3daf8650136b/httptools-0.8.0-cp310-cp310-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:a6f21e2a3b0067bbe7f67e34cfd16276af556e5e52f4c7503be0cb5f90e905e4", size = 450035, upload-time = "2026-05-25T22:16:53.313Z" },
{ url = "https://files.pythonhosted.org/packages/da/72/5a8f787e323f56fbd86c32a4be92a86776e4cfe8b4317db999f452028362/httptools-0.8.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0ea897f0c729581ebf72131a438a7932d9b14efef72d75ada966700cac3caaeb", size = 451101, upload-time = "2026-05-25T22:16:54.696Z" },
{ url = "https://files.pythonhosted.org/packages/ed/41/b44a25560955197674b6744cb903664300e239235a5eaa69df0890d87054/httptools-0.8.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:c0d726cc107fceb7d45f978483b4b70dd8caa836f5914d3434bb18628eb73813", size = 436140, upload-time = "2026-05-25T22:16:56.239Z" },
{ url = "https://files.pythonhosted.org/packages/74/b0/054aac84c03d7e097bf4c605fb7e74eec3d65c0276adf64ee97f3a103ff5/httptools-0.8.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:9878eb2785ba5eb70631ad269b37976f73d647955e26c91d490eb8a4edfda4ba", size = 437041, upload-time = "2026-05-25T22:16:57.716Z" },
{ url = "https://files.pythonhosted.org/packages/bb/e8/86b85bbc0ac7892232f1a99ab96a9aa71936984fa06adfc0afc83ca7789e/httptools-0.8.0-cp310-cp310-win_amd64.whl", hash = "sha256:b205e5f5523fa039679da0dfe5a10132b2a4abeae6a86fdd1ddc035f7f836557", size = 90454, upload-time = "2026-05-25T22:16:58.871Z" },
{ url = "https://files.pythonhosted.org/packages/f8/d2/c3eedaef57de65c3cc5f8dc244cf12d09c84ad258a479055aad6db23206c/httptools-0.8.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:ed377e64805bdba4943c82717333f8f8603a13b09aff9cead2717c6c817fb168", size = 208428, upload-time = "2026-05-25T22:16:59.717Z" },
{ url = "https://files.pythonhosted.org/packages/f1/94/dfe435d90d0ef61ec0f2cc3d480eef78c59727c6c2ce039f433882f6131a/httptools-0.8.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:9518c406d7b310f05adb1a37f80acabac40504a575d7c0da6d3e365c695ac20d", size = 113366, upload-time = "2026-05-25T22:17:00.795Z" },
{ url = "https://files.pythonhosted.org/packages/cc/d4/13025f1a56e615dcb331e0bbe2d9a1143212b58c263385fc5d2e558f5bac/httptools-0.8.0-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:57278e6fa0424c42a8a3e454828ab4f0aff27b40cddf9679579b98c6dce6a376", size = 464676, upload-time = "2026-05-25T22:17:02.014Z" },
{ url = "https://files.pythonhosted.org/packages/bf/95/4c1c26c0b985f8a3331682d802598f14e32dc41bf7509266eb2c04ad4801/httptools-0.8.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bbb8caadb2b742d293169d2b458b5c001ef70e3158704aa3d3ef9597624c5d1d", size = 464235, upload-time = "2026-05-25T22:17:03.109Z" },
{ url = "https://files.pythonhosted.org/packages/a2/82/6735be2b0ca527718c431cdb8e5f70c3862c0844a687df0f572c51e11497/httptools-0.8.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:52dd695b865fe96d9d2b16b64a895f3f57bf3cb064e8383cd3b5713a069e8085", size = 449809, upload-time = "2026-05-25T22:17:04.443Z" },
{ url = "https://files.pythonhosted.org/packages/b5/f9/5811c74f37a758c8a4aa3dc430375119d335947e883efc4664d8f3559a41/httptools-0.8.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:20b4aac66ff65f7db06a375808b78f42a94970aa22e826b3cb2b43eb09174124", size = 452174, upload-time = "2026-05-25T22:17:05.476Z" },
{ url = "https://files.pythonhosted.org/packages/cc/94/97b75870dea07b71e3ec535cebe525b08d723152e4c7d13fa887e51f4de2/httptools-0.8.0-cp311-cp311-win_amd64.whl", hash = "sha256:a1b4c8e7a489a0d750d91894e9a8cdc295838f1924c0ca903ae993456fddec07", size = 90991, upload-time = "2026-05-25T22:17:06.75Z" },
{ url = "https://files.pythonhosted.org/packages/14/88/1d21a36da8f5cb0fa49eafd4b169eba5608d57e75bbcf61845cbc6243216/httptools-0.8.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:880490234c10f70a9830743097e8958d6e4b9f5a0ffc24515023afeef984054d", size = 208247, upload-time = "2026-05-25T22:17:07.843Z" },
{ url = "https://files.pythonhosted.org/packages/a5/42/cc4feea2945cb3051038f090c9b36bd5b8a9d7f5a894a506a8983e33fd1c/httptools-0.8.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:5931891fb7b441b8a3853cf1b85c82c903defce084dd5f6771ca46e31bf862c5", size = 113064, upload-time = "2026-05-25T22:17:09.136Z" },
{ url = "https://files.pythonhosted.org/packages/e3/a6/febbb8b8db0f58b38e44ad6cb946e6a255ae49b55f2e8543408fb7501ccd/httptools-0.8.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:b15fc622b0f869d19207c4089a501d9bcc63ca5e071ffdd2f03f922df882dcb2", size = 523851, upload-time = "2026-05-25T22:17:10.106Z" },
{ url = "https://files.pythonhosted.org/packages/b7/e4/f90a0df0b83beff265b7e3b65f2a4cefd95792d4be0ac3e16049f2acd3c2/httptools-0.8.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:425f83884fd6343828d8c565f046cb72b6d19063f6924093e11bcd8e1548cd09", size = 518842, upload-time = "2026-05-25T22:17:11.218Z" },
{ url = "https://files.pythonhosted.org/packages/9e/2d/0c9ac76dd2c893841fbf6498d6acec4f2442e1b7067f6e3e316a80e494e8/httptools-0.8.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:ef7c3c97f4311c7be57e2986629df89d49cb434dbff78eafcd48c2bff986b15a", size = 501238, upload-time = "2026-05-25T22:17:12.728Z" },
{ url = "https://files.pythonhosted.org/packages/ca/42/906adc91ae3a5fa9c59c0a2f21c139725bd7e5b41ae6acd485cd14123ebf/httptools-0.8.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:a1afd7c9fbff0d9f5d489c4ce2768bd09c84a46ddefc7161e6aa82ae35c85745", size = 509567, upload-time = "2026-05-25T22:17:13.842Z" },
{ url = "https://files.pythonhosted.org/packages/05/0b/4240efeb672751ee5b9b380cb0e3fdc050bc05f68adc7a8aefc4fcd9a69a/httptools-0.8.0-cp312-cp312-win_amd64.whl", hash = "sha256:cd96f29b4bab1d42fa6e3d008711c75e0f79e94e06827330160e3a304227f150", size = 90918, upload-time = "2026-05-25T22:17:15.155Z" },
{ url = "https://files.pythonhosted.org/packages/5e/e5/8cfcabc5546e8022f168be28bcdaa128a240a0befdd03b59d558b4f18bd6/httptools-0.8.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:614ceea8ea606848bece2338ac03b3ce5324bcb4be8dc7d377ed708012fa4db8", size = 205148, upload-time = "2026-05-25T22:17:16.333Z" },
{ url = "https://files.pythonhosted.org/packages/2a/0e/0fb14848c19a686c8062ff9067c1a48793e3224b47bc5b201535b6036fce/httptools-0.8.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2d689918c15a013c65ef52d9fd495d766893ab831a2c8d89f2ac5940a5df847c", size = 111368, upload-time = "2026-05-25T22:17:17.586Z" },
{ url = "https://files.pythonhosted.org/packages/2e/1b/46f1cecf06b9bbde8e4b8c88034ac7908989e5ff7a3a388ef38392949c1f/httptools-0.8.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:eb3028cca2fc0a6d720e52ef61d8ebb62fcbfeb1de56874546d858d3f25a26b7", size = 486447, upload-time = "2026-05-25T22:17:18.564Z" },
{ url = "https://files.pythonhosted.org/packages/77/00/258bfc0837221f81d9725c45f9b948a6a6b2994a147a4fb66e85100c668f/httptools-0.8.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:88bdd940f2b5d487b4d032c6afa5489a7dc4694410d43de3c38c4fb3af0dc45d", size = 482448, upload-time = "2026-05-25T22:17:19.912Z" },
{ url = "https://files.pythonhosted.org/packages/04/ab/d1cef3b5523f4d272a70f42a776c3169a2dddfe3a54de4b2ce4a36341528/httptools-0.8.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:6a43c9dd399758ccc0531acb0a3c4a6c299ee893ee9400e9c893b7bdcfae0681", size = 464460, upload-time = "2026-05-25T22:17:20.882Z" },
{ url = "https://files.pythonhosted.org/packages/ce/48/5d1d072442277bb2b3434e0e60690b8e8c23840ef7de8b6ea54040a536d3/httptools-0.8.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:0770728beb05094c809b98e814edff5fef69d26ad7d21185f2f6d5884a0ba683", size = 471312, upload-time = "2026-05-25T22:17:22.085Z" },
{ url = "https://files.pythonhosted.org/packages/0d/66/b96623b27e51a68199ef4efdda0613cced9233fe3062ac74e50749c5ad37/httptools-0.8.0-cp313-cp313-win_amd64.whl", hash = "sha256:7685df791fad561384bfb139e77fde27a1ffd93134e016f95a0db424ffbf77b1", size = 90117, upload-time = "2026-05-25T22:17:23.074Z" },
{ url = "https://files.pythonhosted.org/packages/1a/12/fa3fbf5f9517b273edea2dc982aa82a8c634091e67c590792b729017bc6f/httptools-0.8.0-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:de242a49b5d18e0a8776e654e9f6bf6d89f3875a5c35b425a0e7ce940feb3fd6", size = 206183, upload-time = "2026-05-25T22:17:24.004Z" },
{ url = "https://files.pythonhosted.org/packages/30/fc/5e7c4cb443370f2090a3aba0453a07384d29ff66b7435bb90e77e1037599/httptools-0.8.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:159e9ab5f701ccd42e555a12f1ad8ff69702910fc1c996cf2bb66e5fcb7a231b", size = 112079, upload-time = "2026-05-25T22:17:25.216Z" },
{ url = "https://files.pythonhosted.org/packages/ba/53/771bd891eb0f236f32145d6a1775777ec85745f3cc983a1f23d1a3b8ddfe/httptools-0.8.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:c4a9f1707e4823d54dfec6c33fa3697d302aed536ed352a7ebb5a061ddb869d0", size = 481596, upload-time = "2026-05-25T22:17:26.186Z" },
{ url = "https://files.pythonhosted.org/packages/62/42/94e15bc68ce3d423243c45d7f1b0c7561f13844f97dc52ae23182fb65628/httptools-0.8.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d76ad7b951387e3632c8716a9bb03ac5b45c5f16119aa409db0459520887944e", size = 480865, upload-time = "2026-05-25T22:17:27.542Z" },
{ url = "https://files.pythonhosted.org/packages/1c/7c/fe2980fc03723272e30f135b62360b075f513dfe7cc73aef36c7f04012bd/httptools-0.8.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:a3b7387147361c3fd47a0bde763c5c91b5b4cd4dc9989b8ece84ff436c99843b", size = 463189, upload-time = "2026-05-25T22:17:28.546Z" },
{ url = "https://files.pythonhosted.org/packages/15/1b/47fc5fff68acd1bfa20b4734059c9a06cadb88119dcd5258b5b0d21d91c8/httptools-0.8.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:f256d6ce930c52ca1cb2a960b7da03548c454e7d28b06059ad41bfe789036ce0", size = 466610, upload-time = "2026-05-25T22:17:29.816Z" },
{ url = "https://files.pythonhosted.org/packages/60/bd/07b13c93ffd9bec9546e0d43f8e19378dd696dbd278511406bc07371ef1f/httptools-0.8.0-cp314-cp314-win_amd64.whl", hash = "sha256:19d1ee275bb59ba2643ba9a3a1e51cc0c788caf2b8df506368e03f56fdd08527", size = 92705, upload-time = "2026-05-25T22:17:31.133Z" },
{ url = "https://files.pythonhosted.org/packages/fd/c4/121648f68ce066d7bd762d6b6d97e620847642d38d54f3d90ff11d947629/httptools-0.8.0-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:de1ed58a974e75d56560acc7e7fed01a454994429456f65209789992e41f2568", size = 215023, upload-time = "2026-05-25T22:17:32.401Z" },
{ url = "https://files.pythonhosted.org/packages/b9/b0/312a062ae741ae3e8baa8c8bf20be81b2e67337b259ab4349bebc7b6142e/httptools-0.8.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:e93c227b595c6926c1acee96891dd9da4be338cfbe82e5cd3bb9d8dd7dc4ac0b", size = 117405, upload-time = "2026-05-25T22:17:33.742Z" },
{ url = "https://files.pythonhosted.org/packages/fc/37/fccd705f795386bb05bf413012fecff2a33e5aa8c2f069096de3e9fd8702/httptools-0.8.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:2a021c3a8e65cc125390d72f59b968afca3bdcaff25bd67965e0a055a14946ca", size = 558497, upload-time = "2026-05-25T22:17:34.732Z" },
{ url = "https://files.pythonhosted.org/packages/bd/39/f172e8003576de35f5ba77ff417cf0e34429d35dc014deef15afa337a72c/httptools-0.8.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:48774d39cbb70e2b1f71f88852a3087ae1d3a1eb80482bb48c13067ab080c14f", size = 571585, upload-time = "2026-05-25T22:17:35.813Z" },
{ url = "https://files.pythonhosted.org/packages/3e/b9/f5564760af99f3dbbf3f9104dc00e5da27e96cf433c6bdcf77617f70bf3f/httptools-0.8.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:88eead8ec8680a9f146c655bc88445a325bd7921cfd8194c7337e9467282427d", size = 543297, upload-time = "2026-05-25T22:17:37.08Z" },
{ url = "https://files.pythonhosted.org/packages/99/67/8d9f2c313618e161b82f3873188e7196126da1d6e29688df40eb3997c77a/httptools-0.8.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:2c032fa028f46871ec7e1fc59fc15e8023eab3e6bbe6ece786a1611719a5d081", size = 539535, upload-time = "2026-05-25T22:17:38.032Z" },
{ url = "https://files.pythonhosted.org/packages/48/63/b906c01e53f50d432c0defe43ce52764a111dc1bdd028bafbeb54dcfd008/httptools-0.8.0-cp314-cp314t-win_amd64.whl", hash = "sha256:384c17174464c8e873398b7af24f0b1f44d992c820328413951a625323155d77", size = 108209, upload-time = "2026-05-25T22:17:39.473Z" },
]
[[package]] [[package]]
name = "httpx" name = "httpx"
version = "0.28.1" version = "0.28.1"
@ -131,11 +190,11 @@ wheels = [
[[package]] [[package]]
name = "idna" name = "idna"
version = "3.16" version = "3.18"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/1a/88/bcf9709822fe69d02c2a6a77956c98ce6ea8ca8767a9aadcedc7eb6a2390/idna-3.16.tar.gz", hash = "sha256:d7a6da03db833450fca25d2358ac9ff06cd624577a4aea3a596d5c0f77b8e03d", size = 203770, upload-time = "2026-05-22T00:16:18.781Z" } sdist = { url = "https://files.pythonhosted.org/packages/cd/63/9496c57188a2ee585e0f1db071d75089a11e98aa86eb99d9d7618fc1edce/idna-3.18.tar.gz", hash = "sha256:ffb385a7e039654cef1ab9ef32c6fafe283c0c0467bba1d9029738ce4a14a848", size = 196711, upload-time = "2026-06-02T14:34:07.794Z" }
wheels = [ wheels = [
{ url = "https://files.pythonhosted.org/packages/94/16/70255075a9859a0e3adb789b68ceb0e210dec03934245fd98d248226572f/idna-3.16-py3-none-any.whl", hash = "sha256:cc246e3a3f89580c3a951b5ad298ca4638078b2cdd4f115654332b5c26daded5", size = 74165, upload-time = "2026-05-22T00:16:16.698Z" }, { url = "https://files.pythonhosted.org/packages/1e/5e/d4e9f1a599fb8e573b7b87160658329fbf28d19eac2718f51fc3def3aa5a/idna-3.18-py3-none-any.whl", hash = "sha256:7f952cbe720b688055e3f87de14f5c3e5fdaa8bc3928985c4077ca689de849a2", size = 65455, upload-time = "2026-06-02T14:34:06.319Z" },
] ]
[[package]] [[package]]
@ -148,34 +207,39 @@ wheels = [
] ]
[[package]] [[package]]
name = "memory-system-api" name = "memory-gateway"
version = "0.1.0" version = "0.1.0"
source = { editable = "." } source = { editable = "." }
dependencies = [ dependencies = [
{ name = "fastapi" }, { name = "fastapi" },
{ name = "httpx" }, { name = "httpx" },
{ name = "pydantic" }, { name = "pydantic" },
{ name = "pyyaml" }, { name = "python-dotenv" },
{ name = "uvicorn" }, { name = "python-multipart" },
{ name = "uvicorn", extra = ["standard"] },
] ]
[package.optional-dependencies] [package.dev-dependencies]
dev = [ dev = [
{ name = "pytest" }, { name = "pytest" },
{ name = "ruff" }, { name = "pytest-asyncio" },
] ]
[package.metadata] [package.metadata]
requires-dist = [ requires-dist = [
{ name = "fastapi", specifier = ">=0.109.0" }, { name = "fastapi", specifier = ">=0.104.0" },
{ name = "httpx", specifier = ">=0.26.0" }, { name = "httpx", specifier = ">=0.25.0" },
{ name = "pydantic", specifier = ">=2.5.0" }, { name = "pydantic", specifier = ">=2.7.1" },
{ name = "pytest", marker = "extra == 'dev'", specifier = ">=8.0.0" }, { name = "python-dotenv", specifier = ">=1.0.1" },
{ name = "pyyaml", specifier = ">=6.0" }, { name = "python-multipart", specifier = ">=0.0.9" },
{ name = "ruff", marker = "extra == 'dev'", specifier = ">=0.1.0" }, { name = "uvicorn", extras = ["standard"], specifier = ">=0.24.0" },
{ name = "uvicorn", specifier = ">=0.27.0" }, ]
[package.metadata.requires-dev]
dev = [
{ name = "pytest", specifier = ">=8.0.0" },
{ name = "pytest-asyncio", specifier = ">=0.23.0" },
] ]
provides-extras = ["dev"]
[[package]] [[package]]
name = "packaging" name = "packaging"
@ -353,6 +417,38 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/d4/24/a372aaf5c9b7208e7112038812994107bc65a84cd00e0354a88c2c77a617/pytest-9.0.3-py3-none-any.whl", hash = "sha256:2c5efc453d45394fdd706ade797c0a81091eccd1d6e4bccfcd476e2b8e0ab5d9", size = 375249, upload-time = "2026-04-07T17:16:16.13Z" }, { url = "https://files.pythonhosted.org/packages/d4/24/a372aaf5c9b7208e7112038812994107bc65a84cd00e0354a88c2c77a617/pytest-9.0.3-py3-none-any.whl", hash = "sha256:2c5efc453d45394fdd706ade797c0a81091eccd1d6e4bccfcd476e2b8e0ab5d9", size = 375249, upload-time = "2026-04-07T17:16:16.13Z" },
] ]
[[package]]
name = "pytest-asyncio"
version = "1.4.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "backports-asyncio-runner", marker = "python_full_version < '3.11'" },
{ name = "pytest" },
{ name = "typing-extensions", marker = "python_full_version < '3.13'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/43/7c/d36d04db312ecf4298932ef77e6e4a9e8ad017906e24e34f0b0c361a2473/pytest_asyncio-1.4.0.tar.gz", hash = "sha256:c6c0d2259945122819f171a32ecea2c349ead889ee28176caaf492143424be42", size = 58514, upload-time = "2026-05-26T09:56:04.083Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/03/e2/08a497ef684b88559c9cc5f4ad53a37e7b99e727094a86d6ea32536d5d3c/pytest_asyncio-1.4.0-py3-none-any.whl", hash = "sha256:933ca923a23075a87fb7070c0ec272a6848489824d887c85c812670932835aa1", size = 16930, upload-time = "2026-05-26T09:56:02.576Z" },
]
[[package]]
name = "python-dotenv"
version = "1.2.2"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/82/ed/0301aeeac3e5353ef3d94b6ec08bbcabd04a72018415dcb29e588514bba8/python_dotenv-1.2.2.tar.gz", hash = "sha256:2c371a91fbd7ba082c2c1dc1f8bf89ca22564a087c2c287cd9b662adde799cf3", size = 50135, upload-time = "2026-03-01T16:00:26.196Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/0b/d7/1959b9648791274998a9c3526f6d0ec8fd2233e4d4acce81bbae76b44b2a/python_dotenv-1.2.2-py3-none-any.whl", hash = "sha256:1d8214789a24de455a8b8bd8ae6fe3c6b69a5e3d64aa8a8e5d68e694bbcb285a", size = 22101, upload-time = "2026-03-01T16:00:25.09Z" },
]
[[package]]
name = "python-multipart"
version = "0.0.32"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/5b/42/55c32bb9b12693c092ad250a0e82edb5b31ddeda6eb772de5f308b3804ad/python_multipart-0.0.32.tar.gz", hash = "sha256:be54b7f3fa167bb83e4fcd936b887b708f4e57fe75911c02aebf53efaf8d938e", size = 46881, upload-time = "2026-06-04T16:18:58.647Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/e1/04/e8135ebd1ad02c56ec633277529b2602ff99ff634be76cdba5744cf554fd/python_multipart-0.0.32-py3-none-any.whl", hash = "sha256:ff6d3f776f16878c894e52e107296ffc890e913c611b1a4ec6c44e2821fe2e23", size = 30042, upload-time = "2026-06-04T16:18:57.319Z" },
]
[[package]] [[package]]
name = "pyyaml" name = "pyyaml"
version = "6.0.3" version = "6.0.3"
@ -417,42 +513,17 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" }, { url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" },
] ]
[[package]]
name = "ruff"
version = "0.15.14"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/dc/8a/8bce2894573e9dae6ff4d77fe34ad727d79b9e6238ad288c5638990d90f6/ruff-0.15.14.tar.gz", hash = "sha256:48e866b165be4a9bdbf310f7d3c9a07edef2fe8cd63ffeb4e00bb590506ebf9f", size = 4700910, upload-time = "2026-05-21T14:34:55.177Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/b9/c8/74a92c6ff9fcfb4f1f947126d3ebee8389276e161ecc85de5bda7cda51bd/ruff-0.15.14-py3-none-linux_armv6l.whl", hash = "sha256:8dd2db9416e487c8d4b01fa7056bb02c4d05969d4f8d17a08c229c2f4ff3c108", size = 10739177, upload-time = "2026-05-21T14:34:37.332Z" },
{ url = "https://files.pythonhosted.org/packages/45/91/254a35c20acc38a7223c9d2d594af12e794432464f2cdeb52af1dc4a892d/ruff-0.15.14-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:be4ff55af755bd71a00ab3dc6bd7ffc467bd76e0df6881e286c2e3d23e8fb43b", size = 11144969, upload-time = "2026-05-21T14:34:43.978Z" },
{ url = "https://files.pythonhosted.org/packages/56/9e/d13e40f83b8d0a94430e6778ce1d94a43b38cf2efe63278bdd2b4c65abbf/ruff-0.15.14-py3-none-macosx_11_0_arm64.whl", hash = "sha256:48d5909d7d06276ce7dde6d32bfa4b0d4cb2651145cd8ee4b440722cbc77832f", size = 10478207, upload-time = "2026-05-21T14:34:48.378Z" },
{ url = "https://files.pythonhosted.org/packages/8d/f1/b15a7839fa4f332f8acec78e20564f26bb2d866e3d21710b877fd0263000/ruff-0.15.14-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ca8cbfa94c4f90984a67561978602746d4cd27103568f745fa90eee3f0d4107d", size = 10818459, upload-time = "2026-05-21T14:34:22.318Z" },
{ url = "https://files.pythonhosted.org/packages/45/33/53d651177f84f94b400a0e27f8824eeada3dddc9d5ee8aeb048f4352a520/ruff-0.15.14-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:9a6bbc0333f1ab053423bcbf6226477d266ca7cec7738c4c8e3f55647803f3c4", size = 10541800, upload-time = "2026-05-21T14:34:20.209Z" },
{ url = "https://files.pythonhosted.org/packages/b8/a6/868f87e0bf9786ed24b5d0d0ad8676b8a94fd1912f42cddf9cfc7857818a/ruff-0.15.14-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:8a24a4f7605d7003a6674d4387651effd939dead3fddd0f36561eb77a9a2e542", size = 11342149, upload-time = "2026-05-21T14:34:46.365Z" },
{ url = "https://files.pythonhosted.org/packages/a7/8b/38cd5c19faffdcc05a408d2b78edccc69492ab9720eadb49ea15ef80d768/ruff-0.15.14-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:049b5326e53ed80978f2fc041a280603f69dd6b0c95464342a2bb4572d9d9e2f", size = 12212563, upload-time = "2026-05-21T14:34:28.579Z" },
{ url = "https://files.pythonhosted.org/packages/3e/4d/a3c5b874a556d5731e3e657aaf04311bb76f0a5c3ec220ed43051be6b64b/ruff-0.15.14-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d4ed42e6696c8dfa5f06728e6441993901f548eb92d73bc472cb5a38d1395fbf", size = 11493299, upload-time = "2026-05-21T14:34:41.836Z" },
{ url = "https://files.pythonhosted.org/packages/1e/c0/56472c251d09858a53e51efbd485b09e1995d8731668b76d52e5dd6ee0f1/ruff-0.15.14-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:715c543cf450c4888251f91c52f1942a800541d9bddd7ac060aa4e6b77ae7cba", size = 11455931, upload-time = "2026-05-21T14:34:57.276Z" },
{ url = "https://files.pythonhosted.org/packages/2c/4a/e2e7b4d8dbf233d4eace59c75bc3435fa6d8bd3bae82d351d4e4300c0fd1/ruff-0.15.14-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:72ebab6013ec887d439d8b7593737a0a4ffb06d45d209d4e4bf2e92813082d3f", size = 11400794, upload-time = "2026-05-21T14:34:39.773Z" },
{ url = "https://files.pythonhosted.org/packages/97/c7/83c0539fe34c3e09136204d1e75d6052492364e0b3cb05e9465423f567d7/ruff-0.15.14-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:49072d36abdbe97a8dd7f480afe9c675699c0c495d4c84076e2c1203c4550581", size = 10804759, upload-time = "2026-05-21T14:34:31.045Z" },
{ url = "https://files.pythonhosted.org/packages/86/a6/18f2bfc095a2ab4a78745644e428205532ce6653a5d0fa8501572891534d/ruff-0.15.14-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:958522aee105068640c2c2ceae08f413ae44d922f52a1374ac13d6a96032fc93", size = 10539517, upload-time = "2026-05-21T14:34:53.064Z" },
{ url = "https://files.pythonhosted.org/packages/54/3a/5a8b3b69c654d4e4bf1d246ac5b49cbcdac6eaab6905925f8915f31e3b80/ruff-0.15.14-py3-none-musllinux_1_2_i686.whl", hash = "sha256:f3707da619a143a2e8830e2abab8224478d69ace2d28cb6c20543ae97c36bf61", size = 11065169, upload-time = "2026-05-21T14:34:24.484Z" },
{ url = "https://files.pythonhosted.org/packages/ed/c5/8864e4e7925b836ea354b31d57641ec03830564e281a8b6f061f8c3e0ec1/ruff-0.15.14-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:bb01d645694e3ec0102105d07ef2d53703970407d59c04e59d3ba0b7a1d53553", size = 11560214, upload-time = "2026-05-21T14:34:50.975Z" },
{ url = "https://files.pythonhosted.org/packages/36/38/012bf76752e1f89ed50b77b99532d90f3a3e287bc7918e1fc0948ac866ac/ruff-0.15.14-py3-none-win32.whl", hash = "sha256:6d0c1ad2a0ab718d39b6d8fd2217981ce4d625cd96a720095f798fb47d8b13e6", size = 10805548, upload-time = "2026-05-21T14:34:33.453Z" },
{ url = "https://files.pythonhosted.org/packages/d1/b7/4ea2c170f10ad760fff2a5250beb18897719dc8b52b53a24cddbb9dd3f19/ruff-0.15.14-py3-none-win_amd64.whl", hash = "sha256:802342981e056db3851a7836e5b070f8f15f67d4a685ae2a6160939d364b2902", size = 11939523, upload-time = "2026-05-21T14:34:18.077Z" },
{ url = "https://files.pythonhosted.org/packages/62/d5/bc97ff895ec35cf3925d4bd60f3b39d822f377a446906ec9bcc87405e59b/ruff-0.15.14-py3-none-win_arm64.whl", hash = "sha256:ff47b90a9ef6a40c9e2f3b479c1fb78531adf055b94c1eba0a7ba04b31951826", size = 11208607, upload-time = "2026-05-21T14:34:26.525Z" },
]
[[package]] [[package]]
name = "starlette" name = "starlette"
version = "1.1.0" version = "1.2.1"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
dependencies = [ dependencies = [
{ name = "anyio" }, { name = "anyio" },
{ name = "typing-extensions", marker = "python_full_version < '3.13'" }, { name = "typing-extensions", marker = "python_full_version < '3.13'" },
] ]
sdist = { url = "https://files.pythonhosted.org/packages/95/66/4d20cdf39a8d6a51e663b7038e3b828ff211d3891a43a713fe7e4643f3a8/starlette-1.1.0.tar.gz", hash = "sha256:e83c7fe0ddecd8719c5b840080325aec0260acec86e9832899e377b91d65e90f", size = 2660060, upload-time = "2026-05-23T16:55:41.376Z" } sdist = { url = "https://files.pythonhosted.org/packages/25/44/ec35f1b6e83094b997da438a02c8c9b0ade2b1e84cfc48bd4656780760a6/starlette-1.2.1.tar.gz", hash = "sha256:9b9b5ebb992e67d6093741e63c2f59e4f6fff986f81163c087867bd7b924b3f6", size = 2701854, upload-time = "2026-05-31T01:07:51.847Z" }
wheels = [ wheels = [
{ url = "https://files.pythonhosted.org/packages/93/79/920b8e0a8b20f793e8d64855095cb8febabf6175b8550b6f7a547d813891/starlette-1.1.0-py3-none-any.whl", hash = "sha256:7f0dfd38e428aad5cb6f9f667f0ca1d2d8ca3f3385dccac8305f79ec98458382", size = 72899, upload-time = "2026-05-23T16:55:39.201Z" }, { url = "https://files.pythonhosted.org/packages/1c/54/196d0c1db10af76baa4f64894448505d60d3cdf70ef92cbb35f46a4e4c71/starlette-1.2.1-py3-none-any.whl", hash = "sha256:4de0082d08c8f6764a85a54cf1120d6939507a19905c7768acad2a9f875d2b89", size = 73350, upload-time = "2026-05-31T01:07:50.09Z" },
] ]
[[package]] [[package]]
@ -532,14 +603,254 @@ wheels = [
[[package]] [[package]]
name = "uvicorn" name = "uvicorn"
version = "0.48.0" version = "0.49.0"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
dependencies = [ dependencies = [
{ name = "click" }, { name = "click" },
{ name = "h11" }, { name = "h11" },
{ name = "typing-extensions", marker = "python_full_version < '3.11'" }, { name = "typing-extensions", marker = "python_full_version < '3.11'" },
] ]
sdist = { url = "https://files.pythonhosted.org/packages/e6/bf/f6544ba992ddb9a6077343a576f9844f7f8f06ab819aefd00206e9255f18/uvicorn-0.48.0.tar.gz", hash = "sha256:a5504207195d08c2511bf9125ede5ac4a4b71725d519e758d01dcf0bc2d31c37", size = 91074, upload-time = "2026-05-24T12:08:41.925Z" } sdist = { url = "https://files.pythonhosted.org/packages/c4/1f/fa18009dea8469069cca78a4e877a008ab78f08b064bfc9ab891579077ff/uvicorn-0.49.0.tar.gz", hash = "sha256:ebf4271aa580d9de97f93192d4595176df6e91f9aae919ca73e4fc07df1e66a3", size = 91284, upload-time = "2026-06-03T22:01:30.448Z" }
wheels = [ wheels = [
{ url = "https://files.pythonhosted.org/packages/01/be/72532be3da7acc5fdfbccdb95215cd04f995a0886532a5b423f929cda4cc/uvicorn-0.48.0-py3-none-any.whl", hash = "sha256:48097851328b87ec36117d3d575234519eb58c2b22d79666e9bbc6c49a761dad", size = 71410, upload-time = "2026-05-24T12:08:40.258Z" }, { url = "https://files.pythonhosted.org/packages/88/fa/e1388bbcf24ef3274f45c0c1c7b501fd14971037c1b6ee23610553307497/uvicorn-0.49.0-py3-none-any.whl", hash = "sha256:ba3d14c3ee7e41c6c654c46c9eb489d33213cdd30aa1696eab1374337c13f68f", size = 71376, upload-time = "2026-06-03T22:01:29.037Z" },
]
[package.optional-dependencies]
standard = [
{ name = "colorama", marker = "sys_platform == 'win32'" },
{ name = "httptools" },
{ name = "python-dotenv" },
{ name = "pyyaml" },
{ name = "uvloop", marker = "platform_python_implementation != 'PyPy' and sys_platform != 'cygwin' and sys_platform != 'win32'" },
{ name = "watchfiles" },
{ name = "websockets" },
]
[[package]]
name = "uvloop"
version = "0.22.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/06/f0/18d39dbd1971d6d62c4629cc7fa67f74821b0dc1f5a77af43719de7936a7/uvloop-0.22.1.tar.gz", hash = "sha256:6c84bae345b9147082b17371e3dd5d42775bddce91f885499017f4607fdaf39f", size = 2443250, upload-time = "2025-10-16T22:17:19.342Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/eb/14/ecceb239b65adaaf7fde510aa8bd534075695d1e5f8dadfa32b5723d9cfb/uvloop-0.22.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:ef6f0d4cc8a9fa1f6a910230cd53545d9a14479311e87e3cb225495952eb672c", size = 1343335, upload-time = "2025-10-16T22:16:11.43Z" },
{ url = "https://files.pythonhosted.org/packages/ba/ae/6f6f9af7f590b319c94532b9567409ba11f4fa71af1148cab1bf48a07048/uvloop-0.22.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:7cd375a12b71d33d46af85a3343b35d98e8116134ba404bd657b3b1d15988792", size = 742903, upload-time = "2025-10-16T22:16:12.979Z" },
{ url = "https://files.pythonhosted.org/packages/09/bd/3667151ad0702282a1f4d5d29288fce8a13c8b6858bf0978c219cd52b231/uvloop-0.22.1-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ac33ed96229b7790eb729702751c0e93ac5bc3bcf52ae9eccbff30da09194b86", size = 3648499, upload-time = "2025-10-16T22:16:14.451Z" },
{ url = "https://files.pythonhosted.org/packages/b3/f6/21657bb3beb5f8c57ce8be3b83f653dd7933c2fd00545ed1b092d464799a/uvloop-0.22.1-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:481c990a7abe2c6f4fc3d98781cc9426ebd7f03a9aaa7eb03d3bfc68ac2a46bd", size = 3700133, upload-time = "2025-10-16T22:16:16.272Z" },
{ url = "https://files.pythonhosted.org/packages/09/e0/604f61d004ded805f24974c87ddd8374ef675644f476f01f1df90e4cdf72/uvloop-0.22.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:a592b043a47ad17911add5fbd087c76716d7c9ccc1d64ec9249ceafd735f03c2", size = 3512681, upload-time = "2025-10-16T22:16:18.07Z" },
{ url = "https://files.pythonhosted.org/packages/bb/ce/8491fd370b0230deb5eac69c7aae35b3be527e25a911c0acdffb922dc1cd/uvloop-0.22.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:1489cf791aa7b6e8c8be1c5a080bae3a672791fcb4e9e12249b05862a2ca9cec", size = 3615261, upload-time = "2025-10-16T22:16:19.596Z" },
{ url = "https://files.pythonhosted.org/packages/c7/d5/69900f7883235562f1f50d8184bb7dd84a2fb61e9ec63f3782546fdbd057/uvloop-0.22.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:c60ebcd36f7b240b30788554b6f0782454826a0ed765d8430652621b5de674b9", size = 1352420, upload-time = "2025-10-16T22:16:21.187Z" },
{ url = "https://files.pythonhosted.org/packages/a8/73/c4e271b3bce59724e291465cc936c37758886a4868787da0278b3b56b905/uvloop-0.22.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:3b7f102bf3cb1995cfeaee9321105e8f5da76fdb104cdad8986f85461a1b7b77", size = 748677, upload-time = "2025-10-16T22:16:22.558Z" },
{ url = "https://files.pythonhosted.org/packages/86/94/9fb7fad2f824d25f8ecac0d70b94d0d48107ad5ece03769a9c543444f78a/uvloop-0.22.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:53c85520781d84a4b8b230e24a5af5b0778efdb39142b424990ff1ef7c48ba21", size = 3753819, upload-time = "2025-10-16T22:16:23.903Z" },
{ url = "https://files.pythonhosted.org/packages/74/4f/256aca690709e9b008b7108bc85fba619a2bc37c6d80743d18abad16ee09/uvloop-0.22.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:56a2d1fae65fd82197cb8c53c367310b3eabe1bbb9fb5a04d28e3e3520e4f702", size = 3804529, upload-time = "2025-10-16T22:16:25.246Z" },
{ url = "https://files.pythonhosted.org/packages/7f/74/03c05ae4737e871923d21a76fe28b6aad57f5c03b6e6bfcfa5ad616013e4/uvloop-0.22.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:40631b049d5972c6755b06d0bfe8233b1bd9a8a6392d9d1c45c10b6f9e9b2733", size = 3621267, upload-time = "2025-10-16T22:16:26.819Z" },
{ url = "https://files.pythonhosted.org/packages/75/be/f8e590fe61d18b4a92070905497aec4c0e64ae1761498cad09023f3f4b3e/uvloop-0.22.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:535cc37b3a04f6cd2c1ef65fa1d370c9a35b6695df735fcff5427323f2cd5473", size = 3723105, upload-time = "2025-10-16T22:16:28.252Z" },
{ url = "https://files.pythonhosted.org/packages/3d/ff/7f72e8170be527b4977b033239a83a68d5c881cc4775fca255c677f7ac5d/uvloop-0.22.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:fe94b4564e865d968414598eea1a6de60adba0c040ba4ed05ac1300de402cd42", size = 1359936, upload-time = "2025-10-16T22:16:29.436Z" },
{ url = "https://files.pythonhosted.org/packages/c3/c6/e5d433f88fd54d81ef4be58b2b7b0cea13c442454a1db703a1eea0db1a59/uvloop-0.22.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:51eb9bd88391483410daad430813d982010f9c9c89512321f5b60e2cddbdddd6", size = 752769, upload-time = "2025-10-16T22:16:30.493Z" },
{ url = "https://files.pythonhosted.org/packages/24/68/a6ac446820273e71aa762fa21cdcc09861edd3536ff47c5cd3b7afb10eeb/uvloop-0.22.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:700e674a166ca5778255e0e1dc4e9d79ab2acc57b9171b79e65feba7184b3370", size = 4317413, upload-time = "2025-10-16T22:16:31.644Z" },
{ url = "https://files.pythonhosted.org/packages/5f/6f/e62b4dfc7ad6518e7eff2516f680d02a0f6eb62c0c212e152ca708a0085e/uvloop-0.22.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7b5b1ac819a3f946d3b2ee07f09149578ae76066d70b44df3fa990add49a82e4", size = 4426307, upload-time = "2025-10-16T22:16:32.917Z" },
{ url = "https://files.pythonhosted.org/packages/90/60/97362554ac21e20e81bcef1150cb2a7e4ffdaf8ea1e5b2e8bf7a053caa18/uvloop-0.22.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e047cc068570bac9866237739607d1313b9253c3051ad84738cbb095be0537b2", size = 4131970, upload-time = "2025-10-16T22:16:34.015Z" },
{ url = "https://files.pythonhosted.org/packages/99/39/6b3f7d234ba3964c428a6e40006340f53ba37993f46ed6e111c6e9141d18/uvloop-0.22.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:512fec6815e2dd45161054592441ef76c830eddaad55c8aa30952e6fe1ed07c0", size = 4296343, upload-time = "2025-10-16T22:16:35.149Z" },
{ url = "https://files.pythonhosted.org/packages/89/8c/182a2a593195bfd39842ea68ebc084e20c850806117213f5a299dfc513d9/uvloop-0.22.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:561577354eb94200d75aca23fbde86ee11be36b00e52a4eaf8f50fb0c86b7705", size = 1358611, upload-time = "2025-10-16T22:16:36.833Z" },
{ url = "https://files.pythonhosted.org/packages/d2/14/e301ee96a6dc95224b6f1162cd3312f6d1217be3907b79173b06785f2fe7/uvloop-0.22.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:1cdf5192ab3e674ca26da2eada35b288d2fa49fdd0f357a19f0e7c4e7d5077c8", size = 751811, upload-time = "2025-10-16T22:16:38.275Z" },
{ url = "https://files.pythonhosted.org/packages/b7/02/654426ce265ac19e2980bfd9ea6590ca96a56f10c76e63801a2df01c0486/uvloop-0.22.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6e2ea3d6190a2968f4a14a23019d3b16870dd2190cd69c8180f7c632d21de68d", size = 4288562, upload-time = "2025-10-16T22:16:39.375Z" },
{ url = "https://files.pythonhosted.org/packages/15/c0/0be24758891ef825f2065cd5db8741aaddabe3e248ee6acc5e8a80f04005/uvloop-0.22.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0530a5fbad9c9e4ee3f2b33b148c6a64d47bbad8000ea63704fa8260f4cf728e", size = 4366890, upload-time = "2025-10-16T22:16:40.547Z" },
{ url = "https://files.pythonhosted.org/packages/d2/53/8369e5219a5855869bcee5f4d317f6da0e2c669aecf0ef7d371e3d084449/uvloop-0.22.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:bc5ef13bbc10b5335792360623cc378d52d7e62c2de64660616478c32cd0598e", size = 4119472, upload-time = "2025-10-16T22:16:41.694Z" },
{ url = "https://files.pythonhosted.org/packages/f8/ba/d69adbe699b768f6b29a5eec7b47dd610bd17a69de51b251126a801369ea/uvloop-0.22.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:1f38ec5e3f18c8a10ded09742f7fb8de0108796eb673f30ce7762ce1b8550cad", size = 4239051, upload-time = "2025-10-16T22:16:43.224Z" },
{ url = "https://files.pythonhosted.org/packages/90/cd/b62bdeaa429758aee8de8b00ac0dd26593a9de93d302bff3d21439e9791d/uvloop-0.22.1-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:3879b88423ec7e97cd4eba2a443aa26ed4e59b45e6b76aabf13fe2f27023a142", size = 1362067, upload-time = "2025-10-16T22:16:44.503Z" },
{ url = "https://files.pythonhosted.org/packages/0d/f8/a132124dfda0777e489ca86732e85e69afcd1ff7686647000050ba670689/uvloop-0.22.1-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:4baa86acedf1d62115c1dc6ad1e17134476688f08c6efd8a2ab076e815665c74", size = 752423, upload-time = "2025-10-16T22:16:45.968Z" },
{ url = "https://files.pythonhosted.org/packages/a3/94/94af78c156f88da4b3a733773ad5ba0b164393e357cc4bd0ab2e2677a7d6/uvloop-0.22.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:297c27d8003520596236bdb2335e6b3f649480bd09e00d1e3a99144b691d2a35", size = 4272437, upload-time = "2025-10-16T22:16:47.451Z" },
{ url = "https://files.pythonhosted.org/packages/b5/35/60249e9fd07b32c665192cec7af29e06c7cd96fa1d08b84f012a56a0b38e/uvloop-0.22.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c1955d5a1dd43198244d47664a5858082a3239766a839b2102a269aaff7a4e25", size = 4292101, upload-time = "2025-10-16T22:16:49.318Z" },
{ url = "https://files.pythonhosted.org/packages/02/62/67d382dfcb25d0a98ce73c11ed1a6fba5037a1a1d533dcbb7cab033a2636/uvloop-0.22.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:b31dc2fccbd42adc73bc4e7cdbae4fc5086cf378979e53ca5d0301838c5682c6", size = 4114158, upload-time = "2025-10-16T22:16:50.517Z" },
{ url = "https://files.pythonhosted.org/packages/f0/7a/f1171b4a882a5d13c8b7576f348acfe6074d72eaf52cccef752f748d4a9f/uvloop-0.22.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:93f617675b2d03af4e72a5333ef89450dfaa5321303ede6e67ba9c9d26878079", size = 4177360, upload-time = "2025-10-16T22:16:52.646Z" },
{ url = "https://files.pythonhosted.org/packages/79/7b/b01414f31546caf0919da80ad57cbfe24c56b151d12af68cee1b04922ca8/uvloop-0.22.1-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:37554f70528f60cad66945b885eb01f1bb514f132d92b6eeed1c90fd54ed6289", size = 1454790, upload-time = "2025-10-16T22:16:54.355Z" },
{ url = "https://files.pythonhosted.org/packages/d4/31/0bb232318dd838cad3fa8fb0c68c8b40e1145b32025581975e18b11fab40/uvloop-0.22.1-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:b76324e2dc033a0b2f435f33eb88ff9913c156ef78e153fb210e03c13da746b3", size = 796783, upload-time = "2025-10-16T22:16:55.906Z" },
{ url = "https://files.pythonhosted.org/packages/42/38/c9b09f3271a7a723a5de69f8e237ab8e7803183131bc57c890db0b6bb872/uvloop-0.22.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:badb4d8e58ee08dad957002027830d5c3b06aea446a6a3744483c2b3b745345c", size = 4647548, upload-time = "2025-10-16T22:16:57.008Z" },
{ url = "https://files.pythonhosted.org/packages/c1/37/945b4ca0ac27e3dc4952642d4c900edd030b3da6c9634875af6e13ae80e5/uvloop-0.22.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b91328c72635f6f9e0282e4a57da7470c7350ab1c9f48546c0f2866205349d21", size = 4467065, upload-time = "2025-10-16T22:16:58.206Z" },
{ url = "https://files.pythonhosted.org/packages/97/cc/48d232f33d60e2e2e0b42f4e73455b146b76ebe216487e862700457fbf3c/uvloop-0.22.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:daf620c2995d193449393d6c62131b3fbd40a63bf7b307a1527856ace637fe88", size = 4328384, upload-time = "2025-10-16T22:16:59.36Z" },
{ url = "https://files.pythonhosted.org/packages/e4/16/c1fd27e9549f3c4baf1dc9c20c456cd2f822dbf8de9f463824b0c0357e06/uvloop-0.22.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6cde23eeda1a25c75b2e07d39970f3374105d5eafbaab2a4482be82f272d5a5e", size = 4296730, upload-time = "2025-10-16T22:17:00.744Z" },
]
[[package]]
name = "watchfiles"
version = "1.2.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "anyio" },
]
sdist = { url = "https://files.pythonhosted.org/packages/cd/41/5e1a4bb12aac5f1493fa1bdc11154eca3b258ca4eba65d39c473fe19d8e9/watchfiles-1.2.0.tar.gz", hash = "sha256:c995fba777f1ea992f090f9236e9284cf7a5d1a0130dd5a3d82c598cacd76838", size = 108252, upload-time = "2026-05-18T04:32:04.251Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/0d/5a/2bf22ecb24916983bf1cc0095e7dea2741d14d6553b0d6a2ac8bc96eca93/watchfiles-1.2.0-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:bb68bf4df85abebe5efddc53cf2075520f243a59868d9b3973278b23e76962a9", size = 400471, upload-time = "2026-05-18T04:31:08.908Z" },
{ url = "https://files.pythonhosted.org/packages/55/70/dea1f6a0e76607841a60fb51af150e70124864673f61704abb62b90cdcc7/watchfiles-1.2.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:c16cb06dd17d43b9d185094268459eac92c9538356f050e55b54e82cf700e1d4", size = 394599, upload-time = "2026-05-18T04:30:19.845Z" },
{ url = "https://files.pythonhosted.org/packages/18/52/752dcc7dc817baef5e89518732925795ce52e36a683a9a3c9fb68b21504e/watchfiles-1.2.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:77a0feab9af4c021c581f695258c642b3d10c5fd4c676e33a0d8606425d82631", size = 455458, upload-time = "2026-05-18T04:30:29.126Z" },
{ url = "https://files.pythonhosted.org/packages/12/48/366ebbb22fcc504c2f72b45f0b7e72f40a18795cc01752c16066d597b67a/watchfiles-1.2.0-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:a16ffe19bf5cf9f5edaa1ad1dd830c5a816e8feec430c522302ab55483a4b994", size = 460513, upload-time = "2026-05-18T04:31:40.85Z" },
{ url = "https://files.pythonhosted.org/packages/ad/44/1f9e1b15e7a729062e0d0c3d0d7225ea4ab98b2267ef87287153be2495fc/watchfiles-1.2.0-cp310-cp310-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:204f299afcbd65918ab78dbc52626b0ae45e9d8cef403fdbf33ecf9e40eac66e", size = 493616, upload-time = "2026-05-18T04:30:58.47Z" },
{ url = "https://files.pythonhosted.org/packages/7e/55/8b1086dcc8a1d6a697a62767bd7ea368e74c61c6fd171683cfe24a3fe5d2/watchfiles-1.2.0-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:11743adfa510bfffebe97659fb280182b5c9b238708f667e866f308c3430dc19", size = 573154, upload-time = "2026-05-18T04:30:37.903Z" },
{ url = "https://files.pythonhosted.org/packages/14/7a/242f400cc77fafa7b18d53d19d9cb64fc6a6f61f28c55913bae7c674d92a/watchfiles-1.2.0-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:eb72919d93e3a16fc451d3aa3d4b1698423daca1b382d3d959c9ac51297c12a8", size = 467046, upload-time = "2026-05-18T04:30:41.869Z" },
{ url = "https://files.pythonhosted.org/packages/02/c8/79eee650c62d2c186598489814468e389b5def0ebe755399ff645b35b1b2/watchfiles-1.2.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b62f042afde2dde21ec1d2c1a74361e804673df86f51e418a999c9acfe671b07", size = 457100, upload-time = "2026-05-18T04:31:13.064Z" },
{ url = "https://files.pythonhosted.org/packages/81/36/519f6dbb7a95e4fe7c1513ed25b1520295ef9905a27f1f2226a73892bfb7/watchfiles-1.2.0-cp310-cp310-manylinux_2_31_riscv64.whl", hash = "sha256:027ae72bfdfd254862065d8b3e2a815c6ab9b1853ce41e6648ece84afd34a551", size = 467038, upload-time = "2026-05-18T04:30:32.915Z" },
{ url = "https://files.pythonhosted.org/packages/2f/12/951af6b9f89097e02511122258402cb3578443021930b70cf968d6310dc0/watchfiles-1.2.0-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:e1cfd51e97e13ff3bd047c140764d277fc9b95b7cb5da59e46a47d167adab310", size = 632563, upload-time = "2026-05-18T04:30:11.539Z" },
{ url = "https://files.pythonhosted.org/packages/28/cc/0cba1f0a6117b7ec117271bdc3cb3a5a252005959755a2c09a745e0942cc/watchfiles-1.2.0-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:24b2405c0a46738dd9e1cf7135aa5dbdb9d42d024628651b3b13d5117e99f8df", size = 660851, upload-time = "2026-05-18T04:31:53.186Z" },
{ url = "https://files.pythonhosted.org/packages/d0/f2/26347558cc8bf6877845e66b315f644d03c173906aa09e233a3f4fd23928/watchfiles-1.2.0-cp310-cp310-win32.whl", hash = "sha256:8c520725602756229f045b032a1ff33d7ef0f7404189d62f6c2438cb6d8ef6a1", size = 277023, upload-time = "2026-05-18T04:30:18.825Z" },
{ url = "https://files.pythonhosted.org/packages/6d/68/a5e67b6b68e94f4c1511d61c46c55eba0737583620b6febf194c7b9cc23f/watchfiles-1.2.0-cp310-cp310-win_amd64.whl", hash = "sha256:03b14855c6f35539e2d95c442ae9530a75762f1e26567152b9ed05f96534a74d", size = 290107, upload-time = "2026-05-18T04:32:09.677Z" },
{ url = "https://files.pythonhosted.org/packages/fc/3d/8024c801df84d1587740d0359e7fdd80afeae3d159011f3d5376dd82f18e/watchfiles-1.2.0-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:704fd259e332e01f9b9c178f4bce9e49027e5587cc2600eeeaf8e76e1c846201", size = 400242, upload-time = "2026-05-18T04:31:19.014Z" },
{ url = "https://files.pythonhosted.org/packages/87/5b/f4dfd45323e949984a3a7f9dc31d1cbb049921e7d98253488dda72ccdaa9/watchfiles-1.2.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:6543cf55d170003296d185c0af981f3e1311564907e1f4e08671fc7693a890a5", size = 394562, upload-time = "2026-05-18T04:30:08.46Z" },
{ url = "https://files.pythonhosted.org/packages/98/d8/19483ef075d601c409bce8bcbb5c0f81a10876fff870400568f08ce484a1/watchfiles-1.2.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:89d8c2394a065ca86f5d2910ff263ae67c127e1376ccc4f9fc35c71db879f80a", size = 456611, upload-time = "2026-05-18T04:30:45.723Z" },
{ url = "https://files.pythonhosted.org/packages/b1/6a/cc81fbe7ee42f2f22e661a6e12def7807e01b14b2f39e0ff83fd373fd307/watchfiles-1.2.0-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:772b80df316480d894a0e3165fdd19cf77f5d17f9a787f94029465ad0e3529d1", size = 461379, upload-time = "2026-05-18T04:31:29.292Z" },
{ url = "https://files.pythonhosted.org/packages/b1/57/7e669002082c0a0f4fb5113bb70125f7110124b846b0a11bc5ae8e90eac1/watchfiles-1.2.0-cp311-cp311-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d158cd89df6053823533e06fb1d73c549133bff5f0396170c0e53d9559340717", size = 493556, upload-time = "2026-05-18T04:30:05.44Z" },
{ url = "https://files.pythonhosted.org/packages/45/7d/f60a2b19807b21fe8281f3a8da4f59eef0d5f96825ac4680ba2d4f2ebf91/watchfiles-1.2.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d516b3283a758e087841aedb8031549fb41ced08f3db10aa6d2bf32dc042525b", size = 575255, upload-time = "2026-05-18T04:30:40.568Z" },
{ url = "https://files.pythonhosted.org/packages/bd/49/77f5b5e6efbcd57482f74948ebb1b97e5c0046d6b61475042d830c84b3ff/watchfiles-1.2.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:53b2290c92e0506d102cd448fbc610d87079553f86caa39d67440856a8b8bba5", size = 467052, upload-time = "2026-05-18T04:31:17.942Z" },
{ url = "https://files.pythonhosted.org/packages/ee/5a/73e2959af1b97fd5d556f9a8bdba017be23ceeef731869d5eaa0a753d5a3/watchfiles-1.2.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a711b51aec4370d0dcda5b6c09463206f133a5759341d7744b953a7b62e1100e", size = 456858, upload-time = "2026-05-18T04:30:30.182Z" },
{ url = "https://files.pythonhosted.org/packages/50/57/1bc8c27fad7e6c19bddee15d276dbb6ab72480ec01c127afff1673aee417/watchfiles-1.2.0-cp311-cp311-manylinux_2_31_riscv64.whl", hash = "sha256:e2ca07fa7d89195ec0865d3d285666286740bfa83d83e5cee204043a31ecc165", size = 467579, upload-time = "2026-05-18T04:32:15.897Z" },
{ url = "https://files.pythonhosted.org/packages/09/6c/3c2e44edba3553c5e3c3b8c8a2a6dee6b9e12ae2cf4bd2378bebf9dc3038/watchfiles-1.2.0-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:e0618518f282c4ebff60f5e5b1247b6d91bb8b9f4476947563a1e74acc66f3c6", size = 633253, upload-time = "2026-05-18T04:31:37.123Z" },
{ url = "https://files.pythonhosted.org/packages/30/c2/d8c84a882ab39bbefcc4915ab3e91830b7a7e990c5570b0b69075aba3faf/watchfiles-1.2.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:0d191c054d0715c3c95c99df9b8dbf6fd096d8c1e021e8f212e1bd8bc444ccb5", size = 660713, upload-time = "2026-05-18T04:31:24.62Z" },
{ url = "https://files.pythonhosted.org/packages/a9/07/f97736a5fc605364fe67b25e9fa4a6965dfd4840d50c406ada507e9d735f/watchfiles-1.2.0-cp311-cp311-win32.whl", hash = "sha256:9342472aff9b093c5acd4f6d8f70ae0937964ab56542502bcf5579782da69ae8", size = 277222, upload-time = "2026-05-18T04:31:21.131Z" },
{ url = "https://files.pythonhosted.org/packages/cf/99/2b04981977fc2608afd60360d928c6aecf6b950292ca221d98f4005f6694/watchfiles-1.2.0-cp311-cp311-win_amd64.whl", hash = "sha256:dbd6c97045dad81227c8d040173da044c1de08de64a5ea8b555da4aee1d5fa22", size = 290274, upload-time = "2026-05-18T04:31:45.966Z" },
{ url = "https://files.pythonhosted.org/packages/3c/74/f7f58a7075ee9cf612b0cfcddb78b8cd8234f0742d6f0075cf0da2dde1c6/watchfiles-1.2.0-cp311-cp311-win_arm64.whl", hash = "sha256:57a2d9fa4fb4c2ecae57b13dfff2c7ab53e21a2ba674fe9f05506680fcdcc0d7", size = 283460, upload-time = "2026-05-18T04:31:39.126Z" },
{ url = "https://files.pythonhosted.org/packages/b8/2f/e42c992d2afda3108ea1c02acecc991b9f31d05c14adc2a7cee9ee211fc4/watchfiles-1.2.0-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:bc13eb17538be00c874699dc0abe4ee2bc8d50bb1166a6b9e175ef3fd7eb8f26", size = 400115, upload-time = "2026-05-18T04:32:02.06Z" },
{ url = "https://files.pythonhosted.org/packages/5f/8f/6af2ea19065c91d8b0ea3516fdfc8c0d349f407e8e9fbf4e5a17360de8ad/watchfiles-1.2.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:2d95ddc1eb6914154253d239089900813f6a767e174b8e6a50e7fdacb7e4236c", size = 393659, upload-time = "2026-05-18T04:30:50.951Z" },
{ url = "https://files.pythonhosted.org/packages/13/01/b32a967c56fb3e3e5be3db52c3d3b87fa4513aa367d8ed1ad96d42952e5f/watchfiles-1.2.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8f70d8b291ef6e88d19b1f297a6905ddb978888d9272b0d05e6f53309856bcfc", size = 453207, upload-time = "2026-05-18T04:31:04.231Z" },
{ url = "https://files.pythonhosted.org/packages/04/98/97557a812180338cb1abd32e1cffcc4588f59b5f23e0cb006b2ba95ba64a/watchfiles-1.2.0-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:56d8641cf834c2836922899105bd3ce3d0dfc69291d52edf0b4d0436829b34c0", size = 459273, upload-time = "2026-05-18T04:31:50.377Z" },
{ url = "https://files.pythonhosted.org/packages/e8/a8/b4b08dcb7653b8087c6586f7ce649505900e866bbcfe40dc9587af02e686/watchfiles-1.2.0-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:2581a94056e55d7d0a31a823ea92bf73749c489ca2285bfdc0fbe6b2bb49d50c", size = 489927, upload-time = "2026-05-18T04:31:42.485Z" },
{ url = "https://files.pythonhosted.org/packages/50/94/3dceea03545d2e5ddfd839f0ddd5e1cecbf1697b5a428d5ba11cef6af95d/watchfiles-1.2.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:41bc1199f7523b3f82843c88cbb979180c949caef0342cf90968f178e5d49b01", size = 570476, upload-time = "2026-05-18T04:31:03.071Z" },
{ url = "https://files.pythonhosted.org/packages/cc/f2/d39a5450c3532092b91f81d274360e613c2371bc874a89c7a1a3c5e8d138/watchfiles-1.2.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7571e4464cb6e434958f867f7f730b8ab0b75e3f8e5eac0499168486ab3c33a8", size = 465650, upload-time = "2026-05-18T04:30:12.701Z" },
{ url = "https://files.pythonhosted.org/packages/22/24/ed72f68cbc1333ca9b9f2200aa048bb6658ae41709bc1caad4310f4bdffd/watchfiles-1.2.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e53a384f76b631c3ae5334ce6a52f0baa3a911eb94a4eac7f160079868b716d5", size = 456398, upload-time = "2026-05-18T04:30:13.784Z" },
{ url = "https://files.pythonhosted.org/packages/0d/64/982ef4a4e5bab5b6e5b6becc8cd5e732f6130a78b855f0abec6439a9a135/watchfiles-1.2.0-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:d20029a60a71a052a24c4db7673bc4de39ab89adbaccbfb5d67987c5d73f424d", size = 465140, upload-time = "2026-05-18T04:31:52.111Z" },
{ url = "https://files.pythonhosted.org/packages/a0/0c/95282abf4ed680b6096010bcfc30c5fa7a041fc5aa5a2ad17a2cc6c75bba/watchfiles-1.2.0-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:2cb93af48550faf1cea04c303107c8b75833de7013e57ce27d3b8d21d8d0f58c", size = 630259, upload-time = "2026-05-18T04:31:25.676Z" },
{ url = "https://files.pythonhosted.org/packages/30/45/607c1de1530c4bdcf2cf1d1ecc2505ddba5d96bd43ba9f2b0e79876f850f/watchfiles-1.2.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:2995c176de7692b86a2e4c58d9ec718f753150a979cb4a754e2b4ffa38e70906", size = 659859, upload-time = "2026-05-18T04:30:24.333Z" },
{ url = "https://files.pythonhosted.org/packages/fa/08/d9e2e0f9e8e6791d33aefc694ad7eefa7f901f63caff84a81ded38692f9c/watchfiles-1.2.0-cp312-cp312-win32.whl", hash = "sha256:7a2cffd17d27d2ecbb310c2b1d8174f222a5495b1a721894afa88ec11e25b898", size = 275480, upload-time = "2026-05-18T04:30:31.307Z" },
{ url = "https://files.pythonhosted.org/packages/1c/e6/9d42569c0102645cc8cea5d8c7d8a1e9d4ada2cb7f05f75e554b8aa2202a/watchfiles-1.2.0-cp312-cp312-win_amd64.whl", hash = "sha256:f155b3a1b2a5fc89cdc70d47ee5d54e3b75e88efa34982028a35daef9ba00379", size = 288718, upload-time = "2026-05-18T04:32:10.745Z" },
{ url = "https://files.pythonhosted.org/packages/0a/26/88e0dc6ee3898169d7fa22bb6a69cabf2502d2ee25cb8c876d1262d204f8/watchfiles-1.2.0-cp312-cp312-win_arm64.whl", hash = "sha256:8fa585ede612ee9f9e91b18bebf9ba11b9ae29a4e3a0d0cf6fca3e382133f0d5", size = 281026, upload-time = "2026-05-18T04:30:22.23Z" },
{ url = "https://files.pythonhosted.org/packages/d1/4d/70a7feced9f87e2ff26dba42667290f41694fc64646c67261fbb8cab5d5c/watchfiles-1.2.0-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:01ea8d66f0693b9b60a6541c8d10263091ca9a9060d242f3c1f3143f9aad2c98", size = 399730, upload-time = "2026-05-18T04:31:38.162Z" },
{ url = "https://files.pythonhosted.org/packages/31/3a/0da302f2307aee316922806ebd5726c542cbd787c938271cf14a074c7daf/watchfiles-1.2.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:7ba0480b9a74af058f43b337e937a451e109295c420916d68ad24e3dc02f5e44", size = 392842, upload-time = "2026-05-18T04:30:27.051Z" },
{ url = "https://files.pythonhosted.org/packages/db/ef/d5bdb705c224dbc256aa0c1ec47bf4e61ec52558f2afb44a71a1fe4d7015/watchfiles-1.2.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4f34e26a19f91f710c08e0183429f0d1d15df734e6bc78c31e77b9ea9c433658", size = 452989, upload-time = "2026-05-18T04:31:11.945Z" },
{ url = "https://files.pythonhosted.org/packages/71/29/5495f2c1661949ef7a35e4d71111d129cfe7606414a26887a919d0a55406/watchfiles-1.2.0-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b4e77f6a55f858504069abd35d336a637555c09bca453dde1ee1e5ada8a6a1fb", size = 458978, upload-time = "2026-05-18T04:30:52.606Z" },
{ url = "https://files.pythonhosted.org/packages/d5/8c/7f9c07c433811c2fffd93e13fdfb7135de9aab5f2ae41be08960fa0047dc/watchfiles-1.2.0-cp313-cp313-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0cb4d80e212f116474a545c21c912b445f16bb0cef9e6a73a498164223e14e2f", size = 490248, upload-time = "2026-05-18T04:31:36.003Z" },
{ url = "https://files.pythonhosted.org/packages/3c/11/d93632febc52fbc21be90231bb7c17fd5387f46c9076fd40a5f9c2ae6910/watchfiles-1.2.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b974946a10af379d425e2eef5b62f5c6ebeaccf91d45eaad6f5b27ecd4f91aa0", size = 571847, upload-time = "2026-05-18T04:31:10.862Z" },
{ url = "https://files.pythonhosted.org/packages/55/b4/383173e73aabb07ad1d9c7aa859d95437ac46a6d6a1e11005facda0c9d19/watchfiles-1.2.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:86bc13c25a8d1fcd70b51d0ce7c9b65e90de5666fcbfd3e34957cc73ee19aeb5", size = 465974, upload-time = "2026-05-18T04:30:17.006Z" },
{ url = "https://files.pythonhosted.org/packages/a7/6c/89b1a230a78f57c52dd8893adb1f92f94411721b6ec12596c56d98c74356/watchfiles-1.2.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ca148d73dea36c9763aaa351e4d7a51780ec1584217c45276f4fe8239c768b71", size = 454782, upload-time = "2026-05-18T04:30:35.656Z" },
{ url = "https://files.pythonhosted.org/packages/24/62/1732118367cfff0a9fce3bf62ff4bfded09ef5df21d9d446b858b3f70a96/watchfiles-1.2.0-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:c525543d91961c6955b2636b308569e84a1d1c5f5f2932041ab9ef46422f43e3", size = 465182, upload-time = "2026-05-18T04:30:20.846Z" },
{ url = "https://files.pythonhosted.org/packages/28/96/716f7e5f51339bf22963f3345f9f27d7f3b30e2eadc597e257c881dd3c53/watchfiles-1.2.0-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:a204794696ffb8f9b10fba6f7cb5216d42f3b2b71860ccac6b6e42f5f10973b0", size = 629841, upload-time = "2026-05-18T04:31:05.397Z" },
{ url = "https://files.pythonhosted.org/packages/4c/fe/c40783950fd771ccf66ab3ec2722d188a9af1c7f96c6e811f36e40c6e03f/watchfiles-1.2.0-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:10d86db20695afe7997ac9e1717637d6714a8d0220458c33f3d2061f54cec427", size = 658028, upload-time = "2026-05-18T04:31:48.22Z" },
{ url = "https://files.pythonhosted.org/packages/71/72/4508db1856d1d87fcbb3b63f4839bab1b5682cb0e8d224d122263c09654a/watchfiles-1.2.0-cp313-cp313-win32.whl", hash = "sha256:eb283ee99e21ad6443c8cdb06ac5b34b1308c329cbdf03fa02b445363714c799", size = 275183, upload-time = "2026-05-18T04:30:59.57Z" },
{ url = "https://files.pythonhosted.org/packages/f9/36/14b76ca57652e5cc5fd1c11f32a261292c08a0d19a00351013c2549cbfb2/watchfiles-1.2.0-cp313-cp313-win_amd64.whl", hash = "sha256:a0f27f01bee51861392bb6b7c4fdb290b27d1eb194e9e28788d68102a0e898d9", size = 288059, upload-time = "2026-05-18T04:32:07.937Z" },
{ url = "https://files.pythonhosted.org/packages/1b/8d/0a85e395398d8d20fadfe5c5d32c726eee17a519e78fb356f2cf7531bffe/watchfiles-1.2.0-cp313-cp313-win_arm64.whl", hash = "sha256:3651aa7058595e9cfb75d35dd5ada2bf9f48a5b8a0f3562821d3e210c507e077", size = 280186, upload-time = "2026-05-18T04:31:54.484Z" },
{ url = "https://files.pythonhosted.org/packages/37/68/36db056f1fdcc5f07302f56e631774d6835bcd6fa3ace402304621d5f9e5/watchfiles-1.2.0-cp313-cp313t-macosx_10_12_x86_64.whl", hash = "sha256:faea288b6f0ab1902ef08f4ca6de005dccf856c4e0c4f21b8c5fce02d90a1b08", size = 399031, upload-time = "2026-05-18T04:30:44.576Z" },
{ url = "https://files.pythonhosted.org/packages/c1/64/01a9d6f66a82a5c101ce939274106cc72759d62427e153f01edd2b9f87c2/watchfiles-1.2.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:01859b11fd9fbca670f4d5da00fbac282cfea9bd67a2125d8b2833a3b5617ea9", size = 391205, upload-time = "2026-05-18T04:30:25.413Z" },
{ url = "https://files.pythonhosted.org/packages/84/2c/0a44fe058cb4bb7b8ede6b6670698bbb7c0400740e378d00022189b7b31d/watchfiles-1.2.0-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:fff610d7bb2256a317bb1e96f0d7862c7aa8076733ee5df0fd41bbe76a24a4f4", size = 451892, upload-time = "2026-05-18T04:32:14.005Z" },
{ url = "https://files.pythonhosted.org/packages/67/a1/351e0d56cd35e6488b5c8b4fb11a809a5bc923e8fe8fed9faf8920be0c89/watchfiles-1.2.0-cp313-cp313t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b141a4891c995a039cd89e9a49e62df1dc8a559a5d1a6e4c7106d16c12777a55", size = 458867, upload-time = "2026-05-18T04:31:22.279Z" },
{ url = "https://files.pythonhosted.org/packages/d5/7d/9d09605187f1b838998624049fcf8bf47b73c1a3b76901fcac1782f62277/watchfiles-1.2.0-cp313-cp313t-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f22943b7770483f6ea0721c6b11d022947a98eb0acae14694de034f4d0d38925", size = 490217, upload-time = "2026-05-18T04:31:43.657Z" },
{ url = "https://files.pythonhosted.org/packages/60/5d/a17a16eccb182f04188cd308ec24b1a71a9b5c4e7098269cf35d9fa56d02/watchfiles-1.2.0-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1bc6195825b7dcd217968bb1f801a60fd4c16e8eeab5bedc7fe917d7d5995ab4", size = 571458, upload-time = "2026-05-18T04:32:11.875Z" },
{ url = "https://files.pythonhosted.org/packages/d3/3d/4dd457062083ab1938e5dfd45032eb425cee2ac817287ca8ff4356183e5d/watchfiles-1.2.0-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d4a4b147f5dca2a5d325a06a832fb43f345751adfbc63204aec30e0d9ca965a2", size = 464707, upload-time = "2026-05-18T04:30:43.492Z" },
{ url = "https://files.pythonhosted.org/packages/c6/71/ea8c57b128f5383de74d0c7d2d9c57ad7c9a65a930c451bd25d524b295b7/watchfiles-1.2.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4543579a9bdb0c9560039b4ffddbdb39545707659fbc430ce4c10f3f68d557f9", size = 454663, upload-time = "2026-05-18T04:30:16.061Z" },
{ url = "https://files.pythonhosted.org/packages/53/fd/2e812bf938406d7db351f0703ddd3fc6c061cf30d96153a77bc79a943a44/watchfiles-1.2.0-cp313-cp313t-manylinux_2_31_riscv64.whl", hash = "sha256:20aa0e708b920bde876a4aa82dc7dd6ebea228a63a67cda6632c2fc87b787efa", size = 463537, upload-time = "2026-05-18T04:31:44.9Z" },
{ url = "https://files.pythonhosted.org/packages/86/56/d17a7f1dd1bc3035f1072694a551301272f1739c2d8e319c927cb9e29b38/watchfiles-1.2.0-cp313-cp313t-musllinux_1_1_aarch64.whl", hash = "sha256:d413349d565dab74297f2a63e84a097936be69bf8f3b3801f27f380e32040f44", size = 629194, upload-time = "2026-05-18T04:31:14.141Z" },
{ url = "https://files.pythonhosted.org/packages/be/06/f1ff66bf5cae50aa4062779a0ecd0bbaf15e466195719074078947d9a17d/watchfiles-1.2.0-cp313-cp313t-musllinux_1_1_x86_64.whl", hash = "sha256:f28b2725eb8cce327b9b3ab02415c853011dc55c95832fe90de6bc56f5315f72", size = 656194, upload-time = "2026-05-18T04:31:47.14Z" },
{ url = "https://files.pythonhosted.org/packages/e7/54/a9c7ea9a82a4ac65e7004c0a03920b5cdd2f9c3b678757d9cd425aa51d53/watchfiles-1.2.0-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:b8c8358484d5fa12ef34f05b7f4168eaf1932f408725ff6d023c33ec17bd79d4", size = 400205, upload-time = "2026-05-18T04:32:05.153Z" },
{ url = "https://files.pythonhosted.org/packages/aa/5d/c9ab3534374a4a67450696905d6ef16a04405448b8dc52bd752ae50423d4/watchfiles-1.2.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:9f04b092229ad2c50126dd3c922c8822e51e605993764a33058d4a791ab42281", size = 392508, upload-time = "2026-05-18T04:30:54.849Z" },
{ url = "https://files.pythonhosted.org/packages/26/ca/1ad30103535cf0cecd7b993e8d50edc5351b1820e38f2d22e3df58962feb/watchfiles-1.2.0-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7a7ce236284f002a156f70add88efe5c70879cccbb658be0822c54b1306fc09d", size = 452448, upload-time = "2026-05-18T04:30:53.727Z" },
{ url = "https://files.pythonhosted.org/packages/37/a1/ceee2cdf2afbd715fa07758d39c9859513eae411b23196f7fd039e5feedd/watchfiles-1.2.0-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b9909cc2b48468b575eefa944919e1fe8a36c5849d5c7c168f80a8c1db69398e", size = 459605, upload-time = "2026-05-18T04:30:23.312Z" },
{ url = "https://files.pythonhosted.org/packages/e8/f6/421e30fd1cb3907a84ed92ab3f1983e37ba2dca015e9a894a048418417a2/watchfiles-1.2.0-cp314-cp314-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0a37faaed405c67e28e6be45a1fa4f206ef5a2860f27c237db9fa30704c38242", size = 490757, upload-time = "2026-05-18T04:30:47.358Z" },
{ url = "https://files.pythonhosted.org/packages/41/b0/55ed1b97ed08be7bba6f9a541cac15f2a858e1d74d2b07b6da70a82aab00/watchfiles-1.2.0-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9649193aa27bd9ff2e80ff29bfaa93085496c7a3a377592823cc58b77ee88add", size = 568672, upload-time = "2026-05-18T04:30:38.915Z" },
{ url = "https://files.pythonhosted.org/packages/d1/cf/d8ae8a80dd7bafab395ea7681c10237311bbf34d37704a8c744e7cf31fc7/watchfiles-1.2.0-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4e4ff8e37f99cf1da89e255e07c9c4b37c214038c4283707bdec308cb1b0ea1f", size = 464197, upload-time = "2026-05-18T04:30:09.914Z" },
{ url = "https://files.pythonhosted.org/packages/7c/8a/3076c496ca8dafe0e8cd03fcebdfc47be4b1174b4e5b24ff6e396e6b3af2/watchfiles-1.2.0-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:054dc20fd2e3132b4c3883b4a00d72fd6e1f56fdaf89fccd12e8057d74cd74d7", size = 453181, upload-time = "2026-05-18T04:30:14.829Z" },
{ url = "https://files.pythonhosted.org/packages/e5/10/9745e17c98e7b8a86454df0a3c7b5686bd650383f1e9f26e4ebcbd6cc0c0/watchfiles-1.2.0-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:e140ed30ebde76796b686e67c182cff10ea2fbab186fafd1560f74bb5a473a6e", size = 465109, upload-time = "2026-05-18T04:30:28.123Z" },
{ url = "https://files.pythonhosted.org/packages/8f/95/8ef4a95481d3e0cb52d62a06fa6e972e81424be2d9698b91a2fecca9904c/watchfiles-1.2.0-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:bb7e52ecf68ba46d22df23467b87cffeb2146908aa523ebfe803019618cfda06", size = 630653, upload-time = "2026-05-18T04:31:49.304Z" },
{ url = "https://files.pythonhosted.org/packages/fd/e4/3b3bf36b0f829b50c6ebcb8d031583863c59f923d6a6af3d485e470d0fac/watchfiles-1.2.0-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:23282a321c8baf9b3a3c4afff673f9fe65eb7fdc2338d765ccad9d3d1916a5ba", size = 657838, upload-time = "2026-05-18T04:31:06.497Z" },
{ url = "https://files.pythonhosted.org/packages/21/b1/6cbbb50c1f3002ab568777d44aa21206dfb8807a840990c4037523b51812/watchfiles-1.2.0-cp314-cp314-win32.whl", hash = "sha256:c0db965c5f79aa49fe672d297cf1febc5ad149b658594944f49a54a2b96270a7", size = 275108, upload-time = "2026-05-18T04:30:06.891Z" },
{ url = "https://files.pythonhosted.org/packages/92/45/190ce6db8dcb4536682cf75d3889ff1a27182a58cb519d343cb6d9ea63d8/watchfiles-1.2.0-cp314-cp314-win_amd64.whl", hash = "sha256:71283b39fd17e5408eb123bd37aeecfd9d54c81fc184421943208aadb879d103", size = 288441, upload-time = "2026-05-18T04:32:12.901Z" },
{ url = "https://files.pythonhosted.org/packages/74/0d/3eae1c2313ab08378431d907c3f8095ecca00f3eda33111cf4f0f2591799/watchfiles-1.2.0-cp314-cp314-win_arm64.whl", hash = "sha256:c5c19526f4e54a00f2666a6c0e9e40d582c09e865055ea7378bf0009aab857b3", size = 280684, upload-time = "2026-05-18T04:31:26.902Z" },
{ url = "https://files.pythonhosted.org/packages/b1/75/fb64e6c25d6b5ca636d03df34ffb1c6e9873303e76d27967e045f8df088f/watchfiles-1.2.0-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:d73a585accffa5ae39c17264c36ec3166d2fad7000c780f5ef83b2722afb9dd2", size = 398857, upload-time = "2026-05-18T04:32:17.108Z" },
{ url = "https://files.pythonhosted.org/packages/73/4e/9f7adf01754cbf81843722ccfec169d8f26c69778281a302855cecd2ee08/watchfiles-1.2.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:ae99b14c5f21e026e0e9d96f40e07d8570ebee6cafd9d8fc318354606daa7a28", size = 392413, upload-time = "2026-05-18T04:31:07.911Z" },
{ url = "https://files.pythonhosted.org/packages/47/c8/bec626bcc2d69f44b9acb24ce7d60ed7b16b73628eea747fcbd169d8edda/watchfiles-1.2.0-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4429f3b105524a10b72c3a819b091c495d2811d419c1e1e8df773a5a5974f831", size = 452409, upload-time = "2026-05-18T04:31:20.142Z" },
{ url = "https://files.pythonhosted.org/packages/00/b7/b6362068e81e7c556d155a34c35d40ac3ef42d747b06d7f6e5bf58e359c2/watchfiles-1.2.0-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:43d818978d06062d9b22c4fab2ebe44cf5213d42dc8e62bda8c2760cfa2eeb33", size = 458827, upload-time = "2026-05-18T04:32:06.219Z" },
{ url = "https://files.pythonhosted.org/packages/67/f8/9a813fa42afb1e0b4625e75f0479826644d3ee8dc287e093799bc01f390c/watchfiles-1.2.0-cp314-cp314t-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b9f732dc58b2dbe69e464ccf8fff7a03b0dd0be439da4c0720d3558527d3d6b4", size = 490104, upload-time = "2026-05-18T04:31:56.034Z" },
{ url = "https://files.pythonhosted.org/packages/2f/bf/27dfb6094ca4c9aad21298b5525b6c53cb36121ee454331d05161e58d130/watchfiles-1.2.0-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8f200104103feb097de4cab8fe4f5dd18a2026934c7dea98c55a2f5fd6d5a33b", size = 571360, upload-time = "2026-05-18T04:31:57.133Z" },
{ url = "https://files.pythonhosted.org/packages/fb/39/44a096d67270ea93df91d33877dbe91fbda3aa4f8ec2edf799d93eda8736/watchfiles-1.2.0-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:63ac26eefbf4af1741247d6fb68b11c49a25b2f7413fbd318a83a12aaa9cf666", size = 464644, upload-time = "2026-05-18T04:30:57.33Z" },
{ url = "https://files.pythonhosted.org/packages/0e/80/c7472203bad6268e3ef1ad260739704847898938ad7ea8b63a5131f46b50/watchfiles-1.2.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0c4997d4e4a55f0d02b6cde327322daf3a0400e5df6c6b15948994bf72497925", size = 454771, upload-time = "2026-05-18T04:30:48.736Z" },
{ url = "https://files.pythonhosted.org/packages/51/cf/3b10b268b4b7f0fc26e9debb5eef1998b515887840f444cd3ec80c688755/watchfiles-1.2.0-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:4c887eba18b7945ac73067a8b4a66f21cd46c2539b2bc68588f7be6c7eb6d26b", size = 463494, upload-time = "2026-05-18T04:31:33.826Z" },
{ url = "https://files.pythonhosted.org/packages/3d/3e/a4302545cd589262a0dc7d140e86f7688eba3f9c72776c27f7e23b8864c4/watchfiles-1.2.0-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:3416ff151bb6b5a8d8d11664974fbef4d9305b9b2957839ab5a270468fd8df30", size = 629383, upload-time = "2026-05-18T04:31:15.596Z" },
{ url = "https://files.pythonhosted.org/packages/db/99/d5649df0a9a410d45b7c882304d0b790903ac9b6e8f2cfd12114e0c6b9f2/watchfiles-1.2.0-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:0e831a271c035d89789cffc386b6aa1375f39f1cd25eb7ca0997e4970d152fc5", size = 656093, upload-time = "2026-05-18T04:31:58.707Z" },
{ url = "https://files.pythonhosted.org/packages/92/b9/362702539275019a54dd2e94511b31a9b89c5f9e6a21966de7eb692549fc/watchfiles-1.2.0-cp315-cp315-macosx_10_12_x86_64.whl", hash = "sha256:37a6721cdf3f65dbb13aa9503510ccb4451603ac837e44d265d7992a597e1374", size = 400109, upload-time = "2026-05-18T04:31:16.879Z" },
{ url = "https://files.pythonhosted.org/packages/8f/75/71d5ba62db781e5587bded1d944c675374bc4aa37ff33d5018d98e8b6538/watchfiles-1.2.0-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:2b37d10b5a63bd4d87e18472d80fa525bd670586fae62e5dd580452764879b65", size = 392167, upload-time = "2026-05-18T04:31:28.058Z" },
{ url = "https://files.pythonhosted.org/packages/3c/01/c66dd95d0423fe30d31820e2d1d5bda773764131bbb6ac0cb1cf303ac328/watchfiles-1.2.0-cp315-cp315-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0a105bc2283f67e8fbec74253ec2d94925de92ed72c0393f1206bf326b7b7b69", size = 452372, upload-time = "2026-05-18T04:31:00.836Z" },
{ url = "https://files.pythonhosted.org/packages/91/15/2fe99557e72f85627c6a8eed50d889e8d101623e060a22ad75b875cb932d/watchfiles-1.2.0-cp315-cp315-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:5327989a465505f05cfe06f04fa9d0c2fd5432bb243e10e6f012b1bdca3c8579", size = 459596, upload-time = "2026-05-18T04:31:34.96Z" },
{ url = "https://files.pythonhosted.org/packages/ed/23/d4acfa0023367428ed48351b3b9b267893037b6cadae55620c61c24bcfd4/watchfiles-1.2.0-cp315-cp315-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ecb47f183a8025b2aa18b546725c3657e542112ae9c0613a2af79b4fa8d04ad7", size = 490869, upload-time = "2026-05-18T04:31:59.923Z" },
{ url = "https://files.pythonhosted.org/packages/a4/5f/3164cbdce06c9fb95c4f7b9e2f9760b5e2797af43a9ecc317ef42a23a278/watchfiles-1.2.0-cp315-cp315-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8520a4ab0e37f770afc34459c4f8f7019e153f9124dc101c15538365875d1ab2", size = 571641, upload-time = "2026-05-18T04:32:00.948Z" },
{ url = "https://files.pythonhosted.org/packages/41/e6/85d3731c55e65cd7690f3f803d24c139588aaf863e4bf2148fe7a7fa1a19/watchfiles-1.2.0-cp315-cp315-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:71cd71740ed2c15211ebb237ced4e39a1cdf6f80566e5fe95428da1626f4fde6", size = 464444, upload-time = "2026-05-18T04:30:34.298Z" },
{ url = "https://files.pythonhosted.org/packages/f4/7d/562641012b8b09872742c3b8adf9629ec479fd78f8d68ae4a0c13da8add6/watchfiles-1.2.0-cp315-cp315-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f88af53d6ddaf72179ef613ddc905e6f4785f712b49b80b3bef9f3525e6194b4", size = 453593, upload-time = "2026-05-18T04:31:23.464Z" },
{ url = "https://files.pythonhosted.org/packages/56/fe/cb8ef3d6f929d14158fdaaad9925985b7310abc9384dcd4d82dd0016fb59/watchfiles-1.2.0-cp315-cp315-manylinux_2_31_riscv64.whl", hash = "sha256:cee9d5efd929efdac5f7e58f72b3376f676b64050a91c5b99a7094c5b2317488", size = 465096, upload-time = "2026-05-18T04:31:30.384Z" },
{ url = "https://files.pythonhosted.org/packages/25/91/80908e835e100527a9267147b08c0eee1fa6ab0ffec15edc04d1d44885f7/watchfiles-1.2.0-cp315-cp315-musllinux_1_1_aarch64.whl", hash = "sha256:b718bf356bbc15e559bd8ef41782b573b8ae0e3f177ab244b440568d7ea02cfb", size = 630638, upload-time = "2026-05-18T04:30:49.89Z" },
{ url = "https://files.pythonhosted.org/packages/46/4b/95ab2f256bb4af3cb2eb23b9317bda984ee6e0f11733a5c004a6c95b06e3/watchfiles-1.2.0-cp315-cp315-musllinux_1_1_x86_64.whl", hash = "sha256:922c0e019fe68b3ae392965a766b02a71ba1168c932cebc3733cd52c5fe5b377", size = 657684, upload-time = "2026-05-18T04:31:32.027Z" },
{ url = "https://files.pythonhosted.org/packages/23/f4/7513ef1e85fc4c6331b59479d6d72661fc391fbe543678052ac72c8b6c19/watchfiles-1.2.0-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:4674d49eb94706dfe666c069fc0a1b646ffcf920473492e209f6d5f60d3f0cc2", size = 403050, upload-time = "2026-05-18T04:30:36.753Z" },
{ url = "https://files.pythonhosted.org/packages/27/0b/a54103cfd732bb703c7a749222011a0483ef3705948dae3b203158601119/watchfiles-1.2.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:094b9b70103d4e963499bdea001ee3c2697b144cd9ae6218a62c0f89ec9e31db", size = 396629, upload-time = "2026-05-18T04:32:03.268Z" },
{ url = "https://files.pythonhosted.org/packages/5e/2c/73f31a3b893886206c3f54d73e8ad8dee58cdb2f69ad2622e0a8a9e07f4e/watchfiles-1.2.0-pp311-pypy311_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b0ef001f8c25ad0fa9529f914c1600647ecd0f542d11c19b7894768c67b6acb7", size = 457318, upload-time = "2026-05-18T04:31:01.932Z" },
{ url = "https://files.pythonhosted.org/packages/e9/f9/45d021e4a5cc7b9dd567f7cbb06d3b75f751a690063fb6cc7ec60f4e46b7/watchfiles-1.2.0-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a88fc94e647bc4eec523f1caa540258eb71d14278b9daf72fa1e2658a98df0f0", size = 457771, upload-time = "2026-05-18T04:30:56.331Z" },
]
[[package]]
name = "websockets"
version = "16.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/04/24/4b2031d72e840ce4c1ccb255f693b15c334757fc50023e4db9537080b8c4/websockets-16.0.tar.gz", hash = "sha256:5f6261a5e56e8d5c42a4497b364ea24d94d9563e8fbd44e78ac40879c60179b5", size = 179346, upload-time = "2026-01-10T09:23:47.181Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/20/74/221f58decd852f4b59cc3354cccaf87e8ef695fede361d03dc9a7396573b/websockets-16.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:04cdd5d2d1dacbad0a7bf36ccbcd3ccd5a30ee188f2560b7a62a30d14107b31a", size = 177343, upload-time = "2026-01-10T09:22:21.28Z" },
{ url = "https://files.pythonhosted.org/packages/19/0f/22ef6107ee52ab7f0b710d55d36f5a5d3ef19e8a205541a6d7ffa7994e5a/websockets-16.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:8ff32bb86522a9e5e31439a58addbb0166f0204d64066fb955265c4e214160f0", size = 175021, upload-time = "2026-01-10T09:22:22.696Z" },
{ url = "https://files.pythonhosted.org/packages/10/40/904a4cb30d9b61c0e278899bf36342e9b0208eb3c470324a9ecbaac2a30f/websockets-16.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:583b7c42688636f930688d712885cf1531326ee05effd982028212ccc13e5957", size = 175320, upload-time = "2026-01-10T09:22:23.94Z" },
{ url = "https://files.pythonhosted.org/packages/9d/2f/4b3ca7e106bc608744b1cdae041e005e446124bebb037b18799c2d356864/websockets-16.0-cp310-cp310-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:7d837379b647c0c4c2355c2499723f82f1635fd2c26510e1f587d89bc2199e72", size = 183815, upload-time = "2026-01-10T09:22:25.469Z" },
{ url = "https://files.pythonhosted.org/packages/86/26/d40eaa2a46d4302becec8d15b0fc5e45bdde05191e7628405a19cf491ccd/websockets-16.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:df57afc692e517a85e65b72e165356ed1df12386ecb879ad5693be08fac65dde", size = 185054, upload-time = "2026-01-10T09:22:27.101Z" },
{ url = "https://files.pythonhosted.org/packages/b0/ba/6500a0efc94f7373ee8fefa8c271acdfd4dca8bd49a90d4be7ccabfc397e/websockets-16.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:2b9f1e0d69bc60a4a87349d50c09a037a2607918746f07de04df9e43252c77a3", size = 184565, upload-time = "2026-01-10T09:22:28.293Z" },
{ url = "https://files.pythonhosted.org/packages/04/b4/96bf2cee7c8d8102389374a2616200574f5f01128d1082f44102140344cc/websockets-16.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:335c23addf3d5e6a8633f9f8eda77efad001671e80b95c491dd0924587ece0b3", size = 183848, upload-time = "2026-01-10T09:22:30.394Z" },
{ url = "https://files.pythonhosted.org/packages/02/8e/81f40fb00fd125357814e8c3025738fc4ffc3da4b6b4a4472a82ba304b41/websockets-16.0-cp310-cp310-win32.whl", hash = "sha256:37b31c1623c6605e4c00d466c9d633f9b812ea430c11c8a278774a1fde1acfa9", size = 178249, upload-time = "2026-01-10T09:22:32.083Z" },
{ url = "https://files.pythonhosted.org/packages/b4/5f/7e40efe8df57db9b91c88a43690ac66f7b7aa73a11aa6a66b927e44f26fa/websockets-16.0-cp310-cp310-win_amd64.whl", hash = "sha256:8e1dab317b6e77424356e11e99a432b7cb2f3ec8c5ab4dabbcee6add48f72b35", size = 178685, upload-time = "2026-01-10T09:22:33.345Z" },
{ url = "https://files.pythonhosted.org/packages/f2/db/de907251b4ff46ae804ad0409809504153b3f30984daf82a1d84a9875830/websockets-16.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:31a52addea25187bde0797a97d6fc3d2f92b6f72a9370792d65a6e84615ac8a8", size = 177340, upload-time = "2026-01-10T09:22:34.539Z" },
{ url = "https://files.pythonhosted.org/packages/f3/fa/abe89019d8d8815c8781e90d697dec52523fb8ebe308bf11664e8de1877e/websockets-16.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:417b28978cdccab24f46400586d128366313e8a96312e4b9362a4af504f3bbad", size = 175022, upload-time = "2026-01-10T09:22:36.332Z" },
{ url = "https://files.pythonhosted.org/packages/58/5d/88ea17ed1ded2079358b40d31d48abe90a73c9e5819dbcde1606e991e2ad/websockets-16.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:af80d74d4edfa3cb9ed973a0a5ba2b2a549371f8a741e0800cb07becdd20f23d", size = 175319, upload-time = "2026-01-10T09:22:37.602Z" },
{ url = "https://files.pythonhosted.org/packages/d2/ae/0ee92b33087a33632f37a635e11e1d99d429d3d323329675a6022312aac2/websockets-16.0-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:08d7af67b64d29823fed316505a89b86705f2b7981c07848fb5e3ea3020c1abe", size = 184631, upload-time = "2026-01-10T09:22:38.789Z" },
{ url = "https://files.pythonhosted.org/packages/c8/c5/27178df583b6c5b31b29f526ba2da5e2f864ecc79c99dae630a85d68c304/websockets-16.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7be95cfb0a4dae143eaed2bcba8ac23f4892d8971311f1b06f3c6b78952ee70b", size = 185870, upload-time = "2026-01-10T09:22:39.893Z" },
{ url = "https://files.pythonhosted.org/packages/87/05/536652aa84ddc1c018dbb7e2c4cbcd0db884580bf8e95aece7593fde526f/websockets-16.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d6297ce39ce5c2e6feb13c1a996a2ded3b6832155fcfc920265c76f24c7cceb5", size = 185361, upload-time = "2026-01-10T09:22:41.016Z" },
{ url = "https://files.pythonhosted.org/packages/6d/e2/d5332c90da12b1e01f06fb1b85c50cfc489783076547415bf9f0a659ec19/websockets-16.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:1c1b30e4f497b0b354057f3467f56244c603a79c0d1dafce1d16c283c25f6e64", size = 184615, upload-time = "2026-01-10T09:22:42.442Z" },
{ url = "https://files.pythonhosted.org/packages/77/fb/d3f9576691cae9253b51555f841bc6600bf0a983a461c79500ace5a5b364/websockets-16.0-cp311-cp311-win32.whl", hash = "sha256:5f451484aeb5cafee1ccf789b1b66f535409d038c56966d6101740c1614b86c6", size = 178246, upload-time = "2026-01-10T09:22:43.654Z" },
{ url = "https://files.pythonhosted.org/packages/54/67/eaff76b3dbaf18dcddabc3b8c1dba50b483761cccff67793897945b37408/websockets-16.0-cp311-cp311-win_amd64.whl", hash = "sha256:8d7f0659570eefb578dacde98e24fb60af35350193e4f56e11190787bee77dac", size = 178684, upload-time = "2026-01-10T09:22:44.941Z" },
{ url = "https://files.pythonhosted.org/packages/84/7b/bac442e6b96c9d25092695578dda82403c77936104b5682307bd4deb1ad4/websockets-16.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:71c989cbf3254fbd5e84d3bff31e4da39c43f884e64f2551d14bb3c186230f00", size = 177365, upload-time = "2026-01-10T09:22:46.787Z" },
{ url = "https://files.pythonhosted.org/packages/b0/fe/136ccece61bd690d9c1f715baaeefd953bb2360134de73519d5df19d29ca/websockets-16.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:8b6e209ffee39ff1b6d0fa7bfef6de950c60dfb91b8fcead17da4ee539121a79", size = 175038, upload-time = "2026-01-10T09:22:47.999Z" },
{ url = "https://files.pythonhosted.org/packages/40/1e/9771421ac2286eaab95b8575b0cb701ae3663abf8b5e1f64f1fd90d0a673/websockets-16.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:86890e837d61574c92a97496d590968b23c2ef0aeb8a9bc9421d174cd378ae39", size = 175328, upload-time = "2026-01-10T09:22:49.809Z" },
{ url = "https://files.pythonhosted.org/packages/18/29/71729b4671f21e1eaa5d6573031ab810ad2936c8175f03f97f3ff164c802/websockets-16.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:9b5aca38b67492ef518a8ab76851862488a478602229112c4b0d58d63a7a4d5c", size = 184915, upload-time = "2026-01-10T09:22:51.071Z" },
{ url = "https://files.pythonhosted.org/packages/97/bb/21c36b7dbbafc85d2d480cd65df02a1dc93bf76d97147605a8e27ff9409d/websockets-16.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e0334872c0a37b606418ac52f6ab9cfd17317ac26365f7f65e203e2d0d0d359f", size = 186152, upload-time = "2026-01-10T09:22:52.224Z" },
{ url = "https://files.pythonhosted.org/packages/4a/34/9bf8df0c0cf88fa7bfe36678dc7b02970c9a7d5e065a3099292db87b1be2/websockets-16.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:a0b31e0b424cc6b5a04b8838bbaec1688834b2383256688cf47eb97412531da1", size = 185583, upload-time = "2026-01-10T09:22:53.443Z" },
{ url = "https://files.pythonhosted.org/packages/47/88/4dd516068e1a3d6ab3c7c183288404cd424a9a02d585efbac226cb61ff2d/websockets-16.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:485c49116d0af10ac698623c513c1cc01c9446c058a4e61e3bf6c19dff7335a2", size = 184880, upload-time = "2026-01-10T09:22:55.033Z" },
{ url = "https://files.pythonhosted.org/packages/91/d6/7d4553ad4bf1c0421e1ebd4b18de5d9098383b5caa1d937b63df8d04b565/websockets-16.0-cp312-cp312-win32.whl", hash = "sha256:eaded469f5e5b7294e2bdca0ab06becb6756ea86894a47806456089298813c89", size = 178261, upload-time = "2026-01-10T09:22:56.251Z" },
{ url = "https://files.pythonhosted.org/packages/c3/f0/f3a17365441ed1c27f850a80b2bc680a0fa9505d733fe152fdf5e98c1c0b/websockets-16.0-cp312-cp312-win_amd64.whl", hash = "sha256:5569417dc80977fc8c2d43a86f78e0a5a22fee17565d78621b6bb264a115d4ea", size = 178693, upload-time = "2026-01-10T09:22:57.478Z" },
{ url = "https://files.pythonhosted.org/packages/cc/9c/baa8456050d1c1b08dd0ec7346026668cbc6f145ab4e314d707bb845bf0d/websockets-16.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:878b336ac47938b474c8f982ac2f7266a540adc3fa4ad74ae96fea9823a02cc9", size = 177364, upload-time = "2026-01-10T09:22:59.333Z" },
{ url = "https://files.pythonhosted.org/packages/7e/0c/8811fc53e9bcff68fe7de2bcbe75116a8d959ac699a3200f4847a8925210/websockets-16.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:52a0fec0e6c8d9a784c2c78276a48a2bdf099e4ccc2a4cad53b27718dbfd0230", size = 175039, upload-time = "2026-01-10T09:23:01.171Z" },
{ url = "https://files.pythonhosted.org/packages/aa/82/39a5f910cb99ec0b59e482971238c845af9220d3ab9fa76dd9162cda9d62/websockets-16.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:e6578ed5b6981005df1860a56e3617f14a6c307e6a71b4fff8c48fdc50f3ed2c", size = 175323, upload-time = "2026-01-10T09:23:02.341Z" },
{ url = "https://files.pythonhosted.org/packages/bd/28/0a25ee5342eb5d5f297d992a77e56892ecb65e7854c7898fb7d35e9b33bd/websockets-16.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:95724e638f0f9c350bb1c2b0a7ad0e83d9cc0c9259f3ea94e40d7b02a2179ae5", size = 184975, upload-time = "2026-01-10T09:23:03.756Z" },
{ url = "https://files.pythonhosted.org/packages/f9/66/27ea52741752f5107c2e41fda05e8395a682a1e11c4e592a809a90c6a506/websockets-16.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c0204dc62a89dc9d50d682412c10b3542d748260d743500a85c13cd1ee4bde82", size = 186203, upload-time = "2026-01-10T09:23:05.01Z" },
{ url = "https://files.pythonhosted.org/packages/37/e5/8e32857371406a757816a2b471939d51c463509be73fa538216ea52b792a/websockets-16.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:52ac480f44d32970d66763115edea932f1c5b1312de36df06d6b219f6741eed8", size = 185653, upload-time = "2026-01-10T09:23:06.301Z" },
{ url = "https://files.pythonhosted.org/packages/9b/67/f926bac29882894669368dc73f4da900fcdf47955d0a0185d60103df5737/websockets-16.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6e5a82b677f8f6f59e8dfc34ec06ca6b5b48bc4fcda346acd093694cc2c24d8f", size = 184920, upload-time = "2026-01-10T09:23:07.492Z" },
{ url = "https://files.pythonhosted.org/packages/3c/a1/3d6ccdcd125b0a42a311bcd15a7f705d688f73b2a22d8cf1c0875d35d34a/websockets-16.0-cp313-cp313-win32.whl", hash = "sha256:abf050a199613f64c886ea10f38b47770a65154dc37181bfaff70c160f45315a", size = 178255, upload-time = "2026-01-10T09:23:09.245Z" },
{ url = "https://files.pythonhosted.org/packages/6b/ae/90366304d7c2ce80f9b826096a9e9048b4bb760e44d3b873bb272cba696b/websockets-16.0-cp313-cp313-win_amd64.whl", hash = "sha256:3425ac5cf448801335d6fdc7ae1eb22072055417a96cc6b31b3861f455fbc156", size = 178689, upload-time = "2026-01-10T09:23:10.483Z" },
{ url = "https://files.pythonhosted.org/packages/f3/1d/e88022630271f5bd349ed82417136281931e558d628dd52c4d8621b4a0b2/websockets-16.0-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:8cc451a50f2aee53042ac52d2d053d08bf89bcb31ae799cb4487587661c038a0", size = 177406, upload-time = "2026-01-10T09:23:12.178Z" },
{ url = "https://files.pythonhosted.org/packages/f2/78/e63be1bf0724eeb4616efb1ae1c9044f7c3953b7957799abb5915bffd38e/websockets-16.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:daa3b6ff70a9241cf6c7fc9e949d41232d9d7d26fd3522b1ad2b4d62487e9904", size = 175085, upload-time = "2026-01-10T09:23:13.511Z" },
{ url = "https://files.pythonhosted.org/packages/bb/f4/d3c9220d818ee955ae390cf319a7c7a467beceb24f05ee7aaaa2414345ba/websockets-16.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:fd3cb4adb94a2a6e2b7c0d8d05cb94e6f1c81a0cf9dc2694fb65c7e8d94c42e4", size = 175328, upload-time = "2026-01-10T09:23:14.727Z" },
{ url = "https://files.pythonhosted.org/packages/63/bc/d3e208028de777087e6fb2b122051a6ff7bbcca0d6df9d9c2bf1dd869ae9/websockets-16.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:781caf5e8eee67f663126490c2f96f40906594cb86b408a703630f95550a8c3e", size = 185044, upload-time = "2026-01-10T09:23:15.939Z" },
{ url = "https://files.pythonhosted.org/packages/ad/6e/9a0927ac24bd33a0a9af834d89e0abc7cfd8e13bed17a86407a66773cc0e/websockets-16.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:caab51a72c51973ca21fa8a18bd8165e1a0183f1ac7066a182ff27107b71e1a4", size = 186279, upload-time = "2026-01-10T09:23:17.148Z" },
{ url = "https://files.pythonhosted.org/packages/b9/ca/bf1c68440d7a868180e11be653c85959502efd3a709323230314fda6e0b3/websockets-16.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:19c4dc84098e523fd63711e563077d39e90ec6702aff4b5d9e344a60cb3c0cb1", size = 185711, upload-time = "2026-01-10T09:23:18.372Z" },
{ url = "https://files.pythonhosted.org/packages/c4/f8/fdc34643a989561f217bb477cbc47a3a07212cbda91c0e4389c43c296ebf/websockets-16.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:a5e18a238a2b2249c9a9235466b90e96ae4795672598a58772dd806edc7ac6d3", size = 184982, upload-time = "2026-01-10T09:23:19.652Z" },
{ url = "https://files.pythonhosted.org/packages/dd/d1/574fa27e233764dbac9c52730d63fcf2823b16f0856b3329fc6268d6ae4f/websockets-16.0-cp314-cp314-win32.whl", hash = "sha256:a069d734c4a043182729edd3e9f247c3b2a4035415a9172fd0f1b71658a320a8", size = 177915, upload-time = "2026-01-10T09:23:21.458Z" },
{ url = "https://files.pythonhosted.org/packages/8a/f1/ae6b937bf3126b5134ce1f482365fde31a357c784ac51852978768b5eff4/websockets-16.0-cp314-cp314-win_amd64.whl", hash = "sha256:c0ee0e63f23914732c6d7e0cce24915c48f3f1512ec1d079ed01fc629dab269d", size = 178381, upload-time = "2026-01-10T09:23:22.715Z" },
{ url = "https://files.pythonhosted.org/packages/06/9b/f791d1db48403e1f0a27577a6beb37afae94254a8c6f08be4a23e4930bc0/websockets-16.0-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:a35539cacc3febb22b8f4d4a99cc79b104226a756aa7400adc722e83b0d03244", size = 177737, upload-time = "2026-01-10T09:23:24.523Z" },
{ url = "https://files.pythonhosted.org/packages/bd/40/53ad02341fa33b3ce489023f635367a4ac98b73570102ad2cdd770dacc9a/websockets-16.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:b784ca5de850f4ce93ec85d3269d24d4c82f22b7212023c974c401d4980ebc5e", size = 175268, upload-time = "2026-01-10T09:23:25.781Z" },
{ url = "https://files.pythonhosted.org/packages/74/9b/6158d4e459b984f949dcbbb0c5d270154c7618e11c01029b9bbd1bb4c4f9/websockets-16.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:569d01a4e7fba956c5ae4fc988f0d4e187900f5497ce46339c996dbf24f17641", size = 175486, upload-time = "2026-01-10T09:23:27.033Z" },
{ url = "https://files.pythonhosted.org/packages/e5/2d/7583b30208b639c8090206f95073646c2c9ffd66f44df967981a64f849ad/websockets-16.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:50f23cdd8343b984957e4077839841146f67a3d31ab0d00e6b824e74c5b2f6e8", size = 185331, upload-time = "2026-01-10T09:23:28.259Z" },
{ url = "https://files.pythonhosted.org/packages/45/b0/cce3784eb519b7b5ad680d14b9673a31ab8dcb7aad8b64d81709d2430aa8/websockets-16.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:152284a83a00c59b759697b7f9e9cddf4e3c7861dd0d964b472b70f78f89e80e", size = 186501, upload-time = "2026-01-10T09:23:29.449Z" },
{ url = "https://files.pythonhosted.org/packages/19/60/b8ebe4c7e89fb5f6cdf080623c9d92789a53636950f7abacfc33fe2b3135/websockets-16.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:bc59589ab64b0022385f429b94697348a6a234e8ce22544e3681b2e9331b5944", size = 186062, upload-time = "2026-01-10T09:23:31.368Z" },
{ url = "https://files.pythonhosted.org/packages/88/a8/a080593f89b0138b6cba1b28f8df5673b5506f72879322288b031337c0b8/websockets-16.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:32da954ffa2814258030e5a57bc73a3635463238e797c7375dc8091327434206", size = 185356, upload-time = "2026-01-10T09:23:32.627Z" },
{ url = "https://files.pythonhosted.org/packages/c2/b6/b9afed2afadddaf5ebb2afa801abf4b0868f42f8539bfe4b071b5266c9fe/websockets-16.0-cp314-cp314t-win32.whl", hash = "sha256:5a4b4cc550cb665dd8a47f868c8d04c8230f857363ad3c9caf7a0c3bf8c61ca6", size = 178085, upload-time = "2026-01-10T09:23:33.816Z" },
{ url = "https://files.pythonhosted.org/packages/9f/3e/28135a24e384493fa804216b79a6a6759a38cc4ff59118787b9fb693df93/websockets-16.0-cp314-cp314t-win_amd64.whl", hash = "sha256:b14dc141ed6d2dde437cddb216004bcac6a1df0935d79656387bd41632ba0bbd", size = 178531, upload-time = "2026-01-10T09:23:35.016Z" },
{ url = "https://files.pythonhosted.org/packages/72/07/c98a68571dcf256e74f1f816b8cc5eae6eb2d3d5cfa44d37f801619d9166/websockets-16.0-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:349f83cd6c9a415428ee1005cadb5c2c56f4389bc06a9af16103c3bc3dcc8b7d", size = 174947, upload-time = "2026-01-10T09:23:36.166Z" },
{ url = "https://files.pythonhosted.org/packages/7e/52/93e166a81e0305b33fe416338be92ae863563fe7bce446b0f687b9df5aea/websockets-16.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:4a1aba3340a8dca8db6eb5a7986157f52eb9e436b74813764241981ca4888f03", size = 175260, upload-time = "2026-01-10T09:23:37.409Z" },
{ url = "https://files.pythonhosted.org/packages/56/0c/2dbf513bafd24889d33de2ff0368190a0e69f37bcfa19009ef819fe4d507/websockets-16.0-pp311-pypy311_pp73-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:f4a32d1bd841d4bcbffdcb3d2ce50c09c3909fbead375ab28d0181af89fd04da", size = 176071, upload-time = "2026-01-10T09:23:39.158Z" },
{ url = "https://files.pythonhosted.org/packages/a5/8f/aea9c71cc92bf9b6cc0f7f70df8f0b420636b6c96ef4feee1e16f80f75dd/websockets-16.0-pp311-pypy311_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0298d07ee155e2e9fda5be8a9042200dd2e3bb0b8a38482156576f863a9d457c", size = 176968, upload-time = "2026-01-10T09:23:41.031Z" },
{ url = "https://files.pythonhosted.org/packages/9a/3f/f70e03f40ffc9a30d817eef7da1be72ee4956ba8d7255c399a01b135902a/websockets-16.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:a653aea902e0324b52f1613332ddf50b00c06fdaf7e92624fbf8c77c78fa5767", size = 178735, upload-time = "2026-01-10T09:23:42.259Z" },
{ url = "https://files.pythonhosted.org/packages/6f/28/258ebab549c2bf3e64d2b0217b973467394a9cea8c42f70418ca2c5d0d2e/websockets-16.0-py3-none-any.whl", hash = "sha256:1637db62fad1dc833276dded54215f2c7fa46912301a24bd94d45d46a011ceec", size = 171598, upload-time = "2026-01-10T09:23:45.395Z" },
] ]