Replace EverMemOS with EverOS backend

This commit is contained in:
2026-05-13 17:56:50 +08:00
parent 0acee1ec6c
commit b226749c61
37 changed files with 1327 additions and 1986 deletions

View File

@ -110,6 +110,6 @@ async def list_audit(limit: int = Query(default=100, ge=1, le=1000)):
return service.list_audit(limit)
@router.get("/evermemos/health")
async def evermemos_health():
return service.evermemos_health()
@router.get("/everos/health")
async def everos_health():
return service.everos_health()

View File

@ -1,6 +1,6 @@
"""Contract-first mapping spec for future v2 backend adapters.
This module intentionally does not call OpenViking, EverMemOS, or Obsidian.
This module intentionally does not call OpenViking, EverOS, or Obsidian.
It documents the stable Gateway control-plane fields that may be persisted in
outbox payload refs, SQLite metadata_json, audit summaries, and related control
records. It is not a validator for transient runtime adapter request objects:
@ -81,21 +81,21 @@ ADAPTER_MAPPING_SPECS: Final[tuple[AdapterMappingSpec, ...]] = (
result_model=BackendRetrieveResult,
),
AdapterMappingSpec(
backend_type=BackendType.EVERMEMOS,
backend_type=BackendType.EVEROS,
operation=BackendOperation.INGEST_TURN,
adapter_method="ingest_message",
backend_capability="message-level memory ingestion",
result_model=BackendWriteResult,
),
AdapterMappingSpec(
backend_type=BackendType.EVERMEMOS,
backend_type=BackendType.EVEROS,
operation=BackendOperation.COMMIT_SESSION,
adapter_method="extract_profile_long_term_v2",
backend_capability="episodic/profile/long-term extraction",
result_model=BackendCommitResult,
),
AdapterMappingSpec(
backend_type=BackendType.EVERMEMOS,
backend_type=BackendType.EVEROS,
operation=BackendOperation.RETRIEVE_CONTEXT,
adapter_method="retrieve_context_v2",
backend_capability="episodic/profile/long-term memory retrieval",

View File

@ -63,16 +63,16 @@ def normalize_openviking_commit_response(raw: dict[str, Any]) -> BackendCommitRe
)
def normalize_evermemos_commit_response(raw: dict[str, Any]) -> BackendCommitResult:
def normalize_everos_commit_response(raw: dict[str, Any]) -> BackendCommitResult:
status = _result_status(raw)
refs = [_produced_ref(BackendType.EVERMEMOS, item) for item in _extract_ref_items(raw)]
refs = [_produced_ref(BackendType.EVEROS, item) for item in _extract_ref_items(raw)]
return BackendCommitResult(
backend_type=BackendType.EVERMEMOS,
backend_type=BackendType.EVEROS,
operation=BackendOperation.COMMIT_SESSION,
status=status,
native_id=raw.get("native_id") or raw.get("session_id"),
native_uri=raw.get("native_uri") or raw.get("uri"),
retryable=_retryable_from_raw(BackendType.EVERMEMOS, raw),
retryable=_retryable_from_raw(BackendType.EVEROS, raw),
error_code=raw.get("error_code"),
error_message=raw.get("error") or raw.get("error_message"),
latency_ms=raw.get("latency_ms"),
@ -85,16 +85,16 @@ def normalize_openviking_ingest_response(raw: dict[str, Any]) -> BackendWriteRes
return _write_result(BackendType.OPENVIKING, raw)
def normalize_evermemos_ingest_response(raw: dict[str, Any]) -> BackendWriteResult:
return _write_result(BackendType.EVERMEMOS, raw)
def normalize_everos_ingest_response(raw: dict[str, Any]) -> BackendWriteResult:
return _write_result(BackendType.EVEROS, raw)
def normalize_openviking_retrieve_response(raw: dict[str, Any]) -> BackendRetrieveResult:
return _retrieve_result(BackendType.OPENVIKING, raw)
def normalize_evermemos_retrieve_response(raw: dict[str, Any]) -> BackendRetrieveResult:
return _retrieve_result(BackendType.EVERMEMOS, raw)
def normalize_everos_retrieve_response(raw: dict[str, Any]) -> BackendRetrieveResult:
return _retrieve_result(BackendType.EVEROS, raw)
def map_backend_error_to_retryable(
@ -129,10 +129,12 @@ def _write_result(backend_type: BackendType, raw: dict[str, Any]) -> BackendWrit
raw.get("native_id")
or raw.get("id")
or raw.get("memory_id")
or raw.get("request_id")
or raw.get("session_id")
or data.get("native_id")
or data.get("id")
or data.get("memory_id")
or data.get("request_id")
or data.get("session_id")
)
native_uri = (
@ -152,8 +154,8 @@ def _write_result(backend_type: BackendType, raw: dict[str, Any]) -> BackendWrit
native_id=native_id,
native_uri=native_uri,
retryable=_retryable_from_raw(backend_type, raw),
error_code=raw.get("error_code"),
error_message=raw.get("error") or raw.get("error_message"),
error_code=raw.get("error_code") or raw.get("code"),
error_message=raw.get("error") or raw.get("error_message") or raw.get("message"),
latency_ms=raw.get("latency_ms"),
metadata=safe_backend_metadata(raw.get("metadata") or raw),
)
@ -174,8 +176,8 @@ def _retrieve_result(backend_type: BackendType, raw: dict[str, Any]) -> BackendR
native_id=raw.get("native_id") or raw.get("session_id"),
native_uri=raw.get("native_uri") or raw.get("uri"),
retryable=_retryable_from_raw(backend_type, raw),
error_code=raw.get("error_code"),
error_message=raw.get("error") or raw.get("error_message"),
error_code=raw.get("error_code") or raw.get("code"),
error_message=raw.get("error") or raw.get("error_message") or raw.get("message"),
latency_ms=raw.get("latency_ms"),
items=[_retrieve_item(backend_type, item) for item in _extract_retrieve_items(raw)],
metadata=safe_backend_metadata(raw.get("metadata") or raw),

View File

@ -11,7 +11,7 @@ OPENVIKING_REF_TYPE_MAP = {
"session_summary": MemoryRefType.SESSION_ARCHIVE,
}
EVERMEMOS_REF_TYPE_MAP = {
EVEROS_REF_TYPE_MAP = {
"message_memory": MemoryRefType.MESSAGE_MEMORY,
"episodic_memory": MemoryRefType.EPISODIC_MEMORY,
"episode": MemoryRefType.EPISODIC_MEMORY,
@ -41,8 +41,8 @@ def map_backend_ref_type(
if backend_type == BackendType.OPENVIKING:
mapped = OPENVIKING_REF_TYPE_MAP.get(normalized, MemoryRefType.SESSION_ARCHIVE)
elif backend_type == BackendType.EVERMEMOS:
mapped = EVERMEMOS_REF_TYPE_MAP.get(normalized, MemoryRefType.LONG_TERM_MEMORY)
elif backend_type == BackendType.EVEROS:
mapped = EVEROS_REF_TYPE_MAP.get(normalized, MemoryRefType.LONG_TERM_MEMORY)
elif backend_type == BackendType.OBSIDIAN:
mapped = OBSIDIAN_REF_TYPE_MAP.get(normalized, MemoryRefType.DRAFT_REVIEW)
else:
@ -59,8 +59,8 @@ def map_backend_ref_type(
def _known_backend_ref_types(backend_type: BackendType) -> set[str]:
if backend_type == BackendType.OPENVIKING:
return set(OPENVIKING_REF_TYPE_MAP)
if backend_type == BackendType.EVERMEMOS:
return set(EVERMEMOS_REF_TYPE_MAP)
if backend_type == BackendType.EVEROS:
return set(EVEROS_REF_TYPE_MAP)
if backend_type == BackendType.OBSIDIAN:
return set(OBSIDIAN_REF_TYPE_MAP)
return set()

View File

@ -6,7 +6,7 @@ from typing import Optional
import yaml
from pydantic import ValidationError
from .types import Config, ServerConfig, OpenVikingConfig, EverMemOSConfig, MemoryConfig, LoggingConfig, LLMConfig, ObsidianConfig, StorageConfig
from .types import Config, ServerConfig, OpenVikingConfig, EverOSConfig, MemoryConfig, LoggingConfig, LLMConfig, ObsidianConfig, StorageConfig
def load_config(config_path: Optional[str] = None) -> Config:
@ -30,7 +30,7 @@ def load_config(config_path: Optional[str] = None) -> Config:
config = Config(
server=ServerConfig(**data.get("server", {})),
openviking=OpenVikingConfig(**data.get("openviking", {})),
evermemos=EverMemOSConfig(**data.get("evermemos", {})),
everos=EverOSConfig(**data.get("everos", {})),
memory=MemoryConfig(**data.get("memory", {})),
logging=LoggingConfig(**data.get("logging", {})),
llm=LLMConfig(**data.get("llm", {})),
@ -62,11 +62,11 @@ _config: Optional[Config] = None
def _apply_env_overrides(config: Config) -> Config:
openviking_updates = _backend_env_updates("OPENVIKING")
evermemos_updates = _backend_env_updates("EVERMEMOS")
everos_updates = _backend_env_updates("EVEROS")
if openviking_updates:
config.openviking = config.openviking.model_copy(update=openviking_updates)
if evermemos_updates:
config.evermemos = config.evermemos.model_copy(update=evermemos_updates)
if everos_updates:
config.everos = config.everos.model_copy(update=everos_updates)
return config
@ -83,6 +83,9 @@ def _backend_env_updates(prefix: str) -> dict:
"TIMEOUT_SECONDS": "timeout",
"VERIFY_SSL": "verify_ssl",
"INGEST_PATH": "ingest_path",
"SEARCH_PATH": "search_path",
"FLUSH_PATH": "flush_path",
"RETRIEVE_METHOD": "retrieve_method",
}
for env_name, field_name in env_map.items():
value = os.environ.get(f"{prefix}_{env_name}")

View File

@ -1,313 +0,0 @@
"""Client for the external EverMemOS consolidation service."""
from __future__ import annotations
from json import JSONDecodeError
from typing import Any
import httpx
from .backend_contracts import BackendCommitResult, BackendOperation, BackendResultStatus, BackendRetrieveResult, BackendWriteResult
from .backend_normalization import (
map_backend_error_to_retryable,
normalize_evermemos_commit_response,
normalize_evermemos_ingest_response,
normalize_evermemos_retrieve_response,
)
from .config import get_config
from .schemas import AccessContext, EpisodeRecord, MemoryRecord
from .schemas_v2 import BackendType
class EverMemOSError(RuntimeError):
"""Raised when the external EverMemOS service cannot consolidate."""
class EverMemOSClient:
"""Small HTTP client with a tolerant response normalizer.
The deployed EverMemOS API may evolve independently from Memory Gateway.
Gateway sends a stable payload and accepts several common response shapes:
`result`, `data`, or the raw top-level object with `candidates/promoted`.
"""
def __init__(
self,
base_url: str | None = None,
api_key: str | None = None,
timeout: int | None = None,
enabled: bool | None = None,
mode: str | None = None,
verify_ssl: bool | None = None,
health_path: str | None = None,
ingest_path: str | None = None,
consolidate_path: str | None = None,
transport: httpx.BaseTransport | None = None,
) -> None:
config = get_config().evermemos
self.base_url = (base_url if base_url is not None else config.url).rstrip("/")
self.api_key = api_key if api_key is not None else config.api_key
self.timeout = timeout or config.timeout
self.enabled = config.enabled if enabled is None else enabled
self.mode = mode or config.mode
self.verify_ssl = config.verify_ssl if verify_ssl is None else verify_ssl
self.health_path = health_path or config.health_path
self.ingest_path = ingest_path or config.ingest_path
self.consolidate_path = consolidate_path or config.consolidate_path
self.transport = transport
def _headers(self) -> dict[str, str]:
headers = {"Content-Type": "application/json"}
if self.api_key:
headers["X-API-Key"] = self.api_key
headers["Authorization"] = f"Bearer {self.api_key}"
return headers
def health(self) -> dict[str, Any]:
url = self.base_url + self.health_path
try:
health_timeout = httpx.Timeout(min(self.timeout, 2.0), connect=min(self.timeout, 0.5))
with httpx.Client(timeout=health_timeout, headers=self._headers()) as client:
response = client.get(url)
response.raise_for_status()
return {"status": "ok", "url": self.base_url, "response": response.json()}
except Exception as exc: # noqa: BLE001
return {"status": "error", "url": self.base_url, "error": str(exc)}
def ingest_message(self, payload: dict[str, Any]) -> BackendWriteResult:
"""v2 adapter placeholder for message-level EverMemOS ingestion.
Mapping spec: `backend_adapter_mapping.AdapterMappingSpec` maps
EverMemOS ingest_turn to this method and requires BackendWriteResult.
Payloads must contain only control-plane fields; raw request bodies are
not persisted by the Gateway control-plane store.
TODO(v2): bind this to EverMemOS `/api/v1/memories` or its stable
message ingestion API after the external contract settles.
"""
runtime_payload = self._build_ingest_payload(payload)
if self._use_real_api:
return self._ingest_message_real(runtime_payload)
raw = {
"status": "skipped",
"memory_id": runtime_payload.get("turn_id"),
"metadata": {
"reason": "evermemos_v2_ingest_adapter_not_configured",
"schema_version": "evermemos.fixture.ingest.v2",
},
}
return self._normalize_ingest_response(raw)
@property
def _use_real_api(self) -> bool:
# Real ingest is strictly gated by mode=real. The legacy `enabled`
# field is retained for config compatibility, but must not trigger
# network traffic by itself.
return self.mode == "real"
def _ingest_message_real(self, runtime_payload: dict[str, Any]) -> BackendWriteResult:
if not self.base_url:
return self._failed_ingest_result(
error_code="config_error",
error_message="EverMemOS real ingest is enabled but base_url is missing",
retryable=False,
)
try:
with httpx.Client(
base_url=self.base_url,
headers=self._headers(),
timeout=self.timeout,
verify=self.verify_ssl,
transport=self.transport,
) as client:
response = client.post(self.ingest_path, json=runtime_payload)
if response.status_code >= 400:
return self._failed_ingest_result(
error_code=f"http_{response.status_code}",
error_message=f"EverMemOS ingest failed with HTTP {response.status_code}",
retryable=self._map_error(response),
)
try:
raw = response.json()
except (JSONDecodeError, ValueError):
return self._failed_ingest_result(
error_code="invalid_json",
error_message="EverMemOS ingest returned invalid JSON",
retryable=True,
)
if not isinstance(raw, dict):
return self._failed_ingest_result(
error_code="unexpected_response",
error_message="EverMemOS ingest returned an unexpected response shape",
retryable=True,
)
return self._normalize_ingest_response(raw)
except httpx.TimeoutException as exc:
return self._failed_ingest_result("timeout", self._safe_error_message(exc), retryable=self._map_error(exc))
except httpx.RequestError as exc:
return self._failed_ingest_result("network_error", self._safe_error_message(exc), retryable=self._map_error(exc))
except Exception as exc: # noqa: BLE001
return self._failed_ingest_result("unexpected_error", self._safe_error_message(exc), retryable=self._map_error(exc))
def extract_profile_long_term_v2(self, payload: dict[str, Any]) -> BackendCommitResult:
"""v2 adapter placeholder for profile / long-term extraction.
Mapping spec: commit_session returns BackendCommitResult and should
produce native episodic/profile/long-term refs once the real API is stable.
"""
runtime_payload = self._build_commit_payload(payload)
raw = {
"status": "success",
"session_id": runtime_payload.get("session_id"),
"metadata": {
"reason": "evermemos_v2_commit_fixture",
"schema_version": "evermemos.fixture.commit.v2",
},
"data": {
"produced_refs": [
{
"ref_type": "profile",
"profile_id": f"em_profile:{runtime_payload.get('user_id') or 'unknown'}",
"metadata": {"schema_version": "evermemos.fixture.profile.v2"},
},
{
"ref_type": "long_term_memory",
"memory_id": f"em_long_term:{runtime_payload.get('session_id')}",
"metadata": {"schema_version": "evermemos.fixture.long_term.v2"},
},
]
},
}
return self._normalize_commit_response(raw)
def retrieve_context_v2(self, payload: dict[str, Any]) -> BackendRetrieveResult:
"""v2 adapter placeholder for episodic/profile/long-term retrieval.
Mapping spec: retrieve_context returns BackendRetrieveResult with
normalized context items, not raw backend payload dumps.
"""
raw = {
"status": "success",
"metadata": {
"reason": "evermemos_v2_retrieve_fixture",
"schema_version": "evermemos.fixture.retrieve.v2",
},
"data": {
"items": [
{
"text": "EverMemOS fixture profile context.",
"profile_id": f"em_profile:{payload.get('user_id') or 'unknown'}",
"score": 0.72,
"memory_type": "profile",
"metadata": {"schema_version": "evermemos.fixture.retrieve.item.v2"},
},
{
"text": "EverMemOS fixture long-term memory context.",
"memory_id": f"em_long_term:{payload.get('session_id') or 'unknown'}",
"score": 0.69,
"memory_type": "long_term_memory",
"metadata": {"schema_version": "evermemos.fixture.retrieve.item.v2"},
},
]
},
}
return self._normalize_retrieve_response(raw)
def _build_ingest_payload(self, payload: dict[str, Any]) -> dict[str, Any]:
# Runtime-only adapter payload. It may include conversation content for
# the current request lifecycle; callers must not persist it to SQLite.
return dict(payload)
def _build_commit_payload(self, payload: dict[str, Any]) -> dict[str, Any]:
return dict(payload)
def _normalize_ingest_response(self, raw: dict[str, Any]) -> BackendWriteResult:
return normalize_evermemos_ingest_response(raw)
def _normalize_commit_response(self, raw: dict[str, Any]) -> BackendCommitResult:
return normalize_evermemos_commit_response(raw)
def _normalize_retrieve_response(self, raw: dict[str, Any]) -> BackendRetrieveResult:
return normalize_evermemos_retrieve_response(raw)
def _map_error(self, exc_or_response: Any) -> bool:
status_code = getattr(exc_or_response, "status_code", None)
error_code = getattr(exc_or_response, "error_code", None)
error_message = str(exc_or_response) if exc_or_response is not None else None
return map_backend_error_to_retryable(
BackendType.EVERMEMOS,
status_code=status_code,
error_code=error_code,
error_message=error_message,
)
def _failed_ingest_result(self, error_code: str, error_message: str, retryable: bool) -> BackendWriteResult:
return BackendWriteResult(
backend_type=BackendType.EVERMEMOS,
operation=BackendOperation.INGEST_TURN,
status=BackendResultStatus.FAILED,
retryable=retryable,
error_code=error_code,
error_message=error_message,
metadata={"error_code": error_code},
)
def _safe_error_message(self, exc: Exception) -> str:
return exc.__class__.__name__
def consolidate_session(
self,
session_id: str,
ctx: AccessContext,
episodes: list[EpisodeRecord],
existing_memories: list[MemoryRecord],
min_importance: float,
target_namespace: str | None,
) -> dict[str, Any]:
payload = {
"schema_version": "memory-gateway.evermemos.consolidate.v1",
"session_id": session_id,
"context": ctx.model_dump(mode="json"),
"min_importance": min_importance,
"target_namespace": target_namespace,
"episodes": [episode.model_dump(mode="json") for episode in episodes],
"existing_memories": [memory.model_dump(mode="json") for memory in existing_memories],
}
paths = [
self.consolidate_path,
"/v1/sessions/consolidate",
"/v1/memory/consolidate",
"/api/v1/sessions/consolidate",
"/api/consolidate",
"/consolidate",
]
errors: list[str] = []
for path in dict.fromkeys(paths):
try:
with httpx.Client(timeout=self.timeout, headers=self._headers()) as client:
response = client.post(self.base_url + path, json=payload)
if response.status_code == 404:
errors.append(f"{path}: 404")
continue
response.raise_for_status()
return self._normalize_response(response.json(), path)
except Exception as exc: # noqa: BLE001
errors.append(f"{path}: {exc}")
if "Connection refused" in str(exc) or "timed out" in str(exc):
break
raise EverMemOSError("; ".join(errors) or "EverMemOS consolidation failed")
def _normalize_response(self, payload: dict[str, Any], path: str) -> dict[str, Any]:
data = payload.get("result") or payload.get("data") or payload
return {
"backend": "external",
"service_url": self.base_url,
"endpoint": path,
"raw": payload,
"session_id": data.get("session_id"),
"episodes": data.get("episodes"),
"candidates": data.get("candidates") or data.get("candidate_memories") or [],
"promoted": data.get("promoted") or data.get("promoted_memories") or data.get("memories") or [],
"duplicates": data.get("duplicates") or [],
"conflicts": data.get("conflicts") or [],
"review_drafts": data.get("review_drafts") or [],
}

View File

@ -1,149 +0,0 @@
"""Standalone EverMemOS-compatible consolidation service.
This is a lightweight local service for POC use. It intentionally exposes the
same HTTP contract that Memory Gateway calls:
POST /v1/sessions/consolidate
The service does not own Memory Gateway's metadata database. It receives
episodes and existing memories in the request, returns candidate/promoted
MemoryRecord payloads, and creates Obsidian review drafts for high-value or
conflicting candidates.
"""
from __future__ import annotations
import argparse
import hashlib
import logging
from typing import Any
from fastapi import FastAPI
from pydantic import BaseModel, Field
from .config import load_config, set_config
from .repositories import InMemoryRepository
from .schemas import AccessContext, EpisodeRecord, MemoryRecord
from .workers.evermemos_worker import EverMemOSWorker
logger = logging.getLogger(__name__)
class ConsolidateRequest(BaseModel):
schema_version: str = "memory-gateway.evermemos.consolidate.v1"
session_id: str
context: dict[str, Any]
min_importance: float = 0.6
target_namespace: str | None = None
episodes: list[dict[str, Any]] = Field(default_factory=list)
existing_memories: list[dict[str, Any]] = Field(default_factory=list)
class MemoryIngestRequest(BaseModel):
workspace_id: str | None = None
user_id: str
session_id: str
turn_id: str
role: str = "user"
content: str
metadata: dict[str, Any] = Field(default_factory=dict)
source_type: str | None = None
source_event_id: str | None = None
app = FastAPI(title="Local EverMemOS POC Service", version="0.1.0")
@app.get("/health")
async def health() -> dict[str, Any]:
return {
"status": "ok",
"service": "evermemos-local",
"version": "0.1.0",
"contract": "memory-gateway.evermemos.consolidate.v1",
}
@app.post("/api/v1/memories")
async def ingest_memory(request: MemoryIngestRequest) -> dict[str, Any]:
"""Accept message-level ingest for local real-adapter smoke tests.
This POC endpoint intentionally does not persist raw conversation content.
It only returns a stable backend reference that Memory Gateway can store as
control-plane metadata.
"""
seed = "|".join(
[
request.workspace_id or "",
request.user_id,
request.session_id,
request.turn_id,
request.source_event_id or "",
]
)
memory_id = "em_" + hashlib.sha256(seed.encode("utf-8")).hexdigest()[:24]
return {
"status": "success",
"memory_id": memory_id,
"native_uri": f"evermemos://memories/{memory_id}",
"metadata": {
"schema_version": "evermemos.local.ingest.v1",
"source_channel": request.metadata.get("source_channel") or request.metadata.get("channel"),
},
}
@app.post("/v1/sessions/consolidate")
async def consolidate_session(request: ConsolidateRequest) -> dict[str, Any]:
repo = InMemoryRepository()
ctx = AccessContext.model_validate(request.context)
for item in request.existing_memories:
try:
repo.upsert_memory(MemoryRecord.model_validate(item))
except Exception as exc: # noqa: BLE001
logger.warning("Skipping invalid existing memory: %s", exc)
for item in request.episodes:
try:
repo.append_episode(EpisodeRecord.model_validate(item))
except Exception as exc: # noqa: BLE001
logger.warning("Skipping invalid episode: %s", exc)
worker = EverMemOSWorker(repo)
result = worker.consolidate_session(
session_id=request.session_id,
ctx=ctx,
min_importance=request.min_importance,
target_namespace=request.target_namespace,
)
return {
"status": "ok",
"backend": "evermemos-local",
"result": {
"session_id": result.session_id,
"episodes": result.episodes,
"candidates": [memory.model_dump(mode="json") for memory in result.candidates],
"promoted": [memory.model_dump(mode="json") for memory in result.promoted],
"duplicates": result.duplicates,
"conflicts": result.conflicts,
"review_drafts": result.review_drafts,
},
}
def main() -> None:
import uvicorn
parser = argparse.ArgumentParser(description="Run the local EverMemOS POC service.")
parser.add_argument("--config", default="config.yaml")
parser.add_argument("--host", default="127.0.0.1")
parser.add_argument("--port", type=int, default=1995)
args = parser.parse_args()
config = load_config(args.config)
set_config(config)
uvicorn.run(app, host=args.host, port=args.port, log_level=config.logging.level.lower())
if __name__ == "__main__":
main()

View File

@ -0,0 +1,496 @@
"""Client for the external EverOS memory service."""
from __future__ import annotations
from datetime import datetime, timezone
from json import JSONDecodeError
from typing import Any
import httpx
from .backend_contracts import BackendCommitResult, BackendOperation, BackendResultStatus, BackendRetrieveResult, BackendWriteResult
from .backend_normalization import (
map_backend_error_to_retryable,
normalize_everos_commit_response,
normalize_everos_ingest_response,
normalize_everos_retrieve_response,
)
from .config import get_config
from .schemas import AccessContext, EpisodeRecord, MemoryRecord
from .schemas_v2 import BackendType
class EverOSError(RuntimeError):
"""Raised when the external EverOS service cannot process a request."""
class EverOSClient:
"""Small HTTP client with a tolerant response normalizer.
The deployed EverOS API may evolve independently from Memory Gateway.
Gateway sends a stable payload and accepts several common response shapes:
`result`, `data`, or the raw top-level object with `candidates/promoted`.
"""
def __init__(
self,
base_url: str | None = None,
api_key: str | None = None,
timeout: int | None = None,
enabled: bool | None = None,
mode: str | None = None,
verify_ssl: bool | None = None,
health_path: str | None = None,
ingest_path: str | None = None,
search_path: str | None = None,
flush_path: str | None = None,
retrieve_method: str | None = None,
transport: httpx.BaseTransport | None = None,
) -> None:
config = get_config().everos
self.base_url = (base_url if base_url is not None else config.url).rstrip("/")
self.api_key = api_key if api_key is not None else config.api_key
self.timeout = timeout or config.timeout
self.enabled = config.enabled if enabled is None else enabled
self.mode = mode or config.mode
self.verify_ssl = config.verify_ssl if verify_ssl is None else verify_ssl
self.health_path = health_path or config.health_path
self.ingest_path = ingest_path or config.ingest_path
self.search_path = search_path or config.search_path
self.flush_path = flush_path or config.flush_path
self.retrieve_method = retrieve_method or config.retrieve_method
self.transport = transport
def _headers(self) -> dict[str, str]:
headers = {"Content-Type": "application/json"}
if self.api_key:
headers["X-API-Key"] = self.api_key
headers["Authorization"] = f"Bearer {self.api_key}"
return headers
def health(self) -> dict[str, Any]:
url = self.base_url + self.health_path
try:
health_timeout = httpx.Timeout(min(self.timeout, 2.0), connect=min(self.timeout, 0.5))
with httpx.Client(timeout=health_timeout, headers=self._headers()) as client:
response = client.get(url)
response.raise_for_status()
return {"status": "ok", "url": self.base_url, "response": response.json()}
except Exception as exc: # noqa: BLE001
return {"status": "error", "url": self.base_url, "error": str(exc)}
def ingest_message(self, payload: dict[str, Any]) -> BackendWriteResult:
"""Write one Gateway turn to EverOS."""
runtime_payload = self._build_ingest_payload(payload)
if self._use_real_api:
return self._ingest_message_real(runtime_payload)
raw = {
"status": "skipped",
"memory_id": (runtime_payload.get("messages") or [{}])[0].get("message_id"),
"metadata": {
"reason": "everos_v2_ingest_adapter_not_configured",
"schema_version": "everos.fixture.ingest.v2",
},
}
return self._normalize_ingest_response(raw)
@property
def _use_real_api(self) -> bool:
# Real ingest is strictly gated by mode=real. The legacy `enabled`
# field is retained for config compatibility, but must not trigger
# network traffic by itself.
return self.mode == "real"
def _ingest_message_real(self, runtime_payload: dict[str, Any]) -> BackendWriteResult:
if not self.base_url:
return self._failed_ingest_result(
error_code="config_error",
error_message="EverOS real ingest is enabled but base_url is missing",
retryable=False,
)
try:
with httpx.Client(
base_url=self.base_url,
headers=self._headers(),
timeout=self.timeout,
verify=self.verify_ssl,
transport=self.transport,
) as client:
response = client.post(self.ingest_path, json=runtime_payload)
if response.status_code >= 400:
return self._failed_ingest_result(
error_code=f"http_{response.status_code}",
error_message=f"EverOS ingest failed with HTTP {response.status_code}",
retryable=self._map_error(response),
)
try:
raw = response.json()
except (JSONDecodeError, ValueError):
return self._failed_ingest_result(
error_code="invalid_json",
error_message="EverOS ingest returned invalid JSON",
retryable=True,
)
if not isinstance(raw, dict):
return self._failed_ingest_result(
error_code="unexpected_response",
error_message="EverOS ingest returned an unexpected response shape",
retryable=True,
)
return self._normalize_ingest_response(raw)
except httpx.TimeoutException as exc:
return self._failed_ingest_result("timeout", self._safe_error_message(exc), retryable=self._map_error(exc))
except httpx.RequestError as exc:
return self._failed_ingest_result("network_error", self._safe_error_message(exc), retryable=self._map_error(exc))
except Exception as exc: # noqa: BLE001
return self._failed_ingest_result("unexpected_error", self._safe_error_message(exc), retryable=self._map_error(exc))
def extract_profile_long_term_v2(self, payload: dict[str, Any]) -> BackendCommitResult:
"""v2 adapter placeholder for profile / long-term extraction.
Mapping spec: commit_session returns BackendCommitResult and should
produce native episodic/profile/long-term refs once the real API is stable.
"""
runtime_payload = self._build_commit_payload(payload)
raw = {
"status": "success",
"session_id": runtime_payload.get("session_id"),
"metadata": {
"reason": "everos_v2_commit_fixture",
"schema_version": "everos.fixture.commit.v2",
},
"data": {
"produced_refs": [
{
"ref_type": "profile",
"profile_id": f"everos_profile:{runtime_payload.get('user_id') or 'unknown'}",
"metadata": {"schema_version": "everos.fixture.profile.v2"},
},
{
"ref_type": "long_term_memory",
"memory_id": f"everos_long_term:{runtime_payload.get('session_id')}",
"metadata": {"schema_version": "everos.fixture.long_term.v2"},
},
]
},
}
return self._normalize_commit_response(raw)
def retrieve_context_v2(self, payload: dict[str, Any]) -> BackendRetrieveResult:
"""
Calls EverOS native API to retrieve memories.
"""
if not self._use_real_api:
return BackendRetrieveResult(
backend_type=BackendType.EVEROS,
operation=BackendOperation.RETRIEVE_CONTEXT,
status=BackendResultStatus.SKIPPED,
items=[],
metadata={"reason": "everos_retrieve_requires_real_mode"},
)
query = payload.get("query", "")
user_id = payload.get("user_id", "")
try:
with httpx.Client(
base_url=self.base_url,
headers=self._headers(),
timeout=self.timeout,
verify=self.verify_ssl,
transport=self.transport,
) as client:
resp = client.post(
self.search_path,
json={
"query": query,
"method": self.retrieve_method,
"memory_types": ["episodic_memory", "profile", "raw_message"],
"top_k": payload.get("limit", 10),
"filters": self._search_filters(user_id=user_id, session_id=payload.get("session_id")),
},
)
if resp.status_code >= 400:
return BackendRetrieveResult(
backend_type=BackendType.EVEROS,
operation=BackendOperation.RETRIEVE_CONTEXT,
status=BackendResultStatus.FAILED,
items=[],
error_code=f"http_{resp.status_code}",
error_message=f"EverOS retrieve failed: {resp.text}",
retryable=False
)
items = self._items_from_search_response(resp.json())
raw = {
"status": "success",
"data": {
"items": items
}
}
return self._normalize_retrieve_response(raw)
except Exception as exc:
return BackendRetrieveResult(
backend_type=BackendType.EVEROS,
operation=BackendOperation.RETRIEVE_CONTEXT,
status=BackendResultStatus.FAILED,
items=[],
error_code="request_error",
error_message=str(exc),
retryable=True
)
def _build_ingest_payload(self, payload: dict[str, Any]) -> dict[str, Any]:
"""
Builds the payload according to EverOS native message schema.
"""
return {
"user_id": payload.get("user_id") or "gateway_user",
"session_id": payload.get("session_id"),
"messages": [
{
"message_id": payload.get("turn_id") or f"msg_{int(datetime.now(timezone.utc).timestamp() * 1000)}",
"sender_id": payload.get("user_id") or "gateway_user",
"sender_name": payload.get("user_id") or "gateway_user",
"role": self._everos_role(payload.get("role", "user")),
"timestamp": self._timestamp_ms(payload),
"content": payload.get("content", ""),
}
],
}
def _build_commit_payload(self, payload: dict[str, Any]) -> dict[str, Any]:
return dict(payload)
def _normalize_ingest_response(self, raw: dict[str, Any]) -> BackendWriteResult:
return normalize_everos_ingest_response(raw)
def _normalize_commit_response(self, raw: dict[str, Any]) -> BackendCommitResult:
return normalize_everos_commit_response(raw)
def _normalize_retrieve_response(self, raw: dict[str, Any]) -> BackendRetrieveResult:
return normalize_everos_retrieve_response(raw)
def _map_error(self, exc_or_response: Any) -> bool:
status_code = getattr(exc_or_response, "status_code", None)
error_code = getattr(exc_or_response, "error_code", None)
error_message = str(exc_or_response) if exc_or_response is not None else None
return map_backend_error_to_retryable(
BackendType.EVEROS,
status_code=status_code,
error_code=error_code,
error_message=error_message,
)
def _failed_ingest_result(self, error_code: str, error_message: str, retryable: bool) -> BackendWriteResult:
return BackendWriteResult(
backend_type=BackendType.EVEROS,
operation=BackendOperation.INGEST_TURN,
status=BackendResultStatus.FAILED,
retryable=retryable,
error_code=error_code,
error_message=error_message,
metadata={"error_code": error_code},
)
def _safe_error_message(self, exc: Exception) -> str:
return exc.__class__.__name__
def consolidate_session(
self,
session_id: str,
ctx: AccessContext,
episodes: list[EpisodeRecord],
existing_memories: list[MemoryRecord],
min_importance: float,
target_namespace: str | None,
) -> dict[str, Any]:
if not self.base_url:
raise EverOSError("EverOS real mode requires base_url")
user_id = ctx.user_id or "gateway_user"
agent_id = ctx.agent_id or "gateway_agent"
with httpx.Client(
base_url=self.base_url,
timeout=self.timeout,
headers=self._headers(),
verify=self.verify_ssl,
transport=self.transport,
) as client:
for episode in episodes:
self._memorize_episode(client, episode=episode, session_id=session_id, user_id=user_id, agent_id=agent_id)
self._flush_session(client, session_id=session_id, user_id=user_id)
promoted = self._fetch_session_memories(client, session_id=session_id, user_id=user_id, target_namespace=target_namespace)
return {
"backend": "external",
"service_url": self.base_url,
"endpoint": self.ingest_path,
"raw": {"result": {"memories": promoted}},
"session_id": session_id,
"episodes": len(episodes),
"candidates": promoted,
"promoted": promoted,
"duplicates": [],
"conflicts": [],
"review_drafts": [],
}
def _memorize_episode(
self,
client: httpx.Client,
*,
episode: dict[str, Any],
session_id: str,
user_id: str,
agent_id: str,
) -> None:
episode_data = episode.model_dump(mode="json") if hasattr(episode, "model_dump") else dict(episode)
episode_id = str(episode_data.get("id") or f"epi_{int(datetime.now(timezone.utc).timestamp())}")
sender = agent_id if episode_data.get("source") == "agent" else user_id
role = "assistant" if sender == agent_id else "user"
created_at = episode_data.get("created_at") or datetime.now(timezone.utc).isoformat()
payload = {
"user_id": user_id,
"session_id": session_id,
"messages": [
{
"message_id": episode_id,
"sender_id": sender,
"sender_name": sender,
"role": role,
"timestamp": self._datetime_to_ms(created_at),
"content": episode_data.get("content") or "",
}
],
}
response = client.post(self.ingest_path, json=payload)
response.raise_for_status()
def _flush_session(self, client: httpx.Client, *, session_id: str, user_id: str) -> None:
response = client.post(self.flush_path, json={"user_id": user_id, "session_id": session_id})
response.raise_for_status()
def _fetch_session_memories(
self,
client: httpx.Client,
*,
session_id: str,
user_id: str,
target_namespace: str | None,
) -> list[dict[str, Any]]:
response = client.post(
self.search_path,
json={
"query": "memory",
"method": self.retrieve_method,
"memory_types": ["episodic_memory"],
"top_k": 20,
"filters": self._search_filters(user_id=user_id, session_id=session_id),
},
)
response.raise_for_status()
memories = self._items_from_search_response(response.json())
normalized: list[dict[str, Any]] = []
for index, memory in enumerate(memories, start=1):
content = memory.get("text") or memory.get("content") or memory.get("summary") or ""
if not content:
continue
normalized.append(
{
"id": memory.get("memory_id") or memory.get("id") or f"everos_{session_id}_{index}",
"namespace": target_namespace or f"user/{user_id}/long_term",
"memory_type": memory.get("memory_type") or "episodic_memory",
"content": content,
"summary": memory.get("summary") or content[:180],
"tags": ["everos-real", "memory-gateway"],
"importance": 0.7,
"confidence": 0.7,
"source": "everos",
"source_ref": memory.get("memory_id") or memory.get("id"),
}
)
return normalized
def _items_from_search_response(self, payload: dict[str, Any]) -> list[dict[str, Any]]:
data = payload.get("data") if isinstance(payload.get("data"), dict) else payload
items: list[dict[str, Any]] = []
for memory_type, key in (
("episodic_memory", "episodes"),
("profile", "profiles"),
("raw_message", "raw_messages"),
):
for item in data.get(key, []) or []:
if isinstance(item, dict):
items.append({**item, "memory_type": item.get("memory_type") or memory_type, "text": self._memory_text(item)})
agent_memory = data.get("agent_memory") or {}
if isinstance(agent_memory, dict):
for item in agent_memory.get("cases", []) or []:
if isinstance(item, dict):
items.append({**item, "memory_type": "agent_case", "text": self._memory_text(item)})
for item in agent_memory.get("skills", []) or []:
if isinstance(item, dict):
items.append({**item, "memory_type": "agent_skill", "text": self._memory_text(item)})
return items
def _memory_text(self, item: dict[str, Any]) -> str:
content_items = item.get("content_items")
if isinstance(content_items, list):
content_text = "\n".join(
str(content.get("text") or content.get("content") or "")
for content in content_items
if isinstance(content, dict)
).strip()
else:
content_text = ""
profile_data = item.get("profile_data")
if isinstance(profile_data, dict):
profile_text = str(profile_data)
else:
profile_text = ""
return (
item.get("episode")
or item.get("summary")
or item.get("subject")
or item.get("atomic_fact")
or item.get("task_intent")
or item.get("approach")
or item.get("content")
or content_text
or item.get("description")
or profile_text
or ""
)
def _search_filters(self, *, user_id: str | None, session_id: str | None = None) -> dict[str, Any]:
filters: dict[str, Any] = {"user_id": user_id or "gateway_user"}
if session_id:
filters["session_id"] = session_id
return filters
def _timestamp_ms(self, payload: dict[str, Any]) -> int:
trace = payload.get("trace") if isinstance(payload.get("trace"), dict) else {}
timestamp = trace.get("timestamp") or payload.get("created_at")
if timestamp:
return self._datetime_to_ms(timestamp)
return int(datetime.now(timezone.utc).timestamp() * 1000)
def _datetime_to_ms(self, value: Any) -> int:
if isinstance(value, (int, float)):
return int(value if value > 1_000_000_000_000 else value * 1000)
if isinstance(value, str):
text = value.replace("Z", "+00:00")
try:
return int(datetime.fromisoformat(text).timestamp() * 1000)
except ValueError:
return int(datetime.now(timezone.utc).timestamp() * 1000)
if isinstance(value, datetime):
return int(value.timestamp() * 1000)
return int(datetime.now(timezone.utc).timestamp() * 1000)
def _everos_role(self, role: str) -> str:
if role in {"assistant", "agent"}:
return "assistant"
if role == "tool":
return "assistant"
return "user"

View File

@ -43,7 +43,7 @@ def write_review_draft(memory: MemoryRecord, reason: str, conflict_ids: list[str
f"created_at: {datetime.now(timezone.utc).isoformat()}",
"tags:",
" - memory/review",
" - source/evermemos",
" - source/everos",
"---",
"",
f"# Memory Review - {title}",

View File

@ -1,7 +1,6 @@
"""OpenViking client wrapper used by Memory Gateway."""
from __future__ import annotations
import json
import logging
import mimetypes
import tempfile
@ -58,6 +57,7 @@ class OpenVikingClient:
headers = {}
if self.api_key:
headers["X-API-Key"] = self.api_key
headers["Authorization"] = f"Bearer {self.api_key}"
headers["X-OpenViking-Account"] = self.account
headers["X-OpenViking-User"] = self.user
return headers
@ -190,36 +190,64 @@ class OpenVikingClient:
return self._normalize_commit_response(raw)
async def retrieve_context_v2(self, payload: dict[str, Any]) -> BackendRetrieveResult:
"""v2 adapter placeholder for OpenViking runtime context retrieval.
Mapping spec: retrieve_context returns BackendRetrieveResult with
runtime context items, not raw backend payload dumps.
"""
raw = {
"status": "ok",
"session_id": payload.get("session_id"),
"metadata": {
"reason": "openviking_v2_retrieve_fixture",
"schema_version": "openviking.fixture.retrieve.v2",
},
"result": {
"items": [
{
"text": "OpenViking fixture runtime context.",
"ref_id": f"ov_context:{payload.get('session_id') or 'unknown'}",
"score": 0.75,
"memory_type": "context_resource",
"metadata": {"schema_version": "openviking.fixture.retrieve.item.v2"},
}
]
},
}
return self._normalize_retrieve_response(raw)
Calls OpenViking native API to retrieve context.
Uses POST /search
"""
if not self._use_real_api:
return BackendRetrieveResult(
backend_type=BackendType.OPENVIKING,
operation=BackendOperation.RETRIEVE_CONTEXT,
status=BackendResultStatus.SKIPPED,
items=[],
metadata={"reason": "openviking_retrieve_requires_real_mode"},
)
query = payload.get("query", "")
session_id = payload.get("session_id")
request_data = {"query": query, "limit": 10}
if session_id:
request_data["session_id"] = session_id
try:
client = await self._get_client()
response = await client.post("/api/v1/search/search", json=request_data)
if response.status_code >= 400:
return BackendRetrieveResult(
backend_type=BackendType.OPENVIKING,
operation=BackendOperation.RETRIEVE_CONTEXT,
status=BackendResultStatus.FAILED,
items=[],
error_code=f"http_{response.status_code}",
error_message=f"OpenViking search failed: {response.text}",
retryable=False
)
return self._normalize_retrieve_response(response.json())
except Exception as exc:
return BackendRetrieveResult(
backend_type=BackendType.OPENVIKING,
operation=BackendOperation.RETRIEVE_CONTEXT,
status=BackendResultStatus.FAILED,
items=[],
error_code="request_error",
error_message=str(exc),
retryable=True
)
def _build_ingest_payload(self, payload: dict[str, Any]) -> dict[str, Any]:
# Runtime-only adapter payload. It may include conversation content for
# the current request lifecycle; callers must not persist it to SQLite.
return dict(payload)
"""
Build payload for native OpenViking AddMessageRequest.
OpenViking only expects role and content, and maybe metadata.
"""
return {
"role": payload.get("role", "user"),
"content": payload.get("content", ""),
"metadata": payload.get("metadata", {}),
"session_id": payload.get("session_id") # kept so format_ingest_path can use it
}
def _format_ingest_path(self, payload: dict[str, Any]) -> str:
session_id = str(payload.get("session_id") or "unknown")
@ -277,9 +305,9 @@ class OpenVikingClient:
payload["limit"] = limit
if uri:
payload["uri"] = uri
payload["target_uri"] = uri
elif namespace:
payload["uri"] = f"viking://{namespace}"
payload["target_uri"] = f"viking://{namespace}"
try:
response = await client.post("/api/v1/search/search", json=payload)
@ -321,7 +349,7 @@ class OpenVikingClient:
ns = namespace or self.config.memory.default_namespace or "user/default/memories"
try:
response = await client.post("/api/v1/sessions", json={"mode": "interactive"})
response = await client.post("/api/v1/sessions")
response.raise_for_status()
session_data = response.json()
@ -329,17 +357,15 @@ class OpenVikingClient:
return session_data
session_id = session_data["result"]["session_id"]
commit_response = await client.post(
f"/api/v1/sessions/{session_id}/commit",
message_response = await client.post(
f"/api/v1/sessions/{session_id}/messages",
json={
"messages": [
{
"role": "user",
"content": f"[{ns}/{memory_type}] {content}",
}
]
"role": "user",
"content": f"[{ns}/{memory_type}] {content}",
},
)
message_response.raise_for_status()
commit_response = await client.post(f"/api/v1/sessions/{session_id}/commit")
commit_response.raise_for_status()
return commit_response.json()
except httpx.HTTPError as e:
@ -396,7 +422,6 @@ class OpenVikingClient:
"temp_path": temp_ref,
"to": uri,
"wait": wait,
"source_name": Path(uri).name or tmp_path.name,
"strict": False,
}
response = await client.post("/api/v1/resources", json=payload)
@ -425,7 +450,7 @@ class OpenVikingClient:
try:
response = await client.post(
"/api/v1/search/search",
json={"query": "", "uri": f"viking://{ns}", "limit": limit or 10},
json={"query": "", "target_uri": f"viking://{ns}", "limit": limit or 10},
)
response.raise_for_status()
data = response.json()
@ -458,7 +483,7 @@ class OpenVikingClient:
try:
response = await client.post(
"/api/v1/search/search",
json={"query": "", "uri": uri, "limit": limit or 10},
json={"query": "", "target_uri": uri, "limit": limit or 10},
)
response.raise_for_status()
data = response.json()

View File

@ -38,7 +38,7 @@ class SourceType(str, Enum):
AGENT = "agent"
OBSIDIAN = "obsidian"
OPENVIKING = "openviking"
EVERMEMOS = "evermemos"
EVEROS = "everos"
MANUAL = "manual"
@ -224,4 +224,3 @@ class NamespaceInfo(BaseModel):
owner_user_id: Optional[str] = None
visibility: Visibility
description: str

View File

@ -30,7 +30,7 @@ class BackendRefStatus(str, Enum):
class BackendType(str, Enum):
OPENVIKING = "openviking"
EVERMEMOS = "evermemos"
EVEROS = "everos"
OBSIDIAN = "obsidian"
@ -54,7 +54,7 @@ class TraceContext(BaseModel):
class IngestPolicy(BaseModel):
allow_openviking: bool = True
allow_evermemos: bool = True
allow_everos: bool = True
allow_obsidian_review: bool = False
redact_sensitive: bool = True
require_human_review: bool = False

View File

@ -478,12 +478,12 @@ async def health_check():
try:
ov_client = await get_openviking_client()
ov_status = await ov_client.health_check()
evermemos_status = v1_service.evermemos_health()
everos_status = v1_service.everos_health()
return {
"status": "ok",
"gateway": "memory-gateway",
"openviking": ov_status,
"evermemos": evermemos_status,
"everos": everos_status,
}
except Exception as e:
return {

View File

@ -1,12 +1,13 @@
"""Application services for the generic Memory Gateway v1 API."""
from __future__ import annotations
from dataclasses import dataclass, field
from datetime import datetime, timezone
from fastapi import HTTPException, status
from .config import get_config
from .evermemos_client import EverMemOSError, EverMemOSClient
from .everos_client import EverOSError, EverOSClient
from .namespace import can_access_memory, default_namespace_for_context, user_long_term_namespace, visible_namespaces
from .openviking_client import get_openviking_client
from .repositories import MetadataRepository, repository
@ -29,13 +30,23 @@ from .schemas import (
UserRecord,
Visibility,
)
from .workers.evermemos_worker import EverMemOSWorker
@dataclass
class ConsolidationResult:
session_id: str
episodes: int
candidates: list[MemoryRecord] = field(default_factory=list)
promoted: list[MemoryRecord] = field(default_factory=list)
duplicates: list[dict] = field(default_factory=list)
review_drafts: list[str] = field(default_factory=list)
conflicts: list[dict] = field(default_factory=list)
class MemoryGatewayService:
def __init__(self, repo: MetadataRepository = repository, evermemos_client: EverMemOSClient | None = None) -> None:
def __init__(self, repo: MetadataRepository = repository, everos_client: EverOSClient | None = None) -> None:
self.repo = repo
self.evermemos_client = evermemos_client
self.everos_client = everos_client
def create_user(self, request: CreateUserRequest) -> UserRecord:
user = UserRecord(
@ -204,10 +215,10 @@ class MemoryGatewayService:
session_id=session_id,
)
target_namespace = request.target_namespace or user_long_term_namespace(request.user_id)
config = get_config().evermemos
config = get_config().everos
if config.enabled:
try:
external_result = (self.evermemos_client or EverMemOSClient()).consolidate_session(
external_result = (self.everos_client or EverOSClient()).consolidate_session(
session_id=session_id,
ctx=ctx,
episodes=episodes,
@ -217,32 +228,29 @@ class MemoryGatewayService:
)
result = self._persist_external_consolidation(external_result, ctx, session_id)
backend = "external"
except EverMemOSError as exc:
except EverOSError as exc:
error = str(exc)
if not config.fallback_to_local:
self._audit(
"evermemos_commit_failed",
"session",
session_id,
actor_user_id=request.user_id,
actor_agent_id=request.agent_id,
decision="deny",
metadata={"error": error},
)
raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY, detail=f"EverMemOS failed: {error}") from exc
result = self._commit_session_locally(session_id, ctx, request)
backend = "local-fallback"
self._audit(
"everos_commit_failed",
"session",
session_id,
actor_user_id=request.user_id,
actor_agent_id=request.agent_id,
decision="deny",
metadata={"error": error},
)
raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY, detail=f"EverOS failed: {error}") from exc
else:
result = self._commit_session_locally(session_id, ctx, request)
backend = "local-disabled"
result = None
backend = "disabled"
else:
result = None
self._audit("commit_session", "session", session_id, actor_user_id=request.user_id, actor_agent_id=request.agent_id)
if not result:
return {"session_id": session_id, "episodes": len(episodes), "promoted": [], "evermemos_backend": backend}
return {"session_id": session_id, "episodes": len(episodes), "promoted": [], "everos_backend": backend}
return {
"evermemos_backend": backend,
"evermemos_error": error,
"everos_backend": backend,
"everos_error": error,
"session_id": session_id,
"episodes": result.episodes,
"candidates": result.candidates,
@ -252,24 +260,13 @@ class MemoryGatewayService:
"review_drafts": result.review_drafts,
}
def evermemos_health(self) -> dict:
config = get_config().evermemos
def everos_health(self) -> dict:
config = get_config().everos
if not config.enabled:
return {"status": "disabled", "url": config.url}
return (self.evermemos_client or EverMemOSClient()).health()
def _commit_session_locally(self, session_id: str, ctx: AccessContext, request: CommitSessionRequest):
worker = EverMemOSWorker(self.repo)
return worker.consolidate_session(
session_id=session_id,
ctx=ctx,
min_importance=request.min_importance,
target_namespace=request.target_namespace or user_long_term_namespace(request.user_id),
)
return (self.everos_client or EverOSClient()).health()
def _persist_external_consolidation(self, external_result: dict, ctx: AccessContext, session_id: str):
from .workers.evermemos_worker import ConsolidationResult
result = ConsolidationResult(
session_id=session_id,
episodes=external_result.get("episodes") or len(self.repo.list_session_episodes(session_id)),
@ -302,11 +299,11 @@ class MemoryGatewayService:
data.setdefault("memory_type", MemoryType.SUMMARY.value)
data.setdefault("content", data.get("text") or data.get("summary") or "")
data.setdefault("summary", data.get("content", "")[:180])
data.setdefault("tags", ["evermemos-external"])
data.setdefault("tags", ["everos-external"])
data.setdefault("importance", 0.7)
data.setdefault("confidence", 0.65)
data.setdefault("visibility", Visibility.PRIVATE.value)
data.setdefault("source", SourceType.EVERMEMOS.value)
data.setdefault("source", SourceType.EVEROS.value)
if not data["content"]:
return None
return MemoryRecord.model_validate(data)

View File

@ -12,13 +12,14 @@ from .backend_contracts import (
BackendOperation,
BackendCommitResult,
BackendProducedRef,
BackendRetrieveResult,
BackendResultStatus,
BackendWriteResult,
CommitJob,
OutboxEvent,
OutboxEventStatus,
)
from .evermemos_client import EverMemOSClient
from .everos_client import EverOSClient
from .openviking_client import get_openviking_client
from .repositories import MetadataRepository, repository
from .schemas import AuditLog
@ -52,11 +53,11 @@ class MemoryGatewayV2Service:
self,
repo: MetadataRepository = repository,
openviking_client_factory: OpenVikingClientFactory = get_openviking_client,
evermemos_client: Any | None = None,
everos_client: Any | None = None,
) -> None:
self.repo = repo
self.openviking_client_factory = openviking_client_factory
self.evermemos_client = evermemos_client
self.everos_client = everos_client
async def ingest_conversation_turn(self, request: IngestRequest) -> IngestResponse:
normalized = self._normalize_ingest_request(request)
@ -92,9 +93,9 @@ class MemoryGatewayV2Service:
)
)
if normalized.policy.allow_evermemos:
if normalized.policy.allow_everos:
refs.append(
await self._write_evermemos_message(
await self._write_everos_message(
normalized,
payload,
gateway_id=gateway_id,
@ -108,7 +109,7 @@ class MemoryGatewayV2Service:
normalized,
gateway_id,
provenance_id,
BackendType.EVERMEMOS,
BackendType.EVEROS,
MemoryRefType.MESSAGE_MEMORY,
BackendRefStatus.SKIPPED,
content_hash=content_hash,
@ -188,8 +189,21 @@ class MemoryGatewayV2Service:
)
async def retrieve_context(self, request: RetrieveRequest) -> RetrieveResponse:
# TODO(v2): expand namespace ACL, fan out concurrently to OpenViking and
# EverMemOS, then apply lightweight merge/rerank before returning.
payload = {
"workspace_id": request.workspace_id,
"user_id": request.user_id,
"agent_id": request.agent_id,
"session_id": request.session_id,
"namespace": request.namespace,
"query": request.query,
"limit": request.limit,
"metadata": request.metadata,
}
results = [
await self._retrieve_openviking_context(payload),
await self._retrieve_everos_context(payload),
]
items = self._merge_retrieve_items(results, limit=request.limit)
refs = self.repo.list_memory_refs(
workspace_id=request.workspace_id,
user_id=request.user_id,
@ -198,21 +212,6 @@ class MemoryGatewayV2Service:
namespace=request.namespace,
limit=request.limit,
)
items = [
ContextItem(
text=None,
source_backend=ref.backend_type,
ref_id=ref.id,
score=0.0,
memory_type=ref.ref_type.value,
metadata={
"status": ref.status.value,
"native_id": ref.native_id,
"native_uri": ref.native_uri,
},
)
for ref in refs
]
trace_id = request.metadata.get("trace_id") if request.metadata else None
return RetrieveResponse(
status=OperationStatus.SUCCESS,
@ -220,7 +219,7 @@ class MemoryGatewayV2Service:
refs=self._view_refs(refs),
conflicts=[],
trace_id=trace_id,
metadata={"skeleton": True},
metadata=self._retrieve_metadata(results),
)
async def record_memory_feedback(self, request: FeedbackRequest) -> FeedbackResponse:
@ -386,6 +385,83 @@ class MemoryGatewayV2Service:
limit=limit,
)
async def _retrieve_openviking_context(self, payload: dict[str, Any]) -> BackendRetrieveResult:
try:
client = await self.openviking_client_factory()
if not hasattr(client, "retrieve_context_v2"):
return BackendRetrieveResult(
backend_type=BackendType.OPENVIKING,
status=BackendResultStatus.SKIPPED,
metadata={"reason": "adapter_method_missing"},
)
result = client.retrieve_context_v2(payload)
if hasattr(result, "__await__"):
result = await result
return result
except Exception as exc: # noqa: BLE001
return BackendRetrieveResult(
backend_type=BackendType.OPENVIKING,
status=BackendResultStatus.FAILED,
error_code="adapter_exception",
error_message=str(exc),
retryable=True,
)
async def _retrieve_everos_context(self, payload: dict[str, Any]) -> BackendRetrieveResult:
try:
client = self.everos_client or EverOSClient()
if not hasattr(client, "retrieve_context_v2"):
return BackendRetrieveResult(
backend_type=BackendType.EVEROS,
status=BackendResultStatus.SKIPPED,
metadata={"reason": "adapter_method_missing"},
)
result = client.retrieve_context_v2(payload)
if hasattr(result, "__await__"):
result = await result
return result
except Exception as exc: # noqa: BLE001
return BackendRetrieveResult(
backend_type=BackendType.EVEROS,
status=BackendResultStatus.FAILED,
error_code="adapter_exception",
error_message=str(exc),
retryable=True,
)
def _merge_retrieve_items(self, results: list[BackendRetrieveResult], limit: int) -> list[ContextItem]:
items: list[ContextItem] = []
for result in results:
if result.status != BackendResultStatus.SUCCESS:
continue
for item in result.items:
items.append(
ContextItem(
text=item.text,
source_backend=item.source_backend,
ref_id=item.ref_id,
score=item.score,
memory_type=item.memory_type,
metadata=item.metadata,
)
)
items.sort(key=lambda item: item.score, reverse=True)
return items[:limit]
def _retrieve_metadata(self, results: list[BackendRetrieveResult]) -> dict[str, Any]:
return {
"backend_results": [
{
"backend_type": result.backend_type.value,
"status": result.status.value,
"items": len(result.items),
"error_code": result.error_code,
"error_message": result.error_message,
}
for result in results
]
}
async def _execute_outbox_event(self, event: OutboxEvent) -> BackendCommitResult | BackendWriteResult:
payload = self._outbox_payload(event)
if event.operation != BackendOperation.COMMIT_SESSION:
@ -406,11 +482,11 @@ class MemoryGatewayV2Service:
)
result = await client.commit_session_v2(payload)
return result
if event.backend_type == BackendType.EVERMEMOS:
client = self.evermemos_client or EverMemOSClient()
if event.backend_type == BackendType.EVEROS:
client = self.everos_client or EverOSClient()
if not hasattr(client, "extract_profile_long_term_v2"):
return BackendCommitResult(
backend_type=BackendType.EVERMEMOS,
backend_type=event.backend_type,
operation=BackendOperation.COMMIT_SESSION,
status=BackendResultStatus.SKIPPED,
metadata={"reason": "adapter_method_missing"},
@ -557,7 +633,7 @@ class MemoryGatewayV2Service:
pass
if event.backend_type == BackendType.OPENVIKING:
return MemoryRefType.SESSION_ARCHIVE
if event.backend_type == BackendType.EVERMEMOS:
if event.backend_type == BackendType.EVEROS:
return MemoryRefType.LONG_TERM_MEMORY
return MemoryRefType.DRAFT_REVIEW
@ -712,7 +788,7 @@ class MemoryGatewayV2Service:
metadata=self._control_metadata(request, content_hash),
)
async def _write_evermemos_message(
async def _write_everos_message(
self,
request: IngestRequest,
payload: dict[str, Any],
@ -721,13 +797,13 @@ class MemoryGatewayV2Service:
content_hash: str,
) -> MemoryRef:
try:
client = self.evermemos_client or EverMemOSClient()
client = self.everos_client or EverOSClient()
if not hasattr(client, "ingest_message"):
return self._save_ref(
request,
gateway_id,
provenance_id,
BackendType.EVERMEMOS,
BackendType.EVEROS,
MemoryRefType.MESSAGE_MEMORY,
BackendRefStatus.SKIPPED,
content_hash=content_hash,
@ -740,7 +816,7 @@ class MemoryGatewayV2Service:
request,
gateway_id,
provenance_id,
BackendType.EVERMEMOS,
BackendType.EVEROS,
MemoryRefType.MESSAGE_MEMORY,
result,
content_hash,
@ -750,7 +826,7 @@ class MemoryGatewayV2Service:
request,
gateway_id,
provenance_id,
BackendType.EVERMEMOS,
BackendType.EVEROS,
MemoryRefType.MESSAGE_MEMORY,
BackendRefStatus.FAILED,
content_hash=content_hash,
@ -946,7 +1022,7 @@ class MemoryGatewayV2Service:
"idempotency_key": request.idempotency_key,
"request_id": request.request_id,
}
for backend_type in (BackendType.OPENVIKING, BackendType.EVERMEMOS):
for backend_type in (BackendType.OPENVIKING, BackendType.EVEROS):
event = OutboxEvent(
id=self._outbox_event_id(gateway_id, backend_type, BackendOperation.COMMIT_SESSION),
event_type="commit_session",

View File

@ -21,8 +21,8 @@ class OpenVikingConfig(BaseModel):
ingest_path: str = "/api/v1/sessions/{session_id}/messages"
class EverMemOSConfig(BaseModel):
"""External EverMemOS consolidation service configuration."""
class EverOSConfig(BaseModel):
"""External EverOS memory service configuration."""
enabled: bool = False
mode: Literal["offline", "skeleton", "real"] = "offline"
url: str = "http://127.0.0.1:1995"
@ -31,9 +31,9 @@ class EverMemOSConfig(BaseModel):
verify_ssl: bool = True
health_path: str = "/health"
ingest_path: str = "/api/v1/memories"
consolidate_path: str = "/v1/sessions/consolidate"
fallback_to_local: bool = True
search_path: str = "/api/v1/memories/search"
flush_path: str = "/api/v1/memories/flush"
retrieve_method: Literal["keyword", "vector", "hybrid", "rrf", "agentic"] = "keyword"
class MemoryConfig(BaseModel):
"""记忆配置"""
@ -71,16 +71,18 @@ class LoggingConfig(BaseModel):
class Config(BaseModel):
"""完整配置"""
def __init__(self, **data: Any) -> None:
super().__init__(**data)
server: ServerConfig = Field(default_factory=ServerConfig)
openviking: OpenVikingConfig = Field(default_factory=OpenVikingConfig)
evermemos: EverMemOSConfig = Field(default_factory=EverMemOSConfig)
everos: EverOSConfig = Field(default_factory=EverOSConfig)
memory: MemoryConfig = Field(default_factory=MemoryConfig)
logging: LoggingConfig = Field(default_factory=LoggingConfig)
llm: LLMConfig = Field(default_factory=LLMConfig)
obsidian: ObsidianConfig = Field(default_factory=ObsidianConfig)
storage: StorageConfig = Field(default_factory=StorageConfig)
class SearchRequest(BaseModel):
"""搜索请求"""
query: str

View File

@ -1,2 +0,0 @@
"""Background worker skeletons."""

View File

@ -1,186 +0,0 @@
"""Minimal EverMemOS-style consolidation worker.
This worker is deliberately deterministic for the POC. It extracts stable
candidate memories from session episodes, deduplicates them against existing
records, promotes eligible records, and sends high-risk/high-value candidates
to Obsidian review rather than blindly polluting long-term memory.
"""
from __future__ import annotations
import hashlib
import re
from dataclasses import dataclass, field
from memory_gateway.namespace import default_namespace_for_context
from memory_gateway.obsidian_review import write_review_draft
from memory_gateway.repositories import MetadataRepository
from memory_gateway.schemas import (
AccessContext,
EpisodeRecord,
MemoryRecord,
MemoryType,
SourceType,
Visibility,
)
_SENTENCE_RE = re.compile(r"(?<=[。!?.!?])\s+|\n+")
_NOISE_RE = re.compile(r"\s+")
@dataclass
class ConsolidationResult:
session_id: str
episodes: int
candidates: list[MemoryRecord] = field(default_factory=list)
promoted: list[MemoryRecord] = field(default_factory=list)
duplicates: list[dict] = field(default_factory=list)
review_drafts: list[str] = field(default_factory=list)
conflicts: list[dict] = field(default_factory=list)
class EverMemOSWorker:
def __init__(self, repo: MetadataRepository) -> None:
self.repo = repo
def consolidate_session(
self,
session_id: str,
ctx: AccessContext,
min_importance: float = 0.6,
target_namespace: str | None = None,
) -> ConsolidationResult:
episodes = self.repo.list_session_episodes(session_id)
result = ConsolidationResult(session_id=session_id, episodes=len(episodes))
existing = list(self.repo.list_memories())
seen_fingerprints = {self._fingerprint(memory.content): memory for memory in existing}
for episode in episodes:
for candidate in self._extract_candidates(episode, ctx, min_importance, target_namespace):
result.candidates.append(candidate)
fingerprint = self._fingerprint(candidate.content)
duplicate = seen_fingerprints.get(fingerprint)
if duplicate:
result.duplicates.append({"candidate_id": candidate.id, "existing_id": duplicate.id})
continue
conflict_ids = self._find_conflicts(candidate, existing)
if conflict_ids:
draft = write_review_draft(candidate, reason="conflict", conflict_ids=conflict_ids)
result.review_drafts.append(str(draft))
result.conflicts.append({"candidate_id": candidate.id, "conflict_ids": conflict_ids})
continue
if candidate.importance >= 0.85:
draft = write_review_draft(candidate, reason="high_value")
result.review_drafts.append(str(draft))
continue
if candidate.importance >= min_importance and candidate.confidence >= 0.55:
self.repo.upsert_memory(candidate)
result.promoted.append(candidate)
seen_fingerprints[fingerprint] = candidate
existing.append(candidate)
return result
def _extract_candidates(
self,
episode: EpisodeRecord,
ctx: AccessContext,
min_importance: float,
target_namespace: str | None,
) -> list[MemoryRecord]:
text = episode.summary or episode.content
parts = [self._normalize(part) for part in _SENTENCE_RE.split(text) if self._normalize(part)]
candidates: list[MemoryRecord] = []
for part in parts:
if len(part) < 20:
continue
memory_type = self._classify_type(part, episode.tags)
importance = self._estimate_importance(part, episode.tags, min_importance)
confidence = 0.65 if episode.summary else 0.58
visibility = Visibility.WORKSPACE_SHARED if "workspace" in episode.tags and ctx.workspace_id else Visibility.PRIVATE
memory_ctx = AccessContext(
user_id=ctx.user_id,
agent_id=ctx.agent_id,
workspace_id=ctx.workspace_id,
session_id=ctx.session_id,
)
candidates.append(
MemoryRecord(
user_id=ctx.user_id,
agent_id=ctx.agent_id,
workspace_id=ctx.workspace_id,
session_id=episode.session_id,
namespace=target_namespace or default_namespace_for_context(memory_ctx, visibility),
memory_type=memory_type,
content=part,
summary=part[:180],
tags=list(set(episode.tags + ["promoted-from-session", "evermemos-candidate"])),
importance=importance,
confidence=confidence,
visibility=visibility,
source=SourceType.EVERMEMOS,
source_ref=episode.id,
)
)
return candidates
def _classify_type(self, text: str, tags: list[str]) -> MemoryType:
lowered = text.lower()
if "preference" in tags or "偏好" in text:
return MemoryType.PREFERENCE
if "decision" in tags or "决定" in text or "决策" in text:
return MemoryType.DECISION
if "procedure" in tags or "步骤" in text or "流程" in text:
return MemoryType.PROCEDURE
if "经验" in text or "worked" in lowered or "failed" in lowered:
return MemoryType.EXPERIENCE
return MemoryType.SUMMARY
def _estimate_importance(self, text: str, tags: list[str], min_importance: float) -> float:
importance = max(min_importance, 0.6)
signal_words = ["必须", "不要", "偏好", "长期", "决策", "结论", "重要", "preference", "decision", "must"]
if any(word in text.lower() for word in signal_words):
importance += 0.15
if "review" in tags or "high-value" in tags:
importance += 0.2
return min(1.0, importance)
def _find_conflicts(self, candidate: MemoryRecord, existing: list[MemoryRecord]) -> list[str]:
candidate_text = candidate.content.lower()
negation_signals = ["不要", "不再", "禁止", "not ", "never", "disable"]
positive_signals = ["需要", "必须", "启用", "prefer", "always", "enable"]
has_negative = any(signal in candidate_text for signal in negation_signals)
has_positive = any(signal in candidate_text for signal in positive_signals)
if not has_negative and not has_positive:
return []
candidate_tokens = self._tokens(candidate.content)
conflicts = []
for memory in existing:
if memory.user_id != candidate.user_id:
continue
if memory.memory_type != candidate.memory_type:
continue
overlap = candidate_tokens.intersection(self._tokens(memory.content))
if len(overlap) < 2:
continue
memory_text = memory.content.lower()
memory_negative = any(signal in memory_text for signal in negation_signals)
memory_positive = any(signal in memory_text for signal in positive_signals)
if has_negative != memory_negative or has_positive != memory_positive:
conflicts.append(memory.id)
return conflicts
def _tokens(self, text: str) -> set[str]:
return {token for token in re.split(r"[^a-zA-Z0-9\u4e00-\u9fff]+", text.lower()) if len(token) >= 2}
def _normalize(self, text: str) -> str:
return _NOISE_RE.sub(" ", text).strip(" -_*#\t")
def _fingerprint(self, text: str) -> str:
normalized = self._normalize(text).lower()
return hashlib.sha1(normalized.encode("utf-8")).hexdigest()