Files
wehub-resource-sync 0ef5fcb1c5
Security / Dependency audit (pip-audit) (push) Has been cancelled
Security / CodeQL (javascript-typescript) (push) Has been cancelled
Security / CodeQL (python) (push) Has been cancelled
Security / Secret scan (gitleaks) (push) Has been cancelled
rust / test (ubuntu) (push) Has been cancelled
rust / simulator e2e (macos-latest) (push) Has been cancelled
rust / simulator e2e (ubuntu-latest) (push) Has been cancelled
rust / simulator e2e (windows-latest) (push) Has been cancelled
rust / wheels (aarch64-apple-darwin) (push) Has been cancelled
rust / wheels (x86_64-unknown-linux-gnu) (push) Has been cancelled
rust / wheels (x86_64-apple-darwin) (push) Has been cancelled
rust / audit (push) Has been cancelled
rust / parity (nightly, allowed to fail during Phase 0) (push) Has been cancelled
CI / commitlint (push) Has been skipped
Dev Containers / validate (.devcontainer/devcontainer.json, default) (push) Failing after 0s
Dev Containers / validate (.devcontainer/memory-stack/devcontainer.json, memory-stack) (push) Failing after 0s
Dev Containers / validate-worktree (push) Failing after 0s
CI / changes (push) Failing after 4s
Deploy Documentation / validate (push) Has been skipped
Deploy Documentation / deploy (push) Failing after 1s
Init Native E2E / init-native (ubuntu-latest, claude) (push) Failing after 1s
Init Native E2E / init-native (ubuntu-latest, codex) (push) Failing after 1s
Install Native E2E / install-native (ubuntu-latest) (push) Failing after 1s
OpenCode Plugin / typecheck + build + test (push) Failing after 1s
Init Native E2E / init-native (ubuntu-latest, copilot) (push) Failing after 1s
Release Please / release-please (push) Failing after 1s
Wrap E2E / docker-wrap-e2e (push) Failing after 1s
Wrap Native E2E / wrap-native (ubuntu-latest) (push) Failing after 1s
Init E2E / docker-init-e2e (push) Failing after 4s
Merge Conflicts / merge-conflicts (push) Failing after 4s
CI / lint (push) Has been cancelled
CI / build-wheel (push) Has been cancelled
CI / build-wheel-windows (push) Has been cancelled
CI / prefetch-model (push) Has been cancelled
CI / test-dashboard-ui (push) Has been cancelled
CI / test (1) (push) Has been cancelled
CI / test (2) (push) Has been cancelled
CI / test (3) (push) Has been cancelled
CI / test (4) (push) Has been cancelled
CI / test-extras (push) Has been cancelled
CI / test-agno (push) Has been cancelled
CI / build (push) Has been cancelled
CI / workflow-validation (push) Has been cancelled
CI / docker-native-e2e (push) Has been cancelled
CI / windows-native-wrapper (push) Has been cancelled
CI / macos-native-wrapper (push) Has been cancelled
Docker / docker-build (map[name:arm64 platform:linux/arm64 runs_on:ubuntu-24.04-arm], map[bake_target:runtime-code-nonroot name:code-nonroot]) (push) Has been cancelled
Docker / docker-build (map[name:arm64 platform:linux/arm64 runs_on:ubuntu-24.04-arm], map[bake_target:runtime-code-slim name:code-slim]) (push) Has been cancelled
Docker / docker-build (map[name:arm64 platform:linux/arm64 runs_on:ubuntu-24.04-arm], map[bake_target:runtime-code-slim-nonroot name:code-slim-nonroot]) (push) Has been cancelled
Docker / docker-build (map[name:arm64 platform:linux/arm64 runs_on:ubuntu-24.04-arm], map[bake_target:runtime-nonroot name:nonroot]) (push) Has been cancelled
Docker / docker-build (map[name:arm64 platform:linux/arm64 runs_on:ubuntu-24.04-arm], map[bake_target:runtime-slim name:slim]) (push) Has been cancelled
Docker / docker-build (map[name:arm64 platform:linux/arm64 runs_on:ubuntu-24.04-arm], map[bake_target:runtime-slim-nonroot name:slim-nonroot]) (push) Has been cancelled
Docker / docker-manifest (map[bake_target:runtime name:]) (push) Has been cancelled
Docker / docker-manifest (map[bake_target:runtime-code name:code]) (push) Has been cancelled
Docker / docker-manifest (map[bake_target:runtime-code-nonroot name:code-nonroot]) (push) Has been cancelled
Docker / docker-manifest (map[bake_target:runtime-code-slim name:code-slim]) (push) Has been cancelled
Docker / docker-manifest (map[bake_target:runtime-code-slim-nonroot name:code-slim-nonroot]) (push) Has been cancelled
Docker / docker-manifest (map[bake_target:runtime-nonroot name:nonroot]) (push) Has been cancelled
Docker / docker-manifest (map[bake_target:runtime-slim name:slim]) (push) Has been cancelled
Docker / docker-manifest (map[bake_target:runtime-slim-nonroot name:slim-nonroot]) (push) Has been cancelled
Docker / docker-build (map[name:amd64 platform:linux/amd64 runs_on:ubuntu-24.04], map[bake_target:runtime name:]) (push) Has been cancelled
Docker / docker-build (map[name:amd64 platform:linux/amd64 runs_on:ubuntu-24.04], map[bake_target:runtime-code name:code]) (push) Has been cancelled
Docker / docker-build (map[name:amd64 platform:linux/amd64 runs_on:ubuntu-24.04], map[bake_target:runtime-code-nonroot name:code-nonroot]) (push) Has been cancelled
Docker / docker-build (map[name:amd64 platform:linux/amd64 runs_on:ubuntu-24.04], map[bake_target:runtime-code-slim name:code-slim]) (push) Has been cancelled
Docker / docker-build (map[name:amd64 platform:linux/amd64 runs_on:ubuntu-24.04], map[bake_target:runtime-code-slim-nonroot name:code-slim-nonroot]) (push) Has been cancelled
Docker / docker-build (map[name:amd64 platform:linux/amd64 runs_on:ubuntu-24.04], map[bake_target:runtime-nonroot name:nonroot]) (push) Has been cancelled
Docker / docker-build (map[name:amd64 platform:linux/amd64 runs_on:ubuntu-24.04], map[bake_target:runtime-slim name:slim]) (push) Has been cancelled
Docker / docker-build (map[name:amd64 platform:linux/amd64 runs_on:ubuntu-24.04], map[bake_target:runtime-slim-nonroot name:slim-nonroot]) (push) Has been cancelled
Docker / docker-build (map[name:arm64 platform:linux/arm64 runs_on:ubuntu-24.04-arm], map[bake_target:runtime name:]) (push) Has been cancelled
Docker / docker-build (map[name:arm64 platform:linux/arm64 runs_on:ubuntu-24.04-arm], map[bake_target:runtime-code name:code]) (push) Has been cancelled
Docker / promote-latest (push) Has been cancelled
Init Native E2E / init-native (macos-latest, claude) (push) Has been cancelled
Init Native E2E / init-native (macos-latest, codex) (push) Has been cancelled
Init Native E2E / init-native (macos-latest, copilot) (push) Has been cancelled
Install Native E2E / install-native (macos-latest) (push) Has been cancelled
Wrap Native E2E / wrap-native (macos-latest) (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 12:03:20 +08:00

411 lines
13 KiB
Python

"""Universal memory sync engine for cross-agent interoperability.
Provides bidirectional sync between headroom's memory DB and any
agent's native memory format via pluggable adapters.
Architecture:
DB ← sync_import → Agent files (agent's knowledge enters the shared DB)
DB → sync_export → Agent files (shared knowledge flows to the agent)
sync() = import + export (bidirectional, fast no-op when unchanged)
Usage:
from headroom.memory.sync import sync, SyncResult
from headroom.memory.sync_adapters.claude_code import ClaudeCodeAdapter
adapter = ClaudeCodeAdapter(memory_dir=Path("~/.claude/projects/.../memory"))
backend = LocalBackend(config)
result: SyncResult = await sync(backend, adapter, user_id="tcms")
"""
from __future__ import annotations
import hashlib
import json
import logging
import time
from abc import ABC, abstractmethod
from dataclasses import dataclass, field
from datetime import datetime, timezone
from pathlib import Path
from typing import Any
from headroom import paths as _paths
logger = logging.getLogger("headroom.memory.sync")
# State file for fast no-op detection (workspace bucket, respects
# HEADROOM_WORKSPACE_DIR). Resolved at import time, matching prior behavior.
_DEFAULT_STATE_PATH = _paths.sync_state_path()
# ---------------------------------------------------------------------------
# Data models
# ---------------------------------------------------------------------------
@dataclass
class SyncResult:
"""Result of a sync operation."""
imported: int = 0 # agent files → DB
exported: int = 0 # DB → agent files
skipped_unchanged: int = 0
skipped_dedup: int = 0
duration_ms: float = 0
@dataclass
class AgentMemory:
"""A memory entry read from an agent's native format."""
content: str
category: str = ""
source_file: str = ""
content_hash: str = ""
metadata: dict[str, Any] = field(default_factory=dict)
def __post_init__(self) -> None:
if not self.content_hash:
self.content_hash = hashlib.sha256(self.content.encode()).hexdigest()[:16]
# ---------------------------------------------------------------------------
# Adapter interface
# ---------------------------------------------------------------------------
class AgentMemoryAdapter(ABC):
"""Base class for agent memory format adapters.
Each agent (Claude Code, Codex, Aider, Cursor) has a subclass
that knows how to read/write that agent's native memory format.
"""
agent_name: str = "unknown"
@abstractmethod
async def read_memories(self) -> list[AgentMemory]:
"""Read memories from the agent's native format.
Returns a list of AgentMemory entries found in the agent's files.
"""
...
@abstractmethod
async def write_memories(self, memories: list[dict[str, Any]]) -> int:
"""Write memories to the agent's native format.
Args:
memories: List of dicts with keys: content, category, importance,
headroom_id, source_agent, content_hash.
Returns:
Count of memories written.
"""
...
@abstractmethod
def fingerprint(self) -> str:
"""Fast hash of the agent's memory state.
Used for no-op detection: if the fingerprint hasn't changed
since last sync, we can skip the full read/compare cycle.
"""
...
# ---------------------------------------------------------------------------
# Sync state persistence
# ---------------------------------------------------------------------------
def _load_sync_state(state_path: Path) -> dict[str, Any]:
"""Load sync state from disk."""
if state_path.exists():
try:
result: dict[str, Any] = json.loads(state_path.read_text(encoding="utf-8"))
return result
except (json.JSONDecodeError, OSError):
pass
return {}
def _save_sync_state(state_path: Path, state: dict[str, Any]) -> None:
"""Save sync state to disk."""
state_path.parent.mkdir(parents=True, exist_ok=True)
state_path.write_text(json.dumps(state, indent=2), encoding="utf-8")
def _db_fingerprint(memories: list[Any]) -> str:
"""Compute a fast fingerprint of DB state."""
if not memories:
return "empty"
# Hash: count + most recent created_at
parts = [str(len(memories))]
for m in memories[:5]: # Sample first 5 for speed
parts.append(getattr(m, "id", "")[:8])
return hashlib.sha256("|".join(parts).encode()).hexdigest()[:16]
# ---------------------------------------------------------------------------
# Sync engine
# ---------------------------------------------------------------------------
async def sync(
backend: Any,
adapter: AgentMemoryAdapter,
user_id: str,
state_path: Path = _DEFAULT_STATE_PATH,
force: bool = False,
) -> SyncResult:
"""Bidirectional sync between headroom DB and an agent's memory.
1. Fast no-op check (fingerprint comparison)
2. Import: agent files → DB (new entries only, deduped by content hash)
3. Export: DB → agent files (entries not already in agent's files)
Args:
backend: LocalBackend instance (must have save_memory, get_user_memories).
adapter: Agent-specific memory adapter.
user_id: User ID for memory scoping.
state_path: Path to sync state file.
force: Skip no-op check and always sync.
Returns:
SyncResult with import/export counts and timing.
"""
start = time.monotonic()
result = SyncResult()
# --- Fast no-op check ---
if not force:
state = _load_sync_state(state_path)
adapter_key = f"{adapter.agent_name}:{user_id}"
prev = state.get(adapter_key, {})
current_agent_fp = adapter.fingerprint()
all_memories = await backend.get_user_memories(user_id, limit=500)
current_db_fp = _db_fingerprint(all_memories)
if (
prev.get("agent_fingerprint") == current_agent_fp
and prev.get("db_fingerprint") == current_db_fp
):
result.duration_ms = (time.monotonic() - start) * 1000
logger.info(
f"Sync [{adapter.agent_name}]: no-op — nothing changed ({result.duration_ms:.1f}ms)"
)
return result
else:
all_memories = await backend.get_user_memories(user_id, limit=500)
# --- Phase 1: Import (agent files → DB) ---
result.imported = await sync_import(backend, adapter, user_id, all_memories)
# --- Phase 2: Export (DB → agent files) ---
# Re-fetch if imports happened (new entries)
if result.imported > 0:
all_memories = await backend.get_user_memories(user_id, limit=500)
result.exported = await sync_export(backend, adapter, user_id, all_memories)
# --- Update sync state ---
state = _load_sync_state(state_path)
adapter_key = f"{adapter.agent_name}:{user_id}"
state[adapter_key] = {
"agent_fingerprint": adapter.fingerprint(),
"db_fingerprint": _db_fingerprint(all_memories),
"last_sync": datetime.now(timezone.utc).isoformat(),
"last_imported": result.imported,
"last_exported": result.exported,
}
_save_sync_state(state_path, state)
result.duration_ms = (time.monotonic() - start) * 1000
logger.info(
f"Sync [{adapter.agent_name}]: imported={result.imported}, "
f"exported={result.exported} ({result.duration_ms:.1f}ms)"
)
return result
async def sync_import(
backend: Any,
adapter: AgentMemoryAdapter,
user_id: str,
existing_memories: list[Any] | None = None,
) -> int:
"""Import: agent files → DB. Returns count imported."""
agent_memories = await adapter.read_memories()
if not agent_memories:
return 0
# Build set of existing content hashes for dedup
if existing_memories is None:
existing_memories = await backend.get_user_memories(user_id, limit=500)
existing_hashes: set[str] = set()
for mem in existing_memories:
h = (mem.metadata or {}).get("content_hash", "")
if h:
existing_hashes.add(h)
# Also hash the content directly for safety
existing_hashes.add(hashlib.sha256(mem.content.encode()).hexdigest()[:16])
imported = 0
for am in agent_memories:
if am.content_hash in existing_hashes:
continue
# Save to DB with lineage metadata
await backend.save_memory(
content=am.content,
user_id=user_id,
importance=0.6,
metadata={
"source_agent": adapter.agent_name,
"source_file": am.source_file,
"content_hash": am.content_hash,
"synced_at": datetime.now(timezone.utc).isoformat(),
"sync_direction": "import",
**am.metadata,
},
)
existing_hashes.add(am.content_hash)
imported += 1
if imported:
logger.info(f"Sync [{adapter.agent_name}]: imported {imported} memories from agent files")
return imported
async def sync_export(
backend: Any,
adapter: AgentMemoryAdapter,
user_id: str,
existing_memories: list[Any] | None = None,
) -> int:
"""Export: DB → agent files. Returns count exported."""
if existing_memories is None:
existing_memories = await backend.get_user_memories(user_id, limit=500)
if not existing_memories:
return 0
# Read what the agent already has (to avoid re-exporting)
agent_memories = await adapter.read_memories()
agent_hashes: set[str] = {am.content_hash for am in agent_memories}
# Find memories to export (not already in agent, not imported FROM this agent)
to_export: list[dict[str, Any]] = []
for mem in existing_memories:
content_hash = hashlib.sha256(mem.content.encode()).hexdigest()[:16]
# Skip if agent already has it
if content_hash in agent_hashes:
continue
# Skip if this memory was originally imported FROM this same agent
# (prevents echo: agent → DB → agent)
meta = mem.metadata or {}
if (
meta.get("source_agent") == adapter.agent_name
and meta.get("sync_direction") == "import"
):
continue
to_export.append(
{
"content": mem.content,
"category": getattr(mem, "category", "") or "",
"importance": getattr(mem, "importance", 0.5),
"headroom_id": mem.id,
"source_agent": meta.get("source_agent", "unknown"),
"content_hash": content_hash,
"created_at": mem.created_at.isoformat()
if hasattr(mem.created_at, "isoformat")
else str(mem.created_at),
}
)
if not to_export:
return 0
exported = await adapter.write_memories(to_export)
if exported:
logger.info(f"Sync [{adapter.agent_name}]: exported {exported} memories to agent files")
return exported
# ---------------------------------------------------------------------------
# CLI entry point: python -m headroom.memory.sync --db ... --user ... --agent ...
# ---------------------------------------------------------------------------
def _build_sync_backend(db_path: str) -> Any:
"""Build the memory backend used by the sync subprocess.
Match the proxy MCP server (see ``headroom/memory/mcp_server.py``): use the
torch-free ONNX embedder so ``wrap --memory`` sync works on the proxy extras
without sentence-transformers/PyTorch (#1092). It loads the same
``all-MiniLM-L6-v2`` 384-dim model as the local embedder, so vectors stay
compatible with what the proxy writes — no DB migration.
"""
from headroom.memory.backends.local import LocalBackend, LocalBackendConfig
config = LocalBackendConfig(db_path=db_path, embedder_backend="onnx")
return LocalBackend(config)
def main() -> None:
"""CLI entry point for running sync from a subprocess."""
import argparse
parser = argparse.ArgumentParser(description="Headroom memory sync")
parser.add_argument("--db", required=True, help="Path to memory DB")
parser.add_argument("--user", required=True, help="User ID")
parser.add_argument("--agent", required=True, choices=["claude", "codex"], help="Agent to sync")
parser.add_argument("--force", action="store_true", help="Skip no-op check")
args = parser.parse_args()
import asyncio
import json as _json
async def _run() -> None:
backend = _build_sync_backend(args.db)
await backend._ensure_initialized()
if args.agent == "claude":
from headroom.memory.sync_adapters.claude_code import (
ClaudeCodeAdapter,
get_claude_memory_dir,
)
adapter: ClaudeCodeAdapter | Any = ClaudeCodeAdapter(get_claude_memory_dir())
elif args.agent == "codex":
from headroom.memory.sync_adapters.codex_agent import CodexAdapter
adapter = CodexAdapter()
else:
print(_json.dumps({"error": f"Unknown agent: {args.agent}"}))
return
result = await sync(backend, adapter, args.user, force=args.force)
await backend.close()
print(
_json.dumps(
{
"imported": result.imported,
"exported": result.exported,
"ms": round(result.duration_ms),
}
)
)
asyncio.run(_run())
if __name__ == "__main__":
main()