Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
44 changes: 44 additions & 0 deletions src/memory/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -635,6 +635,50 @@ def uninstall_opencode_cmd(project):
click.echo(result["message"])


@main.command()
def governor():
"""Run Memory Governor to optimize Core vs Main memory layers."""
import json
from memory.mcp_server import handle_memory_governor

svc = MemoryService()
try:
result_json = handle_memory_governor(svc)
result = json.loads(result_json)

click.echo("\n--- Memory Governor Run ---")
click.echo(f"Message: {result.get('message', 'Complete')}")

actions = result.get("actions", [])
if not actions:
click.echo("No actions recommended at this time.")
else:
click.echo(f"\nRecommended Actions ({len(actions)}):")
for act in actions:
a_type = act.get("action", "UNKNOWN")
m_type = act.get("type", "memory")
m_id = act.get("id", "N/A")
reason = act.get("reason", "")
title = act.get("title", "")

prefix = "+" if a_type == "ADD" else "-"
color = "green" if a_type == "ADD" else "red"

msg = f" {prefix} [{a_type}] {m_type} {m_id[:8]}... "
if title:
msg += f"({title}) "
msg += f"| Reason: {reason}"

click.secho(msg, fg=color)

click.echo(f"\nNext Session ID: {result.get('next_session_id', 'N/A')}")

except Exception as e:
click.secho(f"Error running governor: {e}", fg="red")
finally:
svc.close()


@main.command()
def mcp():
"""Start the EchoVault MCP server (stdio transport)."""
Expand Down
50 changes: 38 additions & 12 deletions src/memory/core.py
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,13 @@ def __init__(self, memory_home: Optional[str] = None):
# Load configuration and initialize database
self.config = load_config(self.config_path)
self.db = MemoryDB(self.db_path)

# Load or create session_id
sid = self.db.get_meta("current_session_id")
if sid is None:
sid = str(uuid.uuid4())
self.db.set_meta("current_session_id", sid)
self.session_id = sid

# Lazy-load embedding provider (expensive operation)
self._embedding_provider: Optional[EmbeddingProvider] = None
Expand Down Expand Up @@ -363,9 +370,10 @@ def search(
)

# Use tiered search: FTS first, embed only if sparse results
results = []
if self.vectors_available:
try:
return tiered_search(
results = tiered_search(
self.db,
self.embedding_provider,
query,
Expand All @@ -379,16 +387,26 @@ def search(
except Exception:
pass

# Fallback: FTS-only search
return tiered_search(
self.db,
None,
query,
limit=limit,
project=project,
source=source,
include_archived=include_archived,
)
if not results:
# Fallback: FTS-only search
results = tiered_search(
self.db,
None,
query,
limit=limit,
project=project,
source=source,
include_archived=include_archived,
)

# Record access for all search results
for res in results:
try:
self.db.record_access(res["id"], self.session_id)
except Exception:
pass

return results

def _ollama_warm(self) -> bool:
base_url = self.config.embedding.base_url or "http://localhost:11434"
Expand Down Expand Up @@ -467,6 +485,7 @@ def get_context(

return results, total

<<<<<<< HEAD
def list_memories(
self,
*,
Expand Down Expand Up @@ -793,15 +812,22 @@ def find_duplicate_candidates(
candidates.sort(key=lambda item: item["score"], reverse=True)
return candidates[:limit]

def get_details(self, memory_id: str) -> Optional[MemoryDetail]:
def get_details(self, memory_id: str, record_usage: bool = True) -> Optional[MemoryDetail]:
"""Get full details for a memory by ID.

Args:
memory_id: UUID of the memory to retrieve details for
record_usage: Whether to record access usage (default: True)

Returns:
MemoryDetail object if details exist, None otherwise
"""
if record_usage:
try:
self.db.record_access(memory_id, self.session_id)
except Exception:
# Fail silently on access recording (e.g. read-only DB in CLI)
pass
return self.db.get_details(memory_id)

def delete(self, memory_id: str) -> bool:
Expand Down
98 changes: 97 additions & 1 deletion src/memory/db.py
Original file line number Diff line number Diff line change
Expand Up @@ -158,7 +158,7 @@ def _create_schema(self) -> None:
END
""")

# Migration: add updated_count column if missing
# Migration: add usage tracking columns if missing
cursor.execute("PRAGMA table_info(memories)")
columns = {row[1] for row in cursor.fetchall()}
if "updated_count" not in columns:
Expand All @@ -171,6 +171,24 @@ def _create_schema(self) -> None:
cursor.execute("ALTER TABLE memories ADD COLUMN archive_reason TEXT")
if "superseded_by" not in columns:
cursor.execute("ALTER TABLE memories ADD COLUMN superseded_by TEXT")
if "access_count" not in columns:
cursor.execute("ALTER TABLE memories ADD COLUMN access_count INTEGER DEFAULT 0")
if "session_access_count" not in columns:
cursor.execute("ALTER TABLE memories ADD COLUMN session_access_count INTEGER DEFAULT 0")
if "last_session_id" not in columns:
cursor.execute("ALTER TABLE memories ADD COLUMN last_session_id TEXT")
if "last_accessed_at" not in columns:
cursor.execute("ALTER TABLE memories ADD COLUMN last_accessed_at TEXT")

# Create core memory usage tracking table
cursor.execute("""
CREATE TABLE IF NOT EXISTS core_memory_usage (
id TEXT PRIMARY KEY,
unused_sessions_count INTEGER DEFAULT 0,
last_used_session_id TEXT,
last_used_at TEXT
)
""")

# Create vec table if dimension is already known (e.g. reopening existing DB)
dim = self.get_embedding_dim()
Expand Down Expand Up @@ -303,6 +321,84 @@ def insert_vector(self, rowid: int, embedding: list[float]) -> None:

self.conn.commit()

def record_access(self, memory_id: str, session_id: str) -> None:
"""Record an access to a memory and increment counters.

Args:
memory_id: Full UUID or prefix of the memory accessed
session_id: Current session identifier
"""
cursor = self.conn.cursor()
from datetime import datetime, timezone
now = datetime.now(timezone.utc).isoformat()

# Resolve full ID
cursor.execute("SELECT id, last_session_id FROM memories WHERE id LIKE ?", (memory_id + "%",))
row = cursor.fetchone()
if not row:
return

full_id = row["id"]
last_sid = row["last_session_id"]

if last_sid != session_id:
# New session for this memory: reset session count and increment total
cursor.execute("""
UPDATE memories
SET access_count = access_count + 1,
session_access_count = 1,
last_session_id = ?,
last_accessed_at = ?
WHERE id = ?
""", (session_id, now, full_id))
else:
# Same session: increment both
cursor.execute("""
UPDATE memories
SET access_count = access_count + 1,
session_access_count = session_access_count + 1,
last_accessed_at = ?
WHERE id = ?
""", (now, full_id))

self.conn.commit()

def record_core_memory_usage(self, core_id: str, session_id: str) -> None:
"""Record usage of a core memory entry.

Args:
core_id: Core memory ID
session_id: Current session identifier
"""
cursor = self.conn.cursor()
from datetime import datetime, timezone
now = datetime.now(timezone.utc).isoformat()

cursor.execute("""
INSERT INTO core_memory_usage (id, unused_sessions_count, last_used_session_id, last_used_at)
VALUES (?, 0, ?, ?)
ON CONFLICT(id) DO UPDATE SET
unused_sessions_count = 0,
last_used_session_id = excluded.last_used_session_id,
last_used_at = excluded.last_used_at
""", (core_id, session_id, now))

self.conn.commit()

def increment_unused_sessions_for_core(self, session_id: str) -> None:
"""Increment unused session count for all core memories not used in this session.

Args:
session_id: Current session identifier
"""
cursor = self.conn.cursor()
cursor.execute("""
UPDATE core_memory_usage
SET unused_sessions_count = unused_sessions_count + 1
WHERE last_used_session_id IS NULL OR last_used_session_id != ?
""", (session_id,))
self.conn.commit()

def get_memory(self, memory_id: str) -> Optional[dict]:
"""Get a memory by ID.

Expand Down
Loading