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 BEAM Architecture |
| #3 | ============================ |
| #4 | Bilevel Episodic-Associative Memory |
| #5 | |
| #6 | Three SQLite tables: |
| #7 | - working_memory: hot, recent context (auto-injected into prompts) |
| #8 | - episodic_memory: long-term storage with native vector + FTS5 search |
| #9 | - scratchpad: temporary agent reasoning workspace |
| #10 | |
| #11 | Native sqlite-vec for vector search. |
| #12 | FTS5 for full-text retrieval. |
| #13 | Hybrid ranking: 50% vector + 30% FTS rank + 20% importance. |
| #14 | """ |
| #15 | |
| #16 | from __future__ import annotations |
| #17 | |
| #18 | import sqlite3 |
| #19 | import json |
| #20 | import hashlib |
| #21 | import threading |
| #22 | import math |
| #23 | from datetime import datetime, timedelta |
| #24 | from typing import List, Dict, Optional, Any, Set, Union |
| #25 | from pathlib import Path |
| #26 | |
| #27 | # Typed memory classification (Phase 1 — zero overhead, pattern-based) |
| #28 | try: |
| #29 | from mnemosyne.core.typed_memory import classify_memory, MemoryType |
| #30 | except ImportError: |
| #31 | classify_memory = None |
| #32 | MemoryType = None |
| #33 | |
| #34 | # Binary vector compression (Phase 2 — Moorcheh ITS) |
| #35 | try: |
| #36 | from mnemosyne.core.binary_vectors import ( |
| #37 | BinaryVectorStore, |
| #38 | maximally_informative_binarization as _mib, |
| #39 | hamming_distance as _hamming, |
| #40 | EMBEDDING_DIM, |
| #41 | BYTES_PER_VECTOR, |
| #42 | ) |
| #43 | except ImportError: |
| #44 | _mib = None |
| #45 | _hamming = None |
| #46 | |
| #47 | # Episodic graph + veracity consolidation (Phases 3-4) |
| #48 | try: |
| #49 | from mnemosyne.core.episodic_graph import EpisodicGraph, GraphEdge |
| #50 | except ImportError: |
| #51 | EpisodicGraph = None |
| #52 | GraphEdge = None |
| #53 | try: |
| #54 | from mnemosyne.core.veracity_consolidation import VeracityConsolidator, VERACITY_WEIGHTS |
| #55 | except ImportError: |
| #56 | VeracityConsolidator = None |
| #57 | VERACITY_WEIGHTS = {} |
| #58 | |
| #59 | try: |
| #60 | import numpy as np |
| #61 | except ImportError: |
| #62 | np = None |
| #63 | |
| #64 | from mnemosyne.core import embeddings as _embeddings |
| #65 | |
| #66 | # sqlite-vec optional dependency |
| #67 | try: |
| #68 | import sqlite_vec |
| #69 | _SQLITE_VEC_AVAILABLE = True |
| #70 | except Exception: |
| #71 | _SQLITE_VEC_AVAILABLE = False |
| #72 | sqlite_vec = None |
| #73 | |
| #74 | _thread_local = threading.local() |
| #75 | |
| #76 | # On Fly.io and other ephemeral VMs, only ~/.hermes is persisted. |
| #77 | # Default to the legacy Hermes path so memories survive restarts. |
| #78 | DEFAULT_DATA_DIR = Path.home() / ".hermes" / "mnemosyne" / "data" |
| #79 | DEFAULT_DB_PATH = DEFAULT_DATA_DIR / "mnemosyne.db" |
| #80 | |
| #81 | import os |
| #82 | |
| #83 | # BEAM benchmark optimizations (opt-in via env var, zero impact on production) |
| #84 | # When enabled: broader FTS5 OR semantics, larger vector scan limits, always-include vectors. |
| #85 | # Set MNEMOSYNE_BEAM_OPTIMIZATIONS=1 to activate for BEAM benchmarking only. |
| #86 | _BEAM_MODE = os.environ.get("MNEMOSYNE_BEAM_OPTIMIZATIONS", "").lower() in ("1", "true", "yes") |
| #87 | |
| #88 | if os.environ.get("MNEMOSYNE_DATA_DIR"): |
| #89 | DEFAULT_DATA_DIR = Path(os.environ.get("MNEMOSYNE_DATA_DIR")) |
| #90 | DEFAULT_DB_PATH = DEFAULT_DATA_DIR / "mnemosyne.db" |
| #91 | |
| #92 | |
| #93 | def _default_data_dir() -> Path: |
| #94 | """Return the current default data directory, honoring runtime env changes.""" |
| #95 | if os.environ.get("MNEMOSYNE_DATA_DIR"): |
| #96 | return Path(os.environ["MNEMOSYNE_DATA_DIR"]) |
| #97 | return DEFAULT_DATA_DIR |
| #98 | |
| #99 | |
| #100 | def _default_db_path() -> Path: |
| #101 | """Return the current default DB path, honoring runtime env changes.""" |
| #102 | return _default_data_dir() / "mnemosyne.db" |
| #103 | |
| #104 | # Config |
| #105 | EMBEDDING_DIM = 384 # bge-small-en-v1.5 |
| #106 | WORKING_MEMORY_MAX_ITEMS = int(os.environ.get("MNEMOSYNE_WM_MAX_ITEMS", "10000")) |
| #107 | WORKING_MEMORY_TTL_HOURS = int(os.environ.get("MNEMOSYNE_WM_TTL_HOURS", "24")) |
| #108 | EPISODIC_RECALL_LIMIT = int(os.environ.get("MNEMOSYNE_EP_LIMIT", "50000")) |
| #109 | SLEEP_BATCH_SIZE = int(os.environ.get("MNEMOSYNE_SLEEP_BATCH", "5000")) |
| #110 | SCRATCHPAD_MAX_ITEMS = int(os.environ.get("MNEMOSYNE_SP_MAX", "1000")) |
| #111 | RECENCY_HALFLIFE_HOURS = float(os.environ.get("MNEMOSYNE_RECENCY_HALFLIFE", "168")) # 1 week default |
| #112 | |
| #113 | # Tiered episodic degradation |
| #114 | TIER2_DAYS = int(os.environ.get("MNEMOSYNE_TIER2_DAYS", "30")) |
| #115 | TIER3_DAYS = int(os.environ.get("MNEMOSYNE_TIER3_DAYS", "180")) |
| #116 | TIER1_WEIGHT = float(os.environ.get("MNEMOSYNE_TIER1_WEIGHT", "1.0")) |
| #117 | TIER2_WEIGHT = float(os.environ.get("MNEMOSYNE_TIER2_WEIGHT", "0.5")) |
| #118 | TIER3_WEIGHT = float(os.environ.get("MNEMOSYNE_TIER3_WEIGHT", "0.25")) |
| #119 | DEGRADE_BATCH_SIZE = int(os.environ.get("MNEMOSYNE_DEGRADE_BATCH", "100")) |
| #120 | SMART_COMPRESS = os.environ.get("MNEMOSYNE_SMART_COMPRESS", "1") not in ("0", "false", "no") |
| #121 | TIER3_MAX_CHARS = int(os.environ.get("MNEMOSYNE_TIER3_MAX_CHARS", "300")) |
| #122 | |
| #123 | # Veracity weighting (memory confidence) |
| #124 | STATED_WEIGHT = float(os.environ.get("MNEMOSYNE_STATED_WEIGHT", "1.0")) |
| #125 | INFERRED_WEIGHT = float(os.environ.get("MNEMOSYNE_INFERRED_WEIGHT", "0.7")) |
| #126 | TOOL_WEIGHT = float(os.environ.get("MNEMOSYNE_TOOL_WEIGHT", "0.5")) |
| #127 | IMPORTED_WEIGHT = float(os.environ.get("MNEMOSYNE_IMPORTED_WEIGHT", "0.6")) |
| #128 | UNKNOWN_WEIGHT = float(os.environ.get("MNEMOSYNE_UNKNOWN_WEIGHT", "0.8")) |
| #129 | |
| #130 | # Vector compression: float32 | int8 | bit |
| #131 | VEC_TYPE = os.environ.get("MNEMOSYNE_VEC_TYPE", "int8").lower() |
| #132 | if VEC_TYPE not in ("float32", "int8", "bit"): |
| #133 | VEC_TYPE = "float32" |
| #134 | |
| #135 | |
| #136 | def _get_connection(db_path: Path = None) -> sqlite3.Connection: |
| #137 | """Get thread-local database connection with extensions loaded.""" |
| #138 | path = db_path or _default_db_path() |
| #139 | if not hasattr(_thread_local, 'conn') or _thread_local.conn is None or getattr(_thread_local, 'db_path', None) != str(path): |
| #140 | path.parent.mkdir(parents=True, exist_ok=True) |
| #141 | conn = sqlite3.connect(str(path), check_same_thread=False) |
| #142 | conn.row_factory = sqlite3.Row |
| #143 | conn.execute("PRAGMA journal_mode=WAL") |
| #144 | conn.execute("PRAGMA busy_timeout=5000") |
| #145 | if _SQLITE_VEC_AVAILABLE: |
| #146 | try: |
| #147 | conn.enable_load_extension(True) |
| #148 | sqlite_vec.load(conn) |
| #149 | except Exception: |
| #150 | pass # Some environments don't support load_extension |
| #151 | _thread_local.conn = conn |
| #152 | _thread_local.db_path = str(path) |
| #153 | return _thread_local.conn |
| #154 | |
| #155 | |
| #156 | def _detect_vec_type(conn: sqlite3.Connection) -> str: |
| #157 | """ |
| #158 | Detect whether sqlite-vec supports int8/bit. |
| #159 | Falls back to float32 if the requested type is unavailable. |
| #160 | """ |
| #161 | if not _SQLITE_VEC_AVAILABLE: |
| #162 | return "float32" |
| #163 | if VEC_TYPE == "float32": |
| #164 | return "float32" |
| #165 | cursor = conn.cursor() |
| #166 | test_type = VEC_TYPE # int8 or bit |
| #167 | try: |
| #168 | cursor.execute(f"CREATE VIRTUAL TABLE IF NOT EXISTS _vec_test USING vec0(embedding {test_type}[{EMBEDDING_DIM}])") |
| #169 | cursor.execute("DROP TABLE IF EXISTS _vec_test") |
| #170 | conn.commit() |
| #171 | return test_type |
| #172 | except Exception: |
| #173 | conn.rollback() |
| #174 | # Try int8 as fallback from bit |
| #175 | if test_type == "bit": |
| #176 | try: |
| #177 | cursor.execute(f"CREATE VIRTUAL TABLE IF NOT EXISTS _vec_test USING vec0(embedding int8[{EMBEDDING_DIM}])") |
| #178 | cursor.execute("DROP TABLE IF EXISTS _vec_test") |
| #179 | conn.commit() |
| #180 | return "int8" |
| #181 | except Exception: |
| #182 | conn.rollback() |
| #183 | return "float32" |
| #184 | |
| #185 | |
| #186 | def init_beam(db_path: Path = None): |
| #187 | """Initialize BEAM schema.""" |
| #188 | conn = _get_connection(db_path) |
| #189 | cursor = conn.cursor() |
| #190 | |
| #191 | # --- WORKING MEMORY --- |
| #192 | cursor.execute(""" |
| #193 | CREATE TABLE IF NOT EXISTS working_memory ( |
| #194 | id TEXT PRIMARY KEY, |
| #195 | content TEXT NOT NULL, |
| #196 | source TEXT, |
| #197 | timestamp TEXT, |
| #198 | session_id TEXT DEFAULT 'default', |
| #199 | importance REAL DEFAULT 0.5, |
| #200 | metadata_json TEXT, |
| #201 | veracity TEXT DEFAULT 'unknown', |
| #202 | created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP |
| #203 | ) |
| #204 | """) |
| #205 | cursor.execute("CREATE INDEX IF NOT EXISTS idx_wm_session ON working_memory(session_id)") |
| #206 | cursor.execute("CREATE INDEX IF NOT EXISTS idx_wm_timestamp ON working_memory(timestamp)") |
| #207 | cursor.execute("CREATE INDEX IF NOT EXISTS idx_wm_source ON working_memory(source)") |
| #208 | |
| #209 | # --- EPISODIC MEMORY --- |
| #210 | cursor.execute(""" |
| #211 | CREATE TABLE IF NOT EXISTS episodic_memory ( |
| #212 | rowid INTEGER PRIMARY KEY AUTOINCREMENT, |
| #213 | id TEXT UNIQUE NOT NULL, |
| #214 | content TEXT NOT NULL, |
| #215 | source TEXT, |
| #216 | timestamp TEXT, |
| #217 | session_id TEXT DEFAULT 'default', |
| #218 | importance REAL DEFAULT 0.5, |
| #219 | metadata_json TEXT, |
| #220 | summary_of TEXT DEFAULT '', |
| #221 | veracity TEXT DEFAULT 'unknown', |
| #222 | created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP |
| #223 | ) |
| #224 | """) |
| #225 | cursor.execute("CREATE INDEX IF NOT EXISTS idx_em_session ON episodic_memory(session_id)") |
| #226 | cursor.execute("CREATE INDEX IF NOT EXISTS idx_em_timestamp ON episodic_memory(timestamp)") |
| #227 | cursor.execute("CREATE INDEX IF NOT EXISTS idx_em_source ON episodic_memory(source)") |
| #228 | |
| #229 | # --- Tiered degradation migration (v2.3) --- |
| #230 | try: |
| #231 | cursor.execute("ALTER TABLE episodic_memory ADD COLUMN tier INTEGER DEFAULT 1") |
| #232 | except sqlite3.OperationalError: |
| #233 | pass # Column already exists |
| #234 | try: |
| #235 | cursor.execute("ALTER TABLE episodic_memory ADD COLUMN degraded_at TEXT") |
| #236 | except sqlite3.OperationalError: |
| #237 | pass |
| #238 | cursor.execute("CREATE INDEX IF NOT EXISTS idx_em_tier ON episodic_memory(tier)") |
| #239 | |
| #240 | # --- Veracity migration (v2.4) --- |
| #241 | try: |
| #242 | cursor.execute("ALTER TABLE working_memory ADD COLUMN veracity TEXT DEFAULT 'unknown'") |
| #243 | except sqlite3.OperationalError: |
| #244 | pass |
| #245 | try: |
| #246 | cursor.execute("ALTER TABLE episodic_memory ADD COLUMN veracity TEXT DEFAULT 'unknown'") |
| #247 | except sqlite3.OperationalError: |
| #248 | pass |
| #249 | |
| #250 | # --- Typed memory migration (Phase 1) --- |
| #251 | try: |
| #252 | cursor.execute("ALTER TABLE working_memory ADD COLUMN memory_type TEXT DEFAULT 'unknown'") |
| #253 | except sqlite3.OperationalError: |
| #254 | pass |
| #255 | try: |
| #256 | cursor.execute("ALTER TABLE episodic_memory ADD COLUMN memory_type TEXT DEFAULT 'unknown'") |
| #257 | except sqlite3.OperationalError: |
| #258 | pass |
| #259 | |
| #260 | # --- Binary vector migration (Phase 2) --- |
| #261 | try: |
| #262 | cursor.execute("ALTER TABLE episodic_memory ADD COLUMN binary_vector BLOB") |
| #263 | except sqlite3.OperationalError: |
| #264 | pass |
| #265 | |
| #266 | # --- SCRATCHPAD --- |
| #267 | cursor.execute(""" |
| #268 | CREATE TABLE IF NOT EXISTS scratchpad ( |
| #269 | id TEXT PRIMARY KEY, |
| #270 | content TEXT NOT NULL, |
| #271 | session_id TEXT DEFAULT 'default', |
| #272 | created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, |
| #273 | updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP |
| #274 | ) |
| #275 | """) |
| #276 | cursor.execute("CREATE INDEX IF NOT EXISTS idx_sp_session ON scratchpad(session_id)") |
| #277 | |
| #278 | # Detect supported vector type |
| #279 | effective_vec_type = _detect_vec_type(conn) |
| #280 | |
| #281 | # --- sqlite-vec VIRTUAL TABLE --- |
| #282 | if _SQLITE_VEC_AVAILABLE: |
| #283 | try: |
| #284 | cursor.execute(f""" |
| #285 | CREATE VIRTUAL TABLE IF NOT EXISTS vec_episodes USING vec0( |
| #286 | embedding {effective_vec_type}[{EMBEDDING_DIM}] |
| #287 | ) |
| #288 | """) |
| #289 | except sqlite3.OperationalError: |
| #290 | pass # May already exist or extension not loadable |
| #291 | |
| #292 | # --- FTS5 VIRTUAL TABLE for episodic --- |
| #293 | cursor.execute(""" |
| #294 | CREATE VIRTUAL TABLE IF NOT EXISTS fts_episodes USING fts5( |
| #295 | content, |
| #296 | content='episodic_memory', |
| #297 | content_rowid='rowid' |
| #298 | ) |
| #299 | """) |
| #300 | |
| #301 | # --- FTS5 VIRTUAL TABLE for working memory (autonomous) --- |
| #302 | cursor.execute(""" |
| #303 | CREATE VIRTUAL TABLE IF NOT EXISTS fts_working USING fts5( |
| #304 | id UNINDEXED, |
| #305 | content |
| #306 | ) |
| #307 | """) |
| #308 | |
| #309 | # --- FTS5 Triggers for episodic --- |
| #310 | cursor.execute(""" |
| #311 | CREATE TRIGGER IF NOT EXISTS em_ai AFTER INSERT ON episodic_memory BEGIN |
| #312 | INSERT INTO fts_episodes(rowid, content) VALUES (new.rowid, new.content); |
| #313 | END |
| #314 | """) |
| #315 | cursor.execute(""" |
| #316 | CREATE TRIGGER IF NOT EXISTS em_ad AFTER DELETE ON episodic_memory BEGIN |
| #317 | INSERT INTO fts_episodes(fts_episodes, rowid, content) VALUES ('delete', old.rowid, old.content); |
| #318 | END |
| #319 | """) |
| #320 | cursor.execute(""" |
| #321 | CREATE TRIGGER IF NOT EXISTS em_au AFTER UPDATE ON episodic_memory BEGIN |
| #322 | INSERT INTO fts_episodes(fts_episodes, rowid, content) VALUES ('delete', old.rowid, old.content); |
| #323 | INSERT INTO fts_episodes(rowid, content) VALUES (new.rowid, new.content); |
| #324 | END |
| #325 | """) |
| #326 | |
| #327 | # --- FTS5 Triggers for working memory --- |
| #328 | cursor.execute(""" |
| #329 | CREATE TRIGGER IF NOT EXISTS wm_ai AFTER INSERT ON working_memory BEGIN |
| #330 | INSERT INTO fts_working(id, content) VALUES (new.id, new.content); |
| #331 | END |
| #332 | """) |
| #333 | cursor.execute(""" |
| #334 | CREATE TRIGGER IF NOT EXISTS wm_ad AFTER DELETE ON working_memory BEGIN |
| #335 | DELETE FROM fts_working WHERE id = old.id; |
| #336 | END |
| #337 | """) |
| #338 | cursor.execute(""" |
| #339 | CREATE TRIGGER IF NOT EXISTS wm_au AFTER UPDATE ON working_memory BEGIN |
| #340 | DELETE FROM fts_working WHERE id = old.id; |
| #341 | INSERT INTO fts_working(id, content) VALUES (new.id, new.content); |
| #342 | END |
| #343 | """) |
| #344 | |
| #345 | # --- Consolidation Log --- |
| #346 | cursor.execute(""" |
| #347 | CREATE TABLE IF NOT EXISTS consolidation_log ( |
| #348 | id INTEGER PRIMARY KEY AUTOINCREMENT, |
| #349 | session_id TEXT, |
| #350 | items_consolidated INTEGER, |
| #351 | summary_preview TEXT, |
| #352 | created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP |
| #353 | ) |
| #354 | """) |
| #355 | |
| #356 | # --- memory_embeddings: fallback for environments without sqlite-vec --- |
| #357 | cursor.execute(""" |
| #358 | CREATE TABLE IF NOT EXISTS memory_embeddings ( |
| #359 | memory_id TEXT PRIMARY KEY, |
| #360 | embedding_json TEXT NOT NULL, |
| #361 | model TEXT, |
| #362 | created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP |
| #363 | ) |
| #364 | """) |
| #365 | |
| #366 | conn.commit() |
| #367 | |
| #368 | # --- Migration: recall tracking columns (v2.1) --- |
| #369 | _add_column_if_missing(conn, "working_memory", "recall_count", "INTEGER DEFAULT 0") |
| #370 | _add_column_if_missing(conn, "working_memory", "last_recalled", "TIMESTAMP DEFAULT NULL") |
| #371 | _add_column_if_missing(conn, "episodic_memory", "recall_count", "INTEGER DEFAULT 0") |
| #372 | _add_column_if_missing(conn, "episodic_memory", "last_recalled", "TIMESTAMP DEFAULT NULL") |
| #373 | |
| #374 | # --- Migration: temporal validity + scope (v2.2) --- |
| #375 | _add_column_if_missing(conn, "working_memory", "valid_until", "TIMESTAMP DEFAULT NULL") |
| #376 | _add_column_if_missing(conn, "working_memory", "superseded_by", "TEXT DEFAULT NULL") |
| #377 | _add_column_if_missing(conn, "working_memory", "scope", "TEXT DEFAULT 'global'") |
| #378 | _add_column_if_missing(conn, "episodic_memory", "valid_until", "TIMESTAMP DEFAULT NULL") |
| #379 | _add_column_if_missing(conn, "episodic_memory", "superseded_by", "TEXT DEFAULT NULL") |
| #380 | _add_column_if_missing(conn, "episodic_memory", "scope", "TEXT DEFAULT 'global'") |
| #381 | |
| #382 | # --- NAI-0 Covering Indexes (v2.5) --- |
| #383 | cursor.execute("""CREATE INDEX IF NOT EXISTS idx_em_scope_imp |
| #384 | ON episodic_memory(scope, importance) WHERE superseded_by IS NULL""") |
| #385 | cursor.execute("""CREATE INDEX IF NOT EXISTS idx_wm_session_recall |
| #386 | ON working_memory(session_id, last_recalled) WHERE valid_until IS NULL""") |
| #387 | cursor.execute("""CREATE INDEX IF NOT EXISTS idx_mem_emb_type |
| #388 | ON memory_embeddings(memory_id, model)""") |
| #389 | |
| #390 | # --- Migration: multi-agent identity layer (v2.1) --- |
| #391 | _add_column_if_missing(conn, "working_memory", "author_id", "TEXT DEFAULT NULL") |
| #392 | _add_column_if_missing(conn, "working_memory", "author_type", "TEXT DEFAULT NULL") |
| #393 | _add_column_if_missing(conn, "working_memory", "channel_id", "TEXT DEFAULT NULL") |
| #394 | _add_column_if_missing(conn, "episodic_memory", "author_id", "TEXT DEFAULT NULL") |
| #395 | _add_column_if_missing(conn, "episodic_memory", "author_type", "TEXT DEFAULT NULL") |
| #396 | _add_column_if_missing(conn, "episodic_memory", "channel_id", "TEXT DEFAULT NULL") |
| #397 | cursor.execute("CREATE INDEX IF NOT EXISTS idx_wm_author ON working_memory(author_id)") |
| #398 | cursor.execute("CREATE INDEX IF NOT EXISTS idx_wm_channel ON working_memory(channel_id)") |
| #399 | cursor.execute("CREATE INDEX IF NOT EXISTS idx_em_author ON episodic_memory(author_id)") |
| #400 | cursor.execute("CREATE INDEX IF NOT EXISTS idx_em_channel ON episodic_memory(channel_id)") |
| #401 | |
| #402 | # --- FACTS (LLM-extracted structured knowledge) --- |
| #403 | cursor.execute(""" |
| #404 | CREATE TABLE IF NOT EXISTS facts ( |
| #405 | fact_id TEXT PRIMARY KEY, |
| #406 | session_id TEXT NOT NULL, |
| #407 | subject TEXT NOT NULL, |
| #408 | predicate TEXT NOT NULL, |
| #409 | object TEXT NOT NULL, |
| #410 | timestamp TEXT, |
| #411 | source_msg_id TEXT, |
| #412 | confidence REAL DEFAULT 1.0, |
| #413 | created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP |
| #414 | ) |
| #415 | """) |
| #416 | cursor.execute("CREATE INDEX IF NOT EXISTS idx_facts_session ON facts(session_id)") |
| #417 | cursor.execute("CREATE INDEX IF NOT EXISTS idx_facts_subject ON facts(subject)") |
| #418 | cursor.execute("CREATE INDEX IF NOT EXISTS idx_facts_source ON facts(source_msg_id)") |
| #419 | |
| #420 | # FTS5 for full-text search on facts |
| #421 | cursor.execute(""" |
| #422 | CREATE VIRTUAL TABLE IF NOT EXISTS fts_facts USING fts5( |
| #423 | subject, predicate, object, content='facts' |
| #424 | ) |
| #425 | """) |
| #426 | # Triggers to keep FTS5 in sync |
| #427 | cursor.execute(""" |
| #428 | CREATE TRIGGER IF NOT EXISTS facts_ai AFTER INSERT ON facts BEGIN |
| #429 | INSERT INTO fts_facts(rowid, subject, predicate, object) |
| #430 | VALUES (new.rowid, new.subject, new.predicate, new.object); |
| #431 | END |
| #432 | """) |
| #433 | cursor.execute(""" |
| #434 | CREATE TRIGGER IF NOT EXISTS facts_ad AFTER DELETE ON facts BEGIN |
| #435 | INSERT INTO fts_facts(fts_facts, rowid, subject, predicate, object) |
| #436 | VALUES ('delete', old.rowid, old.subject, old.predicate, old.object); |
| #437 | END |
| #438 | """) |
| #439 | |
| #440 | # Vector table for facts (sqlite-vec) |
| #441 | try: |
| #442 | cursor.execute(f""" |
| #443 | CREATE VIRTUAL TABLE IF NOT EXISTS vec_facts USING vec0( |
| #444 | embedding {effective_vec_type}[{EMBEDDING_DIM}] |
| #445 | ) |
| #446 | """) |
| #447 | except (sqlite3.OperationalError, RuntimeError): |
| #448 | pass # sqlite-vec not available |
| #449 | |
| #450 | |
| #451 | def _generate_id(content: str) -> str: |
| #452 | return hashlib.sha256(f"{content}{datetime.now().isoformat()}".encode()).hexdigest()[:16] |
| #453 | |
| #454 | |
| #455 | def _add_column_if_missing(conn: sqlite3.Connection, table: str, column: str, col_type: str): |
| #456 | """Safely add a column if it doesn't already exist (SQLite migration helper).""" |
| #457 | cursor = conn.cursor() |
| #458 | cursor.execute(f"PRAGMA table_info({table})") |
| #459 | existing = {row[1] for row in cursor.fetchall()} |
| #460 | if column not in existing: |
| #461 | cursor.execute(f"ALTER TABLE {table} ADD COLUMN {column} {col_type}") |
| #462 | conn.commit() |
| #463 | |
| #464 | |
| #465 | def _normalize_weights(vec_weight: Optional[float], fts_weight: Optional[float], |
| #466 | importance_weight: Optional[float]) -> tuple[float, float, float]: |
| #467 | """ |
| #468 | Normalize hybrid scoring weights to sum to 1.0. |
| #469 | |
| #470 | Falls back to env vars, then defaults: |
| #471 | vec_weight -> MNEMOSYNE_VEC_WEIGHT -> 0.5 |
| #472 | fts_weight -> MNEMOSYNE_FTS_WEIGHT -> 0.3 |
| #473 | importance_weight -> MNEMOSYNE_IMPORTANCE_WEIGHT -> 0.2 |
| #474 | |
| #475 | After normalization: vw + fw + iw == 1.0 |
| #476 | """ |
| #477 | vw = vec_weight if vec_weight is not None else float(os.environ.get("MNEMOSYNE_VEC_WEIGHT", "0.5")) |
| #478 | fw = fts_weight if fts_weight is not None else float(os.environ.get("MNEMOSYNE_FTS_WEIGHT", "0.3")) |
| #479 | iw = importance_weight if importance_weight is not None else float(os.environ.get("MNEMOSYNE_IMPORTANCE_WEIGHT", "0.2")) |
| #480 | |
| #481 | # Clamp to non-negative |
| #482 | vw = max(0.0, vw) |
| #483 | fw = max(0.0, fw) |
| #484 | iw = max(0.0, iw) |
| #485 | |
| #486 | total = vw + fw + iw |
| #487 | if total == 0.0: |
| #488 | # All zero = revert to defaults |
| #489 | return (0.5, 0.3, 0.2) |
| #490 | |
| #491 | return (vw / total, fw / total, iw / total) |
| #492 | |
| #493 | |
| #494 | def _recency_decay(timestamp_str: str, halflife_hours: float = RECENCY_HALFLIFE_HOURS) -> float: |
| #495 | """Calculate recency decay factor. 1.0 = brand new, ~0.5 = one halflife old. |
| #496 | |
| #497 | Exponential decay based on age. Returns 0.5 for unknown/invalid timestamps. |
| #498 | """ |
| #499 | if not timestamp_str: |
| #500 | return 0.5 # Unknown age = neutral |
| #501 | try: |
| #502 | ts = datetime.fromisoformat(timestamp_str) |
| #503 | age_hours = (datetime.now() - ts).total_seconds() / 3600.0 |
| #504 | return math.exp(-age_hours / halflife_hours) |
| #505 | except Exception: |
| #506 | return 0.5 |
| #507 | |
| #508 | |
| #509 | def _parse_query_time(query_time: Optional[Union[str, datetime]]) -> datetime: |
| #510 | """Parse query_time parameter into a datetime object. |
| #511 | |
| #512 | - None -> datetime.now() |
| #513 | - str -> parsed from ISO format |
| #514 | - datetime -> returned as-is |
| #515 | """ |
| #516 | if query_time is None: |
| #517 | return datetime.now() |
| #518 | if isinstance(query_time, datetime): |
| #519 | return query_time |
| #520 | if isinstance(query_time, str): |
| #521 | # Try ISO format with various precisions |
| #522 | try: |
| #523 | return datetime.fromisoformat(query_time) |
| #524 | except ValueError: |
| #525 | # Try appending time if only date provided |
| #526 | try: |
| #527 | return datetime.fromisoformat(f"{query_time}T00:00:00") |
| #528 | except ValueError: |
| #529 | raise ValueError(f"Invalid query_time format: {query_time!r}. Expected ISO datetime string.") |
| #530 | raise TypeError(f"query_time must be str, datetime, or None; got {type(query_time).__name__}") |
| #531 | |
| #532 | |
| #533 | # Fast-path timestamp parsing cache |
| #534 | _TS_CACHE: Dict[str, datetime] = {} |
| #535 | _TS_CACHE_MAX = 2000 |
| #536 | |
| #537 | |
| #538 | def _parse_ts_fast(ts: str) -> Optional[datetime]: |
| #539 | """Parse ISO timestamp with LRU-style cache for performance.""" |
| #540 | if not ts: |
| #541 | return None |
| #542 | cached = _TS_CACHE.get(ts) |
| #543 | if cached is not None: |
| #544 | return cached |
| #545 | try: |
| #546 | dt = datetime.fromisoformat(ts) |
| #547 | except (ValueError, TypeError): |
| #548 | return None |
| #549 | if len(_TS_CACHE) >= _TS_CACHE_MAX: |
| #550 | _TS_CACHE.clear() |
| #551 | _TS_CACHE[ts] = dt |
| #552 | return dt |
| #553 | |
| #554 | |
| #555 | def _temporal_boost(memory_timestamp_str: str, query_time: datetime, |
| #556 | halflife_hours: float = 24.0) -> float: |
| #557 | """Temporal boost factor based on proximity to query_time. |
| #558 | |
| #559 | Formula: exp(-hours_delta / halflife) |
| #560 | - memory at query_time -> boost = 1.0 |
| #561 | - memory 1 halflife away -> boost = exp(-1) ≈ 0.368 |
| #562 | - memory 3 halflives away -> boost = exp(-3) ≈ 0.050 |
| #563 | |
| #564 | Returns 0.0 for invalid timestamps or future timestamps (clamped to now). |
| #565 | """ |
| #566 | ts = _parse_ts_fast(memory_timestamp_str) |
| #567 | if ts is None: |
| #568 | return 0.0 |
| #569 | |
| #570 | # Clamp future timestamps to query_time (no negative deltas) |
| #571 | if ts > query_time: |
| #572 | ts = query_time |
| #573 | |
| #574 | hours_delta = (query_time - ts).total_seconds() / 3600.0 |
| #575 | return math.exp(-hours_delta / halflife_hours) |
| #576 | |
| #577 | |
| #578 | def _vec_available(conn: sqlite3.Connection) -> bool: |
| #579 | if not _SQLITE_VEC_AVAILABLE: |
| #580 | return False |
| #581 | try: |
| #582 | conn.execute("SELECT 1 FROM vec_episodes LIMIT 0") |
| #583 | return True |
| #584 | except Exception: |
| #585 | return False |
| #586 | |
| #587 | |
| #588 | def _extract_and_store_entities(beam: "BeamMemory", memory_id: str, content: str): |
| #589 | """ |
| #590 | Extract entities from content and store as triples. |
| #591 | Called internally by remember() when extract_entities=True. |
| #592 | """ |
| #593 | try: |
| #594 | from mnemosyne.core.entities import extract_entities_regex |
| #595 | from mnemosyne.core.triples import TripleStore |
| #596 | |
| #597 | entities = extract_entities_regex(content) |
| #598 | if not entities: |
| #599 | return |
| #600 | |
| #601 | triples = TripleStore(db_path=beam.db_path) |
| #602 | for entity in entities: |
| #603 | triples.add( |
| #604 | subject=memory_id, |
| #605 | predicate="mentions", |
| #606 | object=entity, |
| #607 | source="regex", |
| #608 | confidence=0.8 |
| #609 | ) |
| #610 | except Exception: |
| #611 | # Entity extraction is best-effort; never fail remember() because of it |
| #612 | pass |
| #613 | |
| #614 | |
| #615 | def _extract_and_store_facts(beam: "BeamMemory", memory_id: str, content: str, source: str = ""): |
| #616 | """ |
| #617 | Extract structured facts from content using LLM and store as triples + facts table. |
| #618 | Called internally by remember() when extract=True. |
| #619 | |
| #620 | Stores in TWO places: |
| #621 | 1. TripleStore (entity-level triples, backward compat) |
| #622 | 2. facts table (structured SPO facts for fact_recall()) |
| #623 | """ |
| #624 | try: |
| #625 | from mnemosyne.core.extraction import extract_facts_safe |
| #626 | from mnemosyne.core.triples import TripleStore |
| #627 | |
| #628 | facts = extract_facts_safe(content) |
| #629 | if not facts: |
| #630 | return |
| #631 | |
| #632 | # Store in triples (existing behavior) |
| #633 | triples = TripleStore(db_path=beam.db_path) |
| #634 | triples.add_facts(memory_id, facts, source=source, confidence=0.7) |
| #635 | |
| #636 | # ALSO store in facts table (new cloud extraction path) |
| #637 | _store_facts_in_table(beam, memory_id, content, source, facts) |
| #638 | |
| #639 | except Exception: |
| #640 | # Fact extraction is best-effort; never fail remember() because of it |
| #641 | pass |
| #642 | |
| #643 | |
| #644 | def _store_facts_in_table(beam: "BeamMemory", memory_id: str, |
| #645 | content: str, source: str, facts: list): |
| #646 | """Store extracted free-text facts as simple SPO entries in the facts table.""" |
| #647 | import hashlib |
| #648 | cursor = beam.conn.cursor() |
| #649 | timestamp = __import__('datetime').datetime.now().isoformat() |
| #650 | |
| #651 | for i, fact_text in enumerate(facts): |
| #652 | # Derive subject from source, predicate = "stated", object = fact text |
| #653 | subject = source or "user" |
| #654 | fact_id = hashlib.sha256( |
| #655 | f"{memory_id}:fact:{i}:{fact_text[:50]}".encode() |
| #656 | ).hexdigest()[:24] |
| #657 | |
| #658 | try: |
| #659 | cursor.execute(""" |
| #660 | INSERT OR IGNORE INTO facts |
| #661 | (fact_id, session_id, subject, predicate, object, |
| #662 | timestamp, source_msg_id, confidence) |
| #663 | VALUES (?, ?, ?, ?, ?, ?, ?, ?) |
| #664 | """, ( |
| #665 | fact_id, |
| #666 | beam.session_id, |
| #667 | subject, |
| #668 | "stated", |
| #669 | fact_text, |
| #670 | timestamp, |
| #671 | memory_id, |
| #672 | 0.7, |
| #673 | )) |
| #674 | except Exception: |
| #675 | continue # Best-effort per fact |
| #676 | |
| #677 | beam.conn.commit() |
| #678 | |
| #679 | |
| #680 | def _find_memories_by_entity(beam: "BeamMemory", entity_name: str, threshold: float = 0.8) -> List[str]: |
| #681 | """ |
| #682 | Find memory IDs that mention an entity (or similar entity via fuzzy match). |
| #683 | Returns list of memory_id strings. |
| #684 | """ |
| #685 | try: |
| #686 | from mnemosyne.core.entities import find_similar_entities |
| #687 | from mnemosyne.core.triples import TripleStore |
| #688 | |
| #689 | triples = TripleStore(db_path=beam.db_path) |
| #690 | |
| #691 | # Get all known entities |
| #692 | known_entities = triples.get_distinct_objects("mentions") |
| #693 | if not known_entities: |
| #694 | return [] |
| #695 | |
| #696 | # Find similar entities |
| #697 | matches = find_similar_entities(entity_name, known_entities, threshold=threshold) |
| #698 | |
| #699 | # Collect memory IDs for all matched entities |
| #700 | memory_ids: Set[str] = set() |
| #701 | for matched_entity, _ in matches: |
| #702 | results = triples.query_by_predicate("mentions", object=matched_entity) |
| #703 | for row in results: |
| #704 | memory_ids.add(row["subject"]) |
| #705 | |
| #706 | return list(memory_ids) |
| #707 | except Exception: |
| #708 | return [] |
| #709 | |
| #710 | |
| #711 | def _find_memories_by_fact(beam: "BeamMemory", query: str) -> List[str]: |
| #712 | """ |
| #713 | Find memory IDs that have extracted facts matching the query. |
| #714 | Does simple keyword matching against stored fact triples. |
| #715 | Returns list of memory_id strings. |
| #716 | """ |
| #717 | try: |
| #718 | from mnemosyne.core.triples import TripleStore |
| #719 | |
| #720 | triples = TripleStore(db_path=beam.db_path) |
| #721 | |
| #722 | # Get all fact triples |
| #723 | all_facts = triples.query_by_predicate("fact") |
| #724 | if not all_facts: |
| #725 | return [] |
| #726 | |
| #727 | query_lower = query.lower() |
| #728 | query_words = set(query_lower.split()) |
| #729 | |
| #730 | # Simple keyword matching against fact text |
| #731 | memory_ids: Set[str] = set() |
| #732 | for fact_row in all_facts: |
| #733 | fact_text = fact_row.get("object", "").lower() |
| #734 | # Check if any query word appears in the fact |
| #735 | if any(word in fact_text for word in query_words): |
| #736 | memory_ids.add(fact_row["subject"]) |
| #737 | # Also check if the full query is a substring of the fact |
| #738 | elif query_lower in fact_text: |
| #739 | memory_ids.add(fact_row["subject"]) |
| #740 | |
| #741 | return list(memory_ids) |
| #742 | except Exception: |
| #743 | return [] |
| #744 | |
| #745 | |
| #746 | def _in_memory_vec_search(conn: sqlite3.Connection, query_embedding: np.ndarray, k: int = 20) -> List[Dict]: |
| #747 | """Fallback vector search using memory_embeddings table + numpy cosine similarity.""" |
| #748 | if np is None: |
| #749 | return [] |
| #750 | cursor = conn.cursor() |
| #751 | # Join with episodic_memory (not memories) since that's where BEAM stores consolidated data |
| #752 | cursor.execute(""" |
| #753 | SELECT em.rowid, me.memory_id, me.embedding_json |
| #754 | FROM memory_embeddings me |
| #755 | JOIN episodic_memory em ON me.memory_id = em.id |
| #756 | LIMIT 10000 |
| #757 | """) |
| #758 | rows = cursor.fetchall() |
| #759 | if not rows: |
| #760 | return [] |
| #761 | |
| #762 | query_norm = np.linalg.norm(query_embedding) |
| #763 | if query_norm == 0: |
| #764 | return [] |
| #765 | query_unit = query_embedding / query_norm |
| #766 | |
| #767 | results = [] |
| #768 | for row in rows: |
| #769 | try: |
| #770 | vec = np.array(json.loads(row["embedding_json"]), dtype=np.float32) |
| #771 | vec_norm = np.linalg.norm(vec) |
| #772 | if vec_norm == 0: |
| #773 | continue |
| #774 | sim = float(np.dot(query_unit, vec / vec_norm)) |
| #775 | # Convert similarity to distance-like metric (1 - sim) for consistent ranking |
| #776 | results.append({"rowid": row["rowid"], "distance": 1.0 - sim}) |
| #777 | except Exception: |
| #778 | continue |
| #779 | |
| #780 | results.sort(key=lambda x: x["distance"]) |
| #781 | return results[:k] |
| #782 | |
| #783 | |
| #784 | def _effective_vec_type(conn: sqlite3.Connection) -> str: |
| #785 | """Re-detect the actual vector type used by vec_episodes.""" |
| #786 | if not _vec_available(conn): |
| #787 | return "float32" |
| #788 | try: |
| #789 | row = conn.execute( |
| #790 | "SELECT sql FROM sqlite_master WHERE type='table' AND name='vec_episodes'" |
| #791 | ).fetchone() |
| #792 | if row and "int8" in row[0]: |
| #793 | return "int8" |
| #794 | if row and "bit" in row[0]: |
| #795 | return "bit" |
| #796 | except Exception: |
| #797 | pass |
| #798 | return "float32" |
| #799 | |
| #800 | |
| #801 | def _vec_insert(conn: sqlite3.Connection, rowid: int, embedding: List[float]): |
| #802 | """Insert embedding into sqlite-vec table with quantization via SQL functions.""" |
| #803 | vec_type = _effective_vec_type(conn) |
| #804 | emb_json = json.dumps(embedding) |
| #805 | if vec_type == "bit": |
| #806 | conn.execute( |
| #807 | "INSERT INTO vec_episodes(rowid, embedding) VALUES (?, vec_quantize_binary(?))", |
| #808 | (rowid, emb_json) |
| #809 | ) |
| #810 | elif vec_type == "int8": |
| #811 | conn.execute( |
| #812 | "INSERT INTO vec_episodes(rowid, embedding) VALUES (?, vec_quantize_int8(?, 'unit'))", |
| #813 | (rowid, emb_json) |
| #814 | ) |
| #815 | else: |
| #816 | conn.execute( |
| #817 | "INSERT INTO vec_episodes(rowid, embedding) VALUES (?, ?)", |
| #818 | (rowid, emb_json) |
| #819 | ) |
| #820 | |
| #821 | |
| #822 | def _vec_search(conn: sqlite3.Connection, embedding: List[float], k: int = 20) -> List[Dict]: |
| #823 | """Search sqlite-vec and return rowids with distances.""" |
| #824 | vec_type = _effective_vec_type(conn) |
| #825 | emb_json = json.dumps(embedding) |
| #826 | # NOTE: sqlite-vec requires the KNN limit to be known at query planning time. |
| #827 | # Parameter binding (LIMIT ?) fails on some versions because xBestIndex |
| #828 | # can't resolve the parameter value. We inline k safely since it's |
| #829 | # always an integer computed internally. |
| #830 | k = int(k) |
| #831 | if vec_type == "bit": |
| #832 | rows = conn.execute( |
| #833 | f"SELECT rowid, distance FROM vec_episodes WHERE embedding MATCH vec_quantize_binary(?) ORDER BY distance LIMIT {k}", |
| #834 | (emb_json,) |
| #835 | ).fetchall() |
| #836 | elif vec_type == "int8": |
| #837 | rows = conn.execute( |
| #838 | f"SELECT rowid, distance FROM vec_episodes WHERE embedding MATCH vec_quantize_int8(?, 'unit') ORDER BY distance LIMIT {k}", |
| #839 | (emb_json,) |
| #840 | ).fetchall() |
| #841 | else: |
| #842 | rows = conn.execute( |
| #843 | f"SELECT rowid, distance FROM vec_episodes WHERE embedding MATCH ? ORDER BY distance LIMIT {k}", |
| #844 | (emb_json,) |
| #845 | ).fetchall() |
| #846 | return [{"rowid": r["rowid"], "distance": r["distance"]} for r in rows] |
| #847 | |
| #848 | |
| #849 | def _fts_search(conn: sqlite3.Connection, query: str, k: int = 20) -> List[Dict]: |
| #850 | """Search FTS5 episodes and return rowids with ranks. |
| #851 | Strips FTS5-special characters, keeps alphanumeric + spaces. |
| #852 | In BEAM mode: filters stop-words, uses OR semantics for broader recall.""" |
| #853 | import re as _re |
| #854 | safe_query = _re.sub(r'[^\w\s]', ' ', query) |
| #855 | safe_query = ' '.join(safe_query.split()) # Collapse whitespace |
| #856 | if not safe_query.strip(): |
| #857 | return [] |
| #858 | |
| #859 | # BEAM mode: OR semantics with stop-word filtering for benchmark recall breadth |
| #860 | if _BEAM_MODE: |
| #861 | _stop_words = {'when','does','do','did','what','how','where','which','who','why', |
| #862 | 'is','are','was','were','can','will','would','should','could','may', |
| #863 | 'the','a','an','in','on','at','to','for','of','with','my','me','i','you'} |
| #864 | content_words = [w for w in safe_query.split() if w.lower() not in _stop_words and len(w) > 1] |
| #865 | if not content_words: |
| #866 | content_words = [w for w in safe_query.split() if len(w) > 1] |
| #867 | # BEAM mode: if stop-word filtering leaves only 1 word, include ALL original |
| #868 | # non-stop-word tokens (not just content_words) to broaden recall |
| #869 | original_words = [w for w in query.split() if w.lower() not in _stop_words and len(w) > 1] |
| #870 | if len(content_words) <= 1 and len(original_words) > 1: |
| #871 | fts_query = " OR ".join(original_words) |
| #872 | else: |
| #873 | fts_query = " OR ".join(content_words) |
| #874 | if not fts_query: |
| #875 | return [] |
| #876 | else: |
| #877 | fts_query = safe_query |
| #878 | |
| #879 | rows = conn.execute( |
| #880 | "SELECT rowid, rank FROM fts_episodes WHERE fts_episodes MATCH ? ORDER BY rank, rowid LIMIT ?", |
| #881 | (fts_query, k) |
| #882 | ).fetchall() |
| #883 | return [{"rowid": r["rowid"], "rank": r["rank"]} for r in rows] |
| #884 | |
| #885 | |
| #886 | def _fts_search_working(conn: sqlite3.Connection, query: str, k: int = 20) -> List[Dict]: |
| #887 | """Search FTS5 working memory and return ids with ranks. |
| #888 | Strips FTS5-special characters, keeps alphanumeric + spaces. |
| #889 | In BEAM mode: filters stop-words, uses OR semantics for broader recall.""" |
| #890 | import re as _re |
| #891 | safe_query = _re.sub(r'[^\w\s]', ' ', query) |
| #892 | safe_query = ' '.join(safe_query.split()) # Collapse whitespace |
| #893 | if not safe_query.strip(): |
| #894 | return [] |
| #895 | |
| #896 | # BEAM mode: OR semantics with stop-word filtering for benchmark recall breadth |
| #897 | if _BEAM_MODE: |
| #898 | _stop_words = {'when','does','do','did','what','how','where','which','who','why', |
| #899 | 'is','are','was','were','can','will','would','should','could','may', |
| #900 | 'the','a','an','in','on','at','to','for','of','with','my','me','i','you'} |
| #901 | content_words = [w for w in safe_query.split() if w.lower() not in _stop_words and len(w) > 1] |
| #902 | if not content_words: |
| #903 | content_words = [w for w in safe_query.split() if len(w) > 1] |
| #904 | fts_query = " OR ".join(content_words) |
| #905 | if not fts_query: |
| #906 | return [] |
| #907 | else: |
| #908 | fts_query = safe_query |
| #909 | |
| #910 | rows = conn.execute( |
| #911 | "SELECT id, rank FROM fts_working WHERE fts_working MATCH ? ORDER BY rank, id LIMIT ?", |
| #912 | (fts_query, k) |
| #913 | ).fetchall() |
| #914 | |
| #915 | # BEAM mode: if phrase query returns 0, fall back to individual word OR search |
| #916 | # This handles cases like "What operating system" where no single entry has |
| #917 | # all content words but individual words like "operating" or "system" may match |
| #918 | if not rows and _BEAM_MODE and len(content_words) > 1: |
| #919 | fts_query_fallback = " OR ".join(content_words) |
| #920 | rows = conn.execute( |
| #921 | "SELECT id, rank FROM fts_working WHERE fts_working MATCH ? ORDER BY rank, id LIMIT ?", |
| #922 | (fts_query_fallback, k) |
| #923 | ).fetchall() |
| #924 | |
| #925 | return [{"id": r["id"], "rank": r["rank"]} for r in rows] |
| #926 | |
| #927 | |
| #928 | def _wm_vec_search(conn: sqlite3.Connection, query_embedding, k: int = 20) -> List[Dict]: |
| #929 | """Vector search against working_memory via memory_embeddings table. |
| #930 | Returns list of dicts with 'id' (memory_id) and 'sim' (cosine similarity).""" |
| #931 | if np is None: |
| #932 | return [] |
| #933 | cursor = conn.cursor() |
| #934 | try: |
| #935 | # BEAM mode: scan up to 500K rows for broad vector recall on large benchmark datasets |
| #936 | _vec_limit = 500000 if _BEAM_MODE else 50000 |
| #937 | cursor.execute(""" |
| #938 | SELECT wm.id, me.embedding_json |
| #939 | FROM memory_embeddings me |
| #940 | JOIN working_memory wm ON me.memory_id = wm.id |
| #941 | WHERE wm.superseded_by IS NULL |
| #942 | AND (wm.valid_until IS NULL OR wm.valid_until > ?) |
| #943 | LIMIT ? |
| #944 | """, (datetime.now().isoformat(), _vec_limit)) |
| #945 | except Exception: |
| #946 | return [] |
| #947 | rows = cursor.fetchall() |
| #948 | if not rows: |
| #949 | return [] |
| #950 | |
| #951 | query_norm = np.linalg.norm(query_embedding) |
| #952 | if query_norm == 0: |
| #953 | return [] |
| #954 | query_unit = query_embedding / query_norm |
| #955 | |
| #956 | results = [] |
| #957 | for row in rows: |
| #958 | try: |
| #959 | vec = np.array(json.loads(row["embedding_json"]), dtype=np.float32) |
| #960 | vec_norm = np.linalg.norm(vec) |
| #961 | if vec_norm == 0: |
| #962 | continue |
| #963 | sim = float(np.dot(query_unit, vec / vec_norm)) |
| #964 | results.append({"id": row["id"], "sim": sim}) |
| #965 | except Exception: |
| #966 | continue |
| #967 | |
| #968 | results.sort(key=lambda x: x["sim"], reverse=True) |
| #969 | return results[:k] |
| #970 | |
| #971 | |
| #972 | class BeamMemory: |
| #973 | """ |
| #974 | BEAM memory interface. |
| #975 | """ |
| #976 | |
| #977 | def __init__(self, session_id: str = "default", db_path: Path = None, |
| #978 | author_id: str = None, author_type: str = None, |
| #979 | channel_id: str = None, use_cloud: bool = False, |
| #980 | event_emitter: "Optional[Callable[[Any], None]]" = None): |
| #981 | self.session_id = session_id |
| #982 | self.author_id = author_id |
| #983 | self.author_type = author_type |
| #984 | self.channel_id = channel_id or session_id # default channel = session |
| #985 | self.db_path = db_path or _default_db_path() |
| #986 | self.use_cloud = use_cloud # Enable LLM fact extraction during remember() |
| #987 | self._extraction_client = None # Lazy-loaded ExtractionClient |
| #988 | self._extraction_buffer = [] # Buffer for batch extraction |
| #989 | self._event_emitter = event_emitter # Streaming event callback |
| #990 | self.conn = _get_connection(self.db_path) |
| #991 | init_beam(self.db_path) |
| #992 | |
| #993 | # Phase 3: Episodic graph (shared connection) |
| #994 | self.episodic_graph = None |
| #995 | if EpisodicGraph is not None: |
| #996 | try: |
| #997 | self.episodic_graph = EpisodicGraph(conn=self.conn, db_path=self.db_path) |
| #998 | except Exception: |
| #999 | pass |
| #1000 | |
| #1001 | # Phase 4: Veracity consolidator (shared connection) |
| #1002 | self.veracity_consolidator = None |
| #1003 | if VeracityConsolidator is not None: |
| #1004 | try: |
| #1005 | self.veracity_consolidator = VeracityConsolidator(conn=self.conn, db_path=self.db_path) |
| #1006 | except Exception: |
| #1007 | pass |
| #1008 | |
| #1009 | # ------------------------------------------------------------------ |
| #1010 | # Working Memory |
| #1011 | # ------------------------------------------------------------------ |
| #1012 | def _find_duplicate(self, content: str) -> Optional[str]: |
| #1013 | """Check if exact same content already exists in working_memory for this session. |
| #1014 | Returns the existing memory_id if found, else None.""" |
| #1015 | cursor = self.conn.cursor() |
| #1016 | cursor.execute(""" |
| #1017 | SELECT id FROM working_memory |
| #1018 | WHERE session_id = ? AND content = ? |
| #1019 | LIMIT 1 |
| #1020 | """, (self.session_id, content)) |
| #1021 | row = cursor.fetchone() |
| #1022 | return row["id"] if row else None |
| #1023 | |
| #1024 | def _emit_event(self, event_type, memory_id: str, content: str = None, |
| #1025 | source: str = None, importance: float = None, |
| #1026 | metadata: Dict = None, delta: Dict = None) -> None: |
| #1027 | """Fire a streaming event if an emitter is registered.""" |
| #1028 | if self._event_emitter is None: |
| #1029 | return |
| #1030 | try: |
| #1031 | from mnemosyne.core.streaming import MemoryEvent, EventType |
| #1032 | evt_type = EventType[event_type] if isinstance(event_type, str) else event_type |
| #1033 | event = MemoryEvent( |
| #1034 | event_type=evt_type, |
| #1035 | memory_id=memory_id, |
| #1036 | session_id=self.session_id, |
| #1037 | content=content, |
| #1038 | source=source, |
| #1039 | importance=importance, |
| #1040 | metadata=metadata, |
| #1041 | delta=delta, |
| #1042 | ) |
| #1043 | self._event_emitter(event) |
| #1044 | except Exception: |
| #1045 | pass # Streaming failures must never block memory operations |
| #1046 | |
| #1047 | def remember(self, content: str, source: str = "conversation", |
| #1048 | importance: float = 0.5, metadata: Dict = None, |
| #1049 | valid_until: str = None, scope: str = "session", |
| #1050 | memory_id: str = None, |
| #1051 | extract_entities: bool = False, |
| #1052 | extract: bool = False, |
| #1053 | veracity: str = "unknown") -> str: |
| #1054 | """Store into working_memory. Deduplicates exact content matches. |
| #1055 | |
| #1056 | When called from the legacy-compatible Mnemosyne.remember() path, |
| #1057 | memory_id is passed through so the legacy memories row and BEAM |
| #1058 | working_memory row stay addressable by the same ID. Direct BEAM calls |
| #1059 | still generate their own deterministic ID. |
| #1060 | |
| #1061 | Args: |
| #1062 | content: The text to remember |
| #1063 | source: Origin of the memory (e.g., "conversation", "document") |
| #1064 | importance: 0.0-1.0 relevance score |
| #1065 | metadata: Optional dict of additional fields |
| #1066 | valid_until: ISO timestamp when this memory expires |
| #1067 | scope: "session" or "global" |
| #1068 | memory_id: Optional pre-generated ID from legacy layer |
| #1069 | extract_entities: If True, extract and store entity mentions as triples |
| #1070 | extract: If True, extract structured facts from content using LLM |
| #1071 | and store as triples. Default False. |
| #1072 | veracity: Confidence level — 'stated', 'inferred', 'tool', 'imported', 'unknown' |
| #1073 | """ |
| #1074 | # --- Typed memory classification (Phase 1 — zero overhead) --- |
| #1075 | memory_type = None |
| #1076 | if classify_memory is not None: |
| #1077 | try: |
| #1078 | result = classify_memory(content) |
| #1079 | memory_type = result.memory_type.value |
| #1080 | except Exception: |
| #1081 | pass # Classifier failures are non-blocking |
| #1082 | |
| #1083 | # --- Deduplication: exact match --- |
| #1084 | existing_id = self._find_duplicate(content) |
| #1085 | if existing_id: |
| #1086 | cursor = self.conn.cursor() |
| #1087 | cursor.execute(""" |
| #1088 | UPDATE working_memory |
| #1089 | SET importance = MAX(importance, ?), timestamp = ?, source = ?, |
| #1090 | valid_until = COALESCE(?, valid_until), |
| #1091 | scope = COALESCE(?, scope), |
| #1092 | author_id = COALESCE(?, author_id), |
| #1093 | author_type = COALESCE(?, author_type), |
| #1094 | channel_id = COALESCE(?, channel_id), |
| #1095 | memory_type = COALESCE(?, memory_type) |
| #1096 | WHERE id = ? AND session_id = ? |
| #1097 | """, (importance, datetime.now().isoformat(), source, |
| #1098 | valid_until, scope, |
| #1099 | self.author_id, self.author_type, self.channel_id, |
| #1100 | memory_type, |
| #1101 | existing_id, self.session_id)) |
| #1102 | self.conn.commit() |
| #1103 | # Run the same entity/fact extraction the new-row path runs, so |
| #1104 | # backfill calls — `mem.remember(same_content, extract=True)` on |
| #1105 | # an already-existing row — actually populate the triples and |
| #1106 | # facts tables. Without this the dedup early-return silently |
| #1107 | # skips everything `extract=True` advertises, breaking the |
| #1108 | # contract on duplicate-content writes (see C12.a /review note). |
| #1109 | if extract_entities: |
| #1110 | _extract_and_store_entities(self, existing_id, content) |
| #1111 | if extract: |
| #1112 | _extract_and_store_facts(self, existing_id, content, source) |
| #1113 | # Phase 3-4: Extract graph and consolidate veracity for dedup update |
| #1114 | self._ingest_graph_and_veracity(existing_id, content, source, veracity) |
| #1115 | self._emit_event("MEMORY_UPDATED", existing_id, content=content, |
| #1116 | source=source, importance=importance, metadata=metadata) |
| #1117 | return existing_id |
| #1118 | |
| #1119 | memory_id = memory_id or _generate_id(content) |
| #1120 | timestamp = datetime.now().isoformat() |
| #1121 | cursor = self.conn.cursor() |
| #1122 | cursor.execute(""" |
| #1123 | INSERT INTO working_memory |
| #1124 | (id, content, source, timestamp, session_id, importance, metadata_json, valid_until, scope, |
| #1125 | author_id, author_type, channel_id, veracity, memory_type) |
| #1126 | VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) |
| #1127 | """, (memory_id, content, source, timestamp, self.session_id, importance, |
| #1128 | json.dumps(metadata or {}), valid_until, scope, |
| #1129 | self.author_id, self.author_type, self.channel_id, veracity, memory_type)) |
| #1130 | self.conn.commit() |
| #1131 | self._trim_working_memory() |
| #1132 | |
| #1133 | # Auto-generate temporal triple |
| #1134 | self._add_temporal_triple(memory_id, timestamp, source, content) |
| #1135 | |
| #1136 | # --- Entity extraction --- |
| #1137 | if extract_entities: |
| #1138 | _extract_and_store_entities(self, memory_id, content) |
| #1139 | |
| #1140 | # --- Structured fact extraction --- |
| #1141 | if extract: |
| #1142 | _extract_and_store_facts(self, memory_id, content, source) |
| #1143 | |
| #1144 | # Phase 3-4: Extract graph and consolidate veracity for new memory |
| #1145 | self._ingest_graph_and_veracity(memory_id, content, source, veracity) |
| #1146 | |
| #1147 | self._emit_event("MEMORY_ADDED", memory_id, content=content, |
| #1148 | source=source, importance=importance, metadata=metadata) |
| #1149 | return memory_id |
| #1150 | |
| #1151 | def remember_batch(self, items: List[Dict]) -> List[str]: |
| #1152 | """ |
| #1153 | Batch insert into working_memory for high-throughput ingestion. |
| #1154 | Each item dict should have keys: content, source, importance, metadata (optional). |
| #1155 | """ |
| #1156 | cursor = self.conn.cursor() |
| #1157 | ids = [] |
| #1158 | timestamp = datetime.now().isoformat() |
| #1159 | for item in items: |
| #1160 | memory_id = _generate_id(item["content"]) |
| #1161 | ids.append(memory_id) |
| #1162 | # Typed memory classification |
| #1163 | item_type = None |
| #1164 | if classify_memory is not None: |
| #1165 | try: |
| #1166 | result = classify_memory(item["content"]) |
| #1167 | item_type = result.memory_type.value |
| #1168 | except Exception: |
| #1169 | pass |
| #1170 | cursor.execute(""" |
| #1171 | INSERT INTO working_memory (id, content, source, timestamp, session_id, importance, metadata_json, |
| #1172 | author_id, author_type, channel_id, memory_type) |
| #1173 | VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) |
| #1174 | """, ( |
| #1175 | memory_id, |
| #1176 | item["content"], |
| #1177 | item.get("source", "conversation"), |
| #1178 | timestamp, |
| #1179 | self.session_id, |
| #1180 | item.get("importance", 0.5), |
| #1181 | json.dumps(item.get("metadata") or {}), |
| #1182 | item.get("author_id", self.author_id), |
| #1183 | item.get("author_type", self.author_type), |
| #1184 | item.get("channel_id", self.channel_id), |
| #1185 | item_type |
| #1186 | )) |
| #1187 | self.conn.commit() |
| #1188 | |
| #1189 | # Generate vector embeddings for working memory hybrid search |
| #1190 | if _embeddings.available(): |
| #1191 | try: |
| #1192 | contents = [item["content"] for item in items] |
| #1193 | vectors = _embeddings.embed(contents) |
| #1194 | if vectors is not None: |
| #1195 | model = _embeddings._DEFAULT_MODEL |
| #1196 | for i, memory_id in enumerate(ids): |
| #1197 | emb_json = _embeddings.serialize(vectors[i]) |
| #1198 | cursor.execute( |
| #1199 | "INSERT OR REPLACE INTO memory_embeddings (memory_id, embedding_json, model) VALUES (?, ?, ?)", |
| #1200 | (memory_id, emb_json, model) |
| #1201 | ) |
| #1202 | except Exception: |
| #1203 | pass # Vector embedding is best-effort, non-blocking |
| #1204 | |
| #1205 | self._trim_working_memory() |
| #1206 | return ids |
| #1207 | |
| #1208 | def _ingest_graph_and_veracity(self, memory_id: str, content: str, |
| #1209 | source: str, veracity: str = "unknown"): |
| #1210 | """Phase 3-4: Extract gists + facts, store in graph, consolidate veracity. |
| #1211 | Non-blocking — failures in graph/veracity don't affect memory storage.""" |
| #1212 | |
| #1213 | gist = None |
| #1214 | facts = [] |
| #1215 | |
| #1216 | # Phase 3: Episodic graph extraction |
| #1217 | if self.episodic_graph is not None: |
| #1218 | try: |
| #1219 | gist = self.episodic_graph.extract_gist(content, memory_id) |
| #1220 | self.episodic_graph.store_gist(gist, memory_id) |
| #1221 | |
| #1222 | facts = self.episodic_graph.extract_facts(content, memory_id) |
| #1223 | for fact in facts: |
| #1224 | self.episodic_graph.store_fact(fact, memory_id) |
| #1225 | |
| #1226 | # Link graph edges between gist and facts |
| #1227 | for fact in facts: |
| #1228 | self.episodic_graph.add_edge(GraphEdge( |
| #1229 | source=gist.id, |
| #1230 | target=fact.id, |
| #1231 | edge_type="ctx", |
| #1232 | weight=fact.confidence, |
| #1233 | timestamp=datetime.now().isoformat() |
| #1234 | )) |
| #1235 | except Exception: |
| #1236 | pass # Graph failures are non-blocking |
| #1237 | |
| #1238 | # Phase 4: Veracity-weighted consolidation (reuses facts from above) |
| #1239 | if self.veracity_consolidator is not None and facts: |
| #1240 | try: |
| #1241 | for fact in facts: |
| #1242 | self.veracity_consolidator.consolidate_fact( |
| #1243 | subject=fact.subject, |
| #1244 | predicate=fact.predicate, |
| #1245 | object=fact.object, |
| #1246 | veracity=veracity, |
| #1247 | source=memory_id |
| #1248 | ) |
| #1249 | except Exception: |
| #1250 | pass # Veracity failures are non-blocking |
| #1251 | |
| #1252 | def _add_temporal_triple(self, memory_id: str, timestamp: str, source: str, content: str): |
| #1253 | """Auto-generate temporal triple for a memory. Bridges BEAM and TripleStore.""" |
| #1254 | try: |
| #1255 | # Import triples module lazily to avoid circular dependency |
| #1256 | from mnemosyne.core.triples import TripleStore, init_triples |
| #1257 | date_str = timestamp[:10] # YYYY-MM-DD |
| #1258 | # Ensure triples table exists |
| #1259 | init_triples(db_path=self.db_path) |
| #1260 | triple_store = TripleStore(db_path=self.db_path) |
| #1261 | triple_store.add( |
| #1262 | subject=memory_id, |
| #1263 | predicate="occurred_on", |
| #1264 | object=date_str, |
| #1265 | valid_from=date_str |
| #1266 | ) |
| #1267 | # Also tag source type |
| #1268 | if source and source not in ("conversation", "user", "assistant"): |
| #1269 | triple_store.add( |
| #1270 | subject=memory_id, |
| #1271 | predicate="has_source", |
| #1272 | object=source, |
| #1273 | valid_from=date_str |
| #1274 | ) |
| #1275 | except Exception: |
| #1276 | # TripleStore is optional; don't fail memory write if triples fail |
| #1277 | pass |
| #1278 | |
| #1279 | def _trim_working_memory(self): |
| #1280 | """Keep working_memory within size/time limits.""" |
| #1281 | cutoff = (datetime.now() - timedelta(hours=WORKING_MEMORY_TTL_HOURS)).isoformat() |
| #1282 | self.conn.execute(""" |
| #1283 | DELETE FROM working_memory |
| #1284 | WHERE session_id = ? AND ( |
| #1285 | timestamp < ? OR |
| #1286 | id NOT IN ( |
| #1287 | SELECT id FROM working_memory |
| #1288 | WHERE session_id = ? |
| #1289 | ORDER BY timestamp DESC |
| #1290 | LIMIT ? |
| #1291 | ) |
| #1292 | ) |
| #1293 | """, (self.session_id, cutoff, self.session_id, WORKING_MEMORY_MAX_ITEMS)) |
| #1294 | self.conn.commit() |
| #1295 | |
| #1296 | def get_context(self, limit: int = 10) -> List[Dict]: |
| #1297 | """Get working_memory for prompt injection. |
| #1298 | Global memories first, then sorted by importance (high first), |
| #1299 | then by recency. High-importance rules/bans surface reliably.""" |
| #1300 | cursor = self.conn.cursor() |
| #1301 | now = datetime.now().isoformat() |
| #1302 | cursor.execute(""" |
| #1303 | SELECT id, content, source, timestamp, importance, scope |
| #1304 | FROM working_memory |
| #1305 | WHERE (session_id = ? OR scope = 'global') |
| #1306 | AND (valid_until IS NULL OR valid_until > ?) |
| #1307 | AND superseded_by IS NULL |
| #1308 | ORDER BY |
| #1309 | CASE WHEN scope = 'global' THEN 0 ELSE 1 END, |
| #1310 | importance DESC, |
| #1311 | timestamp DESC |
| #1312 | LIMIT ? |
| #1313 | """, (self.session_id, now, limit)) |
| #1314 | return [dict(row) for row in cursor.fetchall()] |
| #1315 | |
| #1316 | def invalidate(self, memory_id: str, replacement_id: str = None) -> bool: |
| #1317 | """ |
| #1318 | Mark a memory as invalid/superseded. |
| #1319 | If replacement_id is provided, sets superseded_by. |
| #1320 | Otherwise sets valid_until to now (immediate expiry). |
| #1321 | """ |
| #1322 | cursor = self.conn.cursor() |
| #1323 | now = datetime.now().isoformat() |
| #1324 | # Try working_memory first |
| #1325 | cursor.execute(""" |
| #1326 | UPDATE working_memory |
| #1327 | SET valid_until = ?, superseded_by = ? |
| #1328 | WHERE id = ? AND (session_id = ? OR scope = 'global') |
| #1329 | """, (now, replacement_id, memory_id, self.session_id)) |
| #1330 | if cursor.rowcount > 0: |
| #1331 | self.conn.commit() |
| #1332 | return True |
| #1333 | # Try episodic_memory |
| #1334 | cursor.execute(""" |
| #1335 | UPDATE episodic_memory |
| #1336 | SET valid_until = ?, superseded_by = ? |
| #1337 | WHERE id = ? AND (session_id = ? OR scope = 'global') |
| #1338 | """, (now, replacement_id, memory_id, self.session_id)) |
| #1339 | self.conn.commit() |
| #1340 | return cursor.rowcount > 0 |
| #1341 | |
| #1342 | def get_working_stats(self, author_id: str = None, author_type: str = None, |
| #1343 | channel_id: str = None) -> Dict: |
| #1344 | cursor = self.conn.cursor() |
| #1345 | where_clauses = [] |
| #1346 | params = [] |
| #1347 | if author_id: |
| #1348 | where_clauses.append("author_id = ?") |
| #1349 | params.append(author_id) |
| #1350 | if author_type: |
| #1351 | where_clauses.append("author_type = ?") |
| #1352 | params.append(author_type) |
| #1353 | if channel_id: |
| #1354 | where_clauses.append("channel_id = ?") |
| #1355 | params.append(channel_id) |
| #1356 | where_str = f" WHERE {' AND '.join(where_clauses)}" if where_clauses else "" |
| #1357 | |
| #1358 | cursor.execute(f"SELECT COUNT(*) FROM working_memory{where_str}", params) |
| #1359 | total = cursor.fetchone()[0] |
| #1360 | cursor.execute(f"SELECT timestamp FROM working_memory{where_str} ORDER BY timestamp DESC LIMIT 1", params) |
| #1361 | last = cursor.fetchone() |
| #1362 | return {"total": total, "last": last[0] if last else None} |
| #1363 | |
| #1364 | # DEPRECATED — kept for backward compatibility with hermes_memory_provider/cli.py |
| #1365 | def get_global_working_stats(self) -> Dict: |
| #1366 | """DEPRECATED: Use get_working_stats() instead. Kept for backward compatibility.""" |
| #1367 | return self.get_working_stats() |
| #1368 | |
| #1369 | def update_working(self, memory_id: str, content: str = None, |
| #1370 | importance: float = None) -> bool: |
| #1371 | """Update a working_memory entry.""" |
| #1372 | cursor = self.conn.cursor() |
| #1373 | updates = [] |
| #1374 | params = [] |
| #1375 | if content is not None: |
| #1376 | updates.append("content = ?") |
| #1377 | params.append(content) |
| #1378 | if importance is not None: |
| #1379 | updates.append("importance = ?") |
| #1380 | params.append(importance) |
| #1381 | if not updates: |
| #1382 | return False |
| #1383 | params.extend([memory_id, self.session_id]) |
| #1384 | cursor.execute( |
| #1385 | f"UPDATE working_memory SET {', '.join(updates)} WHERE id = ? AND session_id = ?", |
| #1386 | params |
| #1387 | ) |
| #1388 | self.conn.commit() |
| #1389 | return cursor.rowcount > 0 |
| #1390 | |
| #1391 | def forget_working(self, memory_id: str) -> bool: |
| #1392 | cursor = self.conn.cursor() |
| #1393 | cursor.execute("DELETE FROM working_memory WHERE id = ? AND session_id = ?", (memory_id, self.session_id)) |
| #1394 | self.conn.commit() |
| #1395 | return cursor.rowcount > 0 |
| #1396 | |
| #1397 | # ------------------------------------------------------------------ |
| #1398 | # Episodic Memory |
| #1399 | # ------------------------------------------------------------------ |
| #1400 | def consolidate_to_episodic(self, summary: str, source_wm_ids: List[str], |
| #1401 | source: str = "consolidation", importance: float = 0.6, |
| #1402 | metadata: Dict = None, valid_until: str = None, |
| #1403 | scope: str = "session") -> str: |
| #1404 | """ |
| #1405 | Store a consolidated summary into episodic_memory with optional embedding. |
| #1406 | """ |
| #1407 | memory_id = _generate_id(summary) |
| #1408 | timestamp = datetime.now().isoformat() |
| #1409 | # Typed memory classification |
| #1410 | ep_type = None |
| #1411 | if classify_memory is not None: |
| #1412 | try: |
| #1413 | result = classify_memory(summary) |
| #1414 | ep_type = result.memory_type.value |
| #1415 | except Exception: |
| #1416 | pass |
| #1417 | cursor = self.conn.cursor() |
| #1418 | cursor.execute(""" |
| #1419 | INSERT INTO episodic_memory |
| #1420 | (id, content, source, timestamp, session_id, importance, metadata_json, summary_of, valid_until, scope, |
| #1421 | author_id, author_type, channel_id, memory_type) |
| #1422 | VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) |
| #1423 | """, (memory_id, summary, source, timestamp, self.session_id, importance, |
| #1424 | json.dumps(metadata or {}), ",".join(source_wm_ids), valid_until, scope, |
| #1425 | self.author_id, self.author_type, self.channel_id, ep_type)) |
| #1426 | rowid = cursor.lastrowid |
| #1427 | |
| #1428 | if _embeddings.available(): |
| #1429 | vec = _embeddings.embed([summary]) |
| #1430 | if vec is not None: |
| #1431 | if _vec_available(self.conn): |
| #1432 | _vec_insert(self.conn, rowid, vec[0].tolist()) |
| #1433 | else: |
| #1434 | # Fallback: store in memory_embeddings table for in-memory search |
| #1435 | cursor.execute(""" |
| #1436 | INSERT OR REPLACE INTO memory_embeddings (memory_id, embedding_json, model) |
| #1437 | VALUES (?, ?, ?) |
| #1438 | """, (memory_id, _embeddings.serialize(vec[0]), _embeddings._DEFAULT_MODEL)) |
| #1439 | |
| #1440 | # Binary vector compression (Phase 2 — 32x reduction) |
| #1441 | if _mib is not None: |
| #1442 | try: |
| #1443 | bv = _mib(vec[0]) |
| #1444 | cursor.execute( |
| #1445 | "UPDATE episodic_memory SET binary_vector = ? WHERE rowid = ?", |
| #1446 | (bv, rowid) |
| #1447 | ) |
| #1448 | except Exception: |
| #1449 | pass # Non-blocking |
| #1450 | |
| #1451 | self.conn.commit() |
| #1452 | |
| #1453 | # Phase 3-4: Graph + veracity for consolidated episodic memory |
| #1454 | self._ingest_graph_and_veracity(memory_id, summary, source, veracity="inferred") |
| #1455 | |
| #1456 | self._emit_event("MEMORY_CONSOLIDATED", memory_id, content=summary, |
| #1457 | source=source, importance=importance, |
| #1458 | metadata={"summary_of": source_wm_ids, **(metadata or {})}) |
| #1459 | return memory_id |
| #1460 | |
| #1461 | def recall(self, query: str, top_k: int = 40, *, |
| #1462 | from_date: Optional[str] = None, to_date: Optional[str] = None, |
| #1463 | source: Optional[str] = None, topic: Optional[str] = None, |
| #1464 | author_id: Optional[str] = None, |
| #1465 | author_type: Optional[str] = None, |
| #1466 | channel_id: Optional[str] = None, |
| #1467 | veracity: Optional[str] = None, |
| #1468 | memory_type: Optional[str] = None, |
| #1469 | temporal_weight: float = 0.0, |
| #1470 | query_time: Optional[Any] = None, |
| #1471 | temporal_halflife: Optional[float] = None, |
| #1472 | vec_weight: float = None, |
| #1473 | fts_weight: float = None, |
| #1474 | importance_weight: float = None) -> List[Dict]: |
| #1475 | """ |
| #1476 | Hybrid recall across working_memory + episodic_memory. |
| #1477 | Uses sqlite-vec + FTS5 for episodic, FTS5 for working. |
| #1478 | Falls back to recency-only for working memory if FTS5 unavailable. |
| #1479 | |
| #1480 | Temporal filtering: |
| #1481 | from_date/to_date: ISO date strings (YYYY-MM-DD) to filter by timestamp. |
| #1482 | source: Filter by memory source (e.g., 'cron', 'user', 'conversation'). |
| #1483 | topic: Filter by topic tag (stored in source field for now, pending dedicated column). |
| #1484 | |
| #1485 | Multi-agent identity filtering (v2.1): |
| #1486 | author_id: Filter by author (e.g., 'abdias', 'codex-agent'). |
| #1487 | author_type: Filter by author type ('human', 'agent', 'system'). |
| #1488 | channel_id: Filter by channel/group (e.g., 'fluxspeak-team'). |
| #1489 | |
| #1490 | Temporal scoring (Phase 3): |
| #1491 | temporal_weight: Float 0.0-1.0. Soft boost for memories near query_time. |
| #1492 | 0.0 = no temporal boost (default, backward compatible). |
| #1493 | query_time: Target time for temporal scoring. None = now(). |
| #1494 | temporal_halflife: Hours for temporal decay. None = env var or 24h default. |
| #1495 | |
| #1496 | Temporal scoring (Phase 3): |
| #1497 | temporal_weight: Float 0.0-1.0. Soft boost for memories near query_time. |
| #1498 | 0.0 = no temporal boost (default, backward compatible). |
| #1499 | query_time: Target time for temporal scoring. None = now(). |
| #1500 | temporal_halflife: Hours for temporal decay. None = env var or 24h default. |
| #1501 | |
| #1502 | Configurable hybrid scoring (Phase 4): |
| #1503 | vec_weight: Weight for vector (dense) similarity in episodic scoring. |
| #1504 | None = use env var MNEMOSYNE_VEC_WEIGHT or default 0.5. |
| #1505 | fts_weight: Weight for FTS5 text relevance in episodic scoring. |
| #1506 | None = use env var MNEMOSYNE_FTS_WEIGHT or default 0.3. |
| #1507 | importance_weight: Weight for importance score in all scoring. |
| #1508 | None = use env var MNEMOSYNE_IMPORTANCE_WEIGHT or default 0.2. |
| #1509 | |
| #1510 | The three episodic weights are automatically normalized to sum to 1.0. |
| #1511 | Working memory uses a derived split: keyword gets (1 - importance_weight) * 0.6, |
| #1512 | recency gets (1 - importance_weight) * 0.4. |
| #1513 | """ |
| #1514 | results = [] |
| #1515 | query_lower = query.lower() |
| #1516 | query_words = query_lower.split() |
| #1517 | |
| #1518 | # ---- Configurable hybrid scoring setup (Phase 4) ---- |
| #1519 | vw, fw, iw = _normalize_weights(vec_weight, fts_weight, importance_weight) |
| #1520 | |
| #1521 | # ---- Temporal scoring setup ---- |
| #1522 | parsed_query_time = _parse_query_time(query_time) |
| #1523 | if temporal_halflife is not None: |
| #1524 | th_halflife = temporal_halflife |
| #1525 | else: |
| #1526 | th_halflife = float(os.environ.get("MNEMOSYNE_TEMPORAL_HALFLIFE_HOURS", "24")) |
| #1527 | |
| #1528 | # ---- Working memory (FTS5 fast path) ---- |
| #1529 | try: |
| #1530 | wm_fts = _fts_search_working(self.conn, query, k=max(top_k * 3, 50)) |
| #1531 | except Exception: |
| #1532 | wm_fts = [] |
| #1533 | |
| #1534 | wm_ids = {r["id"] for r in wm_fts} |
| #1535 | wm_ranks = {r["id"]: r["rank"] for r in wm_fts} |
| #1536 | |
| #1537 | # ---- Working memory (vector search) ---- |
| #1538 | wm_vec_sims = {} |
| #1539 | if _embeddings.available(): |
| #1540 | try: |
| #1541 | emb_result = _embeddings.embed_query(query) |
| #1542 | if emb_result is not None: |
| #1543 | wm_vec = _wm_vec_search(self.conn, emb_result, |
| #1544 | k=max(top_k, 20) if _BEAM_MODE else max(top_k * 3, 50)) |
| #1545 | for vr in wm_vec: |
| #1546 | wm_vec_sims[vr["id"]] = vr["sim"] |
| #1547 | wm_ids.add(vr["id"]) # Merge vector results with FTS5 results |
| #1548 | except Exception: |
| #1549 | pass |
| #1550 | |
| #1551 | # Build temporal filter clause for working memory |
| #1552 | wm_where_clauses = [ |
| #1553 | "(valid_until IS NULL OR valid_until > ?)", |
| #1554 | "superseded_by IS NULL" |
| #1555 | ] |
| #1556 | wm_params = [datetime.now().isoformat()] |
| #1557 | |
| #1558 | # Session scope: channel filter only when explicitly specified. |
| #1559 | # Author-only searches have no session/channel restriction. |
| #1560 | if channel_id: |
| #1561 | wm_where_clauses.append("(session_id = ? OR scope = 'global' OR channel_id = ?)") |
| #1562 | wm_params.extend([self.session_id, channel_id]) |
| #1563 | elif author_id or author_type: |
| #1564 | wm_where_clauses.append("(1=1)") |
| #1565 | else: |
| #1566 | wm_where_clauses.append("(session_id = ? OR scope = 'global')") |
| #1567 | wm_params.append(self.session_id) |
| #1568 | |
| #1569 | if from_date: |
| #1570 | wm_where_clauses.append("timestamp >= ?") |
| #1571 | wm_params.append(f"{from_date}T00:00:00") |
| #1572 | if to_date: |
| #1573 | wm_where_clauses.append("timestamp <= ?") |
| #1574 | wm_params.append(f"{to_date}T23:59:59") |
| #1575 | if source: |
| #1576 | wm_where_clauses.append("source = ?") |
| #1577 | wm_params.append(source) |
| #1578 | if topic: |
| #1579 | # Topic stored in source field for now (pending dedicated topic column) |
| #1580 | wm_where_clauses.append("source = ?") |
| #1581 | wm_params.append(topic) |
| #1582 | if veracity: |
| #1583 | wm_where_clauses.append("veracity = ?") |
| #1584 | wm_params.append(veracity) |
| #1585 | if memory_type: |
| #1586 | wm_where_clauses.append("memory_type = ?") |
| #1587 | wm_params.append(memory_type) |
| #1588 | if author_id: |
| #1589 | wm_where_clauses.append("author_id = ?") |
| #1590 | wm_params.append(author_id) |
| #1591 | if author_type: |
| #1592 | wm_where_clauses.append("author_type = ?") |
| #1593 | wm_params.append(author_type) |
| #1594 | if channel_id: |
| #1595 | wm_where_clauses.append("channel_id = ?") |
| #1596 | wm_params.append(channel_id) |
| #1597 | |
| #1598 | wm_where = " AND ".join(wm_where_clauses) |
| #1599 | |
| #1600 | if wm_ids: |
| #1601 | placeholders = ",".join("?" * len(wm_ids)) |
| #1602 | cursor = self.conn.cursor() |
| #1603 | cursor.execute(f""" |
| #1604 | SELECT id, content, source, timestamp, importance, recall_count, last_recalled, valid_until, superseded_by, scope, author_id, author_type, channel_id, veracity, memory_type |
| #1605 | FROM working_memory |
| #1606 | WHERE id IN ({placeholders}) |
| #1607 | AND {wm_where} |
| #1608 | """, (*tuple(wm_ids), *wm_params)) |
| #1609 | rows = cursor.fetchall() |
| #1610 | else: |
| #1611 | # Fallback: fetch recent items and score in Python (old path) |
| #1612 | cursor = self.conn.cursor() |
| #1613 | cursor.execute(f""" |
| #1614 | SELECT id, content, source, timestamp, importance, recall_count, last_recalled, valid_until, superseded_by, scope, author_id, author_type, channel_id, veracity, memory_type |
| #1615 | FROM working_memory |
| #1616 | WHERE {wm_where} |
| #1617 | ORDER BY timestamp DESC |
| #1618 | LIMIT {min(EPISODIC_RECALL_LIMIT, 2000)} |
| #1619 | """, wm_params) |
| #1620 | rows = cursor.fetchall() |
| #1621 | |
| #1622 | # Precompute min_rank/rng for wm_ranks normalization |
| #1623 | if wm_ranks: |
| #1624 | min_rank = min(wm_ranks.values()) |
| #1625 | max_rank = max(wm_ranks.values()) |
| #1626 | rng = max_rank - min_rank if max_rank != min_rank else 1.0 |
| #1627 | else: |
| #1628 | min_rank = 0.0 |
| #1629 | rng = 1.0 |
| #1630 | |
| #1631 | for row in rows: |
| #1632 | content_lower = row["content"].lower() |
| #1633 | content_words_list = content_lower.split() |
| #1634 | content_words_set = set(content_words_list) |
| #1635 | if wm_ranks and row["id"] in wm_ranks: |
| #1636 | normalized = 1.0 - ((wm_ranks[row["id"]] - min_rank) / rng) |
| #1637 | relevance = normalized |
| #1638 | else: |
| #1639 | # exact: query words appearing in content (substring match, not token equality) |
| #1640 | exact = sum(1 for w in query_words if w in content_lower) |
| #1641 | # partial: unique query words with substring match in content words (set-based, not cartesian) |
| #1642 | partial = sum(1 for w in query_words if len(w) >= 2 and any(w in cw or cw in w for cw in content_words_set if len(cw) >= 2)) |
| #1643 | # cross: query substrings matched against content word substrings (set-based) |
| #1644 | query_substr = {w for w in query_words if len(w) >= 2} |
| #1645 | content_substr = {cw for cw in content_words_set if len(cw) >= 2} |
| #1646 | cross = sum(1 for q in query_substr for c in content_substr if q in c or c in q) |
| #1647 | # Also check if the full query is a substring of content (handles spaceless languages) |
| #1648 | full_match = 1.0 if query_lower in content_lower else 0.0 |
| #1649 | if not full_match and content_lower in query_lower: |
| #1650 | full_match = 0.5 |
| #1651 | # Character-level overlap for spaceless languages (e.g. Chinese) |
| #1652 | query_chars = set(query_lower) |
| #1653 | content_chars = set(content_lower) |
| #1654 | char_overlap = len(query_chars & content_chars) / max(len(query_chars), 1) if query_chars else 0.0 |
| #1655 | relevance = (exact * 1.0 + partial * 0.3 + cross * 0.5 + full_match + char_overlap * 0.8) / max(len(query_words), 1) |
| #1656 | if relevance > 0.02 or wm_ranks: |
| #1657 | decay = _recency_decay(row["timestamp"]) |
| #1658 | # Phase 4: configurable scoring for working memory |
| #1659 | # keyword_share = (1 - importance_weight) * 0.6, recency_share = (1 - importance_weight) * 0.4 |
| #1660 | kw_share = (1.0 - iw) * 0.6 |
| #1661 | rc_share = (1.0 - iw) * 0.4 |
| #1662 | base_score = relevance * kw_share + row["importance"] * iw |
| #1663 | # Blend vector similarity into working memory score (weighted toward keyword precision) |
| #1664 | vec_sim = wm_vec_sims.get(row["id"], 0.0) |
| #1665 | if vec_sim > 0: |
| #1666 | base_score = base_score * 0.80 + vec_sim * 0.20 |
| #1667 | score = base_score * (rc_share + (1.0 - rc_share) * decay) |
| #1668 | # Temporal boost (Phase 3) |
| #1669 | if temporal_weight > 0.0: |
| #1670 | t_boost = _temporal_boost(row["timestamp"], parsed_query_time, th_halflife) |
| #1671 | score *= (1.0 + temporal_weight * t_boost) |
| #1672 | results.append({ |
| #1673 | "id": row["id"], |
| #1674 | "content": row["content"][:500], |
| #1675 | "source": row["source"], |
| #1676 | "timestamp": row["timestamp"], |
| #1677 | "tier": "working", |
| #1678 | "score": round(score, 4), |
| #1679 | "keyword_score": round(relevance, 4), |
| #1680 | "dense_score": round(vec_sim, 4), |
| #1681 | "fts_score": round(relevance, 4) if wm_ranks else 0.0, |
| #1682 | "importance": row["importance"], |
| #1683 | "recall_count": row["recall_count"] or 0, |
| #1684 | "last_recalled": row["last_recalled"], |
| #1685 | "recency_decay": round(decay, 4), |
| #1686 | "scope": row["scope"] if "scope" in row.keys() else "session", |
| #1687 | "author_id": row["author_id"] if "author_id" in row.keys() else None, |
| #1688 | "author_type": row["author_type"] if "author_type" in row.keys() else None, |
| #1689 | "channel_id": row["channel_id"] if "channel_id" in row.keys() else None, |
| #1690 | "veracity": row["veracity"] if "veracity" in row.keys() else "unknown", |
| #1691 | "valid_until": row["valid_until"] if "valid_until" in row.keys() else None, |
| #1692 | "superseded_by": row["superseded_by"] if "superseded_by" in row.keys() else None |
| #1693 | }) |
| #1694 | |
| #1695 | # ---- Entity-aware recall ---- |
| #1696 | entity_memory_ids = _find_memories_by_entity(self, query) |
| #1697 | if entity_memory_ids: |
| #1698 | # Fetch entity-matched memories and boost their scores |
| #1699 | placeholders = ",".join("?" * len(entity_memory_ids)) |
| #1700 | cursor = self.conn.cursor() |
| #1701 | cursor.execute(f""" |
| #1702 | SELECT id, content, source, timestamp, importance, recall_count, last_recalled, valid_until, superseded_by, scope, author_id, author_type, channel_id, veracity, memory_type |
| #1703 | FROM working_memory |
| #1704 | WHERE id IN ({placeholders}) |
| #1705 | AND {wm_where} |
| #1706 | """, (*tuple(entity_memory_ids), *wm_params)) |
| #1707 | entity_rows = cursor.fetchall() |
| #1708 | |
| #1709 | # Add entity-matched memories with boosted scores |
| #1710 | existing_ids = {r["id"] for r in results} |
| #1711 | for row in entity_rows: |
| #1712 | if row["id"] in existing_ids: |
| #1713 | # Boost existing result |
| #1714 | for r in results: |
| #1715 | if r["id"] == row["id"]: |
| #1716 | r["score"] = round(min(r["score"] * 1.3, 1.0), 4) |
| #1717 | r["entity_match"] = True |
| #1718 | break |
| #1719 | else: |
| #1720 | decay = _recency_decay(row["timestamp"]) |
| #1721 | score = (0.6 + row["importance"] * 0.2) * (0.7 + 0.3 * decay) |
| #1722 | # Temporal boost (Phase 3) |
| #1723 | if temporal_weight > 0.0: |
| #1724 | t_boost = _temporal_boost(row["timestamp"], parsed_query_time, th_halflife) |
| #1725 | score *= (1.0 + temporal_weight * t_boost) |
| #1726 | results.append({ |
| #1727 | "id": row["id"], |
| #1728 | "content": row["content"][:500], |
| #1729 | "source": row["source"], |
| #1730 | "timestamp": row["timestamp"], |
| #1731 | "tier": "working", |
| #1732 | "score": round(score, 4), |
| #1733 | "keyword_score": 0.0, |
| #1734 | "dense_score": round(wm_vec_sims.get(row["id"], 0.0), 4), |
| #1735 | "fts_score": 0.0, |
| #1736 | "importance": row["importance"], |
| #1737 | "recall_count": row["recall_count"] or 0, |
| #1738 | "last_recalled": row["last_recalled"], |
| #1739 | "recency_decay": round(decay, 4), |
| #1740 | "scope": row["scope"] if "scope" in row.keys() else "session", |
| #1741 | "author_id": row["author_id"] if "author_id" in row.keys() else None, |
| #1742 | "author_type": row["author_type"] if "author_type" in row.keys() else None, |
| #1743 | "channel_id": row["channel_id"] if "channel_id" in row.keys() else None, |
| #1744 | "veracity": row["veracity"] if "veracity" in row.keys() else "unknown", |
| #1745 | "valid_until": row["valid_until"] if "valid_until" in row.keys() else None, |
| #1746 | "superseded_by": row["superseded_by"] if "superseded_by" in row.keys() else None, |
| #1747 | "entity_match": True |
| #1748 | }) |
| #1749 | |
| #1750 | # Also check episodic memory for entity matches |
| #1751 | em_placeholders = ",".join("?" * len(entity_memory_ids)) |
| #1752 | if channel_id: |
| #1753 | em_entity_scope = "(session_id = ? OR scope = 'global' OR channel_id = ?)" |
| #1754 | em_entity_params = [*tuple(entity_memory_ids), self.session_id, channel_id] |
| #1755 | elif author_id or author_type: |
| #1756 | em_entity_scope = "(1=1)" |
| #1757 | em_entity_params = [*tuple(entity_memory_ids)] |
| #1758 | else: |
| #1759 | em_entity_scope = "(session_id = ? OR scope = 'global')" |
| #1760 | em_entity_params = [*tuple(entity_memory_ids), self.session_id] |
| #1761 | em_entity_params.extend([datetime.now().isoformat()]) |
| #1762 | cursor.execute(f""" |
| #1763 | SELECT id, content, source, timestamp, importance, recall_count, last_recalled, valid_until, superseded_by, scope, author_id, author_type, channel_id, veracity, memory_type |
| #1764 | FROM episodic_memory |
| #1765 | WHERE id IN ({em_placeholders}) |
| #1766 | AND {em_entity_scope} |
| #1767 | AND (valid_until IS NULL OR valid_until > ?) |
| #1768 | AND superseded_by IS NULL |
| #1769 | """, (*em_entity_params,)) |
| #1770 | em_entity_rows = cursor.fetchall() |
| #1771 | |
| #1772 | em_existing_ids = {r["id"] for r in results} |
| #1773 | for row in em_entity_rows: |
| #1774 | if row["id"] in em_existing_ids: |
| #1775 | for r in results: |
| #1776 | if r["id"] == row["id"]: |
| #1777 | r["score"] = round(min(r["score"] * 1.3, 1.0), 4) |
| #1778 | r["entity_match"] = True |
| #1779 | break |
| #1780 | else: |
| #1781 | decay = _recency_decay(row["timestamp"]) |
| #1782 | score = (0.6 + row["importance"] * 0.2) * (0.7 + 0.3 * decay) |
| #1783 | # Temporal boost (Phase 3) |
| #1784 | if temporal_weight > 0.0: |
| #1785 | t_boost = _temporal_boost(row["timestamp"], parsed_query_time, th_halflife) |
| #1786 | score *= (1.0 + temporal_weight * t_boost) |
| #1787 | results.append({ |
| #1788 | "id": row["id"], |
| #1789 | "content": row["content"][:500], |
| #1790 | "source": row["source"], |
| #1791 | "timestamp": row["timestamp"], |
| #1792 | "tier": "episodic", |
| #1793 | "score": round(score, 4), |
| #1794 | "keyword_score": 0.0, |
| #1795 | "dense_score": round(wm_vec_sims.get(row["id"], 0.0), 4), |
| #1796 | "fts_score": 0.0, |
| #1797 | "importance": row["importance"], |
| #1798 | "recall_count": row["recall_count"] or 0, |
| #1799 | "last_recalled": row["last_recalled"], |
| #1800 | "recency_decay": round(decay, 4), |
| #1801 | "scope": row["scope"] if "scope" in row.keys() else "session", |
| #1802 | "author_id": row["author_id"] if "author_id" in row.keys() else None, |
| #1803 | "author_type": row["author_type"] if "author_type" in row.keys() else None, |
| #1804 | "channel_id": row["channel_id"] if "channel_id" in row.keys() else None, |
| #1805 | "veracity": row["veracity"] if "veracity" in row.keys() else "unknown", |
| #1806 | "valid_until": row["valid_until"] if "valid_until" in row.keys() else None, |
| #1807 | "superseded_by": row["superseded_by"] if "superseded_by" in row.keys() else None, |
| #1808 | "entity_match": True |
| #1809 | }) |
| #1810 | |
| #1811 | # ---- Fact-aware recall ---- |
| #1812 | fact_memory_ids = _find_memories_by_fact(self, query) |
| #1813 | if fact_memory_ids: |
| #1814 | placeholders = ",".join("?" * len(fact_memory_ids)) |
| #1815 | cursor = self.conn.cursor() |
| #1816 | # Check working_memory for fact matches |
| #1817 | cursor.execute(f""" |
| #1818 | SELECT id, content, source, timestamp, importance, recall_count, last_recalled, valid_until, superseded_by, scope, author_id, author_type, channel_id, veracity, memory_type |
| #1819 | FROM working_memory |
| #1820 | WHERE id IN ({placeholders}) |
| #1821 | AND {wm_where} |
| #1822 | """, (*tuple(fact_memory_ids), *wm_params)) |
| #1823 | fact_rows = cursor.fetchall() |
| #1824 | |
| #1825 | existing_ids = {r["id"] for r in results} |
| #1826 | for row in fact_rows: |
| #1827 | if row["id"] in existing_ids: |
| #1828 | for r in results: |
| #1829 | if r["id"] == row["id"]: |
| #1830 | r["score"] = round(min(r["score"] * 1.2, 1.0), 4) |
| #1831 | r["fact_match"] = True |
| #1832 | break |
| #1833 | else: |
| #1834 | decay = _recency_decay(row["timestamp"]) |
| #1835 | score = (0.5 + row["importance"] * 0.2) * (0.7 + 0.3 * decay) |
| #1836 | # Temporal boost (Phase 3) |
| #1837 | if temporal_weight > 0.0: |
| #1838 | t_boost = _temporal_boost(row["timestamp"], parsed_query_time, th_halflife) |
| #1839 | score *= (1.0 + temporal_weight * t_boost) |
| #1840 | results.append({ |
| #1841 | "id": row["id"], |
| #1842 | "content": row["content"][:500], |
| #1843 | "source": row["source"], |
| #1844 | "timestamp": row["timestamp"], |
| #1845 | "tier": "working", |
| #1846 | "score": round(score, 4), |
| #1847 | "keyword_score": 0.0, |
| #1848 | "dense_score": round(wm_vec_sims.get(row["id"], 0.0), 4), |
| #1849 | "fts_score": 0.0, |
| #1850 | "importance": row["importance"], |
| #1851 | "recall_count": row["recall_count"] or 0, |
| #1852 | "last_recalled": row["last_recalled"], |
| #1853 | "recency_decay": round(decay, 4), |
| #1854 | "scope": row["scope"] if "scope" in row.keys() else "session", |
| #1855 | "author_id": row["author_id"] if "author_id" in row.keys() else None, |
| #1856 | "author_type": row["author_type"] if "author_type" in row.keys() else None, |
| #1857 | "channel_id": row["channel_id"] if "channel_id" in row.keys() else None, |
| #1858 | "veracity": row["veracity"] if "veracity" in row.keys() else "unknown", |
| #1859 | "valid_until": row["valid_until"] if "valid_until" in row.keys() else None, |
| #1860 | "superseded_by": row["superseded_by"] if "superseded_by" in row.keys() else None, |
| #1861 | "fact_match": True |
| #1862 | }) |
| #1863 | |
| #1864 | # Also check episodic memory for fact matches |
| #1865 | if channel_id: |
| #1866 | fact_em_scope = "(session_id = ? OR scope = 'global' OR channel_id = ?)" |
| #1867 | fact_em_params = [*tuple(fact_memory_ids), self.session_id, channel_id] |
| #1868 | elif author_id or author_type: |
| #1869 | fact_em_scope = "(1=1)" |
| #1870 | fact_em_params = [*tuple(fact_memory_ids)] |
| #1871 | else: |
| #1872 | fact_em_scope = "(session_id = ? OR scope = 'global')" |
| #1873 | fact_em_params = [*tuple(fact_memory_ids), self.session_id] |
| #1874 | fact_em_params.extend([datetime.now().isoformat()]) |
| #1875 | cursor.execute(f""" |
| #1876 | SELECT id, content, source, timestamp, importance, recall_count, last_recalled, valid_until, superseded_by, scope, author_id, author_type, channel_id, veracity, memory_type |
| #1877 | FROM episodic_memory |
| #1878 | WHERE id IN ({placeholders}) |
| #1879 | AND {fact_em_scope} |
| #1880 | AND (valid_until IS NULL OR valid_until > ?) |
| #1881 | AND superseded_by IS NULL |
| #1882 | """, (*fact_em_params,)) |
| #1883 | em_fact_rows = cursor.fetchall() |
| #1884 | |
| #1885 | em_existing_ids = {r["id"] for r in results} |
| #1886 | for row in em_fact_rows: |
| #1887 | if row["id"] in em_existing_ids: |
| #1888 | for r in results: |
| #1889 | if r["id"] == row["id"]: |
| #1890 | r["score"] = round(min(r["score"] * 1.2, 1.0), 4) |
| #1891 | r["fact_match"] = True |
| #1892 | break |
| #1893 | else: |
| #1894 | decay = _recency_decay(row["timestamp"]) |
| #1895 | score = (0.5 + row["importance"] * 0.2) * (0.7 + 0.3 * decay) |
| #1896 | # Temporal boost (Phase 3) |
| #1897 | if temporal_weight > 0.0: |
| #1898 | t_boost = _temporal_boost(row["timestamp"], parsed_query_time, th_halflife) |
| #1899 | score *= (1.0 + temporal_weight * t_boost) |
| #1900 | results.append({ |
| #1901 | "id": row["id"], |
| #1902 | "content": row["content"][:500], |
| #1903 | "source": row["source"], |
| #1904 | "timestamp": row["timestamp"], |
| #1905 | "tier": "episodic", |
| #1906 | "score": round(score, 4), |
| #1907 | "keyword_score": 0.0, |
| #1908 | "dense_score": round(wm_vec_sims.get(row["id"], 0.0), 4), |
| #1909 | "fts_score": 0.0, |
| #1910 | "importance": row["importance"], |
| #1911 | "recall_count": row["recall_count"] or 0, |
| #1912 | "last_recalled": row["last_recalled"], |
| #1913 | "recency_decay": round(decay, 4), |
| #1914 | "scope": row["scope"] if "scope" in row.keys() else "session", |
| #1915 | "author_id": row["author_id"] if "author_id" in row.keys() else None, |
| #1916 | "author_type": row["author_type"] if "author_type" in row.keys() else None, |
| #1917 | "channel_id": row["channel_id"] if "channel_id" in row.keys() else None, |
| #1918 | "veracity": row["veracity"] if "veracity" in row.keys() else "unknown", |
| #1919 | "valid_until": row["valid_until"] if "valid_until" in row.keys() else None, |
| #1920 | "superseded_by": row["superseded_by"] if "superseded_by" in row.keys() else None, |
| #1921 | "fact_match": True |
| #1922 | }) |
| #1923 | |
| #1924 | # ---- Pre-compute query binary vector (Phase 5 binary voice) ---- |
| #1925 | query_bv = None |
| #1926 | query_emb_for_bv = None |
| #1927 | if _embeddings.available() and _mib is not None: |
| #1928 | emb_result = _embeddings.embed_query(query) |
| #1929 | if emb_result is not None: |
| #1930 | query_emb_for_bv = emb_result |
| #1931 | query_bv = _mib(emb_result) |
| #1932 | |
| #1933 | # ---- Episodic memory (vec + FTS5 hybrid) ---- |
| #1934 | vec_results = {} |
| #1935 | max_distance = 0.0 |
| #1936 | if _embeddings.available(): |
| #1937 | emb_result = _embeddings.embed_query(query) |
| #1938 | if emb_result is not None: |
| #1939 | if _vec_available(self.conn): |
| #1940 | vec_rows = _vec_search(self.conn, emb_result.tolist(), k=max(top_k * 3, 20)) |
| #1941 | else: |
| #1942 | # Fallback: in-memory cosine similarity search |
| #1943 | vec_rows = _in_memory_vec_search(self.conn, emb_result, k=max(top_k * 3, 20)) |
| #1944 | if vec_rows: |
| #1945 | max_distance = max(vr["distance"] for vr in vec_rows) |
| #1946 | for vr in vec_rows: |
| #1947 | sim = max(0.0, 1.0 - (vr["distance"] / max_distance)) if max_distance > 0 else 1.0 |
| #1948 | vec_results[vr["rowid"]] = sim |
| #1949 | |
| #1950 | fts_results = {} |
| #1951 | fts_rows = _fts_search(self.conn, query, k=max(top_k * 3, 20)) |
| #1952 | if fts_rows: |
| #1953 | min_rank = min(r["rank"] for r in fts_rows) |
| #1954 | max_rank = max(r["rank"] for r in fts_rows) |
| #1955 | rng = max_rank - min_rank if max_rank != min_rank else 1.0 |
| #1956 | for fr in fts_rows: |
| #1957 | normalized = 1.0 - ((fr["rank"] - min_rank) / rng) |
| #1958 | fts_results[fr["rowid"]] = normalized |
| #1959 | |
| #1960 | episodic_rowids = set(vec_results.keys()) | set(fts_results.keys()) |
| #1961 | |
| #1962 | # Build temporal filter for episodic memory |
| #1963 | em_where_clauses = [ |
| #1964 | "(valid_until IS NULL OR valid_until > ?)", |
| #1965 | "superseded_by IS NULL" |
| #1966 | ] |
| #1967 | em_params = [datetime.now().isoformat()] |
| #1968 | |
| #1969 | # Session scope: channel filter only when explicitly specified. |
| #1970 | # Author-only searches have no session/channel restriction. |
| #1971 | if channel_id: |
| #1972 | em_where_clauses.append("(session_id = ? OR scope = 'global' OR channel_id = ?)") |
| #1973 | em_params.extend([self.session_id, channel_id]) |
| #1974 | elif author_id or author_type: |
| #1975 | em_where_clauses.append("(1=1)") |
| #1976 | else: |
| #1977 | em_where_clauses.append("(session_id = ? OR scope = 'global')") |
| #1978 | em_params.append(self.session_id) |
| #1979 | |
| #1980 | if from_date: |
| #1981 | em_where_clauses.append("timestamp >= ?") |
| #1982 | em_params.append(f"{from_date}T00:00:00") |
| #1983 | if to_date: |
| #1984 | em_where_clauses.append("timestamp <= ?") |
| #1985 | em_params.append(f"{to_date}T23:59:59") |
| #1986 | if source: |
| #1987 | em_where_clauses.append("source = ?") |
| #1988 | em_params.append(source) |
| #1989 | if topic: |
| #1990 | em_where_clauses.append("source = ?") |
| #1991 | em_params.append(topic) |
| #1992 | if veracity: |
| #1993 | em_where_clauses.append("veracity = ?") |
| #1994 | em_params.append(veracity) |
| #1995 | if memory_type: |
| #1996 | em_where_clauses.append("memory_type = ?") |
| #1997 | em_params.append(memory_type) |
| #1998 | if author_id: |
| #1999 | em_where_clauses.append("author_id = ?") |
| #2000 | em_params.append(author_id) |
| #2001 | if author_type: |
| #2002 | em_where_clauses.append("author_type = ?") |
| #2003 | em_params.append(author_type) |
| #2004 | if channel_id: |
| #2005 | em_where_clauses.append("channel_id = ?") |
| #2006 | em_params.append(channel_id) |
| #2007 | |
| #2008 | em_where = " AND ".join(em_where_clauses) |
| #2009 | |
| #2010 | if episodic_rowids: |
| #2011 | placeholders = ",".join("?" * len(episodic_rowids)) |
| #2012 | cursor = self.conn.cursor() |
| #2013 | cursor.execute(f""" |
| #2014 | SELECT rowid, id, content, source, timestamp, importance, recall_count, last_recalled, valid_until, superseded_by, scope, author_id, author_type, channel_id, memory_type, binary_vector |
| #2015 | FROM episodic_memory |
| #2016 | WHERE rowid IN ({placeholders}) |
| #2017 | AND {em_where} |
| #2018 | """, (*tuple(episodic_rowids), *em_params)) |
| #2019 | for row in cursor.fetchall(): |
| #2020 | rid = row["rowid"] |
| #2021 | sim = vec_results.get(rid, 0.0) |
| #2022 | fts = fts_results.get(rid, 0.0) |
| #2023 | decay = _recency_decay(row["timestamp"]) |
| #2024 | # Phase 4: configurable hybrid scoring for episodic memory |
| #2025 | # vec_weight + fts_weight + importance_weight are normalized to sum to 1.0 |
| #2026 | base_score = sim * vw + fts * fw + row["importance"] * iw |
| #2027 | |
| #2028 | # Phase 5: Graph + fact voices (polyphonic recall bonus) |
| #2029 | graph_bonus = 0.0 |
| #2030 | fact_bonus = 0.0 |
| #2031 | binary_bonus = 0.0 |
| #2032 | memory_id = row["id"] |
| #2033 | content_lower = row["content"].lower() |
| #2034 | bv = row["binary_vector"] |
| #2035 | if self.episodic_graph is not None: |
| #2036 | try: |
| #2037 | # Count graph edges for this memory (well-connected = more relevant) |
| #2038 | cursor2 = self.conn.cursor() |
| #2039 | cursor2.execute( |
| #2040 | "SELECT COUNT(*) FROM graph_edges WHERE source LIKE ? OR target LIKE ?", |
| #2041 | (f"%{memory_id}%", f"%{memory_id}%")) |
| #2042 | edge_count = cursor2.fetchone()[0] |
| #2043 | graph_bonus = min(edge_count * 0.02, 0.08) |
| #2044 | except Exception: |
| #2045 | pass |
| #2046 | if self.episodic_graph is not None: |
| #2047 | try: |
| #2048 | # Check if facts from graph match query terms via set-overlap |
| #2049 | cursor2 = self.conn.cursor() |
| #2050 | cursor2.execute( |
| #2051 | "SELECT subject, predicate, object FROM facts WHERE source_msg_id = ?", |
| #2052 | (memory_id,)) |
| #2053 | query_word_set = {w for w in query.lower().split() if len(w) > 2} |
| #2054 | match_count = 0 |
| #2055 | for frow in cursor2.fetchall(): |
| #2056 | fact_tokens = {t.lower() for t in (f"{frow['subject']} {frow['predicate']} {frow['object']}").split() if len(t) > 2} |
| #2057 | if query_word_set & fact_tokens: |
| #2058 | match_count += 1 |
| #2059 | fact_bonus = min(match_count * 0.04, 0.1) |
| #2060 | except Exception: |
| #2061 | pass |
| #2062 | # Binary vector voice (Phase 5): re-enabled — binary vectors are now |
| #2063 | # backfilled for all episodic entries. ITS discriminability improves at |
| #2064 | # scale (1033 entries); clustering concern was for small synthetic sets. |
| #2065 | if query_bv is not None and bv is not None: |
| #2066 | try: |
| #2067 | # Compute hamming distance via XOR + popcount |
| #2068 | q_arr = np.frombuffer(query_bv, dtype=np.uint8) |
| #2069 | m_arr = np.frombuffer(bv, dtype=np.uint8) |
| #2070 | xor_arr = np.bitwise_xor(q_arr, m_arr) |
| #2071 | popcount_table = np.array([bin(i).count('1') for i in range(256)], dtype=np.uint32) |
| #2072 | h_dist = int(np.sum(popcount_table[xor_arr])) |
| #2073 | # Sigmoid: max bonus at distance=0, bonus ~0 at distance=EMBEDDING_DIM |
| #2074 | # Use tanh for smooth falloff; bonus range [0, 0.08] |
| #2075 | normalized_dist = h_dist / EMBEDDING_DIM # 0.0 (identical) to 1.0 (opposite) |
| #2076 | binary_bonus = 0.08 * (1.0 - np.tanh(normalized_dist * 3.0)) |
| #2077 | except Exception: |
| #2078 | binary_bonus = 0.0 |
| #2079 | else: |
| #2080 | binary_bonus = 0.0 |
| #2081 | |
| #2082 | score = base_score * (0.7 + 0.3 * decay) |
| #2083 | score += graph_bonus + fact_bonus + binary_bonus # Phase 5: polyphonic bonuses |
| #2084 | # Temporal boost (Phase 3) |
| #2085 | if temporal_weight > 0.0: |
| #2086 | t_boost = _temporal_boost(row["timestamp"], parsed_query_time, th_halflife) |
| #2087 | score *= (1.0 + temporal_weight * t_boost) |
| #2088 | results.append({ |
| #2089 | "id": row["id"], |
| #2090 | "content": row["content"][:500], |
| #2091 | "source": row["source"], |
| #2092 | "timestamp": row["timestamp"], |
| #2093 | "tier": "episodic", |
| #2094 | "score": round(score, 4), |
| #2095 | "keyword_score": 0.0, |
| #2096 | "dense_score": round(sim, 4), |
| #2097 | "fts_score": round(fts, 4), |
| #2098 | "importance": row["importance"], |
| #2099 | "recall_count": row["recall_count"] or 0, |
| #2100 | "last_recalled": row["last_recalled"], |
| #2101 | "recency_decay": round(decay, 4), |
| #2102 | "scope": row["scope"] if "scope" in row.keys() else "session", |
| #2103 | "author_id": row["author_id"] if "author_id" in row.keys() else None, |
| #2104 | "author_type": row["author_type"] if "author_type" in row.keys() else None, |
| #2105 | "channel_id": row["channel_id"] if "channel_id" in row.keys() else None, |
| #2106 | "valid_until": row["valid_until"] if "valid_until" in row.keys() else None, |
| #2107 | "superseded_by": row["superseded_by"] if "superseded_by" in row.keys() else None |
| #2108 | }) |
| #2109 | |
| #2110 | # Fallback: if no episodic matches from vec/FTS, scan recent episodic entries |
| #2111 | if not episodic_rowids: |
| #2112 | cursor = self.conn.cursor() |
| #2113 | cursor.execute(f""" |
| #2114 | SELECT rowid, id, content, source, timestamp, importance, recall_count, last_recalled, valid_until, superseded_by, scope, author_id, author_type, channel_id, memory_type, binary_vector |
| #2115 | FROM episodic_memory |
| #2116 | WHERE {em_where} |
| #2117 | ORDER BY timestamp DESC |
| #2118 | LIMIT {min(EPISODIC_RECALL_LIMIT, 500)} |
| #2119 | """, em_params) |
| #2120 | for row in cursor.fetchall(): |
| #2121 | content_lower = row["content"].lower() |
| #2122 | content_words_set = set(content_lower.split()) |
| #2123 | # exact: query words appearing as complete tokens in content |
| #2124 | exact = sum(1 for w in query_words if w in content_words_set) |
| #2125 | # partial: unique query words with substring match in content words (set-based, not cartesian) |
| #2126 | partial = sum(1 for w in query_words if len(w) >= 2 and any(w in cw or cw in w for cw in content_words_set if len(cw) >= 2)) |
| #2127 | # cross: query substrings matched against content word substrings (set-based) |
| #2128 | query_substr = {w for w in query_words if len(w) >= 2} |
| #2129 | content_substr = {cw for cw in content_words_set if len(cw) >= 2} |
| #2130 | cross = sum(1 for q in query_substr for c in content_substr if q in c or c in q) |
| #2131 | full_match = 1.0 if query_lower in content_lower else 0.0 |
| #2132 | if not full_match and content_lower in query_lower: |
| #2133 | full_match = 0.5 |
| #2134 | # Character-level overlap for spaceless languages (e.g. Chinese) |
| #2135 | query_chars = set(query_lower) |
| #2136 | content_chars = set(content_lower) |
| #2137 | char_overlap = len(query_chars & content_chars) / max(len(query_chars), 1) if query_chars else 0.0 |
| #2138 | relevance = (exact * 1.0 + partial * 0.3 + cross * 0.5 + full_match + char_overlap * 0.8) / max(len(query_words), 1) |
| #2139 | if relevance > 0.02: |
| #2140 | decay = _recency_decay(row["timestamp"]) |
| #2141 | # Phase 4: configurable scoring for episodic fallback |
| #2142 | kw_share = (1.0 - iw) * 0.6 |
| #2143 | rc_share = (1.0 - iw) * 0.4 |
| #2144 | base_score = relevance * kw_share + row["importance"] * iw |
| #2145 | score = base_score * (rc_share + (1.0 - rc_share) * decay) |
| #2146 | |
| #2147 | # Phase 5: Graph + fact + binary bonuses for fallback |
| #2148 | graph_b = 0.0 |
| #2149 | fact_b = 0.0 |
| #2150 | binary_b = 0.0 |
| #2151 | try: |
| #2152 | cursor2 = self.conn.cursor() |
| #2153 | cursor2.execute( |
| #2154 | "SELECT COUNT(*) FROM graph_edges WHERE source LIKE ? OR target LIKE ?", |
| #2155 | (f"%{row['id']}%", f"%{row['id']}%")) |
| #2156 | graph_b = min(cursor2.fetchone()[0] * 0.02, 0.08) |
| #2157 | except Exception: |
| #2158 | pass |
| #2159 | try: |
| #2160 | cursor2 = self.conn.cursor() |
| #2161 | cursor2.execute( |
| #2162 | "SELECT subject, predicate, object FROM facts WHERE source_msg_id = ?", |
| #2163 | (row["id"],)) |
| #2164 | q_word_set = {w for w in query.lower().split() if len(w) > 2} |
| #2165 | mc = 0 |
| #2166 | for frow in cursor2.fetchall(): |
| #2167 | f_tokens = {t.lower() for t in (f"{frow['subject']} {frow['predicate']} {frow['object']}").split() if len(t) > 2} |
| #2168 | if q_word_set & f_tokens: |
| #2169 | mc += 1 |
| #2170 | fact_b = min(mc * 0.04, 0.1) |
| #2171 | except Exception: |
| #2172 | pass |
| #2173 | # Binary vector bonus disabled (same reason as main path — ITS clustering) |
| #2174 | binary_b = 0.0 |
| #2175 | score += graph_b + fact_b + binary_b |
| #2176 | # Temporal boost (Phase 3) |
| #2177 | if temporal_weight > 0.0: |
| #2178 | t_boost = _temporal_boost(row["timestamp"], parsed_query_time, th_halflife) |
| #2179 | score *= (1.0 + temporal_weight * t_boost) |
| #2180 | results.append({ |
| #2181 | "id": row["id"], |
| #2182 | "content": row["content"][:500], |
| #2183 | "source": row["source"], |
| #2184 | "timestamp": row["timestamp"], |
| #2185 | "tier": "episodic", |
| #2186 | "score": round(score, 4), |
| #2187 | "keyword_score": round(relevance, 4), |
| #2188 | "dense_score": round(wm_vec_sims.get(row["id"], 0.0), 4), |
| #2189 | "fts_score": 0.0, |
| #2190 | "importance": row["importance"], |
| #2191 | "recall_count": row["recall_count"] or 0, |
| #2192 | "last_recalled": row["last_recalled"], |
| #2193 | "recency_decay": round(decay, 4), |
| #2194 | "scope": row["scope"] if "scope" in row.keys() else "session", |
| #2195 | "author_id": row["author_id"] if "author_id" in row.keys() else None, |
| #2196 | "author_type": row["author_type"] if "author_type" in row.keys() else None, |
| #2197 | "channel_id": row["channel_id"] if "channel_id" in row.keys() else None, |
| #2198 | "veracity": row["veracity"] if "veracity" in row.keys() else "unknown", |
| #2199 | "valid_until": row["valid_until"] if "valid_until" in row.keys() else None, |
| #2200 | "superseded_by": row["superseded_by"] if "superseded_by" in row.keys() else None |
| #2201 | }) |
| #2202 | |
| #2203 | # --- Tiered degradation weighting: apply tier multiplier to episodic scores --- |
| #2204 | weight_map = {1: TIER1_WEIGHT, 2: TIER2_WEIGHT, 3: TIER3_WEIGHT} |
| #2205 | veracity_map = {"stated": STATED_WEIGHT, "inferred": INFERRED_WEIGHT, |
| #2206 | "tool": TOOL_WEIGHT, "imported": IMPORTED_WEIGHT, |
| #2207 | "unknown": UNKNOWN_WEIGHT} |
| #2208 | em_ids_for_tier = [r["id"] for r in results if r.get("tier") == "episodic"] |
| #2209 | if em_ids_for_tier: |
| #2210 | placeholders = ",".join("?" * len(em_ids_for_tier)) |
| #2211 | tier_rows = cursor.execute( |
| #2212 | f"SELECT id, tier, veracity FROM episodic_memory WHERE id IN ({placeholders})", |
| #2213 | em_ids_for_tier |
| #2214 | ).fetchall() |
| #2215 | tier_lookup = {r["id"]: (r["tier"] or 1) for r in tier_rows} |
| #2216 | veracity_lookup = {r["id"]: (r["veracity"] or "unknown") for r in tier_rows} |
| #2217 | for r in results: |
| #2218 | if r.get("tier") == "episodic": |
| #2219 | ep_tier = tier_lookup.get(r["id"], 1) |
| #2220 | ep_veracity = veracity_lookup.get(r["id"], "unknown") |
| #2221 | r["degradation_tier"] = ep_tier |
| #2222 | r["veracity"] = ep_veracity |
| #2223 | r["score"] *= weight_map.get(ep_tier, 1.0) |
| #2224 | r["score"] *= veracity_map.get(ep_veracity, UNKNOWN_WEIGHT) |
| #2225 | |
| #2226 | results.sort(key=lambda x: x["score"], reverse=True) |
| #2227 | final_results = results[:top_k] |
| #2228 | |
| #2229 | # --- Recall tracking: increment counts + set last_recalled --- |
| #2230 | now_iso = datetime.now().isoformat() |
| #2231 | wm_ids = [r["id"] for r in final_results if r.get("tier") == "working"] |
| #2232 | em_ids = [r["id"] for r in final_results if r.get("tier") == "episodic"] |
| #2233 | cursor = self.conn.cursor() |
| #2234 | if channel_id: |
| #2235 | rec_scope = "(session_id = ? OR scope = 'global' OR channel_id = ?)" |
| #2236 | elif author_id or author_type: |
| #2237 | rec_scope = "(1=1)" |
| #2238 | else: |
| #2239 | rec_scope = "(session_id = ? OR scope = 'global')" |
| #2240 | if wm_ids: |
| #2241 | placeholders = ",".join("?" * len(wm_ids)) |
| #2242 | rec_params = [now_iso, *tuple(wm_ids)] |
| #2243 | if channel_id: |
| #2244 | rec_params.extend([self.session_id, channel_id]) |
| #2245 | elif not (author_id or author_type): |
| #2246 | rec_params.append(self.session_id) |
| #2247 | cursor.execute(f""" |
| #2248 | UPDATE working_memory |
| #2249 | SET recall_count = recall_count + 1, last_recalled = ? |
| #2250 | WHERE id IN ({placeholders}) AND {rec_scope} |
| #2251 | """, (*rec_params,)) |
| #2252 | if em_ids: |
| #2253 | placeholders = ",".join("?" * len(em_ids)) |
| #2254 | rec_params = [now_iso, *tuple(em_ids)] |
| #2255 | if channel_id: |
| #2256 | rec_params.extend([self.session_id, channel_id]) |
| #2257 | elif not (author_id or author_type): |
| #2258 | rec_params.append(self.session_id) |
| #2259 | cursor.execute(f""" |
| #2260 | UPDATE episodic_memory |
| #2261 | SET recall_count = recall_count + 1, last_recalled = ? |
| #2262 | WHERE id IN ({placeholders}) AND {rec_scope} |
| #2263 | """, (*rec_params,)) |
| #2264 | self.conn.commit() |
| #2265 | |
| #2266 | return final_results |
| #2267 | |
| #2268 | # ── Phase NAI-0: Context Formatting ──────────────────────────── |
| #2269 | |
| #2270 | def _sandwich_order(self, results: List[Dict], top_k: int = 10) -> dict: |
| #2271 | """Sort by score and partition into high/medium/closing for sandwich ordering. |
| #2272 | |
| #2273 | U-shaped attention: LLMs pay most attention to first AND last items. |
| #2274 | High-scored facts go first, medium in the middle, high-scored again at end. |
| #2275 | """ |
| #2276 | scored = sorted(results, key=lambda r: r.get("score", 0), reverse=True) |
| #2277 | high = [r for r in scored if r.get("score", 0) > 0.7][:3] |
| #2278 | medium = [r for r in scored if 0.3 < r.get("score", 0) <= 0.7][:5] |
| #2279 | # Closing: last few high-scored items (not already in high) |
| #2280 | closing_pool = [r for r in scored if r not in high][:3] |
| #2281 | closing = closing_pool if closing_pool else high[:2] |
| #2282 | return {"high": high, "medium": medium, "closing": closing} |
| #2283 | |
| #2284 | def _fact_line(self, result: Dict) -> str: |
| #2285 | """Clean one-line fact: 'User prefers dark mode (2026-05-09, user, c:0.9)'""" |
| #2286 | content = (result.get("content") or "")[:200].strip() |
| #2287 | ts_raw = result.get("timestamp") or "" |
| #2288 | ts = ts_raw[:10] if ts_raw else "?" |
| #2289 | source = result.get("source", "unknown") |
| #2290 | score = result.get("score") or result.get("importance") or 0 |
| #2291 | return f"{content} ({ts}, {source}, c:{score:.1f})" |
| #2292 | |
| #2293 | def format_context(self, results: List[Dict], format: str = "bullet") -> str: |
| #2294 | """Format recall results as structured context for LLM injection. |
| #2295 | |
| #2296 | Args: |
| #2297 | results: List of recall result dicts (from recall() or polyphonic recall) |
| #2298 | format: 'bullet' (default) for markdown bullets, 'json' for structured JSON |
| #2299 | |
| #2300 | Returns: |
| #2301 | Formatted context string ready for LLM prompt injection. |
| #2302 | """ |
| #2303 | sandwich = self._sandwich_order(results) |
| #2304 | |
| #2305 | if format == "json": |
| #2306 | return self._format_context_json(sandwich) |
| #2307 | return self._format_context_bullet(sandwich) |
| #2308 | |
| #2309 | def _format_context_json(self, sandwich: dict) -> str: |
| #2310 | """JSON structured context with sandwich ordering.""" |
| #2311 | import json as _json |
| #2312 | context = { |
| #2313 | "top_facts": [self._fact_line(r) for r in sandwich["high"]], |
| #2314 | "supporting_context": [self._fact_line(r) for r in sandwich["medium"]], |
| #2315 | "recent_memories": [self._fact_line(r) for r in sandwich["closing"]], |
| #2316 | "total_memories": sum(len(v) for v in sandwich.values()), |
| #2317 | } |
| #2318 | return _json.dumps(context, indent=2, ensure_ascii=False) |
| #2319 | |
| #2320 | def _format_context_bullet(self, sandwich: dict) -> str: |
| #2321 | """Bullet-point context with sandwich ordering (U-shaped attention). |
| #2322 | |
| #2323 | Highest-scored first, medium middle, high-scored again at end. |
| #2324 | """ |
| #2325 | lines = [] |
| #2326 | lines.append("## Top Facts") |
| #2327 | for r in sandwich["high"]: |
| #2328 | lines.append(f"- {self._fact_line(r)}") |
| #2329 | if sandwich["medium"]: |
| #2330 | lines.append("") |
| #2331 | lines.append("## Supporting Context") |
| #2332 | for r in sandwich["medium"]: |
| #2333 | lines.append(f"- {self._fact_line(r)}") |
| #2334 | if sandwich["closing"]: |
| #2335 | lines.append("") |
| #2336 | lines.append("## Recent Signals") |
| #2337 | for r in sandwich["closing"]: |
| #2338 | lines.append(f"- {self._fact_line(r)}") |
| #2339 | total = sum(len(v) for v in sandwich.values()) |
| #2340 | lines.append(f"\n_({total} memories retrieved)_") |
| #2341 | return "\n".join(lines) |
| #2342 | |
| #2343 | def fact_recall(self, query: str, top_k: int = 30) -> List[Dict]: |
| #2344 | """Search the facts table (LLM-extracted structured knowledge). |
| #2345 | |
| #2346 | Returns facts as list of dicts with: content, score, fact_id, subject, predicate. |
| #2347 | |
| #2348 | Falls back gracefully if facts table is empty or sqlite-vec unavailable. |
| #2349 | """ |
| #2350 | cursor = self.conn.cursor() |
| #2351 | results = [] |
| #2352 | query_lower = query.lower() |
| #2353 | |
| #2354 | # Try FTS5 search first |
| #2355 | try: |
| #2356 | fts_rows = cursor.execute( |
| #2357 | "SELECT rowid, rank FROM fts_facts WHERE fts_facts MATCH ? ORDER BY rank, rowid LIMIT ?", |
| #2358 | (query, top_k * 3) |
| #2359 | ).fetchall() |
| #2360 | except Exception: |
| #2361 | fts_rows = [] |
| #2362 | |
| #2363 | if not fts_rows: |
| #2364 | # Fallback: simple LIKE scan |
| #2365 | for word in query_lower.split()[:6]: |
| #2366 | if len(word) < 3: |
| #2367 | continue |
| #2368 | try: |
| #2369 | like_rows = cursor.execute( |
| #2370 | "SELECT rowid FROM facts WHERE subject LIKE ? OR predicate LIKE ? OR object LIKE ? LIMIT ?", |
| #2371 | (f"%{word}%", f"%{word}%", f"%{word}%", top_k) |
| #2372 | ).fetchall() |
| #2373 | except Exception: |
| #2374 | continue |
| #2375 | for row in like_rows: |
| #2376 | if row["rowid"] not in {r["rowid"] for r in fts_rows}: |
| #2377 | fts_rows.append({"rowid": row["rowid"], "rank": 0}) |
| #2378 | |
| #2379 | if not fts_rows: |
| #2380 | return [] |
| #2381 | |
| #2382 | # Get full rows for matched fact IDs |
| #2383 | fact_ids = [r["rowid"] for r in fts_rows[:top_k]] |
| #2384 | placeholders = ",".join("?" * len(fact_ids)) |
| #2385 | |
| #2386 | try: |
| #2387 | cursor.execute(f""" |
| #2388 | SELECT fact_id, subject, predicate, object, |
| #2389 | timestamp, confidence |
| #2390 | FROM facts |
| #2391 | WHERE rowid IN ({placeholders}) |
| #2392 | ORDER BY confidence DESC |
| #2393 | LIMIT ? |
| #2394 | """, (*fact_ids, top_k)) |
| #2395 | fact_rows = cursor.fetchall() |
| #2396 | except Exception: |
| #2397 | return [] |
| #2398 | |
| #2399 | for raw_row in fact_rows: |
| #2400 | # sqlite3.Row supports bracket access but not .get(); convert to |
| #2401 | # dict so the column-with-default reads below work. Without this |
| #2402 | # conversion fact_recall crashes the moment the facts table |
| #2403 | # contains rows — a latent bug that was masked while the |
| #2404 | # Mnemosyne.remember(extract=True) wrapper never populated the |
| #2405 | # table (see C12.a). |
| #2406 | row = dict(raw_row) |
| #2407 | confidence = row.get("confidence") |
| #2408 | subject = row.get("subject") |
| #2409 | predicate = row.get("predicate") |
| #2410 | obj = row.get("object") |
| #2411 | fact_text = obj if obj else f"{subject} {predicate} {obj}" |
| #2412 | results.append({ |
| #2413 | "content": fact_text, |
| #2414 | "score": confidence if confidence is not None else 0.5, |
| #2415 | "fact_id": row["fact_id"], |
| #2416 | "subject": subject if subject is not None else "", |
| #2417 | "predicate": predicate if predicate is not None else "", |
| #2418 | }) |
| #2419 | |
| #2420 | return results |
| #2421 | |
| #2422 | def get_episodic_stats(self, author_id: str = None, author_type: str = None, |
| #2423 | channel_id: str = None) -> Dict: |
| #2424 | cursor = self.conn.cursor() |
| #2425 | where_clauses = [] |
| #2426 | params = [] |
| #2427 | if author_id: |
| #2428 | where_clauses.append("author_id = ?") |
| #2429 | params.append(author_id) |
| #2430 | if author_type: |
| #2431 | where_clauses.append("author_type = ?") |
| #2432 | params.append(author_type) |
| #2433 | if channel_id: |
| #2434 | where_clauses.append("channel_id = ?") |
| #2435 | params.append(channel_id) |
| #2436 | where_str = f" WHERE {' AND '.join(where_clauses)}" if where_clauses else "" |
| #2437 | |
| #2438 | cursor.execute(f"SELECT COUNT(*) FROM episodic_memory{where_str}", params) |
| #2439 | total = cursor.fetchone()[0] |
| #2440 | cursor.execute(f"SELECT timestamp FROM episodic_memory{where_str} ORDER BY timestamp DESC LIMIT 1", params) |
| #2441 | last = cursor.fetchone() |
| #2442 | vec_count = 0 |
| #2443 | vec_type = "none" |
| #2444 | if _vec_available(self.conn): |
| #2445 | try: |
| #2446 | vec_count = cursor.execute("SELECT COUNT(*) FROM vec_episodes").fetchone()[0] |
| #2447 | vec_type = _effective_vec_type(self.conn) |
| #2448 | except Exception: |
| #2449 | pass |
| #2450 | return {"total": total, "last": last[0] if last else None, "vectors": vec_count, "vec_type": vec_type} |
| #2451 | |
| #2452 | # ------------------------------------------------------------------ |
| #2453 | # Scratchpad |
| #2454 | # ------------------------------------------------------------------ |
| #2455 | def scratchpad_write(self, content: str) -> str: |
| #2456 | pad_id = _generate_id(content) |
| #2457 | ts = datetime.now().isoformat() |
| #2458 | self.conn.execute(""" |
| #2459 | INSERT INTO scratchpad (id, content, session_id, created_at, updated_at) |
| #2460 | VALUES (?, ?, ?, ?, ?) |
| #2461 | ON CONFLICT(id) DO UPDATE SET content=excluded.content, updated_at=excluded.updated_at |
| #2462 | """, (pad_id, content, self.session_id, ts, ts)) |
| #2463 | self.conn.commit() |
| #2464 | return pad_id |
| #2465 | |
| #2466 | def scratchpad_read(self) -> List[Dict]: |
| #2467 | cursor = self.conn.cursor() |
| #2468 | cursor.execute(f""" |
| #2469 | SELECT id, content, created_at, updated_at |
| #2470 | FROM scratchpad |
| #2471 | WHERE session_id = ? |
| #2472 | ORDER BY updated_at DESC |
| #2473 | LIMIT {SCRATCHPAD_MAX_ITEMS} |
| #2474 | """, (self.session_id,)) |
| #2475 | return [dict(row) for row in cursor.fetchall()] |
| #2476 | |
| #2477 | def scratchpad_clear(self): |
| #2478 | self.conn.execute("DELETE FROM scratchpad WHERE session_id = ?", (self.session_id,)) |
| #2479 | self.conn.commit() |
| #2480 | |
| #2481 | # ------------------------------------------------------------------ |
| #2482 | # Tiered Episodic Degradation |
| #2483 | # ------------------------------------------------------------------ |
| #2484 | def _extract_key_signal(self, content: str, max_chars: int = 300) -> str: |
| #2485 | """Extract the highest-signal sentences from content for tier 3 compression. |
| #2486 | |
| #2487 | Scores each sentence by entity/keyword density (proper nouns, technical |
| #2488 | terms, preference indicators) and keeps top-scoring sentences until the |
| #2489 | character budget is reached. Falls back to first-N-chars if content has |
| #2490 | no clear sentence boundaries. |
| #2491 | """ |
| #2492 | import re |
| #2493 | if len(content) <= max_chars: |
| #2494 | return content |
| #2495 | |
| #2496 | # Split into sentences |
| #2497 | sentences = re.split(r'(?<=[.!?])\s+', content) |
| #2498 | if len(sentences) <= 1: |
| #2499 | # No sentence boundaries — take first max_chars |
| #2500 | return content[:max_chars] + " [...]" |
| #2501 | |
| #2502 | # Scoring patterns |
| #2503 | signal_patterns = [ |
| #2504 | (r'\b[A-Z][a-z]+(?:\s+[A-Z][a-z]+)+\b', 3), # Proper nouns: "GitHub Actions", "Docker Compose" |
| #2505 | (r'\b[A-Z]{2,}\b', 3), # Acronyms: "XKCD", "CI/CD", "API", "AWS" |
| #2506 | (r'\b(Docker|Kubernetes|AWS|GCP|Azure|Terraform|Python|Rust|Go|TypeScript|React|Next\.?js|Node\.?js|SQLite|Postgres|Redis|nginx|systemd|Linux|macOS|Windows)\b', 4), |
| #2507 | (r'\b(prefers?|uses?|likes?|loves?|hates?|dislikes?|wants?|needs?)\b', 2), # Preference indicators |
| #2508 | (r'\b(password|token|secret|key|credential|auth|encrypt|decrypt|private)\b', 3), # Security terms |
| #2509 | (r'\b(production|staging|deploy|database|backup|migration)\b', 2), # Infra terms |
| #2510 | (r'\b(critical|urgent|important|breaking|incident|outage|down)\b', 3), # Urgency |
| #2511 | (r'\b(always|never|every|must|should)\b', 1), # Emphasis words |
| #2512 | (r'\b(\d{1,3}\.\d{1,3}\.\d{1,3})\b', 3), # Version numbers |
| #2513 | (r'\b(https?://|www\.|[a-z]+\.[a-z]{2,})\b', 2), # URLs / domains |
| #2514 | (r'["\'].*?["\']', 1), # Quoted strings |
| #2515 | ] |
| #2516 | |
| #2517 | scored = [] |
| #2518 | for sentence in sentences: |
| #2519 | if not sentence.strip(): |
| #2520 | continue |
| #2521 | score = 0 |
| #2522 | # Bonus for shorter sentences (signal density) |
| #2523 | if len(sentence) < 120: |
| #2524 | score += 1 |
| #2525 | for pattern, weight in signal_patterns: |
| #2526 | score += len(re.findall(pattern, sentence)) * weight |
| #2527 | scored.append((score, sentence)) |
| #2528 | |
| #2529 | # Sort by score descending, keep top sentences up to max_chars |
| #2530 | scored.sort(key=lambda x: x[0], reverse=True) |
| #2531 | result = [] |
| #2532 | used = 0 |
| #2533 | for _, sentence in scored: |
| #2534 | if used + len(sentence) + 1 > max_chars: |
| #2535 | break |
| #2536 | result.append(sentence) |
| #2537 | used += len(sentence) + 1 # +1 for space |
| #2538 | |
| #2539 | if not result: |
| #2540 | return content[:max_chars] + " [...]" |
| #2541 | |
| #2542 | compressed = " ".join(result) |
| #2543 | if len(content) > len(compressed): |
| #2544 | compressed += " [...]" |
| #2545 | return compressed |
| #2546 | |
| #2547 | def _refresh_episodic_embedding(self, memory_id: str, rowid: int, new_content: str): |
| #2548 | """Refresh dense-recall embedding stores for an episodic row whose |
| #2549 | content has been mutated (degraded). Without this the |
| #2550 | vec_episodes / memory_embeddings / binary_vector entries continue |
| #2551 | representing the pre-mutation content, so dense recall scores |
| #2552 | rows by semantics that no longer match what the row displays. |
| #2553 | See C18.b in the memory-contract ledger. |
| #2554 | |
| #2555 | - If embeddings provider is available: regenerate using the new |
| #2556 | content and overwrite the existing vector store entries. |
| #2557 | - If unavailable: invalidate (DELETE / NULL) the stale entries so |
| #2558 | dense recall stops returning semantically misleading hits. The |
| #2559 | row remains discoverable via FTS. |
| #2560 | """ |
| #2561 | cursor = self.conn.cursor() |
| #2562 | |
| #2563 | vec_available_now = _vec_available(self.conn) |
| #2564 | |
| #2565 | if _embeddings.available(): |
| #2566 | try: |
| #2567 | vec = _embeddings.embed([new_content]) |
| #2568 | except Exception: |
| #2569 | vec = None |
| #2570 | if vec is not None: |
| #2571 | # vec_episodes is a sqlite-vec virtual table; vec0 doesn't |
| #2572 | # support UPDATE on the embedding column reliably, so we |
| #2573 | # DELETE+INSERT to refresh. |
| #2574 | if vec_available_now: |
| #2575 | cursor.execute("DELETE FROM vec_episodes WHERE rowid = ?", (rowid,)) |
| #2576 | _vec_insert(self.conn, rowid, vec[0].tolist()) |
| #2577 | else: |
| #2578 | cursor.execute(""" |
| #2579 | INSERT OR REPLACE INTO memory_embeddings (memory_id, embedding_json, model) |
| #2580 | VALUES (?, ?, ?) |
| #2581 | """, (memory_id, _embeddings.serialize(vec[0]), _embeddings._DEFAULT_MODEL)) |
| #2582 | |
| #2583 | if _mib is not None: |
| #2584 | try: |
| #2585 | bv = _mib(vec[0]) |
| #2586 | cursor.execute( |
| #2587 | "UPDATE episodic_memory SET binary_vector = ? WHERE id = ?", |
| #2588 | (bv, memory_id), |
| #2589 | ) |
| #2590 | except Exception: |
| #2591 | pass |
| #2592 | return |
| #2593 | |
| #2594 | # Provider unavailable (or embed() returned None). Invalidate the |
| #2595 | # stale entries so dense recall doesn't lie. The row keeps its |
| #2596 | # FTS-searchable content and remains otherwise intact. Each DELETE |
| #2597 | # is gated on the matching store's availability — vec_episodes is |
| #2598 | # a sqlite-vec virtual table that doesn't exist when the extension |
| #2599 | # isn't loaded, so an unconditional DELETE there raises |
| #2600 | # OperationalError and the caller's broad except would silently |
| #2601 | # skip the memory_embeddings cleanup too. |
| #2602 | if vec_available_now: |
| #2603 | cursor.execute("DELETE FROM vec_episodes WHERE rowid = ?", (rowid,)) |
| #2604 | cursor.execute("DELETE FROM memory_embeddings WHERE memory_id = ?", (memory_id,)) |
| #2605 | if _mib is not None: |
| #2606 | cursor.execute( |
| #2607 | "UPDATE episodic_memory SET binary_vector = NULL WHERE id = ?", |
| #2608 | (memory_id,), |
| #2609 | ) |
| #2610 | |
| #2611 | def degrade_episodic(self, dry_run: bool = False) -> Dict: |
| #2612 | """Degrade old episodic memories through tier 1→2→3 compression. |
| #2613 | |
| #2614 | Tier 1 (0-TIER2_DAYS): Full detail, 1.0x recall weight |
| #2615 | Tier 2 (TIER2_DAYS-TIER3_DAYS): LLM-summarized, 0.5x weight |
| #2616 | Tier 3 (TIER3_DAYS+): Text extraction compressed, 0.25x weight |
| #2617 | |
| #2618 | Each tier transition that mutates content also refreshes the |
| #2619 | row's dense-recall embedding (or invalidates it if the embeddings |
| #2620 | provider is unavailable) so vec_episodes / memory_embeddings / |
| #2621 | binary_vector stay aligned with the displayed text. See C18.b. |
| #2622 | |
| #2623 | Returns summary of tier transitions performed. |
| #2624 | """ |
| #2625 | cursor = self.conn.cursor() |
| #2626 | now = datetime.now() |
| #2627 | results = {"status": "dry_run" if dry_run else "degraded", |
| #2628 | "tier1_to_tier2": 0, "tier2_to_tier3": 0} |
| #2629 | |
| #2630 | # --- Find candidates for degradation --- |
| #2631 | tier2_cutoff = (now - timedelta(days=TIER2_DAYS)).isoformat() |
| #2632 | tier3_cutoff = (now - timedelta(days=TIER3_DAYS)).isoformat() |
| #2633 | |
| #2634 | # Tier 1 → Tier 2: old enough, still at tier 1. |
| #2635 | # rowid is selected so the embedding refresh can address vec_episodes. |
| #2636 | cursor.execute(""" |
| #2637 | SELECT id, rowid, content, importance FROM episodic_memory |
| #2638 | WHERE tier = 1 AND created_at < ? |
| #2639 | ORDER BY created_at ASC LIMIT ? |
| #2640 | """, (tier2_cutoff, DEGRADE_BATCH_SIZE)) |
| #2641 | tier1_rows = cursor.fetchall() |
| #2642 | |
| #2643 | # Tier 2 → Tier 3: very old, at tier 2 |
| #2644 | cursor.execute(""" |
| #2645 | SELECT id, rowid, content FROM episodic_memory |
| #2646 | WHERE tier = 2 AND created_at < ? |
| #2647 | ORDER BY created_at ASC LIMIT ? |
| #2648 | """, (tier3_cutoff, DEGRADE_BATCH_SIZE // 2)) |
| #2649 | tier2_rows = cursor.fetchall() |
| #2650 | |
| #2651 | if dry_run: |
| #2652 | results["tier1_to_tier2"] = len(tier1_rows) |
| #2653 | results["tier2_to_tier3"] = len(tier2_rows) |
| #2654 | return results |
| #2655 | |
| #2656 | # --- Degrade tier 1 → tier 2: LLM summarization --- |
| #2657 | # Each row's UPDATE + embedding refresh runs inside a SAVEPOINT so |
| #2658 | # a refresh failure rolls back the content mutation too. Without |
| #2659 | # this the broad except below would swallow the refresh exception |
| #2660 | # while leaving the UPDATE staged in the implicit transaction, |
| #2661 | # which then commits at the end of degrade_episodic — producing |
| #2662 | # the very content/embedding drift this fix exists to prevent |
| #2663 | # (caught by /review for C18.b). |
| #2664 | from mnemosyne.core import local_llm |
| #2665 | for row in tier1_rows: |
| #2666 | cursor.execute("SAVEPOINT degrade_row") |
| #2667 | try: |
| #2668 | compressed = row["content"] |
| #2669 | if local_llm.llm_available() and len(row["content"]) > 300: |
| #2670 | summary = local_llm.summarize_memories([row["content"]]) |
| #2671 | if summary: |
| #2672 | compressed = summary[:400] |
| #2673 | final_content = compressed[:800] |
| #2674 | cursor.execute( |
| #2675 | "UPDATE episodic_memory SET content = ?, tier = 2, degraded_at = ? WHERE id = ?", |
| #2676 | (final_content, now.isoformat(), row["id"]) |
| #2677 | ) |
| #2678 | # Only refresh the embedding when content actually changed. |
| #2679 | # If LLM was unavailable and content is unchanged the |
| #2680 | # existing embedding is already correct and an embed() |
| #2681 | # call would be wasted. |
| #2682 | if final_content != row["content"]: |
| #2683 | self._refresh_episodic_embedding(row["id"], row["rowid"], final_content) |
| #2684 | cursor.execute("RELEASE degrade_row") |
| #2685 | results["tier1_to_tier2"] += 1 |
| #2686 | except Exception: |
| #2687 | try: |
| #2688 | cursor.execute("ROLLBACK TO degrade_row") |
| #2689 | cursor.execute("RELEASE degrade_row") |
| #2690 | except Exception: |
| #2691 | pass |
| #2692 | |
| #2693 | # --- Degrade tier 2 → tier 3: smart extraction (keep key entities) --- |
| #2694 | for row in tier2_rows: |
| #2695 | cursor.execute("SAVEPOINT degrade_row") |
| #2696 | try: |
| #2697 | content = row["content"] |
| #2698 | if SMART_COMPRESS and len(content) > TIER3_MAX_CHARS: |
| #2699 | compressed = self._extract_key_signal(content, max_chars=TIER3_MAX_CHARS) |
| #2700 | else: |
| #2701 | compressed = content[:TIER3_MAX_CHARS] |
| #2702 | if len(content) > TIER3_MAX_CHARS: |
| #2703 | compressed += " [...]" |
| #2704 | cursor.execute( |
| #2705 | "UPDATE episodic_memory SET content = ?, tier = 3, degraded_at = ? WHERE id = ?", |
| #2706 | (compressed, now.isoformat(), row["id"]) |
| #2707 | ) |
| #2708 | if compressed != row["content"]: |
| #2709 | self._refresh_episodic_embedding(row["id"], row["rowid"], compressed) |
| #2710 | cursor.execute("RELEASE degrade_row") |
| #2711 | results["tier2_to_tier3"] += 1 |
| #2712 | except Exception: |
| #2713 | try: |
| #2714 | cursor.execute("ROLLBACK TO degrade_row") |
| #2715 | cursor.execute("RELEASE degrade_row") |
| #2716 | except Exception: |
| #2717 | pass |
| #2718 | |
| #2719 | self.conn.commit() |
| #2720 | return results |
| #2721 | |
| #2722 | def get_contaminated(self, limit: int = 50, min_importance: float = 0.0) -> List[Dict]: |
| #2723 | """Return potentially contaminated memories for review. |
| #2724 | |
| #2725 | Contaminated = veracity in ('inferred', 'tool', 'imported', 'unknown') |
| #2726 | — i.e., anything not explicitly stated by the user. Sorted by |
| #2727 | importance descending so the highest-stakes items surface first. |
| #2728 | |
| #2729 | Args: |
| #2730 | limit: Max memories to return |
| #2731 | min_importance: Only return memories with importance >= this |
| #2732 | """ |
| #2733 | cursor = self.conn.cursor() |
| #2734 | cursor.execute(""" |
| #2735 | SELECT id, content, source, veracity, tier, importance, |
| #2736 | created_at, degraded_at, session_id |
| #2737 | FROM episodic_memory |
| #2738 | WHERE veracity IN ('inferred', 'tool', 'imported', 'unknown') |
| #2739 | AND importance >= ? |
| #2740 | ORDER BY importance DESC, created_at DESC |
| #2741 | LIMIT ? |
| #2742 | """, (min_importance, limit)) |
| #2743 | return [dict(row) for row in cursor.fetchall()] |
| #2744 | |
| #2745 | # ------------------------------------------------------------------ |
| #2746 | # Consolidation / Sleep |
| #2747 | # ------------------------------------------------------------------ |
| #2748 | def sleep(self, dry_run: bool = False) -> Dict: |
| #2749 | """ |
| #2750 | Consolidate old working_memory for this session into episodic summaries. |
| #2751 | Uses a local lightweight LLM when available; falls back to aaak |
| #2752 | compression if the model is missing or inference fails. |
| #2753 | Returns summary of what was done. |
| #2754 | |
| #2755 | Note: this method intentionally remains session-scoped. Use |
| #2756 | sleep_all_sessions() for maintenance that consolidates eligible old |
| #2757 | working memories across inactive sessions. |
| #2758 | """ |
| #2759 | from mnemosyne.core.aaak import encode as aaak_encode |
| #2760 | from mnemosyne.core import local_llm |
| #2761 | |
| #2762 | cursor = self.conn.cursor() |
| #2763 | cutoff = (datetime.now() - timedelta(hours=WORKING_MEMORY_TTL_HOURS // 2)).isoformat() |
| #2764 | # COALESCE(session_id, 'default') so a "default"-session beam also |
| #2765 | # consolidates rows with literal NULL session_id (which can land |
| #2766 | # via imports or schema migrations). Without the COALESCE these |
| #2767 | # NULL-session rows are stranded — sleep_all_sessions's GROUP BY |
| #2768 | # collects them as a NULL group, maps to "default" for the loop, |
| #2769 | # then beam.sleep("default") would query session_id = 'default' |
| #2770 | # and miss the NULL rows. See Codex /review note for C9. |
| #2771 | cursor.execute(f""" |
| #2772 | SELECT id, content, source, timestamp, importance, metadata_json, scope, valid_until |
| #2773 | FROM working_memory |
| #2774 | WHERE COALESCE(session_id, 'default') = ? AND timestamp < ? |
| #2775 | ORDER BY timestamp ASC |
| #2776 | LIMIT {SLEEP_BATCH_SIZE} |
| #2777 | """, (self.session_id, cutoff)) |
| #2778 | rows = cursor.fetchall() |
| #2779 | if not rows: |
| #2780 | return {"status": "no_op", "message": "No old working memories to consolidate"} |
| #2781 | |
| #2782 | grouped: Dict[str, List[Dict]] = {} |
| #2783 | for row in rows: |
| #2784 | grouped.setdefault(row["source"], []).append(dict(row)) |
| #2785 | |
| #2786 | consolidated_ids = [] |
| #2787 | summaries_created = 0 |
| #2788 | llm_used_count = 0 |
| #2789 | for source, items in grouped.items(): |
| #2790 | lines = [item["content"] for item in items] |
| #2791 | ids = [item["id"] for item in items] |
| #2792 | |
| #2793 | # Aggregate scope: if ANY item is global, the summary is global |
| #2794 | aggregated_scope = "session" |
| #2795 | aggregated_valid_until = None |
| #2796 | for item in items: |
| #2797 | if item.get("scope") == "global": |
| #2798 | aggregated_scope = "global" |
| #2799 | if item.get("valid_until"): |
| #2800 | if aggregated_valid_until is None or item["valid_until"] < aggregated_valid_until: |
| #2801 | aggregated_valid_until = item["valid_until"] |
| #2802 | |
| #2803 | # --- Try LLM summarization (chunked to fit context) --- |
| #2804 | summary = None |
| #2805 | llm_succeeded = False |
| #2806 | if local_llm.llm_available(): |
| #2807 | chunks = local_llm.chunk_memories_by_budget(lines, source=source) |
| #2808 | if chunks: |
| #2809 | if len(chunks) == 1: |
| #2810 | # All memories fit in one prompt |
| #2811 | summary = local_llm.summarize_memories(chunks[0], source=source) |
| #2812 | else: |
| #2813 | # Multi-chunk: summarize each chunk, then summarize the summaries |
| #2814 | chunk_summaries = [] |
| #2815 | for chunk in chunks: |
| #2816 | chunk_summary = local_llm.summarize_memories(chunk, source=source) |
| #2817 | if chunk_summary: |
| #2818 | chunk_summaries.append(chunk_summary) |
| #2819 | if chunk_summaries: |
| #2820 | # Second-pass: summarize the chunk summaries |
| #2821 | if len(chunk_summaries) == 1: |
| #2822 | summary = chunk_summaries[0] |
| #2823 | else: |
| #2824 | summary = local_llm.summarize_memories( |
| #2825 | chunk_summaries, |
| #2826 | source=f"{source} (consolidated)" |
| #2827 | ) |
| #2828 | # If second-pass also overflows, concatenate |
| #2829 | if not summary: |
| #2830 | summary = " | ".join(chunk_summaries) |
| #2831 | if summary: |
| #2832 | llm_used_count += 1 |
| #2833 | llm_succeeded = True |
| #2834 | |
| #2835 | # --- Fallback to aaak encoding --- |
| #2836 | if summary is None: |
| #2837 | combined = " | ".join(lines) |
| #2838 | compressed = aaak_encode(combined) |
| #2839 | summary = f"[{source}] {compressed}" |
| #2840 | |
| #2841 | if not dry_run: |
| #2842 | self.consolidate_to_episodic( |
| #2843 | summary=summary, |
| #2844 | source_wm_ids=ids, |
| #2845 | source="sleep_consolidation", |
| #2846 | importance=0.6, |
| #2847 | scope=aggregated_scope, |
| #2848 | valid_until=aggregated_valid_until, |
| #2849 | metadata={ |
| #2850 | "original_count": len(items), |
| #2851 | "source": source, |
| #2852 | "llm_used": llm_succeeded |
| #2853 | } |
| #2854 | ) |
| #2855 | placeholders = ",".join("?" * len(ids)) |
| #2856 | cursor.execute(f"DELETE FROM working_memory WHERE id IN ({placeholders})", ids) |
| #2857 | self.conn.commit() |
| #2858 | consolidated_ids.extend(ids) |
| #2859 | summaries_created += 1 |
| #2860 | |
| #2861 | method = "llm" if llm_used_count == summaries_created else ("llm+aaak" if llm_used_count > 0 else "aaak") |
| #2862 | if not dry_run: |
| #2863 | cursor.execute(""" |
| #2864 | INSERT INTO consolidation_log (session_id, items_consolidated, summary_preview) |
| #2865 | VALUES (?, ?, ?) |
| #2866 | """, (self.session_id, len(consolidated_ids), f"{summaries_created} summaries ({method}) from {len(consolidated_ids)} items")) |
| #2867 | self.conn.commit() |
| #2868 | |
| #2869 | # Run tiered degradation after consolidation |
| #2870 | degrade_result = self.degrade_episodic(dry_run=dry_run) |
| #2871 | |
| #2872 | return { |
| #2873 | "status": "dry_run" if dry_run else "consolidated", |
| #2874 | "items_consolidated": len(consolidated_ids), |
| #2875 | "summaries_created": summaries_created, |
| #2876 | "llm_used": llm_used_count, |
| #2877 | "method": method, |
| #2878 | "consolidated_ids": consolidated_ids, |
| #2879 | "degradation": degrade_result |
| #2880 | } |
| #2881 | |
| #2882 | def sleep_all_sessions(self, dry_run: bool = False) -> Dict: |
| #2883 | """ |
| #2884 | Consolidate eligible old working memories across all sessions. |
| #2885 | |
| #2886 | This is the maintenance-oriented counterpart to sleep(), which remains |
| #2887 | scoped to self.session_id. It prevents inactive sessions from leaving |
| #2888 | old working_memory rows stranded after they pass the sleep cutoff. |
| #2889 | """ |
| #2890 | cursor = self.conn.cursor() |
| #2891 | cutoff = (datetime.now() - timedelta(hours=WORKING_MEMORY_TTL_HOURS // 2)).isoformat() |
| #2892 | cursor.execute(""" |
| #2893 | SELECT session_id, COUNT(*) AS eligible |
| #2894 | FROM working_memory |
| #2895 | WHERE timestamp < ? |
| #2896 | GROUP BY session_id |
| #2897 | ORDER BY MIN(timestamp) ASC |
| #2898 | """, (cutoff,)) |
| #2899 | session_rows = cursor.fetchall() |
| #2900 | if not session_rows: |
| #2901 | return { |
| #2902 | "status": "no_op", |
| #2903 | "message": "No old working memories to consolidate", |
| #2904 | "sessions_scanned": 0, |
| #2905 | "sessions_consolidated": 0, |
| #2906 | "items_consolidated": 0, |
| #2907 | "summaries_created": 0, |
| #2908 | "llm_used": 0, |
| #2909 | "errors": 0, |
| #2910 | "session_results": [], |
| #2911 | } |
| #2912 | |
| #2913 | session_results = [] |
| #2914 | sessions_consolidated = 0 |
| #2915 | items_consolidated = 0 |
| #2916 | summaries_created = 0 |
| #2917 | llm_used = 0 |
| #2918 | errors = [] |
| #2919 | |
| #2920 | for row in session_rows: |
| #2921 | session_id = row["session_id"] if hasattr(row, "keys") else row[0] |
| #2922 | if session_id is None: |
| #2923 | session_id = "default" |
| #2924 | try: |
| #2925 | # Pass author_id/author_type so the alien-session BeamMemory |
| #2926 | # tags consolidated episodic rows with the caller's authorship |
| #2927 | # (e.g. a maintenance bot can audit-recall its own work). |
| #2928 | # |
| #2929 | # channel_id is intentionally NOT propagated. BeamMemory.__init__ |
| #2930 | # defaults channel_id to its own session_id when None — passing |
| #2931 | # self.channel_id (which may itself be the caller's defaulted |
| #2932 | # session_id) would tag alien rows with the caller's channel, |
| #2933 | # creating cross-session pollution where filter by |
| #2934 | # channel_id=caller surfaces alien content. Letting it default |
| #2935 | # to the alien session_id is the semantically correct behavior. |
| #2936 | # See C9 + adversarial review in the memory-contract ledger. |
| #2937 | beam = self if session_id == self.session_id else BeamMemory( |
| #2938 | session_id=session_id, |
| #2939 | db_path=self.db_path, |
| #2940 | author_id=self.author_id, |
| #2941 | author_type=self.author_type, |
| #2942 | ) |
| #2943 | result = beam.sleep(dry_run=dry_run) |
| #2944 | result = dict(result) |
| #2945 | result["session_id"] = session_id |
| #2946 | result["eligible"] = row["eligible"] if hasattr(row, "keys") else row[1] |
| #2947 | session_results.append(result) |
| #2948 | |
| #2949 | if result.get("status") in ("consolidated", "dry_run"): |
| #2950 | sessions_consolidated += 1 |
| #2951 | items_consolidated += int(result.get("items_consolidated", 0) or 0) |
| #2952 | summaries_created += int(result.get("summaries_created", 0) or 0) |
| #2953 | llm_used += int(result.get("llm_used", 0) or 0) |
| #2954 | except Exception as exc: |
| #2955 | errors.append({"session_id": session_id, "error": repr(exc)}) |
| #2956 | |
| #2957 | # Run tiered degradation after all-sessions consolidation |
| #2958 | degrade_result = self.degrade_episodic(dry_run=dry_run) |
| #2959 | |
| #2960 | return { |
| #2961 | "status": "dry_run" if dry_run else ("consolidated" if items_consolidated else "no_op"), |
| #2962 | "sessions_scanned": len(session_rows), |
| #2963 | "sessions_consolidated": sessions_consolidated, |
| #2964 | "items_consolidated": items_consolidated, |
| #2965 | "summaries_created": summaries_created, |
| #2966 | "llm_used": llm_used, |
| #2967 | "errors": len(errors), |
| #2968 | "error_details": errors, |
| #2969 | "session_results": session_results, |
| #2970 | "degradation": degrade_result |
| #2971 | } |
| #2972 | |
| #2973 | def get_consolidation_log(self, limit: int = 10) -> List[Dict]: |
| #2974 | cursor = self.conn.cursor() |
| #2975 | cursor.execute(""" |
| #2976 | SELECT id, session_id, items_consolidated, summary_preview, created_at |
| #2977 | FROM consolidation_log |
| #2978 | WHERE session_id = ? |
| #2979 | ORDER BY created_at DESC |
| #2980 | LIMIT ? |
| #2981 | """, (self.session_id, limit)) |
| #2982 | return [dict(row) for row in cursor.fetchall()] |
| #2983 | |
| #2984 | # ------------------------------------------------------------------ |
| #2985 | # Export / Import |
| #2986 | # ------------------------------------------------------------------ |
| #2987 | def export_to_dict(self) -> Dict: |
| #2988 | """ |
| #2989 | Export all BEAM data to a portable dictionary. |
| #2990 | Includes working_memory, episodic_memory, embeddings, scratchpad, |
| #2991 | and consolidation_log across ALL sessions (not just current). |
| #2992 | """ |
| #2993 | cursor = self.conn.cursor() |
| #2994 | export = { |
| #2995 | "mnemosyne_export": { |
| #2996 | "version": "1.0", |
| #2997 | "export_date": datetime.now().isoformat(), |
| #2998 | "source_db": str(self.db_path), |
| #2999 | "component": "beam" |
| #3000 | } |
| #3001 | } |
| #3002 | |
| #3003 | # Working memory (all sessions) |
| #3004 | cursor.execute(""" |
| #3005 | SELECT id, content, source, timestamp, session_id, importance, |
| #3006 | metadata_json, valid_until, superseded_by, scope, |
| #3007 | recall_count, last_recalled, created_at |
| #3008 | FROM working_memory |
| #3009 | ORDER BY session_id, timestamp |
| #3010 | """) |
| #3011 | export["working_memory"] = [dict(row) for row in cursor.fetchall()] |
| #3012 | |
| #3013 | # Episodic memory (all sessions) |
| #3014 | cursor.execute(""" |
| #3015 | SELECT rowid, id, content, source, timestamp, session_id, importance, |
| #3016 | metadata_json, summary_of, valid_until, superseded_by, scope, |
| #3017 | recall_count, last_recalled, created_at |
| #3018 | FROM episodic_memory |
| #3019 | ORDER BY session_id, timestamp |
| #3020 | """) |
| #3021 | export["episodic_memory"] = [dict(row) for row in cursor.fetchall()] |
| #3022 | |
| #3023 | # Episodic embeddings from vec_episodes |
| #3024 | export["episodic_embeddings"] = [] |
| #3025 | if _vec_available(self.conn): |
| #3026 | try: |
| #3027 | cursor.execute("SELECT rowid, embedding FROM vec_episodes") |
| #3028 | for row in cursor.fetchall(): |
| #3029 | emb = row["embedding"] |
| #3030 | if isinstance(emb, bytes): |
| #3031 | emb = list(emb) |
| #3032 | elif isinstance(emb, str): |
| #3033 | try: |
| #3034 | emb = json.loads(emb) |
| #3035 | except Exception: |
| #3036 | pass |
| #3037 | export["episodic_embeddings"].append({ |
| #3038 | "rowid": row["rowid"], |
| #3039 | "embedding": emb |
| #3040 | }) |
| #3041 | except Exception: |
| #3042 | pass |
| #3043 | |
| #3044 | # Scratchpad (all sessions) |
| #3045 | cursor.execute(""" |
| #3046 | SELECT id, content, session_id, created_at, updated_at |
| #3047 | FROM scratchpad |
| #3048 | ORDER BY session_id, updated_at |
| #3049 | """) |
| #3050 | export["scratchpad"] = [dict(row) for row in cursor.fetchall()] |
| #3051 | |
| #3052 | # Consolidation log (all sessions) |
| #3053 | cursor.execute(""" |
| #3054 | SELECT id, session_id, items_consolidated, summary_preview, created_at |
| #3055 | FROM consolidation_log |
| #3056 | ORDER BY session_id, created_at |
| #3057 | """) |
| #3058 | export["consolidation_log"] = [dict(row) for row in cursor.fetchall()] |
| #3059 | |
| #3060 | return export |
| #3061 | |
| #3062 | def import_from_dict(self, data: Dict, force: bool = False) -> Dict: |
| #3063 | """ |
| #3064 | Import BEAM data from a dictionary produced by export_to_dict(). |
| #3065 | Idempotent by default: skips records whose id already exists. |
| #3066 | Set force=True to overwrite existing records. |
| #3067 | Returns import statistics. |
| #3068 | """ |
| #3069 | stats = { |
| #3070 | "working_memory": {"inserted": 0, "skipped": 0, "overwritten": 0}, |
| #3071 | "episodic_memory": {"inserted": 0, "skipped": 0, "overwritten": 0, "embeddings_inserted": 0}, |
| #3072 | "scratchpad": {"inserted": 0, "updated": 0}, |
| #3073 | "consolidation_log": {"inserted": 0}, |
| #3074 | } |
| #3075 | cursor = self.conn.cursor() |
| #3076 | |
| #3077 | # -- Working memory -- |
| #3078 | for item in data.get("working_memory", []): |
| #3079 | mid = item.get("id") |
| #3080 | cursor.execute("SELECT 1 FROM working_memory WHERE id = ?", (mid,)) |
| #3081 | exists = cursor.fetchone() is not None |
| #3082 | if exists and not force: |
| #3083 | stats["working_memory"]["skipped"] += 1 |
| #3084 | continue |
| #3085 | if exists and force: |
| #3086 | cursor.execute("DELETE FROM working_memory WHERE id = ?", (mid,)) |
| #3087 | stats["working_memory"]["overwritten"] += 1 |
| #3088 | else: |
| #3089 | stats["working_memory"]["inserted"] += 1 |
| #3090 | cursor.execute(""" |
| #3091 | INSERT INTO working_memory |
| #3092 | (id, content, source, timestamp, session_id, importance, metadata_json, |
| #3093 | valid_until, superseded_by, scope, recall_count, last_recalled, created_at) |
| #3094 | VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) |
| #3095 | """, ( |
| #3096 | mid, item.get("content"), item.get("source"), item.get("timestamp"), |
| #3097 | item.get("session_id", "default"), item.get("importance", 0.5), |
| #3098 | item.get("metadata_json", "{}"), item.get("valid_until"), |
| #3099 | item.get("superseded_by"), item.get("scope", "session"), |
| #3100 | item.get("recall_count", 0), item.get("last_recalled"), item.get("created_at") |
| #3101 | )) |
| #3102 | self.conn.commit() |
| #3103 | |
| #3104 | # -- Episodic memory -- |
| #3105 | old_to_new_rowid = {} |
| #3106 | for item in data.get("episodic_memory", []): |
| #3107 | mid = item.get("id") |
| #3108 | cursor.execute("SELECT rowid FROM episodic_memory WHERE id = ?", (mid,)) |
| #3109 | existing = cursor.fetchone() |
| #3110 | if existing and not force: |
| #3111 | stats["episodic_memory"]["skipped"] += 1 |
| #3112 | old_to_new_rowid[item.get("rowid")] = existing["rowid"] |
| #3113 | continue |
| #3114 | if existing and force: |
| #3115 | cursor.execute("DELETE FROM episodic_memory WHERE id = ?", (mid,)) |
| #3116 | stats["episodic_memory"]["overwritten"] += 1 |
| #3117 | else: |
| #3118 | stats["episodic_memory"]["inserted"] += 1 |
| #3119 | cursor.execute(""" |
| #3120 | INSERT INTO episodic_memory |
| #3121 | (id, content, source, timestamp, session_id, importance, metadata_json, |
| #3122 | summary_of, valid_until, superseded_by, scope, recall_count, last_recalled, created_at) |
| #3123 | VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) |
| #3124 | """, ( |
| #3125 | mid, item.get("content"), item.get("source"), item.get("timestamp"), |
| #3126 | item.get("session_id", "default"), item.get("importance", 0.5), |
| #3127 | item.get("metadata_json", "{}"), item.get("summary_of", ""), |
| #3128 | item.get("valid_until"), item.get("superseded_by"), |
| #3129 | item.get("scope", "session"), item.get("recall_count", 0), |
| #3130 | item.get("last_recalled"), item.get("created_at") |
| #3131 | )) |
| #3132 | new_rowid = cursor.lastrowid |
| #3133 | old_to_new_rowid[item.get("rowid")] = new_rowid |
| #3134 | self.conn.commit() |
| #3135 | |
| #3136 | # -- Episodic embeddings -- |
| #3137 | vec_ok = _vec_available(self.conn) |
| #3138 | for emb_item in data.get("episodic_embeddings", []): |
| #3139 | old_rowid = emb_item.get("rowid") |
| #3140 | new_rowid = old_to_new_rowid.get(old_rowid) |
| #3141 | if not new_rowid: |
| #3142 | continue |
| #3143 | embedding = emb_item.get("embedding") |
| #3144 | if not embedding: |
| #3145 | continue |
| #3146 | if vec_ok: |
| #3147 | try: |
| #3148 | _vec_insert(self.conn, new_rowid, embedding) |
| #3149 | stats["episodic_memory"]["embeddings_inserted"] += 1 |
| #3150 | except Exception: |
| #3151 | pass |
| #3152 | if vec_ok: |
| #3153 | self.conn.commit() |
| #3154 | |
| #3155 | # -- Scratchpad -- |
| #3156 | for item in data.get("scratchpad", []): |
| #3157 | pid = item.get("id") |
| #3158 | cursor.execute("SELECT 1 FROM scratchpad WHERE id = ?", (pid,)) |
| #3159 | exists = cursor.fetchone() is not None |
| #3160 | if exists: |
| #3161 | cursor.execute(""" |
| #3162 | UPDATE scratchpad SET content=?, session_id=?, created_at=?, updated_at=? |
| #3163 | WHERE id=? |
| #3164 | """, (item.get("content"), item.get("session_id", "default"), |
| #3165 | item.get("created_at"), item.get("updated_at"), pid)) |
| #3166 | stats["scratchpad"]["updated"] += 1 |
| #3167 | else: |
| #3168 | cursor.execute(""" |
| #3169 | INSERT INTO scratchpad (id, content, session_id, created_at, updated_at) |
| #3170 | VALUES (?, ?, ?, ?, ?) |
| #3171 | """, (pid, item.get("content"), item.get("session_id", "default"), |
| #3172 | item.get("created_at"), item.get("updated_at"))) |
| #3173 | stats["scratchpad"]["inserted"] += 1 |
| #3174 | self.conn.commit() |
| #3175 | |
| #3176 | # -- Consolidation log -- |
| #3177 | for item in data.get("consolidation_log", []): |
| #3178 | cursor.execute(""" |
| #3179 | INSERT INTO consolidation_log (session_id, items_consolidated, summary_preview, created_at) |
| #3180 | VALUES (?, ?, ?, ?) |
| #3181 | """, (item.get("session_id", "default"), item.get("items_consolidated", 0), |
| #3182 | item.get("summary_preview", ""), item.get("created_at"))) |
| #3183 | stats["consolidation_log"]["inserted"] += 1 |
| #3184 | self.conn.commit() |
| #3185 | |
| #3186 | return stats |
| #3187 |