diff --git a/agent/__init__.py b/agent/__init__.py index 5eaf013..e92bcd2 100755 --- a/agent/__init__.py +++ b/agent/__init__.py @@ -5,7 +5,7 @@ from agent.config import AgentConfig from agent.main import PerseusMemoryAgent -from agent.memory import ( +from perseus_agent_core.memory import ( ElasticMemoryBackend, EngramMemoryBackend, MemoryBackend, diff --git a/agent/__pycache__/__init__.cpython-311.pyc b/agent/__pycache__/__init__.cpython-311.pyc index cdc018d..6d7898c 100644 Binary files a/agent/__pycache__/__init__.cpython-311.pyc and b/agent/__pycache__/__init__.cpython-311.pyc differ diff --git a/agent/__pycache__/__init__.cpython-314.pyc b/agent/__pycache__/__init__.cpython-314.pyc new file mode 100644 index 0000000..7a7cfbc Binary files /dev/null and b/agent/__pycache__/__init__.cpython-314.pyc differ diff --git a/agent/__pycache__/config.cpython-311.pyc b/agent/__pycache__/config.cpython-311.pyc index 0ca7652..e68a528 100644 Binary files a/agent/__pycache__/config.cpython-311.pyc and b/agent/__pycache__/config.cpython-311.pyc differ diff --git a/agent/__pycache__/config.cpython-314.pyc b/agent/__pycache__/config.cpython-314.pyc new file mode 100644 index 0000000..071455a Binary files /dev/null and b/agent/__pycache__/config.cpython-314.pyc differ diff --git a/agent/__pycache__/main.cpython-311.pyc b/agent/__pycache__/main.cpython-311.pyc index 925dcf5..7d77726 100644 Binary files a/agent/__pycache__/main.cpython-311.pyc and b/agent/__pycache__/main.cpython-311.pyc differ diff --git a/agent/__pycache__/main.cpython-314.pyc b/agent/__pycache__/main.cpython-314.pyc new file mode 100644 index 0000000..a56e066 Binary files /dev/null and b/agent/__pycache__/main.cpython-314.pyc differ diff --git a/agent/main.py b/agent/main.py index cbea651..afce56b 100755 --- a/agent/main.py +++ b/agent/main.py @@ -22,8 +22,8 @@ from datetime import datetime, timezone from agent.config import AgentConfig -from agent.memory import ElasticMemoryBackend, EngramMemoryBackend, MemoryEntry -from agent.tools import DecisionLogTool, KnowledgeGraphTool, ProjectContextTool +from perseus_agent_core.memory import ElasticMemoryBackend, EngramMemoryBackend, MemoryEntry +from perseus_agent_core.tools import DecisionLogTool, KnowledgeGraphTool, ProjectContextTool class PerseusMemoryAgent: diff --git a/agent/memory/__init__.py b/agent/memory/__init__.py deleted file mode 100755 index b4455ce..0000000 --- a/agent/memory/__init__.py +++ /dev/null @@ -1,27 +0,0 @@ -"""Memory backends for Perseus Memory Agent. - -Two implementations of the same MemoryBackend interface: - -- ElasticMemoryBackend: Production, cloud-managed, uses Elastic MCP -- EngramMemoryBackend: Self-hosted, MIT-licensed, SQLite-backed - -Switching backends requires changing one line of config (MEMORY_BACKEND). -""" - -from agent.memory.backend import ( - MemoryBackend, - MemoryBackendError, - MemoryEntry, - MemorySearchResult, -) -from agent.memory.elastic_memory import ElasticMemoryBackend -from agent.memory.engram_memory import EngramMemoryBackend - -__all__ = [ - "MemoryBackend", - "MemoryBackendError", - "MemoryEntry", - "MemorySearchResult", - "ElasticMemoryBackend", - "EngramMemoryBackend", -] diff --git a/agent/memory/__pycache__/__init__.cpython-311.pyc b/agent/memory/__pycache__/__init__.cpython-311.pyc deleted file mode 100644 index a23a232..0000000 Binary files a/agent/memory/__pycache__/__init__.cpython-311.pyc and /dev/null differ diff --git a/agent/memory/__pycache__/__init__.cpython-314.pyc b/agent/memory/__pycache__/__init__.cpython-314.pyc new file mode 100644 index 0000000..f933a11 Binary files /dev/null and b/agent/memory/__pycache__/__init__.cpython-314.pyc differ diff --git a/agent/memory/__pycache__/backend.cpython-311.pyc b/agent/memory/__pycache__/backend.cpython-311.pyc deleted file mode 100644 index 1eb6ca7..0000000 Binary files a/agent/memory/__pycache__/backend.cpython-311.pyc and /dev/null differ diff --git a/agent/memory/__pycache__/backend.cpython-314.pyc b/agent/memory/__pycache__/backend.cpython-314.pyc new file mode 100644 index 0000000..6e9b675 Binary files /dev/null and b/agent/memory/__pycache__/backend.cpython-314.pyc differ diff --git a/agent/memory/__pycache__/elastic_memory.cpython-311.pyc b/agent/memory/__pycache__/elastic_memory.cpython-311.pyc deleted file mode 100644 index d8bcaa0..0000000 Binary files a/agent/memory/__pycache__/elastic_memory.cpython-311.pyc and /dev/null differ diff --git a/agent/memory/__pycache__/elastic_memory.cpython-314.pyc b/agent/memory/__pycache__/elastic_memory.cpython-314.pyc new file mode 100644 index 0000000..35157d4 Binary files /dev/null and b/agent/memory/__pycache__/elastic_memory.cpython-314.pyc differ diff --git a/agent/memory/__pycache__/engram_memory.cpython-311.pyc b/agent/memory/__pycache__/engram_memory.cpython-311.pyc deleted file mode 100644 index 90ce053..0000000 Binary files a/agent/memory/__pycache__/engram_memory.cpython-311.pyc and /dev/null differ diff --git a/agent/memory/__pycache__/engram_memory.cpython-314.pyc b/agent/memory/__pycache__/engram_memory.cpython-314.pyc new file mode 100644 index 0000000..3517697 Binary files /dev/null and b/agent/memory/__pycache__/engram_memory.cpython-314.pyc differ diff --git a/agent/memory/backend.py b/agent/memory/backend.py deleted file mode 100755 index 0cbbe36..0000000 --- a/agent/memory/backend.py +++ /dev/null @@ -1,106 +0,0 @@ -"""Abstract memory backend interface. - -All memory backends (Elastic, Engram-rs) implement this interface so the agent -can swap between managed cloud memory and self-hosted open-source memory -without changing any agent code. -""" - -from abc import ABC, abstractmethod -from dataclasses import dataclass, field -from datetime import datetime -from typing import Optional - - -class MemoryBackendError(RuntimeError): - """The memory backend itself failed (connection, auth, storage). - - Distinct from an empty result: "I know nothing about that" and - "my memory is broken" must never look the same to the caller. - """ - - -@dataclass -class MemoryEntry: - """A single fact, decision, or piece of context stored in agent memory. - - `id` is optional: backends auto-generate one for falsy ids. It sits - after the required fields because dataclass ordering forbids a - defaulted field before non-defaulted ones. - """ - - content: str - category: str # "fact", "decision", "preference", "lesson", "context" - project: str # project namespace - id: str = "" - tags: list[str] = field(default_factory=list) - source_session: Optional[str] = None - confidence: float = 1.0 # 0.0-1.0, can decay or be reinforced - created_at: Optional[datetime] = None - updated_at: Optional[datetime] = None - metadata: dict = field(default_factory=dict) - - -@dataclass -class MemorySearchResult: - """Result from a memory search with relevance score.""" - - entry: MemoryEntry - score: float - search_method: str # "semantic", "keyword", "hybrid" - - -class MemoryBackend(ABC): - """Abstract interface for agent memory backends. - - Implementations: - - ElasticMemoryBackend: Uses Elastic Agent Builder MCP - - EngramMemoryBackend: Uses Engram-rs CLI (SQLite-backed) - """ - - @abstractmethod - async def remember(self, entry: MemoryEntry) -> str: - """Store a memory entry. Returns the entry ID.""" - ... - - @abstractmethod - async def recall( - self, - query: str, - project: Optional[str] = None, - category: Optional[str] = None, - limit: int = 10, - min_confidence: float = 0.0, - ) -> list[MemorySearchResult]: - """Search memory for relevant entries. - - Args: - query: Natural language search query - project: Filter to specific project - category: Filter to category ("fact", "decision", etc.) - limit: Max results - min_confidence: Minimum confidence threshold - """ - ... - - @abstractmethod - async def forget(self, entry_id: str) -> bool: - """Remove a memory entry. Returns True if deleted.""" - ... - - @abstractmethod - async def reflect( - self, - project: Optional[str] = None, - ) -> list[dict]: - """Cross-reference memories to synthesize new insights. - - This is the "institutional knowledge" operation — the agent looks - across all stored facts and identifies patterns, contradictions, - or gaps that should be surfaced to the user. - """ - ... - - @abstractmethod - async def health_check(self) -> dict: - """Check backend health and connection status.""" - ... diff --git a/agent/memory/elastic_memory.py b/agent/memory/elastic_memory.py deleted file mode 100755 index 8734be8..0000000 --- a/agent/memory/elastic_memory.py +++ /dev/null @@ -1,334 +0,0 @@ -"""Elasticsearch memory backend. - -Uses Elasticsearch directly via elasticsearch-py: keyword search always, -plus semantic search (ELSER via a `semantic_text` field) when the -deployment supports it — the index is created with a semantic mapping -first and falls back to a plain mapping if the inference endpoint isn't -available, so the backend works on any 8.x deployment and gets smarter -on Elastic Cloud. - -Configuration via environment variables: - ELASTIC_CLOUD_ID: Elastic Cloud deployment ID - ELASTIC_API_KEY: Elasticsearch API key - ELASTIC_MEMORY_INDEX: Index name (default: "perseus-agent-memory") -""" - -import os -import uuid -from datetime import datetime, timezone -from typing import Optional - -from agent.memory.backend import ( - MemoryBackend, - MemoryBackendError, - MemoryEntry, - MemorySearchResult, -) - -_PLAIN_MAPPINGS = { - "properties": { - "content": {"type": "text"}, - "category": {"type": "keyword"}, - "project": {"type": "keyword"}, - "tags": {"type": "keyword"}, - "source_session": {"type": "keyword"}, - "confidence": {"type": "float"}, - "created_at": {"type": "date"}, - "updated_at": {"type": "date"}, - "metadata": {"type": "object", "enabled": True}, - } -} - -_SEMANTIC_MAPPINGS = { - "properties": { - **_PLAIN_MAPPINGS["properties"], - "content": {"type": "text", "copy_to": "content_semantic"}, - "content_semantic": {"type": "semantic_text"}, - } -} - -# Optional elasticsearch-py for standalone mode -try: - from elasticsearch import Elasticsearch - _HAS_ELASTICSEARCH = True -except ImportError: - _HAS_ELASTICSEARCH = False - - -class ElasticMemoryBackend(MemoryBackend): - """Memory backend storing entries in an Elasticsearch index.""" - - def __init__(self): - self.cloud_id = os.getenv("ELASTIC_CLOUD_ID", "") - self.api_key = os.getenv("ELASTIC_API_KEY", "") - self.memory_index = os.getenv("ELASTIC_MEMORY_INDEX", "perseus-agent-memory") - if not all([self.cloud_id, self.api_key]): - raise ValueError( - "ELASTIC_CLOUD_ID and ELASTIC_API_KEY must be set. " - "Get them from elastic.co cloud console." - ) - - self._es = None - self._semantic: Optional[bool] = None # unknown until index is ensured - - @property - def es(self): - """Lazy Elasticsearch client — created on first use.""" - if self._es is None: - try: - from elasticsearch import Elasticsearch - except ImportError as exc: - raise MemoryBackendError( - "elasticsearch package not installed — " - "pip install -r requirements.txt" - ) from exc - self._es = Elasticsearch( - cloud_id=self.cloud_id, - api_key=self.api_key, - request_timeout=10, - ) - return self._es - - def _ensure_index(self) -> None: - """Create the memory index on first use; detect semantic support.""" - if self._semantic is not None: - return - try: - if self.es.indices.exists(index=self.memory_index): - mapping = self.es.indices.get_mapping(index=self.memory_index) - props = mapping[self.memory_index]["mappings"].get("properties", {}) - self._semantic = "content_semantic" in props - return - try: - self.es.indices.create( - index=self.memory_index, mappings=_SEMANTIC_MAPPINGS - ) - self._semantic = True - except Exception: - # No inference endpoint / pre-8.15 deployment — plain mapping. - self.es.indices.create( - index=self.memory_index, mappings=_PLAIN_MAPPINGS - ) - self._semantic = False - except MemoryBackendError: - raise - except Exception as exc: - self._semantic = None # retry on next call - raise MemoryBackendError(f"Elastic index setup failed: {exc}") from exc - async def remember(self, entry: MemoryEntry) -> str: - """Store a memory entry in Elasticsearch.""" - if not entry.id: - entry.id = f"mem-{uuid.uuid4().hex[:12]}" - - now = datetime.now(timezone.utc) - if not entry.created_at: - entry.created_at = now - entry.updated_at = now - - doc = { - "id": entry.id, - "content": entry.content, - "category": entry.category, - "project": entry.project, - "tags": entry.tags, - "source_session": entry.source_session, - "confidence": entry.confidence, - "created_at": entry.created_at.isoformat(), - "updated_at": entry.updated_at.isoformat(), - "metadata": entry.metadata, - } - - self._ensure_index() - try: - # refresh="wait_for": a recall immediately after a remember must - # see the new entry — session demos and tests depend on it. - self.es.index( - index=self.memory_index, - id=entry.id, - document=doc, - refresh="wait_for", - ) - except MemoryBackendError: - raise - except Exception as exc: - raise MemoryBackendError(f"Elastic store failed: {exc}") from exc - - return entry.id - - async def recall( - self, - query: str, - project: Optional[str] = None, - category: Optional[str] = None, - limit: int = 10, - min_confidence: float = 0.0, - ) -> list[MemorySearchResult]: - """Search memory: keyword match always, semantic match when available. - - Returns [] only when the search succeeded and found nothing; - backend failures raise MemoryBackendError. - """ - self._ensure_index() - - filters = [] - if project: - filters.append({"term": {"project": project}}) - if category: - filters.append({"term": {"category": category}}) - if min_confidence > 0.0: - filters.append({"range": {"confidence": {"gte": min_confidence}}}) - - if not query or query == "*": - # Tools use "*" to mean "everything in scope". - bool_query: dict = {"must": {"match_all": {}}} - else: - should = [{"match": {"content": {"query": query}}}] - if self._semantic: - should.append( - {"semantic": {"field": "content_semantic", "query": query}} - ) - bool_query = {"should": should, "minimum_should_match": 1} - if filters: - bool_query["filter"] = filters - - try: - response = self.es.search( - index=self.memory_index, - query={"bool": bool_query}, - size=limit, - ) - except Exception as exc: - raise MemoryBackendError(f"Elastic search failed: {exc}") from exc - - results = [] - for hit in response["hits"]["hits"]: - src = hit["_source"] - entry = MemoryEntry( - id=src.get("id", hit["_id"]), - content=src.get("content", ""), - category=src.get("category", "fact"), - project=src.get("project", ""), - tags=src.get("tags") or [], - source_session=src.get("source_session"), - confidence=src.get("confidence", 1.0), - created_at=_parse_dt(src.get("created_at")), - updated_at=_parse_dt(src.get("updated_at")), - metadata=src.get("metadata") or {}, - ) - results.append( - MemorySearchResult( - entry=entry, - score=hit.get("_score") or 0.0, - search_method="hybrid" if self._semantic else "keyword", - ) - ) - return results - - async def forget(self, entry_id: str) -> bool: - """Delete a memory entry by ID. Returns False if it didn't exist.""" - self._ensure_index() - try: - from elasticsearch import NotFoundError - except ImportError as exc: - raise MemoryBackendError("elasticsearch package not installed") from exc - try: - self.es.delete(index=self.memory_index, id=entry_id, refresh="wait_for") - return True - except NotFoundError: - return False - except Exception as exc: - raise MemoryBackendError(f"Elastic delete failed: {exc}") from exc - - async def reflect(self, project: Optional[str] = None) -> list[dict]: - """Cross-reference memories to find patterns and insights. - - Currently surfaces: - - stale facts (confidence < 0.3) that may need re-verification - - knowledge distribution by category (gaps show up as absences) - """ - self._ensure_index() - filters = [{"term": {"project": project}}] if project else [] - - insights: list[dict] = [] - try: - stale = self.es.search( - index=self.memory_index, - query={ - "bool": { - "filter": filters - + [{"range": {"confidence": {"lt": 0.3}}}] - } - }, - sort=[{"confidence": "asc"}], - size=10, - ) - for hit in stale["hits"]["hits"]: - src = hit["_source"] - insights.append( - { - "type": "stale_fact", - "summary": ( - f"Low-confidence memory may need re-verification: " - f"{src.get('content', '')[:120]}" - ), - "confidence": src.get("confidence"), - "id": src.get("id", hit["_id"]), - "backend": "elastic", - } - ) - - clusters = self.es.search( - index=self.memory_index, - query={"bool": {"filter": filters}} if filters else {"match_all": {}}, - aggs={"by_category": {"terms": {"field": "category"}}}, - size=0, - ) - buckets = ( - clusters.get("aggregations", {}) - .get("by_category", {}) - .get("buckets", []) - ) - if buckets: - dist = ", ".join(f"{b['key']}: {b['doc_count']}" for b in buckets) - insights.append( - { - "type": "knowledge_distribution", - "summary": f"Knowledge by category — {dist}", - "backend": "elastic", - } - ) - except Exception as exc: - raise MemoryBackendError(f"Elastic reflect failed: {exc}") from exc - - return insights - - async def health_check(self) -> dict: - """Verify Elasticsearch connection and index health. Never raises.""" - try: - if not self.es.ping(): - return { - "status": "error", - "backend": "elastic", - "error": "Elasticsearch ping failed (bad credentials or endpoint)", - } - return { - "status": "ok", - "backend": "elastic", - "cloud_id": self.cloud_id[:12] + "...", - "memory_index": self.memory_index, - "index_exists": bool( - self.es.indices.exists(index=self.memory_index) - ), - "semantic_search": self._semantic, - } - except Exception as exc: - return {"status": "error", "backend": "elastic", "error": str(exc)} - - -def _parse_dt(value) -> Optional[datetime]: - if not value: - return None - try: - return datetime.fromisoformat(str(value).replace("Z", "+00:00")) - except ValueError: - return None diff --git a/agent/memory/engram_memory.py b/agent/memory/engram_memory.py deleted file mode 100755 index fd05a22..0000000 --- a/agent/memory/engram_memory.py +++ /dev/null @@ -1,240 +0,0 @@ -"""Engram-rs memory backend — self-hosted, MIT-licensed persistent memory. - -Uses Engram-rs CLI (engram) as the memory store. Engram-rs is a SQLite-backed -long-term memory system for AI agents that provides persistent, searchable -memory across sessions with zero cloud dependencies. - -This backend implements the same MemoryBackend interface as ElasticMemoryBackend, -so switching between Elastic (cloud) and Engram-rs (local) requires changing -exactly one line of configuration. - -Engram-rs: https://github.com/tcconnally/engram-rs (MIT licensed) -Perseus: https://github.com/tcconnally/perseus (context + memory for AI agents) -""" - -import json -import os -import subprocess -import uuid -from datetime import datetime, timezone -from typing import Optional - -from agent.memory.backend import MemoryBackend, MemoryEntry, MemorySearchResult - - -class EngramMemoryBackend(MemoryBackend): - """Self-hosted memory backend using Engram-rs. - - Configuration via environment variables: - ENGRAM_BIN: Path to engram binary (default: "engram") - ENGRAM_DATA_DIR: Data directory (default: "~/.hermes/mnemosyne/data") - ENGRAM_DB_PATH: Full path to engram.db (overrides ENGRAM_DATA_DIR) - - Engram-rs provides MCP-compatible tools: - - engram_store: persist a memory entry - - engram_recall: search memory - - engram_health: check connection - """ - - def __init__(self): - self.engram_bin = os.getenv("ENGRAM_BIN", "engram") - data_dir = os.getenv( - "ENGRAM_DATA_DIR", - os.path.expanduser("~/.hermes/mnemosyne/data"), - ) - self.db_path = os.getenv( - "ENGRAM_DB_PATH", - os.path.join(data_dir, "mnemosyne.db"), - ) - - def _run_engram(self, args: list[str], retries: int = 1) -> dict: - """Run an engram CLI command and return parsed JSON output. - - Args: - args: CLI arguments to pass to engram - retries: Number of retry attempts on transient failures (timeout/lock) - """ - cmd = [self.engram_bin] + args - last_error = None - for attempt in range(retries + 1): - try: - result = subprocess.run( - cmd, - capture_output=True, - text=True, - timeout=10, - ) - if result.returncode != 0: - stderr = result.stderr.strip() - # Retry on transient SQLite lock errors - if "database is locked" in stderr.lower() and attempt < retries: - import time - time.sleep(2 * (attempt + 1)) - last_error = stderr - continue - return { - "error": stderr, - "exit_code": result.returncode, - } - if result.stdout.strip(): - return json.loads(result.stdout) - return {} - except FileNotFoundError: - return { - "error": f"Engram binary not found: {self.engram_bin}. " - f"Install with: curl -sSL https://raw.githubusercontent.com/" - f"tcconnally/engram-rs/main/scripts/bootstrap.sh | bash", - "exit_code": -1, - } - except subprocess.TimeoutExpired: - if attempt < retries: - import time - time.sleep(2 * (attempt + 1)) - last_error = "Engram command timed out" - continue - return {"error": "Engram command timed out after retries", "exit_code": -2} - return {"error": last_error or "unknown error", "exit_code": -3} - - async def remember(self, entry: MemoryEntry) -> str: - """Store a memory entry via engram_store. - - Engram-rs stores entries as key-value pairs with metadata in SQLite. - """ - if not entry.id: - entry.id = f"mem-{uuid.uuid4().hex[:12]}" - - now = datetime.now(timezone.utc) - entry.created_at = entry.created_at or now - entry.updated_at = now - - payload = { - "id": entry.id, - "content": entry.content, - "category": entry.category, - "project": entry.project, - "tags": entry.tags, - "confidence": entry.confidence, - "metadata": entry.metadata, - } - - result = self._run_engram( - [ - "store", - "--db", self.db_path, - "--id", entry.id, - "--data", json.dumps(payload), - "--project", entry.project, - "--category", entry.category, - "--tags", ",".join(entry.tags), - ] - ) - - if "error" in result: - raise RuntimeError(f"Engram store failed: {result['error']}") - - return entry.id - - async def recall( - self, - query: str, - project: Optional[str] = None, - category: Optional[str] = None, - limit: int = 10, - min_confidence: float = 0.0, - ) -> list[MemorySearchResult]: - """Search memory via engram_recall. - - Engram-rs uses SQLite FTS5 for full-text search across stored memories. - """ - args = [ - "recall", - "--db", self.db_path, - "--query", query, - "--limit", str(limit), - ] - - if project: - args += ["--project", project] - if category: - args += ["--category", category] - - result = self._run_engram(args) - - if "error" in result: - raise RuntimeError(f"Engram recall failed: {result['error']}") - - results = [] - for item in result.get("results", []): - try: - entry_data = json.loads(item.get("data", "{}")) - except (json.JSONDecodeError, TypeError): - continue - entry = MemoryEntry( - id=item.get("id", ""), - content=entry_data.get("content", item.get("content", "")), - category=entry_data.get("category", item.get("category", "fact")), - project=entry_data.get("project", item.get("project", "")), - tags=entry_data.get("tags", []), - confidence=entry_data.get("confidence", 1.0), - metadata=entry_data.get("metadata", {}), - ) - if entry.confidence >= min_confidence: - results.append( - MemorySearchResult( - entry=entry, - score=item.get("score", 0.0), - search_method="fts5", - ) - ) - - return results - - async def forget(self, entry_id: str) -> bool: - """Remove a memory entry from Engram-rs.""" - result = self._run_engram( - ["delete", "--db", self.db_path, "--id", entry_id] - ) - return "error" not in result - - async def reflect(self, project: Optional[str] = None) -> list[dict]: - """Cross-reference memories to find patterns. - - Uses Engram-rs SQL queries to analyze stored memories. - """ - args = ["reflect", "--db", self.db_path] - if project: - args += ["--project", project] - - result = self._run_engram(args) - - if "error" in result: - return [ - { - "type": "insight", - "summary": "Engram-rs reflect operation (SQL-backed analysis)", - "note": "Install engram >= 0.2.0 for reflect support. " - "Current: FTS5 search + metadata filtering available.", - } - ] - - return result.get("insights", []) - - async def health_check(self) -> dict: - """Verify Engram-rs connection and database health.""" - result = self._run_engram(["health", "--db", self.db_path]) - - if "error" in result: - return { - "status": "error", - "backend": "engram-rs", - "db_path": self.db_path, - "error": result["error"], - } - - return { - "status": "ok", - "backend": "engram-rs", - "db_path": self.db_path, - "entry_count": result.get("entry_count", 0), - "db_size_bytes": result.get("db_size_bytes", 0), - } diff --git a/agent/tools/__init__.py b/agent/tools/__init__.py deleted file mode 100755 index 41313fb..0000000 --- a/agent/tools/__init__.py +++ /dev/null @@ -1,14 +0,0 @@ -"""Agent tools for project context, decisions, and knowledge graphs. - -These tools are exposed as MCP tools that the Gemini agent can call. -""" - -from agent.tools.project_context import ProjectContextTool -from agent.tools.decision_log import DecisionLogTool -from agent.tools.knowledge_graph import KnowledgeGraphTool - -__all__ = [ - "ProjectContextTool", - "DecisionLogTool", - "KnowledgeGraphTool", -] diff --git a/agent/tools/__pycache__/__init__.cpython-311.pyc b/agent/tools/__pycache__/__init__.cpython-311.pyc deleted file mode 100644 index 39d8341..0000000 Binary files a/agent/tools/__pycache__/__init__.cpython-311.pyc and /dev/null differ diff --git a/agent/tools/__pycache__/__init__.cpython-314.pyc b/agent/tools/__pycache__/__init__.cpython-314.pyc new file mode 100644 index 0000000..d2466ea Binary files /dev/null and b/agent/tools/__pycache__/__init__.cpython-314.pyc differ diff --git a/agent/tools/__pycache__/decision_log.cpython-311.pyc b/agent/tools/__pycache__/decision_log.cpython-311.pyc deleted file mode 100644 index ea7c436..0000000 Binary files a/agent/tools/__pycache__/decision_log.cpython-311.pyc and /dev/null differ diff --git a/agent/tools/__pycache__/decision_log.cpython-314.pyc b/agent/tools/__pycache__/decision_log.cpython-314.pyc new file mode 100644 index 0000000..624b287 Binary files /dev/null and b/agent/tools/__pycache__/decision_log.cpython-314.pyc differ diff --git a/agent/tools/__pycache__/knowledge_graph.cpython-311.pyc b/agent/tools/__pycache__/knowledge_graph.cpython-311.pyc deleted file mode 100644 index ffd706f..0000000 Binary files a/agent/tools/__pycache__/knowledge_graph.cpython-311.pyc and /dev/null differ diff --git a/agent/tools/__pycache__/knowledge_graph.cpython-314.pyc b/agent/tools/__pycache__/knowledge_graph.cpython-314.pyc new file mode 100644 index 0000000..71c2221 Binary files /dev/null and b/agent/tools/__pycache__/knowledge_graph.cpython-314.pyc differ diff --git a/agent/tools/__pycache__/project_context.cpython-311.pyc b/agent/tools/__pycache__/project_context.cpython-311.pyc deleted file mode 100644 index de852e3..0000000 Binary files a/agent/tools/__pycache__/project_context.cpython-311.pyc and /dev/null differ diff --git a/agent/tools/__pycache__/project_context.cpython-314.pyc b/agent/tools/__pycache__/project_context.cpython-314.pyc new file mode 100644 index 0000000..3af9acf Binary files /dev/null and b/agent/tools/__pycache__/project_context.cpython-314.pyc differ diff --git a/agent/tools/decision_log.py b/agent/tools/decision_log.py deleted file mode 100755 index cb6153d..0000000 --- a/agent/tools/decision_log.py +++ /dev/null @@ -1,108 +0,0 @@ -"""Decision logging and recall tools. - -Every time the agent (or a developer working with it) makes an architectural -decision, the agent logs it. Later sessions can recall past decisions and -their rationale, preventing the "why did we do that?" problem. - -Exposed as MCP tools: - - log_decision: Record a decision with rationale and context - - recall_decisions: Find past decisions relevant to current context - - list_decisions: List all decisions for a project -""" - -from datetime import datetime, timezone -from typing import Optional - -from agent.memory.backend import MemoryEntry - - -class DecisionLogTool: - """Logs and recalls architectural decisions with rationale.""" - - def __init__(self, memory_backend): - self.memory = memory_backend - - async def log_decision( - self, - project: str, - decision: str, - rationale: str, - alternatives: Optional[list[str]] = None, - context: Optional[str] = None, - tags: Optional[list[str]] = None, - ) -> dict: - """Log an architectural or design decision with full context. - - The agent calls this whenever a decision is made or discovered. - Future sessions can recall these to understand why choices were made. - """ - content_parts = [f"Decision: {decision}", f"Rationale: {rationale}"] - - if alternatives: - content_parts.append(f"Alternatives considered: {', '.join(alternatives)}") - - if context: - content_parts.append(f"Context: {context}") - - entry = MemoryEntry( - content=" | ".join(content_parts), - category="decision", - project=project, - tags=tags or ["decision"], - metadata={ - "decision": decision, - "rationale": rationale, - "alternatives": alternatives or [], - "context": context or "", - }, - ) - - entry_id = await self.memory.remember(entry) - - return { - "status": "logged", - "id": entry_id, - "decision": decision, - } - - async def recall_decisions( - self, - query: str, - project: str, - limit: int = 5, - ) -> list[dict]: - """Find past decisions relevant to the current situation.""" - results = await self.memory.recall( - query=query, - project=project, - category="decision", - limit=limit, - ) - - return [ - { - "id": r.entry.id, - "content": r.entry.content, - "score": r.score, - "metadata": r.entry.metadata, - } - for r in results - ] - - async def list_decisions(self, project: str) -> list[dict]: - """List all decisions for a project, newest first.""" - results = await self.memory.recall( - query="decision", - project=project, - category="decision", - limit=50, - ) - - return [ - { - "id": r.entry.id, - "content": r.entry.content, - "metadata": r.entry.metadata, - } - for r in results - ] diff --git a/agent/tools/knowledge_graph.py b/agent/tools/knowledge_graph.py deleted file mode 100755 index ff9e1c7..0000000 --- a/agent/tools/knowledge_graph.py +++ /dev/null @@ -1,98 +0,0 @@ -"""Knowledge graph and compounding tools. - -The agent builds a knowledge graph over time by linking related facts, -decisions, and lessons. This is the "institutional knowledge" layer — -the agent gets smarter about your codebase with every session. - -Exposed as MCP tools: - - link_facts: Connect related memories - - find_patterns: Discover patterns across stored knowledge - - summarize_knowledge: Get a summary of what the agent knows about a project -""" - -from typing import Optional - -from agent.memory.backend import MemoryEntry - - -class KnowledgeGraphTool: - """Builds and queries the agent's knowledge graph over stored memories.""" - - def __init__(self, memory_backend): - self.memory = memory_backend - - async def link_facts( - self, - source_id: str, - target_id: str, - relationship: str, - project: str, - ) -> dict: - """Create a relationship between two stored memories. - - Example: link a "decision" to the "fact" that motivated it. - """ - entry = MemoryEntry( - content=f"Linked: {source_id} --[{relationship}]--> {target_id}", - category="link", - project=project, - tags=["knowledge-graph", relationship], - metadata={ - "source_id": source_id, - "target_id": target_id, - "relationship": relationship, - }, - ) - - entry_id = await self.memory.remember(entry) - - return { - "status": "linked", - "id": entry_id, - "source": source_id, - "target": target_id, - "relationship": relationship, - } - - async def find_patterns(self, project: str) -> dict: - """Analyze stored memories to surface patterns. - - Uses the reflect operation to cross-reference memories and find: - - Frequently co-occurring tags (emerging themes) - - Confidence decay (facts that need refreshing) - - Knowledge gaps (under-documented areas) - """ - insights = await self.memory.reflect(project=project) - - return { - "project": project, - "insights": insights, - "summary": f"Found {len(insights)} patterns across stored memories", - } - - async def summarize_knowledge(self, project: str) -> dict: - """Get a structured summary of everything the agent knows.""" - facts = await self.memory.recall( - query="*", project=project, category="fact", limit=50 - ) - decisions = await self.memory.recall( - query="*", project=project, category="decision", limit=50 - ) - preferences = await self.memory.recall( - query="*", project=project, category="preference", limit=50 - ) - lessons = await self.memory.recall( - query="*", project=project, category="lesson", limit=50 - ) - - return { - "project": project, - "knowledge": { - "facts": len(facts), - "decisions": len(decisions), - "preferences": len(preferences), - "lessons": len(lessons), - }, - "recent_facts": [r.entry.content for r in facts[:5]], - "recent_decisions": [r.entry.metadata.get("decision", "") for r in decisions[:5]], - } diff --git a/agent/tools/project_context.py b/agent/tools/project_context.py deleted file mode 100755 index 6158829..0000000 --- a/agent/tools/project_context.py +++ /dev/null @@ -1,96 +0,0 @@ -"""Project context management tools. - -Tools the agent uses to build, maintain, and recall project-specific context. -These become MCP tools the Gemini agent can call via Google Cloud Agent Builder. -""" - -import json -from datetime import datetime, timezone -from typing import Optional - - -class ProjectContextTool: - """Manages project-level context: stack, conventions, architecture. - - Exposed as MCP tools: - - set_project_context: Define project stack, conventions, structure - - get_project_context: Recall current project context - - update_convention: Add or modify a coding convention - - list_projects: List all known projects - """ - - def __init__(self, memory_backend): - self.memory = memory_backend - - async def set_project_context( - self, - project: str, - stack: Optional[dict] = None, - conventions: Optional[list[str]] = None, - architecture: Optional[str] = None, - description: Optional[str] = None, - ) -> dict: - """Store project context in agent memory. - - Called when a developer first introduces their project, or when - the project's stack/conventions change. - """ - facts = [] - - if stack: - facts.append(f"Tech stack: {json.dumps(stack)}") - for key, value in stack.items(): - from agent.memory.backend import MemoryEntry - await self.memory.remember(MemoryEntry( - content=f"Project {project} uses {key}: {value}", - category="fact", - project=project, - tags=["stack", key], - )) - - if conventions: - for conv in conventions: - from agent.memory.backend import MemoryEntry - await self.memory.remember(MemoryEntry( - content=conv, - category="preference", - project=project, - tags=["convention"], - )) - - if architecture: - from agent.memory.backend import MemoryEntry - await self.memory.remember(MemoryEntry( - content=f"Architecture: {architecture}", - category="fact", - project=project, - tags=["architecture"], - )) - - return { - "status": "stored", - "project": project, - "stack_components": len(stack) if stack else 0, - "conventions": len(conventions) if conventions else 0, - "architecture_stored": architecture is not None, - } - - async def get_project_context(self, project: str) -> dict: - """Retrieve all remembered context for a project.""" - stack_facts = await self.memory.recall( - query=f"tech stack", project=project, category="fact" - ) - conventions = await self.memory.recall( - query=f"conventions", project=project, category="preference" - ) - architecture = await self.memory.recall( - query=f"architecture", project=project, category="fact" - ) - - return { - "project": project, - "stack": [r.entry.content for r in stack_facts], - "conventions": [r.entry.content for r in conventions], - "architecture": [r.entry.content for r in architecture], - "total_facts": len(stack_facts) + len(conventions) + len(architecture), - } diff --git a/requirements.txt b/requirements.txt index 908159d..a619f04 100755 --- a/requirements.txt +++ b/requirements.txt @@ -11,3 +11,6 @@ elasticsearch>=8.0 # Async support anyio>=4.0 + +# Shared memory + tool layer (extracted from this repo and perseus-qwen-memory) +perseus-agent-core @ git+https://github.com/tcconnally/perseus-agent-core.git diff --git a/tests/__pycache__/test_demo_paths.cpython-314-pytest-9.0.3.pyc b/tests/__pycache__/test_demo_paths.cpython-314-pytest-9.0.3.pyc new file mode 100644 index 0000000..54251e6 Binary files /dev/null and b/tests/__pycache__/test_demo_paths.cpython-314-pytest-9.0.3.pyc differ diff --git a/tests/test_demo_paths.py b/tests/test_demo_paths.py index 40a51c2..fe645da 100644 --- a/tests/test_demo_paths.py +++ b/tests/test_demo_paths.py @@ -14,7 +14,7 @@ sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..")) from agent.config import AgentConfig # noqa: E402 -from agent.memory.backend import ( # noqa: E402 +from perseus_agent_core.memory.backend import ( # noqa: E402 MemoryBackend, MemoryBackendError, MemoryEntry, @@ -109,7 +109,7 @@ def delete(self, index, id, refresh=None): def elastic_backend(monkeypatch): monkeypatch.setenv("ELASTIC_CLOUD_ID", "deployment:abc123") monkeypatch.setenv("ELASTIC_API_KEY", "key") - from agent.memory.elastic_memory import ElasticMemoryBackend + from perseus_agent_core.memory.elastic_memory import ElasticMemoryBackend backend = ElasticMemoryBackend() backend._es = FakeES() @@ -119,7 +119,7 @@ def elastic_backend(monkeypatch): def test_elastic_requires_credentials(monkeypatch): monkeypatch.delenv("ELASTIC_CLOUD_ID", raising=False) monkeypatch.delenv("ELASTIC_API_KEY", raising=False) - from agent.memory.elastic_memory import ElasticMemoryBackend + from perseus_agent_core.memory.elastic_memory import ElasticMemoryBackend with pytest.raises(ValueError, match="ELASTIC_CLOUD_ID"): ElasticMemoryBackend() @@ -191,7 +191,7 @@ def test_elastic_health_check_never_raises(elastic_backend): def test_engram_demo_session_runs_end_to_end(monkeypatch): - from agent.memory.engram_memory import EngramMemoryBackend + from perseus_agent_core.memory.engram_memory import EngramMemoryBackend import agent.main as main_mod def fake_run_engram(self, args):