repositories
loading repo index
repositories
loading repo index
repository
loading code, commits, and activity
public Clawd ADK gateway launch mirror
stars
latest
clone command
git clone gitlawb://did:key:z6Mkq5mY...iFZ5/my-project-publ...git clone gitlawb://did:key:z6Mkq5mY.../my-project-publ...2fa351d6docs: add automaton and perps launch sources16d ago| #1 | """ |
| #2 | Mnemosyne Core - Direct SQLite Integration |
| #3 | No HTTP, no server, just pure Python + SQLite |
| #4 | |
| #5 | This is the heart of Mnemosyne — a zero-dependency memory system |
| #6 | that delivers sub-millisecond performance through direct SQLite access. |
| #7 | |
| #8 | Now upgraded with BEAM architecture: |
| #9 | - working_memory: hot context auto-injected into prompts |
| #10 | - episodic_memory: long-term storage with sqlite-vec + FTS5 |
| #11 | - scratchpad: temporary agent reasoning workspace |
| #12 | """ |
| #13 | |
| #14 | import sqlite3 |
| #15 | import json |
| #16 | import hashlib |
| #17 | import threading |
| #18 | from datetime import datetime |
| #19 | from typing import List, Dict, Optional, Any |
| #20 | from pathlib import Path |
| #21 | |
| #22 | from mnemosyne.core import embeddings as _embeddings |
| #23 | from mnemosyne.core.beam import BeamMemory, init_beam, _get_connection as _beam_get_connection |
| #24 | |
| #25 | # Single shared connection per thread (legacy path) |
| #26 | _thread_local = threading.local() |
| #27 | |
| #28 | # Default data directory |
| #29 | # NOTE: On Fly.io and ephemeral VMs, only ~/.hermes is persisted. |
| #30 | # This MUST match beam.py's DEFAULT_DATA_DIR to avoid split-brain. |
| #31 | DEFAULT_DATA_DIR = Path.home() / ".hermes" / "mnemosyne" / "data" |
| #32 | DEFAULT_DB_PATH = DEFAULT_DATA_DIR / "mnemosyne.db" |
| #33 | |
| #34 | # Allow override via environment |
| #35 | import os |
| #36 | if os.environ.get("MNEMOSYNE_DATA_DIR"): |
| #37 | DEFAULT_DATA_DIR = Path(os.environ.get("MNEMOSYNE_DATA_DIR")) |
| #38 | DEFAULT_DB_PATH = DEFAULT_DATA_DIR / "mnemosyne.db" |
| #39 | |
| #40 | |
| #41 | def _default_data_dir() -> Path: |
| #42 | """Return the current default data directory, honoring runtime env changes.""" |
| #43 | if os.environ.get("MNEMOSYNE_DATA_DIR"): |
| #44 | return Path(os.environ["MNEMOSYNE_DATA_DIR"]) |
| #45 | return DEFAULT_DATA_DIR |
| #46 | |
| #47 | |
| #48 | def _default_db_path() -> Path: |
| #49 | """Return the current default DB path, honoring runtime env changes.""" |
| #50 | return _default_data_dir() / "mnemosyne.db" |
| #51 | |
| #52 | |
| #53 | def _get_connection(db_path = None) -> sqlite3.Connection: |
| #54 | """Get thread-local database connection""" |
| #55 | path = Path(db_path) if db_path else _default_db_path() |
| #56 | if not hasattr(_thread_local, 'conn') or _thread_local.conn is None or getattr(_thread_local, 'db_path', None) != str(path): |
| #57 | path.parent.mkdir(parents=True, exist_ok=True) |
| #58 | _thread_local.conn = sqlite3.connect(str(path), check_same_thread=False) |
| #59 | _thread_local.conn.row_factory = sqlite3.Row |
| #60 | _thread_local.conn.execute("PRAGMA journal_mode=WAL") |
| #61 | _thread_local.conn.execute("PRAGMA busy_timeout=5000") |
| #62 | _thread_local.db_path = str(path) |
| #63 | return _thread_local.conn |
| #64 | |
| #65 | |
| #66 | def init_db(db_path: Path = None): |
| #67 | """Initialize legacy database schema + BEAM schema""" |
| #68 | conn = _get_connection(db_path) |
| #69 | cursor = conn.cursor() |
| #70 | |
| #71 | # Legacy memories table (kept for backward compatibility) |
| #72 | cursor.execute(""" |
| #73 | CREATE TABLE IF NOT EXISTS memories ( |
| #74 | id TEXT PRIMARY KEY, |
| #75 | content TEXT NOT NULL, |
| #76 | source TEXT, |
| #77 | timestamp TEXT, |
| #78 | session_id TEXT DEFAULT 'default', |
| #79 | importance REAL DEFAULT 0.5, |
| #80 | metadata_json TEXT, |
| #81 | created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP |
| #82 | ) |
| #83 | """) |
| #84 | cursor.execute("CREATE INDEX IF NOT EXISTS idx_session ON memories(session_id)") |
| #85 | cursor.execute("CREATE INDEX IF NOT EXISTS idx_timestamp ON memories(timestamp)") |
| #86 | cursor.execute("CREATE INDEX IF NOT EXISTS idx_source ON memories(source)") |
| #87 | |
| #88 | # Legacy embeddings table |
| #89 | cursor.execute(""" |
| #90 | CREATE TABLE IF NOT EXISTS memory_embeddings ( |
| #91 | memory_id TEXT PRIMARY KEY, |
| #92 | embedding_json TEXT NOT NULL, |
| #93 | model TEXT DEFAULT 'bge-small-en-v1.5', |
| #94 | created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, |
| #95 | FOREIGN KEY (memory_id) REFERENCES memories(id) ON DELETE CASCADE |
| #96 | ) |
| #97 | """) |
| #98 | |
| #99 | conn.commit() |
| #100 | |
| #101 | # Initialize BEAM schema on same DB |
| #102 | init_beam(db_path) |
| #103 | |
| #104 | |
| #105 | # Initialize on module load |
| #106 | init_db() |
| #107 | |
| #108 | |
| #109 | def generate_id(content: str) -> str: |
| #110 | """Generate unique ID for memory""" |
| #111 | return hashlib.sha256(f"{content}{datetime.now().isoformat()}".encode()).hexdigest()[:16] |
| #112 | |
| #113 | |
| #114 | class Mnemosyne: |
| #115 | """ |
| #116 | Native memory interface - no HTTP, direct SQLite. |
| #117 | Now backed by BEAM architecture for scalable retrieval. |
| #118 | |
| #119 | Supports memory bank isolation via the `bank` parameter. |
| #120 | Each bank is a separate SQLite database for complete isolation. |
| #121 | """ |
| #122 | |
| #123 | def __init__(self, session_id: str = "default", db_path: Path = None, bank: str = None, |
| #124 | author_id: str = None, author_type: str = None, |
| #125 | channel_id: str = None): |
| #126 | self.session_id = session_id |
| #127 | self.bank = bank or "default" |
| #128 | self.author_id = author_id |
| #129 | self.author_type = author_type |
| #130 | self.channel_id = channel_id or session_id # default channel = session |
| #131 | |
| #132 | # Resolve database path based on bank |
| #133 | if db_path: |
| #134 | self.db_path = db_path |
| #135 | elif bank and bank != "default": |
| #136 | from mnemosyne.core.banks import BankManager |
| #137 | self.db_path = BankManager().get_bank_db_path(bank) |
| #138 | else: |
| #139 | self.db_path = _default_db_path() |
| #140 | |
| #141 | self.conn = _get_connection(self.db_path) |
| #142 | init_db(self.db_path) |
| #143 | |
| #144 | # Phase 8: Streaming + Patterns + Plugins (lazy init) |
| #145 | self._stream = None |
| #146 | self._compressor = None |
| #147 | self._pattern_detector = None |
| #148 | self._delta_sync = None |
| #149 | self._plugin_manager = None |
| #150 | |
| #151 | # Create beam with streaming emitter wired |
| #152 | self.beam = BeamMemory(session_id=session_id, db_path=self.db_path, |
| #153 | author_id=author_id, author_type=author_type, |
| #154 | channel_id=channel_id, |
| #155 | event_emitter=self._stream_emit) |
| #156 | |
| #157 | # ─── Phase 8: Streaming ───────────────────────────────────────── |
| #158 | |
| #159 | @property |
| #160 | def stream(self): |
| #161 | """Lazy-initialized memory event stream.""" |
| #162 | if self._stream is None: |
| #163 | from mnemosyne.core.streaming import MemoryStream |
| #164 | self._stream = MemoryStream() |
| #165 | return self._stream |
| #166 | |
| #167 | def enable_streaming(self) -> "Mnemosyne": |
| #168 | """Enable event streaming for this memory instance. |
| #169 | |
| #170 | Wires the stream into BeamMemory so all write operations emit events. |
| #171 | Call once after construction to activate the streaming subsystem. |
| #172 | """ |
| #173 | _ = self.stream # Force init |
| #174 | # Retroactively wire emitter into existing beam (handles lazy init case) |
| #175 | if self.beam._event_emitter is None: |
| #176 | self.beam._event_emitter = self._stream_emit |
| #177 | return self |
| #178 | |
| #179 | def _stream_emit(self, event) -> None: |
| #180 | """Callback passed to BeamMemory; routes events to the lazy-init stream.""" |
| #181 | if self._stream is not None: |
| #182 | self._stream.emit(event) |
| #183 | |
| #184 | # ─── Phase 8: Compression ─────────────────────────────────────── |
| #185 | |
| #186 | @property |
| #187 | def compressor(self): |
| #188 | """Lazy-initialized memory compressor.""" |
| #189 | if self._compressor is None: |
| #190 | from mnemosyne.core.patterns import MemoryCompressor |
| #191 | self._compressor = MemoryCompressor() |
| #192 | return self._compressor |
| #193 | |
| #194 | def compress(self, content: str, method: str = "auto"): |
| #195 | """Compress memory content. Returns (compressed, stats).""" |
| #196 | return self.compressor.compress(content, method=method) |
| #197 | |
| #198 | def decompress(self, content: str, method: str = "dict") -> str: |
| #199 | """Decompress memory content.""" |
| #200 | return self.compressor.decompress(content, method=method) |
| #201 | |
| #202 | def compress_memories(self, memories: list, method: str = "auto"): |
| #203 | """Compress a batch of memories. Returns (compressed_memories, stats).""" |
| #204 | return self.compressor.compress_batch(memories, method=method) |
| #205 | |
| #206 | # ─── Phase 8: Pattern Detection ───────────────────────────────── |
| #207 | |
| #208 | @property |
| #209 | def patterns(self): |
| #210 | """Lazy-initialized pattern detector.""" |
| #211 | if self._pattern_detector is None: |
| #212 | from mnemosyne.core.patterns import PatternDetector |
| #213 | self._pattern_detector = PatternDetector() |
| #214 | return self._pattern_detector |
| #215 | |
| #216 | def detect_patterns(self, memories: list = None) -> list: |
| #217 | """Detect patterns in memories. Uses all working+episodic if none provided.""" |
| #218 | if memories is None: |
| #219 | memories = self.get_all_memories() |
| #220 | return self.patterns.detect_all(memories) |
| #221 | |
| #222 | def summarize_patterns(self, memories: list = None) -> dict: |
| #223 | """Generate a summary of detected patterns.""" |
| #224 | if memories is None: |
| #225 | memories = self.get_all_memories() |
| #226 | return self.patterns.summarize_patterns(memories) |
| #227 | |
| #228 | def get_all_memories(self) -> List[Dict]: |
| #229 | """Return all working + episodic rows for pattern analysis. |
| #230 | |
| #231 | Scoped to the active session (and global memories), with the same |
| #232 | validity filters that get_context() and recall() apply: invalidated |
| #233 | and expired memories are excluded so retracted notes do not skew |
| #234 | pattern detection. |
| #235 | """ |
| #236 | now = datetime.now().isoformat() |
| #237 | cursor = self.beam.conn.cursor() |
| #238 | cursor.execute(""" |
| #239 | SELECT id, content, source, timestamp, session_id, importance |
| #240 | FROM working_memory |
| #241 | WHERE (session_id = ? OR scope = 'global') |
| #242 | AND (valid_until IS NULL OR valid_until > ?) |
| #243 | AND superseded_by IS NULL |
| #244 | """, (self.session_id, now)) |
| #245 | rows = [dict(row) for row in cursor.fetchall()] |
| #246 | seen_ids = {r["id"] for r in rows} |
| #247 | cursor.execute(""" |
| #248 | SELECT id, content, source, timestamp, session_id, importance |
| #249 | FROM episodic_memory |
| #250 | WHERE (session_id = ? OR scope = 'global') |
| #251 | AND (valid_until IS NULL OR valid_until > ?) |
| #252 | AND superseded_by IS NULL |
| #253 | """, (self.session_id, now)) |
| #254 | for row in cursor.fetchall(): |
| #255 | if row["id"] not in seen_ids: |
| #256 | rows.append(dict(row)) |
| #257 | return rows |
| #258 | |
| #259 | # ─── Phase 8: Delta Sync ────────────────────────────────────── |
| #260 | |
| #261 | @property |
| #262 | def delta_sync(self): |
| #263 | """Lazy-initialized delta sync.""" |
| #264 | if self._delta_sync is None: |
| #265 | from mnemosyne.core.streaming import DeltaSync |
| #266 | self._delta_sync = DeltaSync(self) |
| #267 | return self._delta_sync |
| #268 | |
| #269 | def sync_to(self, peer_id: str, table: str = "working_memory") -> dict: |
| #270 | """Compute delta for a peer. Returns {peer_id, table, delta, count}.""" |
| #271 | return self.delta_sync.sync_to(peer_id, table) |
| #272 | |
| #273 | def sync_from(self, peer_id: str, delta: list, table: str = "working_memory") -> dict: |
| #274 | """Apply delta from a peer. Returns {peer_id, table, stats, checkpoint}.""" |
| #275 | return self.delta_sync.sync_from(peer_id, delta, table) |
| #276 | |
| #277 | # ─── Phase 8: Plugins ─────────────────────────────────────────── |
| #278 | |
| #279 | @property |
| #280 | def plugins(self): |
| #281 | """Lazy-initialized plugin manager.""" |
| #282 | if self._plugin_manager is None: |
| #283 | from mnemosyne.core.plugins import PluginManager |
| #284 | self._plugin_manager = PluginManager() |
| #285 | return self._plugin_manager |
| #286 | |
| #287 | @plugins.setter |
| #288 | def plugins(self, manager): |
| #289 | """Attach an external PluginManager.""" |
| #290 | self._plugin_manager = manager |
| #291 | |
| #292 | def remember(self, content: str, source: str = "conversation", |
| #293 | importance: float = 0.5, metadata: Dict = None, |
| #294 | valid_until: str = None, scope: str = "session", |
| #295 | extract_entities: bool = False, |
| #296 | extract: bool = False) -> str: |
| #297 | """ |
| #298 | Store a memory directly to SQLite. |
| #299 | Writes to both BEAM working_memory and legacy memories table. |
| #300 | |
| #301 | Args: |
| #302 | extract_entities: If True, extract entities from content and store |
| #303 | as triples for fuzzy entity-aware recall. Default False. |
| #304 | extract: If True, extract structured facts from content using LLM |
| #305 | and store as triples. Default False. |
| #306 | """ |
| #307 | # BEAM write first (generates its own ID). Extract flags are passed |
| #308 | # through so BeamMemory's canonical _extract_and_store_entities and |
| #309 | # _extract_and_store_facts helpers run — these populate the `facts` |
| #310 | # table that fact_recall() queries (the wrapper used to reimplement |
| #311 | # only the triples half of extraction inline, leaving facts table |
| #312 | # writes silently skipped — see C12.a). |
| #313 | memory_id = self.beam.remember( |
| #314 | content, source=source, importance=importance, metadata=metadata, |
| #315 | valid_until=valid_until, scope=scope, |
| #316 | extract_entities=extract_entities, extract=extract, |
| #317 | ) |
| #318 | timestamp = datetime.now().isoformat() |
| #319 | |
| #320 | # Legacy dual-write with same ID (INSERT OR REPLACE for dedup safety) |
| #321 | cursor = self.conn.cursor() |
| #322 | cursor.execute(""" |
| #323 | INSERT OR REPLACE INTO memories (id, content, source, timestamp, session_id, importance, metadata_json) |
| #324 | VALUES (?, ?, ?, ?, ?, ?, ?) |
| #325 | """, ( |
| #326 | memory_id, content, source, timestamp, self.session_id, |
| #327 | importance, json.dumps(metadata or {}) |
| #328 | )) |
| #329 | |
| #330 | # Legacy embedding store |
| #331 | if _embeddings.available(): |
| #332 | vec = _embeddings.embed([content]) |
| #333 | if vec is not None: |
| #334 | cursor.execute(""" |
| #335 | INSERT OR REPLACE INTO memory_embeddings (memory_id, embedding_json, model) |
| #336 | VALUES (?, ?, ?) |
| #337 | """, (memory_id, _embeddings.serialize(vec[0]), _embeddings._DEFAULT_MODEL)) |
| #338 | |
| #339 | self.conn.commit() |
| #340 | |
| #341 | # The first BEAM write already inserted the working_memory row with |
| #342 | # the correct memory_id (we used it for the legacy dual-write above). |
| #343 | # A second beam.remember call would only re-run the dedup branch and |
| #344 | # _ingest_graph_and_veracity — duplicating gist/fact graph edges and |
| #345 | # bumping mention_count for what is a single user-level remember. So |
| #346 | # this function returns directly after the legacy write. |
| #347 | return memory_id |
| #348 | |
| #349 | def recall(self, query: str, top_k: int = 5, *, |
| #350 | from_date: Optional[str] = None, to_date: Optional[str] = None, |
| #351 | source: Optional[str] = None, topic: Optional[str] = None, |
| #352 | author_id: Optional[str] = None, |
| #353 | author_type: Optional[str] = None, |
| #354 | channel_id: Optional[str] = None, |
| #355 | temporal_weight: float = 0.0, |
| #356 | query_time: Optional[Any] = None, |
| #357 | temporal_halflife: Optional[float] = None, |
| #358 | vec_weight: float = None, |
| #359 | fts_weight: float = None, |
| #360 | importance_weight: float = None) -> List[Dict]: |
| #361 | """ |
| #362 | Search memories with hybrid relevance scoring. |
| #363 | Uses BEAM episodic + working memory retrieval (sqlite-vec + FTS5). |
| #364 | Supports temporal filtering: from_date, to_date, source, topic. |
| #365 | Supports multi-agent identity filtering: author_id, author_type, channel_id. |
| #366 | Supports temporal scoring: temporal_weight, query_time, temporal_halflife. |
| #367 | Supports scoring weight overrides: vec_weight, fts_weight, importance_weight. |
| #368 | """ |
| #369 | return self.beam.recall(query, top_k=top_k, |
| #370 | from_date=from_date, to_date=to_date, |
| #371 | source=source, topic=topic, |
| #372 | author_id=author_id, author_type=author_type, |
| #373 | channel_id=channel_id, |
| #374 | temporal_weight=temporal_weight, |
| #375 | query_time=query_time, |
| #376 | temporal_halflife=temporal_halflife, |
| #377 | vec_weight=vec_weight, |
| #378 | fts_weight=fts_weight, |
| #379 | importance_weight=importance_weight) |
| #380 | |
| #381 | def _emit_wrapper(self, event_type: str, memory_id: str, **kwargs) -> None: |
| #382 | """Emit a streaming event through the Mnemosyne wrapper layer.""" |
| #383 | if self._stream is not None: |
| #384 | try: |
| #385 | from mnemosyne.core.streaming import MemoryEvent, EventType |
| #386 | evt = EventType[event_type] |
| #387 | event = MemoryEvent( |
| #388 | event_type=evt, |
| #389 | memory_id=memory_id, |
| #390 | session_id=self.session_id, |
| #391 | **kwargs, |
| #392 | ) |
| #393 | self._stream.emit(event) |
| #394 | except Exception: |
| #395 | pass |
| #396 | |
| #397 | def get_context(self, limit: int = 10) -> List[Dict]: |
| #398 | """ |
| #399 | Get recent memories from current session for context injection. |
| #400 | Pulls from BEAM working_memory. |
| #401 | """ |
| #402 | return self.beam.get_context(limit=limit) |
| #403 | |
| #404 | def get_stats(self, author_id: str = None, author_type: str = None, |
| #405 | channel_id: str = None) -> Dict: |
| #406 | """Get memory system statistics (legacy + BEAM). Supports multi-agent identity filters.""" |
| #407 | cursor = self.conn.cursor() |
| #408 | |
| #409 | cursor.execute("SELECT COUNT(*) FROM memories") |
| #410 | total_legacy = cursor.fetchone()[0] |
| #411 | |
| #412 | cursor.execute("SELECT COUNT(DISTINCT session_id) FROM memories") |
| #413 | sessions = cursor.fetchone()[0] |
| #414 | |
| #415 | cursor.execute("SELECT source, COUNT(*) FROM memories GROUP BY source") |
| #416 | sources = {row[0]: row[1] for row in cursor.fetchall()} |
| #417 | |
| #418 | cursor.execute("SELECT timestamp FROM memories ORDER BY timestamp DESC LIMIT 1") |
| #419 | last = cursor.fetchone() |
| #420 | |
| #421 | beam_wm = self.beam.get_working_stats(author_id=author_id, author_type=author_type, |
| #422 | channel_id=channel_id) |
| #423 | beam_ep = self.beam.get_episodic_stats(author_id=author_id, author_type=author_type, |
| #424 | channel_id=channel_id) |
| #425 | |
| #426 | # Triples count — table is created lazily by TripleStore.init_triples; |
| #427 | # if it does not exist yet (no triple has ever been written), report 0. |
| #428 | # Narrow the suppression to the missing-table case so DB locks, I/O |
| #429 | # errors, and corruption are not silently turned into "0 triples". |
| #430 | triple_total = 0 |
| #431 | try: |
| #432 | cursor.execute("SELECT COUNT(*) FROM triples") |
| #433 | triple_total = cursor.fetchone()[0] |
| #434 | except sqlite3.OperationalError as e: |
| #435 | if "no such table" not in str(e).lower(): |
| #436 | raise |
| #437 | |
| #438 | # Bank list — scoped to the same data dir as this Mnemosyne instance so |
| #439 | # a per-bank or per-tmp-dir caller does not get bank names from the |
| #440 | # default ~/.hermes tree. Banks live at <data_dir>/banks/, where |
| #441 | # data_dir is the parent of self.db_path. |
| #442 | try: |
| #443 | from mnemosyne.core.banks import BankManager |
| #444 | banks = BankManager(data_dir=Path(self.db_path).parent).list_banks() |
| #445 | except Exception: |
| #446 | banks = ["default"] |
| #447 | |
| #448 | return { |
| #449 | "total_memories": total_legacy, |
| #450 | "total_sessions": sessions, |
| #451 | "sources": sources, |
| #452 | "last_memory": last[0] if last else None, |
| #453 | "database": str(self.db_path), |
| #454 | "mode": "beam", |
| #455 | "banks": banks, |
| #456 | "beam": { |
| #457 | "working_memory": beam_wm, |
| #458 | "episodic_memory": beam_ep, |
| #459 | "triples": {"total": triple_total}, |
| #460 | } |
| #461 | } |
| #462 | |
| #463 | def forget(self, memory_id: str) -> bool: |
| #464 | """Delete a memory by ID from legacy table and working_memory.""" |
| #465 | cursor = self.conn.cursor() |
| #466 | cursor.execute("DELETE FROM memories WHERE id = ? AND session_id = ?", |
| #467 | (memory_id, self.session_id)) |
| #468 | self.conn.commit() |
| #469 | result = self.beam.forget_working(memory_id) |
| #470 | self._emit_wrapper("MEMORY_INVALIDATED", memory_id) |
| #471 | return result |
| #472 | |
| #473 | def update(self, memory_id: str, content: str = None, |
| #474 | importance: float = None) -> bool: |
| #475 | """Update an existing memory in legacy table and BEAM.""" |
| #476 | cursor = self.conn.cursor() |
| #477 | |
| #478 | updates = [] |
| #479 | params = [] |
| #480 | |
| #481 | if content is not None: |
| #482 | updates.append("content = ?") |
| #483 | params.append(content) |
| #484 | |
| #485 | if importance is not None: |
| #486 | updates.append("importance = ?") |
| #487 | params.append(importance) |
| #488 | |
| #489 | if not updates: |
| #490 | return False |
| #491 | |
| #492 | params.extend([memory_id, self.session_id]) |
| #493 | cursor.execute( |
| #494 | f"UPDATE memories SET {', '.join(updates)} WHERE id = ? AND session_id = ?", |
| #495 | params |
| #496 | ) |
| #497 | self.conn.commit() |
| #498 | |
| #499 | # Sync BEAM working_memory |
| #500 | self.beam.update_working(memory_id, content=content, importance=importance) |
| #501 | |
| #502 | self._emit_wrapper("MEMORY_UPDATED", memory_id, content=content, importance=importance) |
| #503 | return cursor.rowcount > 0 |
| #504 | |
| #505 | def invalidate(self, memory_id: str, replacement_id: str = None) -> bool: |
| #506 | """Mark a memory as expired or superseded. Delegates to BEAM.""" |
| #507 | result = self.beam.invalidate(memory_id, replacement_id=replacement_id) |
| #508 | self._emit_wrapper("MEMORY_INVALIDATED", memory_id, replacement_id=replacement_id) |
| #509 | return result |
| #510 | |
| #511 | # ------------------------------------------------------------------ |
| #512 | # BEAM-specific public methods |
| #513 | # ------------------------------------------------------------------ |
| #514 | def sleep(self, dry_run: bool = False) -> Dict: |
| #515 | """Run consolidation sleep cycle for the current session.""" |
| #516 | return self.beam.sleep(dry_run=dry_run) |
| #517 | |
| #518 | def sleep_all_sessions(self, dry_run: bool = False) -> Dict: |
| #519 | """Run consolidation sleep cycle across all sessions with eligible old working memories.""" |
| #520 | return self.beam.sleep_all_sessions(dry_run=dry_run) |
| #521 | |
| #522 | def scratchpad_write(self, content: str) -> str: |
| #523 | """Write to scratchpad.""" |
| #524 | return self.beam.scratchpad_write(content) |
| #525 | |
| #526 | def scratchpad_read(self) -> List[Dict]: |
| #527 | """Read scratchpad entries.""" |
| #528 | return self.beam.scratchpad_read() |
| #529 | |
| #530 | def scratchpad_clear(self): |
| #531 | """Clear scratchpad.""" |
| #532 | self.beam.scratchpad_clear() |
| #533 | |
| #534 | def consolidation_log(self, limit: int = 10) -> List[Dict]: |
| #535 | """Get consolidation history.""" |
| #536 | return self.beam.get_consolidation_log(limit=limit) |
| #537 | |
| #538 | def export_to_file(self, output_path: str) -> Dict: |
| #539 | """ |
| #540 | Export all Mnemosyne data (legacy + BEAM + triples) to a JSON file. |
| #541 | Returns export metadata. |
| #542 | """ |
| #543 | from mnemosyne.core.triples import TripleStore |
| #544 | import json as _json |
| #545 | |
| #546 | export = { |
| #547 | "mnemosyne_export": { |
| #548 | "version": "1.0", |
| #549 | "export_date": datetime.now().isoformat(), |
| #550 | "source_db": str(self.db_path), |
| #551 | } |
| #552 | } |
| #553 | |
| #554 | # BEAM data |
| #555 | beam_data = self.beam.export_to_dict() |
| #556 | export["working_memory"] = beam_data.get("working_memory", []) |
| #557 | export["episodic_memory"] = beam_data.get("episodic_memory", []) |
| #558 | export["episodic_embeddings"] = beam_data.get("episodic_embeddings", []) |
| #559 | export["scratchpad"] = beam_data.get("scratchpad", []) |
| #560 | export["consolidation_log"] = beam_data.get("consolidation_log", []) |
| #561 | |
| #562 | # Legacy memories |
| #563 | cursor = self.conn.cursor() |
| #564 | cursor.execute(""" |
| #565 | SELECT id, content, source, timestamp, session_id, importance, |
| #566 | metadata_json, created_at |
| #567 | FROM memories |
| #568 | ORDER BY session_id, timestamp |
| #569 | """) |
| #570 | export["legacy_memories"] = [dict(row) for row in cursor.fetchall()] |
| #571 | |
| #572 | # Legacy embeddings |
| #573 | cursor.execute(""" |
| #574 | SELECT memory_id, embedding_json, model, created_at |
| #575 | FROM memory_embeddings |
| #576 | ORDER BY memory_id |
| #577 | """) |
| #578 | export["legacy_embeddings"] = [dict(row) for row in cursor.fetchall()] |
| #579 | |
| #580 | # Triples |
| #581 | triples = TripleStore(db_path=self.db_path) |
| #582 | export["triples"] = triples.export_all() |
| #583 | |
| #584 | with open(output_path, "w", encoding="utf-8") as f: |
| #585 | _json.dump(export, f, indent=2, ensure_ascii=False, default=str) |
| #586 | |
| #587 | return { |
| #588 | "status": "exported", |
| #589 | "path": output_path, |
| #590 | "working_memory_count": len(export["working_memory"]), |
| #591 | "episodic_memory_count": len(export["episodic_memory"]), |
| #592 | "scratchpad_count": len(export["scratchpad"]), |
| #593 | "legacy_memories_count": len(export["legacy_memories"]), |
| #594 | "triples_count": len(export["triples"]), |
| #595 | } |
| #596 | |
| #597 | def import_from_file(self, input_path: str, force: bool = False) -> Dict: |
| #598 | """ |
| #599 | Import Mnemosyne data from a JSON file produced by export_to_file(). |
| #600 | Idempotent by default: skips existing records. |
| #601 | Set force=True to overwrite. |
| #602 | Returns import statistics. |
| #603 | """ |
| #604 | from mnemosyne.core.triples import TripleStore |
| #605 | import json as _json |
| #606 | |
| #607 | with open(input_path, "r", encoding="utf-8") as f: |
| #608 | data = _json.load(f) |
| #609 | |
| #610 | if not isinstance(data, dict): |
| #611 | raise ValueError("Import file must contain a Mnemosyne export object") |
| #612 | |
| #613 | # Validate |
| #614 | meta = data.get("mnemosyne_export", {}) |
| #615 | if meta.get("version") != "1.0": |
| #616 | raise ValueError(f"Unsupported export version: {meta.get('version')}") |
| #617 | |
| #618 | stats = {"beam": {}, "legacy": {}, "triples": {}} |
| #619 | |
| #620 | # BEAM import |
| #621 | beam_stats = self.beam.import_from_dict(data, force=force) |
| #622 | stats["beam"] = beam_stats |
| #623 | |
| #624 | # Legacy memories |
| #625 | l_stats = {"inserted": 0, "skipped": 0, "overwritten": 0} |
| #626 | cursor = self.conn.cursor() |
| #627 | for item in data.get("legacy_memories", []): |
| #628 | mid = item.get("id") |
| #629 | cursor.execute("SELECT 1 FROM memories WHERE id = ?", (mid,)) |
| #630 | exists = cursor.fetchone() is not None |
| #631 | if exists and not force: |
| #632 | l_stats["skipped"] += 1 |
| #633 | continue |
| #634 | if exists and force: |
| #635 | cursor.execute("DELETE FROM memories WHERE id = ?", (mid,)) |
| #636 | l_stats["overwritten"] += 1 |
| #637 | else: |
| #638 | l_stats["inserted"] += 1 |
| #639 | cursor.execute(""" |
| #640 | INSERT INTO memories (id, content, source, timestamp, session_id, |
| #641 | importance, metadata_json, created_at) |
| #642 | VALUES (?, ?, ?, ?, ?, ?, ?, ?) |
| #643 | """, ( |
| #644 | mid, item.get("content"), item.get("source"), item.get("timestamp"), |
| #645 | item.get("session_id", "default"), item.get("importance", 0.5), |
| #646 | item.get("metadata_json", "{}"), item.get("created_at") |
| #647 | )) |
| #648 | self.conn.commit() |
| #649 | |
| #650 | # Legacy embeddings |
| #651 | for item in data.get("legacy_embeddings", []): |
| #652 | mid = item.get("memory_id") |
| #653 | cursor.execute("SELECT 1 FROM memory_embeddings WHERE memory_id = ?", (mid,)) |
| #654 | exists = cursor.fetchone() is not None |
| #655 | if exists and not force: |
| #656 | continue |
| #657 | if exists and force: |
| #658 | cursor.execute("DELETE FROM memory_embeddings WHERE memory_id = ?", (mid,)) |
| #659 | cursor.execute(""" |
| #660 | INSERT INTO memory_embeddings (memory_id, embedding_json, model, created_at) |
| #661 | VALUES (?, ?, ?, ?) |
| #662 | """, (mid, item.get("embedding_json"), item.get("model", "bge-small-en-v1.5"), item.get("created_at"))) |
| #663 | self.conn.commit() |
| #664 | stats["legacy"] = l_stats |
| #665 | |
| #666 | # Triples |
| #667 | triples = TripleStore(db_path=self.db_path) |
| #668 | t_stats = triples.import_all(data.get("triples", []), force=force) |
| #669 | stats["triples"] = t_stats |
| #670 | |
| #671 | return stats |
| #672 | |
| #673 | |
| #674 | # Global instance for module-level convenience functions |
| #675 | _default_instance = None |
| #676 | _default_bank = "default" |
| #677 | |
| #678 | |
| #679 | def _get_default(bank: str = None): |
| #680 | """Get or create the default Mnemosyne instance. Supports bank switching.""" |
| #681 | global _default_instance, _default_bank |
| #682 | target_bank = bank or _default_bank or "default" |
| #683 | if _default_instance is None or _default_instance.bank != target_bank: |
| #684 | _default_bank = target_bank |
| #685 | _default_instance = Mnemosyne(bank=target_bank) |
| #686 | return _default_instance |
| #687 | |
| #688 | |
| #689 | def set_bank(bank: str): |
| #690 | """ |
| #691 | Switch the global default memory bank. |
| #692 | All subsequent module-level calls (remember, recall, etc.) will use this bank. |
| #693 | """ |
| #694 | global _default_bank, _default_instance |
| #695 | _default_bank = bank |
| #696 | _default_instance = None # Force re-creation on next access |
| #697 | |
| #698 | |
| #699 | def get_bank() -> str: |
| #700 | """Get the current default bank name.""" |
| #701 | return _default_bank or "default" |
| #702 | |
| #703 | |
| #704 | # Module-level convenience functions |
| #705 | def remember(content: str, source: str = "conversation", |
| #706 | importance: float = 0.5, metadata: Dict = None, |
| #707 | scope: str = "session", valid_until: str = None, |
| #708 | extract_entities: bool = False, |
| #709 | extract: bool = False, bank: str = None) -> str: |
| #710 | """Store a memory using the global instance""" |
| #711 | return _get_default(bank).remember(content, source, importance, metadata, |
| #712 | scope=scope, valid_until=valid_until, |
| #713 | extract_entities=extract_entities, |
| #714 | extract=extract) |
| #715 | |
| #716 | |
| #717 | def recall(query: str, top_k: int = 5, *, |
| #718 | from_date: Optional[str] = None, to_date: Optional[str] = None, |
| #719 | source: Optional[str] = None, topic: Optional[str] = None, |
| #720 | temporal_weight: float = 0.0, |
| #721 | query_time: Optional[Any] = None, |
| #722 | temporal_halflife: Optional[float] = None, |
| #723 | vec_weight: float = None, |
| #724 | fts_weight: float = None, |
| #725 | importance_weight: float = None, |
| #726 | bank: str = None) -> List[Dict]: |
| #727 | """Search memories using the global instance with temporal filtering and scoring""" |
| #728 | return _get_default(bank).recall(query, top_k, |
| #729 | from_date=from_date, to_date=to_date, |
| #730 | source=source, topic=topic, |
| #731 | temporal_weight=temporal_weight, |
| #732 | query_time=query_time, |
| #733 | temporal_halflife=temporal_halflife, |
| #734 | vec_weight=vec_weight, |
| #735 | fts_weight=fts_weight, |
| #736 | importance_weight=importance_weight) |
| #737 | |
| #738 | |
| #739 | def get_context(limit: int = 10, bank: str = None) -> List[Dict]: |
| #740 | """Get session context using the global instance""" |
| #741 | return _get_default(bank).get_context(limit) |
| #742 | |
| #743 | |
| #744 | def get_stats(bank: str = None) -> Dict: |
| #745 | """Get stats using the global instance""" |
| #746 | return _get_default(bank).get_stats() |
| #747 | |
| #748 | |
| #749 | def forget(memory_id: str, bank: str = None) -> bool: |
| #750 | """Delete memory using the global instance""" |
| #751 | return _get_default(bank).forget(memory_id) |
| #752 | |
| #753 | |
| #754 | def update(memory_id: str, content: str = None, importance: float = None, bank: str = None) -> bool: |
| #755 | """Update memory using the global instance""" |
| #756 | return _get_default(bank).update(memory_id, content, importance) |
| #757 | |
| #758 | |
| #759 | def sleep(dry_run: bool = False, bank: str = None) -> Dict: |
| #760 | """Run consolidation sleep cycle for the global instance's current session""" |
| #761 | return _get_default(bank).sleep(dry_run=dry_run) |
| #762 | |
| #763 | |
| #764 | def sleep_all_sessions(dry_run: bool = False, bank: str = None) -> Dict: |
| #765 | """Run consolidation sleep cycle across all sessions using the global instance""" |
| #766 | return _get_default(bank).sleep_all_sessions(dry_run=dry_run) |
| #767 | |
| #768 | |
| #769 | def scratchpad_write(content: str, bank: str = None) -> str: |
| #770 | """Write to scratchpad using the global instance""" |
| #771 | return _get_default(bank).scratchpad_write(content) |
| #772 | |
| #773 | |
| #774 | def scratchpad_read(bank: str = None) -> List[Dict]: |
| #775 | """Read scratchpad using the global instance""" |
| #776 | return _get_default(bank).scratchpad_read() |
| #777 | |
| #778 | |
| #779 | def scratchpad_clear(): |
| #780 | """Clear scratchpad using the global instance""" |
| #781 | return _get_default().scratchpad_clear() |
| #782 |