Replace EverMemOS with EverOS backend
This commit is contained in:
@ -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()
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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),
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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}")
|
||||
|
||||
@ -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 [],
|
||||
}
|
||||
@ -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()
|
||||
496
memory_gateway/everos_client.py
Normal file
496
memory_gateway/everos_client.py
Normal 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"
|
||||
@ -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}",
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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
|
||||
|
||||
@ -1,2 +0,0 @@
|
||||
"""Background worker skeletons."""
|
||||
|
||||
@ -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()
|
||||
|
||||
Reference in New Issue
Block a user