Compare commits
11 Commits
000415404b
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| f77454b4cc | |||
| 12c767cd68 | |||
| e5cd87789f | |||
| 15462a95cb | |||
| 8fcf0941c0 | |||
| 42de7f9da0 | |||
| 126ae4eafa | |||
| a29009dc07 | |||
| 8afb460883 | |||
| 7155704b73 | |||
| b74923e435 |
27
.env.example
Normal file
27
.env.example
Normal 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
33
.gitignore
vendored
@ -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
168
AGENTS.md
@ -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.
|
|
||||||
@ -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
7
core/__init__.py
Normal 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
598
core/api.py
Normal 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
50
core/backend_client.py
Normal 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
83
core/config.py
Normal 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
145
core/db.py
Normal 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
375
core/repository.py
Normal 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
1182
core/service.py
Normal file
File diff suppressed because it is too large
Load Diff
@ -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}}
|
|
||||||
@ -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 .
|
|
||||||
|
|
||||||
上传文件
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@ -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`。
|
|
||||||
@ -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
|
|
||||||
}'
|
|
||||||
|
|
||||||
|
|
||||||
@ -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.
|
||||||
@ -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.
|
||||||
@ -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.
|
||||||
@ -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.
|
||||||
@ -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.
|
||||||
@ -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 的附件;
|
||||||
|
- 现有测试继续通过。
|
||||||
@ -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 隔离测试继续通过。
|
||||||
@ -1 +0,0 @@
|
|||||||
"""Evaluation utilities."""
|
|
||||||
@ -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.
|
|
||||||
@ -1,2 +0,0 @@
|
|||||||
"""Hermes memory evaluation helpers."""
|
|
||||||
|
|
||||||
@ -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
|
|
||||||
@ -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
@ -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"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
]
|
|
||||||
@ -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()
|
|
||||||
@ -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()
|
|
||||||
@ -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']}"
|
|
||||||
@ -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()
|
|
||||||
@ -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
22
main.py
Normal 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",
|
||||||
|
)
|
||||||
@ -1 +0,0 @@
|
|||||||
"""Lightweight Memory System API package."""
|
|
||||||
@ -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
|
|
||||||
@ -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")
|
|
||||||
@ -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,
|
|
||||||
)
|
|
||||||
@ -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
|
|
||||||
@ -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]
|
|
||||||
@ -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()
|
|
||||||
@ -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
|
|
||||||
@ -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}"
|
|
||||||
@ -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>"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -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.
|
|
||||||
@ -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())
|
|
||||||
@ -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
|
|
||||||
@ -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
|
|
||||||
@ -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",
|
||||||
|
]
|
||||||
|
|||||||
64
skill/memory-gateway-agent/SKILL.md
Normal file
64
skill/memory-gateway-agent/SKILL.md
Normal 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.
|
||||||
4
skill/memory-gateway-agent/agents/openai.yaml
Normal file
4
skill/memory-gateway-agent/agents/openai.yaml
Normal 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."
|
||||||
260
skill/memory-gateway-agent/references/api.md
Normal file
260
skill/memory-gateway-agent/references/api.md
Normal 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.
|
||||||
466
skill/memory-gateway-agent/scripts/memory_gateway.py
Executable file
466
skill/memory-gateway-agent/scripts/memory_gateway.py
Executable 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())
|
||||||
@ -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
|
|
||||||
```
|
|
||||||
@ -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."
|
|
||||||
@ -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 |
BIN
tests/simple-multimodal-image.png
Normal file
BIN
tests/simple-multimodal-image.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 259 B |
BIN
tests/simple-tone.wav
Normal file
BIN
tests/simple-tone.wav
Normal file
Binary file not shown.
1
tests/test.md
Normal file
1
tests/test.md
Normal file
@ -0,0 +1 @@
|
|||||||
|
这是测试文件
|
||||||
80
tests/test_backend_integration.py
Normal file
80
tests/test_backend_integration.py
Normal 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
35
tests/test_branding.py
Normal 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
325
tests/test_command.md
Normal 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
1604
tests/test_gateway.py
Normal file
File diff suppressed because it is too large
Load Diff
@ -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"
|
|
||||||
204
tests/test_memory_gateway_skill.py
Normal file
204
tests/test_memory_gateway_skill.py
Normal 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"])
|
||||||
@ -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,
|
|
||||||
)
|
|
||||||
]
|
|
||||||
@ -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
|
|
||||||
)
|
|
||||||
@ -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
|
|
||||||
@ -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
405
uv.lock
generated
@ -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" },
|
||||||
]
|
]
|
||||||
|
|||||||
Reference in New Issue
Block a user