loop-memory 0.4.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (84) hide show
  1. loop_memory/__init__.py +62 -0
  2. loop_memory/backends/__init__.py +13 -0
  3. loop_memory/backends/embedding.py +82 -0
  4. loop_memory/backends/sentence_embedder.py +30 -0
  5. loop_memory/backends/vector_store.py +139 -0
  6. loop_memory/cli/__init__.py +0 -0
  7. loop_memory/cli/_common.py +68 -0
  8. loop_memory/cli/commands/__init__.py +13 -0
  9. loop_memory/cli/commands/cognitive.py +205 -0
  10. loop_memory/cli/commands/diag.py +346 -0
  11. loop_memory/cli/commands/graph.py +21 -0
  12. loop_memory/cli/commands/hooks.py +212 -0
  13. loop_memory/cli/commands/read.py +362 -0
  14. loop_memory/cli/commands/serve.py +147 -0
  15. loop_memory/cli/commands/write.py +138 -0
  16. loop_memory/cli/main.py +115 -0
  17. loop_memory/engine/__init__.py +0 -0
  18. loop_memory/engine/loop.py +247 -0
  19. loop_memory/engine/reflect.py +89 -0
  20. loop_memory/examples/__init__.py +0 -0
  21. loop_memory/examples/demo.py +39 -0
  22. loop_memory/export/__init__.py +39 -0
  23. loop_memory/export/memory_md.py +629 -0
  24. loop_memory/graph/__init__.py +0 -0
  25. loop_memory/graph/build.py +259 -0
  26. loop_memory/graph/extract.py +197 -0
  27. loop_memory/ingest/__init__.py +0 -0
  28. loop_memory/ingest/loader.py +782 -0
  29. loop_memory/ingest/pipeline.py +458 -0
  30. loop_memory/jobs/__init__.py +0 -0
  31. loop_memory/jobs/cognitive.py +353 -0
  32. loop_memory/jobs/compact.py +371 -0
  33. loop_memory/jobs/consolidate.py +95 -0
  34. loop_memory/jobs/contradiction.py +281 -0
  35. loop_memory/jobs/evolution.py +2021 -0
  36. loop_memory/jobs/graph.py +395 -0
  37. loop_memory/jobs/llm_compact_pass.py +24 -0
  38. loop_memory/jobs/llm_consolidate.py +980 -0
  39. loop_memory/jobs/scheduler.py +495 -0
  40. loop_memory/llm/__init__.py +0 -0
  41. loop_memory/llm/base.py +80 -0
  42. loop_memory/llm/openai_adapter.py +31 -0
  43. loop_memory/llm/providers.py +517 -0
  44. loop_memory/mcp/__init__.py +804 -0
  45. loop_memory/memory/__init__.py +0 -0
  46. loop_memory/memory/types.py +199 -0
  47. loop_memory/privacy/__init__.py +22 -0
  48. loop_memory/privacy/private.py +46 -0
  49. loop_memory/privacy/redact.py +188 -0
  50. loop_memory/py.typed +0 -0
  51. loop_memory/sdk.py +875 -0
  52. loop_memory/sdk_extensions.py +384 -0
  53. loop_memory/security/__init__.py +20 -0
  54. loop_memory/security/secrets.py +464 -0
  55. loop_memory/serve/__init__.py +0 -0
  56. loop_memory/serve/app.py +506 -0
  57. loop_memory/serve/handlers.py +316 -0
  58. loop_memory/serve/routes/_shared.py +59 -0
  59. loop_memory/serve/routes/admin.py +970 -0
  60. loop_memory/serve/routes/cognitive.py +64 -0
  61. loop_memory/serve/routes/export.py +65 -0
  62. loop_memory/serve/routes/graph.py +101 -0
  63. loop_memory/serve/routes/insights.py +702 -0
  64. loop_memory/serve/routes/memories.py +435 -0
  65. loop_memory/serve/routes/sessions.py +75 -0
  66. loop_memory/serve/routes/system.py +493 -0
  67. loop_memory/serve/routes/wiki.py +812 -0
  68. loop_memory/serve/static/__init__.py +0 -0
  69. loop_memory/serve/static/index.html +15 -0
  70. loop_memory/serve/watcher.py +451 -0
  71. loop_memory/storage/__init__.py +5 -0
  72. loop_memory/storage/retrieval.py +365 -0
  73. loop_memory/storage/sqlite_store.py +3627 -0
  74. loop_memory/wiki/__init__.py +41 -0
  75. loop_memory/wiki/backfill.py +143 -0
  76. loop_memory/wiki/classifier.py +238 -0
  77. loop_memory/wiki/prompts.py +295 -0
  78. loop_memory/wiki/scope.py +227 -0
  79. loop_memory-0.4.0.dist-info/METADATA +627 -0
  80. loop_memory-0.4.0.dist-info/RECORD +84 -0
  81. loop_memory-0.4.0.dist-info/WHEEL +5 -0
  82. loop_memory-0.4.0.dist-info/entry_points.txt +2 -0
  83. loop_memory-0.4.0.dist-info/licenses/LICENSE +21 -0
  84. loop_memory-0.4.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,3627 @@
1
+ """SQLite-backed persistent memory store.
2
+
3
+ Designed to out-live a single Python process: every ``MemoryItem``
4
+ turns into a row, and a small set of indexes lets the UI list, search,
5
+ and re-score by time.
6
+
7
+ Schema (versioned):
8
+
9
+ memories(id, kind, text, importance, source, session_id,
10
+ created_at, updated_at, score, ttl, tags, embedding BLOB)
11
+
12
+ sessions(id, source, external_id, title, started_at, ended_at, message_count)
13
+
14
+ entities(id, name, kind, mention_count, weight, created_at, updated_at)
15
+ relations(id, src, dst, kind, weight, evidence_ids)
16
+
17
+ Embeddings are stored as a tight float32 blob so we never have to
18
+ decode JSON at query time.
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ import json
24
+ import logging
25
+ import math
26
+ import sqlite3
27
+ import struct
28
+ import time
29
+ import uuid
30
+
31
+ # Local imports are deferred inside recall_hybrid() to avoid a circular
32
+ # dependency on .retrieval during package import; the helpers used by
33
+ # _hydrate_* are imported here for the same reason.
34
+ from .retrieval import temporal_score # noqa: E402
35
+ from collections.abc import Iterator
36
+ from contextlib import contextmanager
37
+ from dataclasses import dataclass
38
+ from pathlib import Path
39
+
40
+ logger = logging.getLogger(__name__)
41
+ log = logger
42
+
43
+
44
+ SCHEMA = """
45
+ CREATE TABLE IF NOT EXISTS sessions (
46
+ id TEXT PRIMARY KEY,
47
+ source TEXT NOT NULL,
48
+ external_id TEXT,
49
+ title TEXT,
50
+ started_at REAL NOT NULL,
51
+ ended_at REAL,
52
+ message_count INTEGER NOT NULL DEFAULT 0,
53
+ metadata TEXT
54
+ );
55
+
56
+ CREATE TABLE IF NOT EXISTS memories (
57
+ id TEXT PRIMARY KEY,
58
+ session_id TEXT,
59
+ kind TEXT NOT NULL,
60
+ text TEXT NOT NULL,
61
+ importance REAL NOT NULL DEFAULT 0.5,
62
+ source TEXT,
63
+ created_at REAL NOT NULL,
64
+ updated_at REAL NOT NULL,
65
+ score REAL NOT NULL DEFAULT 0.5,
66
+ ttl REAL,
67
+ tags TEXT,
68
+ embedding BLOB,
69
+ agent_id TEXT,
70
+ user_id TEXT,
71
+ external_id TEXT
72
+ );
73
+
74
+ CREATE INDEX IF NOT EXISTS idx_mem_session ON memories(session_id);
75
+ CREATE INDEX IF NOT EXISTS idx_mem_created ON memories(created_at);
76
+ CREATE INDEX IF NOT EXISTS idx_mem_score ON memories(score);
77
+ CREATE INDEX IF NOT EXISTS idx_mem_kind ON memories(kind);
78
+ -- Per-agent (agent_id, user_id, external_id) indexes are created
79
+ -- in _init_schema *after* the ALTER TABLE that adds the columns,
80
+ -- so opening an old DB doesn't fail with "no such column: agent_id".
81
+ -- See _init_schema for the migration block.
82
+
83
+ -- FTS5 mirror of memories.text + tags. We keep it in sync via triggers
84
+ -- (see end of this schema block) so every INSERT/UPDATE/DELETE on
85
+ -- memories propagates to memories_fts without app-level code.
86
+ -- The bm25() ranking is the kernel-side OK API; downstream callers
87
+ -- fuse this with the existing semantic score via Reciprocal Rank
88
+ -- Fusion (see recall_hybrid below).
89
+ -- FTS5 mirror of memories.text + tags. The trigram tokenizer
90
+ -- (SQLite ≥ 3.34) gives us substring search, which is the only
91
+ -- thing that works for CJK text without an external ICU build.
92
+ -- Trade-off vs unicode61: trigram produces larger indexes and
93
+ -- "word boundary" semantics are looser (e.g. "javascript" matches
94
+ -- "java"). For our use case (mixed Chinese/English with code
95
+ -- snippets) trigram wins decisively. The mirrors are kept in
96
+ -- sync via triggers so every INSERT/UPDATE/DELETE on memories
97
+ -- propagates automatically.
98
+ CREATE VIRTUAL TABLE IF NOT EXISTS memories_fts USING fts5(
99
+ text,
100
+ tags,
101
+ source,
102
+ tokenize = 'trigram'
103
+ );
104
+
105
+ -- FTS5 mirror of wiki_pages. Same trigger pattern.
106
+ CREATE VIRTUAL TABLE IF NOT EXISTS wiki_fts USING fts5(
107
+ title,
108
+ body,
109
+ summary,
110
+ tags,
111
+ tokenize = 'trigram'
112
+ );
113
+
114
+ -- Per-memory entity mentions: every time we extract entities from a
115
+ -- memory we record which entities appeared, so recall can boost
116
+ -- results whose entities overlap with the query's entities.
117
+ -- (memory_id, entity_id) is unique so re-ingest is idempotent.
118
+ CREATE TABLE IF NOT EXISTS entity_mentions (
119
+ memory_id TEXT NOT NULL,
120
+ entity_id TEXT NOT NULL,
121
+ weight REAL NOT NULL DEFAULT 0.5,
122
+ created_at REAL NOT NULL,
123
+ PRIMARY KEY (memory_id, entity_id)
124
+ );
125
+ CREATE INDEX IF NOT EXISTS idx_em_entity ON entity_mentions(entity_id);
126
+ CREATE INDEX IF NOT EXISTS idx_em_memory ON entity_mentions(memory_id);
127
+
128
+ -- Scope column on wiki_pages: 'global' or a comma-separated list of
129
+ -- source names like 'codex,claude' meaning only those sources should
130
+ -- see this page during recall. Existing rows are kept global on
131
+ -- migration; write paths may apply the local auto-scope evaluator.
132
+ -- SQLite has no
133
+ -- 'ADD COLUMN IF NOT EXISTS', so the migration is wrapped in a
134
+ -- guard in `_init_schema` that checks pragma_table_info first.
135
+ -- (The CREATE INDEX below IS idempotent.)
136
+
137
+ -- Triggers to keep FTS mirrors in sync. We intentionally rebuild from
138
+ -- the source row (rather than try to copy the new text) so the FTS
139
+ -- tokenizer is the only thing that ever touches the FTS row.
140
+ CREATE TRIGGER IF NOT EXISTS memories_ai AFTER INSERT ON memories BEGIN
141
+ INSERT INTO memories_fts(rowid, text, tags, source)
142
+ VALUES (new.rowid, new.text, COALESCE(new.tags,''), COALESCE(new.source,''));
143
+ END;
144
+ CREATE TRIGGER IF NOT EXISTS memories_ad AFTER DELETE ON memories BEGIN
145
+ DELETE FROM memories_fts WHERE rowid = old.rowid;
146
+ END;
147
+ CREATE TRIGGER IF NOT EXISTS memories_au AFTER UPDATE ON memories BEGIN
148
+ DELETE FROM memories_fts WHERE rowid = old.rowid;
149
+ INSERT INTO memories_fts(rowid, text, tags, source)
150
+ VALUES (new.rowid, new.text, COALESCE(new.tags,''), COALESCE(new.source,''));
151
+ END;
152
+
153
+ CREATE TABLE IF NOT EXISTS entities (
154
+ id TEXT PRIMARY KEY,
155
+ name TEXT NOT NULL,
156
+ kind TEXT NOT NULL DEFAULT 'concept',
157
+ mention_count INTEGER NOT NULL DEFAULT 1,
158
+ weight REAL NOT NULL DEFAULT 0.5,
159
+ created_at REAL NOT NULL,
160
+ updated_at REAL NOT NULL,
161
+ UNIQUE(name, kind)
162
+ );
163
+ CREATE INDEX IF NOT EXISTS idx_entities_name ON entities(name);
164
+
165
+ CREATE TABLE IF NOT EXISTS relations (
166
+ id TEXT PRIMARY KEY,
167
+ src TEXT NOT NULL,
168
+ dst TEXT NOT NULL,
169
+ kind TEXT NOT NULL DEFAULT 'related',
170
+ weight REAL NOT NULL DEFAULT 0.5,
171
+ evidence_ids TEXT,
172
+ created_at REAL NOT NULL,
173
+ UNIQUE(src, dst, kind)
174
+ );
175
+ CREATE INDEX IF NOT EXISTS idx_rel_src ON relations(src);
176
+ CREATE INDEX IF NOT EXISTS idx_rel_dst ON relations(dst);
177
+
178
+ CREATE TABLE IF NOT EXISTS schema_meta(k TEXT PRIMARY KEY, v TEXT);
179
+
180
+ -- User-tunable settings (LLM provider, schedule, behaviour).
181
+ -- One row per key; v is JSON-encoded.
182
+ CREATE TABLE IF NOT EXISTS settings (
183
+ k TEXT PRIMARY KEY,
184
+ v TEXT NOT NULL,
185
+ updated_at REAL NOT NULL
186
+ );
187
+
188
+ -- Consolidation / rescore / summarize run history.
189
+ CREATE TABLE IF NOT EXISTS consolidation_runs (
190
+ id TEXT PRIMARY KEY,
191
+ started_at REAL NOT NULL,
192
+ finished_at REAL,
193
+ trigger TEXT NOT NULL, -- 'manual' | 'schedule' | 'realtime'
194
+ status TEXT NOT NULL, -- 'running' | 'done' | 'error'
195
+ stats_json TEXT,
196
+ error TEXT,
197
+ model TEXT
198
+ );
199
+ CREATE INDEX IF NOT EXISTS idx_cr_started ON consolidation_runs(started_at);
200
+
201
+ -- Distilled wiki pages: long-form, polished knowledge synthesized from
202
+ -- raw memories by the LLM consolidator. One row per topic; re-running
203
+ -- consolidation updates the body and bumps the version.
204
+ CREATE TABLE IF NOT EXISTS wiki_pages (
205
+ id TEXT PRIMARY KEY,
206
+ slug TEXT NOT NULL UNIQUE,
207
+ title TEXT NOT NULL,
208
+ body TEXT NOT NULL,
209
+ summary TEXT,
210
+ tags TEXT, -- JSON array of strings
211
+ importance REAL NOT NULL DEFAULT 0.5,
212
+ evidence_ids TEXT, -- JSON array of memory ids that contributed
213
+ run_id TEXT, -- consolidation run that produced/updated it
214
+ version INTEGER NOT NULL DEFAULT 1,
215
+ created_at REAL NOT NULL,
216
+ updated_at REAL NOT NULL,
217
+ key_facts TEXT, -- JSON array of single-sentence facts
218
+ contradicting_ids TEXT, -- JSON array of wiki page ids this page
219
+ -- contradicts; populated by the
220
+ -- contradiction detector on write.
221
+ auto_classification TEXT -- JSON audit of automatic scope routing
222
+ );
223
+ CREATE INDEX IF NOT EXISTS idx_wiki_updated ON wiki_pages(updated_at);
224
+ CREATE INDEX IF NOT EXISTS idx_wiki_import ON wiki_pages(importance);
225
+ CREATE INDEX IF NOT EXISTS idx_wiki_slug ON wiki_pages(slug);
226
+
227
+ -- FTS5 sync triggers for wiki_pages (positioned AFTER the base table
228
+ -- because SQLite parses ``executescript`` linearly; defining triggers
229
+ -- before the table they reference would raise "no such table").
230
+ CREATE TRIGGER IF NOT EXISTS wiki_ai AFTER INSERT ON wiki_pages BEGIN
231
+ INSERT INTO wiki_fts(rowid, title, body, summary, tags)
232
+ VALUES (new.rowid, new.title, new.body, COALESCE(new.summary,''), COALESCE(new.tags,''));
233
+ END;
234
+ CREATE TRIGGER IF NOT EXISTS wiki_ad AFTER DELETE ON wiki_pages BEGIN
235
+ DELETE FROM wiki_fts WHERE rowid = old.rowid;
236
+ END;
237
+ CREATE TRIGGER IF NOT EXISTS wiki_au AFTER UPDATE ON wiki_pages BEGIN
238
+ DELETE FROM wiki_fts WHERE rowid = old.rowid;
239
+ INSERT INTO wiki_fts(rowid, title, body, summary, tags)
240
+ VALUES (new.rowid, new.title, new.body, COALESCE(new.summary,''), COALESCE(new.tags,''));
241
+ END;
242
+
243
+ -- Per-memory behavioural signals used by the evolution consolidator.
244
+ -- recall_count: how many times this memory was returned by recall() / search
245
+ -- positive: explicit user 👍 (or implicit: kept after LLM re-eval)
246
+ -- negative: explicit user 👎 (or implicit: deleted after LLM re-eval)
247
+ -- last_recalled_at: last time it was returned by a query
248
+ -- Universal Agent Memory v7: wiki versioning, cognitive audit
249
+ -- trail, and per-(user, agent) bearer tokens. All additive, all
250
+ -- nullable, all with sensible defaults so existing rows are
251
+ -- untouched.
252
+ CREATE TABLE IF NOT EXISTS wiki_versions (
253
+ id TEXT PRIMARY KEY,
254
+ page_id TEXT NOT NULL,
255
+ version INTEGER NOT NULL,
256
+ title TEXT NOT NULL,
257
+ body TEXT NOT NULL,
258
+ summary TEXT,
259
+ tags TEXT,
260
+ importance REAL NOT NULL DEFAULT 0.5,
261
+ key_facts TEXT,
262
+ scope TEXT,
263
+ branched_at REAL NOT NULL,
264
+ branch_tag TEXT
265
+ );
266
+ CREATE INDEX IF NOT EXISTS idx_wv_page ON wiki_versions(page_id, version);
267
+ CREATE INDEX IF NOT EXISTS idx_wv_branch ON wiki_versions(branch_tag);
268
+
269
+ -- Cognitive audit: every "should I forget / merge / contradict" call
270
+ -- writes one row here. The dashboard reads from this table to show
271
+ -- "agent decided to forget X" history; CLI ``loop-memory audit``
272
+ -- dumps it.
273
+ CREATE TABLE IF NOT EXISTS cognitive_audit (
274
+ id TEXT PRIMARY KEY,
275
+ ts REAL NOT NULL,
276
+ kind TEXT NOT NULL, -- 'forget'|'merge'|'contradict'|'stale'|'low_value'
277
+ action TEXT NOT NULL, -- 'suggest'|'applied'|'reverted'
278
+ target_kind TEXT NOT NULL, -- 'memory'|'wiki_page'
279
+ target_id TEXT,
280
+ target_text TEXT,
281
+ reason TEXT,
282
+ score REAL,
283
+ payload TEXT
284
+ );
285
+ CREATE INDEX IF NOT EXISTS idx_ca_ts ON cognitive_audit(ts);
286
+ CREATE INDEX IF NOT EXISTS idx_ca_kind ON cognitive_audit(kind, action);
287
+
288
+ -- Per-(user, agent) bearer tokens. Optional; the local server keeps
289
+ -- the route open by default. Loop-memory serve with --auth
290
+ -- --token-required enables it; the SDK auto-attaches the bearer
291
+ -- header when ``MemoryClient.http(..., token=...)`` is given.
292
+ CREATE TABLE IF NOT EXISTS auth_tokens (
293
+ id TEXT PRIMARY KEY,
294
+ user_id TEXT,
295
+ agent_id TEXT,
296
+ label TEXT,
297
+ token_hash TEXT NOT NULL,
298
+ created_at REAL NOT NULL,
299
+ last_used_at REAL,
300
+ expires_at REAL,
301
+ revoked INTEGER NOT NULL DEFAULT 0
302
+ );
303
+ CREATE INDEX IF NOT EXISTS idx_at_user ON auth_tokens(user_id, agent_id);
304
+
305
+ CREATE TABLE IF NOT EXISTS memory_signals (
306
+ memory_id TEXT PRIMARY KEY,
307
+ recall_count INTEGER NOT NULL DEFAULT 0,
308
+ positive INTEGER NOT NULL DEFAULT 0,
309
+ negative INTEGER NOT NULL DEFAULT 0,
310
+ last_recalled_at REAL,
311
+ last_feedback_at REAL,
312
+ updated_at REAL NOT NULL
313
+ );
314
+ CREATE INDEX IF NOT EXISTS idx_signals_recall ON memory_signals(recall_count);
315
+ CREATE INDEX IF NOT EXISTS idx_signals_neg ON memory_signals(negative);
316
+
317
+ -- Per-pair ignore list for contradiction detection: keyed by ordered
318
+ -- hash of (a_id, b_id) so the dashboard "ignore" button actually does
319
+ -- something (the pair disappears from future pulses).
320
+ CREATE TABLE IF NOT EXISTS contradiction_ignored (
321
+ pair_key TEXT PRIMARY KEY, -- sorted(a_id, b_id) joined with '|'
322
+ ignored_at REAL NOT NULL
323
+ );
324
+
325
+ -- LLM audit log: every provider call records prompt/response/tokens/cost
326
+ -- so we can replay, debug distillation failures, and watch spend.
327
+ CREATE TABLE IF NOT EXISTS llm_audit (
328
+ id TEXT PRIMARY KEY,
329
+ ts REAL NOT NULL,
330
+ provider TEXT NOT NULL,
331
+ model TEXT NOT NULL,
332
+ kind TEXT NOT NULL, -- "consolidate" | "wiki" | "test" | ...
333
+ run_id TEXT, -- consolidation run id when applicable
334
+ prompt_hash TEXT, -- sha1 of prompt for dedup / lookup
335
+ prompt_text TEXT,
336
+ response_text TEXT,
337
+ prompt_tokens INTEGER,
338
+ completion_tokens INTEGER,
339
+ total_tokens INTEGER,
340
+ cost_usd REAL, -- estimated, optional
341
+ latency_ms INTEGER,
342
+ ok INTEGER NOT NULL DEFAULT 1,
343
+ error TEXT
344
+ );
345
+ CREATE INDEX IF NOT EXISTS idx_audit_ts ON llm_audit(ts);
346
+ CREATE INDEX IF NOT EXISTS idx_audit_kind ON llm_audit(kind);
347
+ CREATE INDEX IF NOT EXISTS idx_audit_run ON llm_audit(run_id);
348
+
349
+ -- Pipeline stage counters: one row per (stage, window). Used by the
350
+ -- dashboard to render the live data-flow animation.
351
+ CREATE TABLE IF NOT EXISTS pipeline_runs (
352
+ id TEXT PRIMARY KEY,
353
+ started_at REAL NOT NULL,
354
+ finished_at REAL,
355
+ stage TEXT NOT NULL, -- 'ingest'|'score'|'cluster'|'distill'|'wiki'|'graph'
356
+ in_count INTEGER NOT NULL DEFAULT 0,
357
+ out_count INTEGER NOT NULL DEFAULT 0,
358
+ note TEXT,
359
+ stats_json TEXT
360
+ );
361
+ CREATE INDEX IF NOT EXISTS idx_pipe_stage_started ON pipeline_runs(stage, started_at);
362
+
363
+ -- WriteGuard drops: small counter table so the dashboard can show live
364
+ -- per-kind rejection totals (and last-rejected timestamp per reason).
365
+ CREATE TABLE IF NOT EXISTS write_guard_drops (
366
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
367
+ ts REAL NOT NULL,
368
+ source TEXT NOT NULL,
369
+ kind TEXT NOT NULL, -- 'duplicate'|'too_short'|'too_long'|'low_signal'
370
+ text_preview TEXT,
371
+ matched_id TEXT,
372
+ matched_score REAL
373
+ );
374
+ CREATE INDEX IF NOT EXISTS idx_wgd_ts ON write_guard_drops(ts);
375
+ CREATE INDEX IF NOT EXISTS idx_wgd_src ON write_guard_drops(source, kind);
376
+ """
377
+
378
+
379
+ def _to_blob(vec: list[float] | None) -> bytes | None:
380
+ if vec is None:
381
+ return None
382
+ return struct.pack(f"{len(vec)}f", *vec)
383
+
384
+
385
+ def _from_blob(blob: bytes | None) -> list[float] | None:
386
+ if blob is None:
387
+ return None
388
+ return list(struct.unpack(f"{len(blob) // 4}f", blob))
389
+
390
+
391
+ @dataclass
392
+ class StoredMemory:
393
+ id: str
394
+ kind: str
395
+ text: str
396
+ importance: float
397
+ source: str | None
398
+ session_id: str | None
399
+ created_at: float
400
+ updated_at: float
401
+ score: float
402
+ ttl: float | None
403
+ tags: list[str]
404
+ embedding: list[float] | None
405
+ agent_id: str | None = None
406
+ user_id: str | None = None
407
+ external_id: str | None = None
408
+
409
+
410
+ @dataclass
411
+ class StoredSession:
412
+ id: str
413
+ source: str
414
+ external_id: str | None
415
+ title: str | None
416
+ started_at: float
417
+ ended_at: float | None
418
+ message_count: int
419
+ metadata: dict
420
+
421
+
422
+ @dataclass
423
+ class GraphEntity:
424
+ id: str
425
+ name: str
426
+ kind: str
427
+ mention_count: int
428
+ weight: float
429
+
430
+
431
+ @dataclass
432
+ class GraphRelation:
433
+ id: str
434
+ src: str # entity name (canonicalised)
435
+ dst: str # entity name (canonicalised)
436
+ kind: str
437
+ weight: float
438
+ evidence_ids: list[str]
439
+
440
+
441
+ class MemoryStore:
442
+ """Persistent, transactional store backed by SQLite.
443
+
444
+ The zero-dep claim holds — Python ships with sqlite3 and struct.
445
+ """
446
+
447
+ SCHEMA_VERSION = "8"
448
+
449
+ def __init__(self, path: str | Path) -> None:
450
+ self.path = Path(path).expanduser()
451
+ self.path.parent.mkdir(parents=True, exist_ok=True)
452
+ self._init_schema()
453
+
454
+ @contextmanager
455
+ def _conn(self) -> Iterator[sqlite3.Connection]:
456
+ conn = sqlite3.connect(str(self.path))
457
+ conn.row_factory = sqlite3.Row
458
+ conn.execute("PRAGMA journal_mode=WAL")
459
+ conn.execute("PRAGMA foreign_keys=ON")
460
+ try:
461
+ yield conn
462
+ conn.commit()
463
+ finally:
464
+ conn.close()
465
+
466
+ def _init_schema(self) -> None:
467
+ # All `CREATE TABLE IF NOT EXISTS` runs every time so we can
468
+ # add new tables without a manual migration step. Then upsert
469
+ # the schema version so a downgrade is loud. We also handle
470
+ # the few idempotent-but-not-IF-NOT-EXISTS migrations inline
471
+ # below (SQLite has no ADD COLUMN IF NOT EXISTS).
472
+ with self._conn() as c:
473
+ # Run the DDL first so all base tables exist; then we can
474
+ # safely check + add the few columns that aren't covered
475
+ # by ``CREATE TABLE IF NOT EXISTS`` (SQLite has no
476
+ # ``ADD COLUMN IF NOT EXISTS``).
477
+ c.executescript(SCHEMA)
478
+ c.execute(
479
+ "INSERT INTO schema_meta(k,v) VALUES('version',?) "
480
+ "ON CONFLICT(k) DO UPDATE SET v=excluded.v",
481
+ (self.SCHEMA_VERSION,),
482
+ )
483
+ cols = {row["name"] for row in c.execute("PRAGMA table_info(wiki_pages)").fetchall()}
484
+ if "scope" not in cols:
485
+ c.execute("ALTER TABLE wiki_pages ADD COLUMN scope TEXT NOT NULL DEFAULT 'global'")
486
+ if "key_facts" not in cols:
487
+ c.execute("ALTER TABLE wiki_pages ADD COLUMN key_facts TEXT")
488
+ if "contradicting_ids" not in cols:
489
+ c.execute("ALTER TABLE wiki_pages ADD COLUMN contradicting_ids TEXT")
490
+ if "auto_classification" not in cols:
491
+ c.execute("ALTER TABLE wiki_pages ADD COLUMN auto_classification TEXT")
492
+ c.execute("CREATE INDEX IF NOT EXISTS idx_wiki_scope ON wiki_pages(scope)")
493
+
494
+ # Universal Agent Memory migration: add per-agent identity
495
+ # columns to memories. Bumping SCHEMA_VERSION from "5" → "6"
496
+ # so a future downgrade is loud.
497
+ mem_cols = {row["name"] for row in c.execute("PRAGMA table_info(memories)").fetchall()}
498
+ if "agent_id" not in mem_cols:
499
+ c.execute("ALTER TABLE memories ADD COLUMN agent_id TEXT")
500
+ if "user_id" not in mem_cols:
501
+ c.execute("ALTER TABLE memories ADD COLUMN user_id TEXT")
502
+ if "external_id" not in mem_cols:
503
+ c.execute("ALTER TABLE memories ADD COLUMN external_id TEXT")
504
+ # Indexes must run after the ALTER TABLE so the columns
505
+ # they reference actually exist on legacy DBs.
506
+ c.execute("CREATE INDEX IF NOT EXISTS idx_mem_agent ON memories(agent_id)")
507
+ c.execute("CREATE INDEX IF NOT EXISTS idx_mem_user ON memories(user_id)")
508
+ c.execute(
509
+ "CREATE UNIQUE INDEX IF NOT EXISTS idx_mem_external "
510
+ "ON memories(agent_id, user_id, external_id) "
511
+ "WHERE external_id IS NOT NULL AND external_id != ''"
512
+ )
513
+
514
+ # One-shot FTS5 tokenizer migration. ``CREATE VIRTUAL TABLE
515
+ # IF NOT EXISTS`` will *not* rebuild an existing FTS5 table
516
+ # if its schema differs from the DDL — which is exactly
517
+ # what we need when switching from the legacy ``unicode61``
518
+ # tokenizer to ``trigram`` (the only one that can do
519
+ # substring search over CJK text without an external
520
+ # ICU build). Detect any mirror that is missing the
521
+ # ``trigram`` token, drop the FTS mirrors + their sync
522
+ # triggers, then re-run the DDL (which will now create
523
+ # them fresh) and re-backfill from the source tables.
524
+ needs_fts_rebuild = False
525
+ for tbl in ("memories_fts", "wiki_fts"):
526
+ row = c.execute(
527
+ "SELECT sql FROM sqlite_master WHERE type='table' AND name=?", (tbl,)
528
+ ).fetchone()
529
+ sql = row[0] if row else None
530
+ # Two triggers force a rebuild:
531
+ # 1. tokenizer isn't ``trigram`` (legacy unicode61)
532
+ # 2. table is ``content=''`` (contentless) — that
533
+ # forbids DELETE, which breaks the cascade path
534
+ # from ``delete_session`` / ``delete_memory``.
535
+ if sql is None:
536
+ needs_fts_rebuild = True
537
+ break
538
+ if "trigram" not in sql:
539
+ needs_fts_rebuild = True
540
+ break
541
+ if "content=''" in sql:
542
+ needs_fts_rebuild = True
543
+ break
544
+ if needs_fts_rebuild:
545
+ # Drop the FTS mirrors and their triggers. We drop
546
+ # triggers BEFORE the table (FTS5 errors otherwise).
547
+ for trig in ("memories_ai", "memories_ad", "memories_au",
548
+ "wiki_ai", "wiki_ad", "wiki_au"):
549
+ c.execute(f"DROP TRIGGER IF EXISTS {trig}")
550
+ for tbl in ("memories_fts", "wiki_fts"):
551
+ c.execute(f"DROP TABLE IF EXISTS {tbl}")
552
+ # Re-run the DDL — now the FTS CREATE statements will
553
+ # actually take effect (because the tables are gone)
554
+ # and the triggers will be re-created.
555
+ c.executescript(SCHEMA)
556
+ # Backfill from the source tables. The triggers will
557
+ # take over from this point on.
558
+ n_mem = c.execute("SELECT COUNT(*) c FROM memories").fetchone()["c"]
559
+ if n_mem > 0:
560
+ c.execute(
561
+ "INSERT INTO memories_fts(rowid, text, tags, source) "
562
+ "SELECT rowid, text, COALESCE(tags,''), COALESCE(source,'') "
563
+ "FROM memories"
564
+ )
565
+ n_wiki = c.execute("SELECT COUNT(*) c FROM wiki_pages").fetchone()["c"]
566
+ if n_wiki > 0:
567
+ c.execute(
568
+ "INSERT INTO wiki_fts(rowid, title, body, summary, tags) "
569
+ "SELECT rowid, title, body, COALESCE(summary,''), COALESCE(tags,'') "
570
+ "FROM wiki_pages"
571
+ )
572
+ # Track the rebuild so we never redo it on a healthy DB.
573
+ c.execute(
574
+ "INSERT INTO schema_meta(k,v) VALUES('fts5_tokenizer',?) "
575
+ "ON CONFLICT(k) DO UPDATE SET v=excluded.v",
576
+ ("trigram",),
577
+ )
578
+
579
+ # Default existing wiki pages to 'global' scope on first
580
+ # run after the scope migration. ALTER TABLE already added
581
+ # the column with DEFAULT 'global', so new rows are fine;
582
+ # this is just belt-and-braces for pre-existing rows.
583
+ c.execute("UPDATE wiki_pages SET scope='global' WHERE scope IS NULL OR scope=''")
584
+ # One-shot backfill for ``entity_mentions`` (memory → entity
585
+ # links). The table was introduced together with FTS5 in this
586
+ # migration, but pre-existing memories were never linked, so
587
+ # the entity channel of hybrid recall would silently return
588
+ # nothing for them. We do an inexpensive substring match
589
+ # against existing entity names so the channel becomes
590
+ # useful on the first recall after this migration; ongoing
591
+ # ``upsert_entity`` calls keep the link fresh.
592
+ em_count = c.execute(
593
+ "SELECT COUNT(*) c FROM entity_mentions"
594
+ ).fetchone()["c"]
595
+ if em_count == 0:
596
+ _now = time.time()
597
+ ent_rows = c.execute(
598
+ "SELECT id, name FROM entities WHERE name NOT LIKE 'tag:%'"
599
+ ).fetchall()
600
+ inserts: list[tuple] = []
601
+ for ent in ent_rows:
602
+ full = (ent["name"] or "").strip().lower()
603
+ if not full:
604
+ continue
605
+ suffix = full.split(":")[-1] if ":" in full else full
606
+ if not suffix or len(suffix) < 2:
607
+ continue
608
+ # Escape LIKE wildcards in the suffix.
609
+ esc = (
610
+ suffix.replace("\\", "\\\\")
611
+ .replace("%", "\\%")
612
+ .replace("_", "\\_")
613
+ )
614
+ hits = c.execute(
615
+ "SELECT id FROM memories WHERE LOWER(text) LIKE ? ESCAPE '\\' LIMIT 64",
616
+ (f"%{esc}%",),
617
+ ).fetchall()
618
+ for m in hits:
619
+ inserts.append((m["id"], ent["id"], 0.5, _now))
620
+ if inserts:
621
+ c.executemany(
622
+ "INSERT OR IGNORE INTO entity_mentions(memory_id, entity_id, weight, created_at) "
623
+ "VALUES (?,?,?,?)",
624
+ inserts,
625
+ )
626
+
627
+ # Legacy flag from the original FTS5 rollout, kept for
628
+ # backward-compat with downstream tooling.
629
+ c.execute(
630
+ "INSERT INTO schema_meta(k,v) VALUES('fts5_backfilled',?) "
631
+ "ON CONFLICT(k) DO UPDATE SET v=excluded.v",
632
+ ("1",),
633
+ )
634
+
635
+ # --- sessions ---------------------------------------------------------
636
+
637
+ def upsert_session(
638
+ self,
639
+ source: str,
640
+ external_id: str | None = None,
641
+ title: str | None = None,
642
+ started_at: float | None = None,
643
+ ended_at: float | None = None,
644
+ message_count: int = 0,
645
+ metadata: dict | None = None,
646
+ ) -> StoredSession:
647
+ import json
648
+
649
+ started = started_at or time.time()
650
+ ended = ended_at
651
+ with self._conn() as c:
652
+ row = c.execute(
653
+ "SELECT id FROM sessions WHERE source=? AND external_id IS ?",
654
+ (source, external_id),
655
+ ).fetchone()
656
+ if row is not None:
657
+ sid = row["id"]
658
+ c.execute(
659
+ """UPDATE sessions
660
+ SET title=COALESCE(?, title),
661
+ ended_at=COALESCE(?, ended_at),
662
+ message_count=?,
663
+ metadata=COALESCE(?, metadata)
664
+ WHERE id=?""",
665
+ (title, ended, message_count, json.dumps(metadata) if metadata else None, sid),
666
+ )
667
+ else:
668
+ sid = uuid.uuid4().hex
669
+ c.execute(
670
+ """INSERT INTO sessions
671
+ (id, source, external_id, title, started_at, ended_at,
672
+ message_count, metadata)
673
+ VALUES (?,?,?,?,?,?,?,?)""",
674
+ (
675
+ sid,
676
+ source,
677
+ external_id,
678
+ title,
679
+ started,
680
+ ended,
681
+ message_count,
682
+ json.dumps(metadata or {}),
683
+ ),
684
+ )
685
+ return StoredSession(
686
+ id=sid,
687
+ source=source,
688
+ external_id=external_id,
689
+ title=title,
690
+ started_at=started,
691
+ ended_at=ended,
692
+ message_count=message_count,
693
+ metadata=metadata or {},
694
+ )
695
+
696
+ def list_sessions(self, limit: int = 100, source: str | None = None) -> list[StoredSession]:
697
+ # Sort by last-activity time so an active but long-running session
698
+ # (its started_at is from days ago, its ended_at keeps advancing)
699
+ # bubbles to the top instead of being buried under newer short-lived
700
+ # sessions like cron reports.
701
+ with self._conn() as c:
702
+ if source:
703
+ rows = c.execute(
704
+ "SELECT * FROM sessions WHERE source=? "
705
+ "ORDER BY COALESCE(ended_at, started_at) DESC LIMIT ?",
706
+ (source, limit),
707
+ ).fetchall()
708
+ else:
709
+ rows = c.execute(
710
+ "SELECT * FROM sessions "
711
+ "ORDER BY COALESCE(ended_at, started_at) DESC LIMIT ?",
712
+ (limit,),
713
+ ).fetchall()
714
+ return [self._row_to_session(r) for r in rows]
715
+
716
+ def get_session(self, session_id: str) -> StoredSession | None:
717
+ with self._conn() as c:
718
+ row = c.execute("SELECT * FROM sessions WHERE id=?", (session_id,)).fetchone()
719
+ return self._row_to_session(row) if row else None
720
+
721
+ def _row_to_session(self, row: sqlite3.Row) -> StoredSession:
722
+ import json
723
+
724
+ meta: dict = {}
725
+ if row["metadata"]:
726
+ try:
727
+ meta = json.loads(row["metadata"])
728
+ except (ValueError, TypeError):
729
+ logger.warning("corrupt session metadata for row %s; resetting", row["id"])
730
+ meta = {}
731
+ return StoredSession(
732
+ id=row["id"],
733
+ source=row["source"],
734
+ external_id=row["external_id"],
735
+ title=row["title"],
736
+ started_at=row["started_at"],
737
+ ended_at=row["ended_at"],
738
+ message_count=row["message_count"],
739
+ metadata=meta,
740
+ )
741
+
742
+ # --- memories ---------------------------------------------------------
743
+
744
+
745
+ # ----- Signal feedback (v5) ----------------------------------------
746
+
747
+ def record_signal(
748
+ self,
749
+ memory_id: str,
750
+ *,
751
+ recall: bool = False,
752
+ positive: bool | None = None,
753
+ ) -> None:
754
+ """Update behavioural signals for one memory.
755
+
756
+ * ``recall=True`` bumps ``recall_count`` and ``last_recalled_at``.
757
+ * ``positive=True/False`` bumps positive/negative counters and
758
+ ``last_feedback_at``. This is the user-driven 👍/👎 path.
759
+ """
760
+ now = time.time()
761
+ with self._conn() as c:
762
+ c.execute(
763
+ """INSERT INTO memory_signals (memory_id, recall_count, positive, negative,
764
+ last_recalled_at, last_feedback_at, updated_at)
765
+ VALUES (?, ?, ?, ?, ?, ?, ?)
766
+ ON CONFLICT(memory_id) DO UPDATE SET
767
+ recall_count = recall_count + ?,
768
+ positive = positive + ?,
769
+ negative = negative + ?,
770
+ last_recalled_at = COALESCE(?, last_recalled_at),
771
+ last_feedback_at = COALESCE(?, last_feedback_at),
772
+ updated_at = ?""",
773
+ (
774
+ memory_id,
775
+ 1 if recall else 0,
776
+ 1 if positive is True else 0,
777
+ 1 if positive is False else 0,
778
+ now if recall else None,
779
+ now if positive is not None else None,
780
+ now,
781
+ 1 if recall else 0,
782
+ 1 if positive is True else 0,
783
+ 1 if positive is False else 0,
784
+ now if recall else None,
785
+ now if positive is not None else None,
786
+ now,
787
+ ),
788
+ )
789
+
790
+
791
+ def bump_recalls(self, memory_ids):
792
+ """Increment recall_count for every id. Used by MCP/web search to
793
+ feed the evolution loop. Returns the number of rows updated."""
794
+ ids = [str(x) for x in memory_ids if x]
795
+ if not ids:
796
+ return 0
797
+ now = time.time()
798
+ n = 0
799
+ with self._conn() as c:
800
+ for mid in ids:
801
+ c.execute(
802
+ """INSERT INTO memory_signals (memory_id, recall_count, positive, negative,
803
+ last_recalled_at, updated_at)
804
+ VALUES (?, 1, 0, 0, ?, ?)
805
+ ON CONFLICT(memory_id) DO UPDATE SET
806
+ recall_count = recall_count + 1,
807
+ last_recalled_at = ?,
808
+ updated_at = ?""",
809
+ (mid, now, now, now, now),
810
+ )
811
+ n += 1
812
+ return n
813
+
814
+ def get_signal(self, memory_id: str) -> Dict[str, Any]:
815
+ with self._conn() as c:
816
+ row = c.execute(
817
+ "SELECT * FROM memory_signals WHERE memory_id=?", (memory_id,)
818
+ ).fetchone()
819
+ if row is None:
820
+ return {"recall_count": 0, "positive": 0, "negative": 0,
821
+ "last_recalled_at": None, "last_feedback_at": None}
822
+ return {
823
+ "recall_count": row["recall_count"] or 0,
824
+ "positive": row["positive"] or 0,
825
+ "negative": row["negative"] or 0,
826
+ "last_recalled_at": row["last_recalled_at"],
827
+ "last_feedback_at": row["last_feedback_at"],
828
+ }
829
+
830
+ def top_signals(self, kind: str = "recall_count", limit: int = 20) -> list[Dict[str, Any]]:
831
+ """Top-N memories by a signal column (recall_count / positive / negative)."""
832
+ col = kind if kind in ("recall_count", "positive", "negative") else "recall_count"
833
+ with self._conn() as c:
834
+ rows = c.execute(
835
+ f"""SELECT m.id, m.kind, m.text, m.importance, m.score, m.tags,
836
+ s.recall_count, s.positive, s.negative
837
+ FROM memories m
838
+ LEFT JOIN memory_signals s ON s.memory_id = m.id
839
+ WHERE COALESCE(s.{col}, 0) > 0
840
+ ORDER BY s.{col} DESC
841
+ LIMIT ?""",
842
+ (limit,),
843
+ ).fetchall()
844
+ return [dict(r) for r in rows]
845
+
846
+ # ----- Pipeline stage recording (v5) ------------------------------
847
+
848
+ def start_pipeline_run(self, stage: str) -> str:
849
+ import uuid as _uuid
850
+ rid = _uuid.uuid4().hex
851
+ with self._conn() as c:
852
+ c.execute(
853
+ """INSERT INTO pipeline_runs (id, stage, started_at)
854
+ VALUES (?, ?, ?)""",
855
+ (rid, stage, time.time()),
856
+ )
857
+ return rid
858
+
859
+ def finish_pipeline_run(
860
+ self,
861
+ rid: str,
862
+ *,
863
+ in_count: int = 0,
864
+ out_count: int = 0,
865
+ note: str = "",
866
+ stats: Dict[str, Any] | None = None,
867
+ ) -> None:
868
+ import json as _json
869
+ with self._conn() as c:
870
+ c.execute(
871
+ """UPDATE pipeline_runs
872
+ SET finished_at = ?, in_count = ?, out_count = ?,
873
+ note = ?, stats_json = ?
874
+ WHERE id = ?""",
875
+ (time.time(), in_count, out_count, note,
876
+ _json.dumps(stats or {}), rid),
877
+ )
878
+
879
+ def latest_pipeline_runs(self, limit: int = 60) -> list[Dict[str, Any]]:
880
+ with self._conn() as c:
881
+ rows = c.execute(
882
+ """SELECT * FROM pipeline_runs
883
+ ORDER BY started_at DESC LIMIT ?""",
884
+ (limit,),
885
+ ).fetchall()
886
+ return [dict(r) for r in rows]
887
+
888
+ def upsert_memory(
889
+ self,
890
+ *,
891
+ id: str | None = None,
892
+ kind: str,
893
+ text: str,
894
+ importance: float = 0.5,
895
+ source: str | None = None,
896
+ session_id: str | None = None,
897
+ created_at: float | None = None,
898
+ updated_at: float | None = None,
899
+ ttl: float | None = None,
900
+ tags: list[str] | None = None,
901
+ embedding: list[float] | None = None,
902
+ agent_id: str | None = None,
903
+ user_id: str | None = None,
904
+ external_id: str | None = None,
905
+ ) -> StoredMemory:
906
+ """Create or update a memory.
907
+
908
+ Two idempotency paths:
909
+
910
+ * by ``id`` (caller supplies a UUID-like id)
911
+ * by ``(agent_id, user_id, external_id)`` tuple — any Agent
912
+ that re-pushes the same ``external_id`` updates the row in
913
+ place instead of duplicating it. This is the path used by
914
+ the universal ``MemoryClient.remember()`` SDK and the
915
+ ``/api/v1/memories`` endpoint.
916
+
917
+ Either input may be ``None`` (the unique index excludes
918
+ NULL/empty external_ids, so a memory with no external_id
919
+ cannot collide and gets a fresh row).
920
+ """
921
+ import json
922
+
923
+ now = time.time()
924
+ ext = (external_id or "").strip() or None
925
+ ts = created_at or now
926
+ uts = updated_at or ts
927
+ tags_json = json.dumps(tags or [])
928
+ score = self.compute_score(importance, ts, now)
929
+ with self._conn() as c:
930
+ # Re-route by external_id when the caller did not pin an id.
931
+ if id is None and ext and agent_id is not None:
932
+ row = c.execute(
933
+ "SELECT id FROM memories "
934
+ "WHERE agent_id IS ? AND user_id IS ? AND external_id = ?",
935
+ (agent_id, user_id, ext),
936
+ ).fetchone()
937
+ if row is not None:
938
+ id = row["id"]
939
+ mid = id or uuid.uuid4().hex
940
+ c.execute(
941
+ """INSERT INTO memories
942
+ (id, session_id, kind, text, importance, source,
943
+ created_at, updated_at, score, ttl, tags, embedding,
944
+ agent_id, user_id, external_id)
945
+ VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)
946
+ ON CONFLICT(id) DO UPDATE SET
947
+ text=excluded.text,
948
+ importance=excluded.importance,
949
+ updated_at=excluded.updated_at,
950
+ score=excluded.score,
951
+ tags=excluded.tags,
952
+ embedding=COALESCE(excluded.embedding, memories.embedding),
953
+ agent_id=COALESCE(excluded.agent_id, memories.agent_id),
954
+ user_id=COALESCE(excluded.user_id, memories.user_id),
955
+ external_id=COALESCE(excluded.external_id, memories.external_id),
956
+ session_id=COALESCE(excluded.session_id, memories.session_id),
957
+ source=COALESCE(excluded.source, memories.source)""",
958
+ (
959
+ mid,
960
+ session_id,
961
+ kind,
962
+ text,
963
+ importance,
964
+ source,
965
+ ts,
966
+ uts,
967
+ score,
968
+ ttl,
969
+ tags_json,
970
+ _to_blob(embedding),
971
+ agent_id,
972
+ user_id,
973
+ ext,
974
+ ),
975
+ )
976
+ item = self.get_memory(mid)
977
+ if item is None:
978
+ raise RuntimeError(f"memory {mid} disappeared after upsert")
979
+ return item
980
+
981
+ def get_memory(self, mid: str) -> StoredMemory | None:
982
+ with self._conn() as c:
983
+ row = c.execute("SELECT * FROM memories WHERE id=?", (mid,)).fetchone()
984
+ return self._row_to_memory(row) if row else None
985
+
986
+ def list_memories(
987
+ self,
988
+ limit: int = 200,
989
+ session_id: str | None = None,
990
+ kind: str | None = None,
991
+ source: str | None = None,
992
+ min_score: float | None = None,
993
+ query: str | None = None,
994
+ since: float | None = None,
995
+ until: float | None = None,
996
+ ids: list[str] | None = None,
997
+ agent_id: str | None = None,
998
+ user_id: str | None = None,
999
+ external_id: str | None = None,
1000
+ ) -> list[StoredMemory]:
1001
+ clauses: list[str] = []
1002
+ params: list = []
1003
+ if session_id:
1004
+ clauses.append("session_id = ?")
1005
+ params.append(session_id)
1006
+ if kind:
1007
+ clauses.append("kind = ?")
1008
+ params.append(kind)
1009
+ if source:
1010
+ clauses.append("source = ?")
1011
+ params.append(source)
1012
+ if min_score is not None:
1013
+ clauses.append("score >= ?")
1014
+ params.append(min_score)
1015
+ if since is not None:
1016
+ clauses.append("created_at >= ?")
1017
+ params.append(since)
1018
+ if until is not None:
1019
+ clauses.append("created_at <= ?")
1020
+ params.append(until)
1021
+ if query:
1022
+ clauses.append("text LIKE ?")
1023
+ params.append(f"%{query}%")
1024
+ if ids:
1025
+ placeholders = ",".join("?" for _ in ids)
1026
+ clauses.append(f"id IN ({placeholders})")
1027
+ params.extend(ids)
1028
+ if agent_id is not None:
1029
+ clauses.append("agent_id = ?")
1030
+ params.append(agent_id)
1031
+ if user_id is not None:
1032
+ clauses.append("user_id = ?")
1033
+ params.append(user_id)
1034
+ if external_id is not None:
1035
+ clauses.append("external_id = ?")
1036
+ params.append(external_id)
1037
+ where = ("WHERE " + " AND ".join(clauses)) if clauses else ""
1038
+ with self._conn() as c:
1039
+ rows = c.execute(
1040
+ f"SELECT * FROM memories {where} ORDER BY created_at DESC LIMIT ?",
1041
+ (*params, limit),
1042
+ ).fetchall()
1043
+ return [self._row_to_memory(r) for r in rows]
1044
+
1045
+ def list_memories_cursor(
1046
+ self,
1047
+ limit: int = 100,
1048
+ before_id: str | None = None,
1049
+ after_id: str | None = None,
1050
+ session_id: str | None = None,
1051
+ source: str | None = None,
1052
+ kind: str | None = None,
1053
+ min_score: float | None = None,
1054
+ ) -> tuple[list, str | None, str | None]:
1055
+ """Cursor-paginated memory list (audit O11).
1056
+
1057
+ Returns ``(rows, next_before_id, next_after_id)`` so the UI
1058
+ can request additional pages with a stable, opaque cursor
1059
+ (the memory id of the boundary row) instead of an offset
1060
+ that breaks when new rows are inserted.
1061
+
1062
+ ``before_id`` returns rows strictly older than that id;
1063
+ ``after_id`` returns rows strictly newer than that id. Both
1064
+ default to ``None`` (start from the most-recent / oldest row
1065
+ respectively).
1066
+ """
1067
+ with self._conn() as c:
1068
+ cursor_ts = None
1069
+ if before_id:
1070
+ row = c.execute(
1071
+ "SELECT created_at FROM memories WHERE id = ?",
1072
+ (before_id,),
1073
+ ).fetchone()
1074
+ if row is None:
1075
+ raise ValueError(f"before_id not found: {before_id}")
1076
+ cursor_ts = row["created_at"]
1077
+ elif after_id:
1078
+ row = c.execute(
1079
+ "SELECT created_at FROM memories WHERE id = ?",
1080
+ (after_id,),
1081
+ ).fetchone()
1082
+ if row is None:
1083
+ raise ValueError(f"after_id not found: {after_id}")
1084
+ cursor_ts = row["created_at"]
1085
+ clauses: list[str] = []
1086
+ params: list = []
1087
+ if session_id:
1088
+ clauses.append("session_id = ?"); params.append(session_id)
1089
+ if source:
1090
+ clauses.append("source = ?"); params.append(source)
1091
+ if kind:
1092
+ clauses.append("kind = ?"); params.append(kind)
1093
+ if min_score is not None:
1094
+ clauses.append("score >= ?"); params.append(min_score)
1095
+ if before_id and cursor_ts is not None:
1096
+ clauses.append("created_at < ?"); params.append(cursor_ts)
1097
+ if after_id and cursor_ts is not None:
1098
+ clauses.append("created_at > ?"); params.append(cursor_ts)
1099
+ where = ("WHERE " + " AND ".join(clauses)) if clauses else ""
1100
+ order = "ASC" if after_id else "DESC"
1101
+ rows = c.execute(
1102
+ f"SELECT * FROM memories {where} ORDER BY created_at {order} LIMIT ?",
1103
+ (*params, limit),
1104
+ ).fetchall()
1105
+ mems = [self._row_to_memory(r) for r in rows]
1106
+ if after_id:
1107
+ next_before_id = None
1108
+ next_after_id = mems[-1].id if mems else None
1109
+ else:
1110
+ next_before_id = mems[-1].id if mems else None
1111
+ next_after_id = None
1112
+ return mems, next_before_id, next_after_id
1113
+
1114
+ def find_memory_by_external_id(
1115
+ self,
1116
+ agent_id: str,
1117
+ external_id: str,
1118
+ user_id: str | None = None,
1119
+ ) -> StoredMemory | None:
1120
+ """Look up a memory by its (agent_id, user_id, external_id) tuple.
1121
+
1122
+ Returns ``None`` when no row matches. Used by the SDK + REST
1123
+ API so external systems can update / delete / feedback on
1124
+ memories they pushed without needing the internal row id.
1125
+ """
1126
+ if not agent_id or not external_id:
1127
+ return None
1128
+ with self._conn() as c:
1129
+ if user_id is None:
1130
+ row = c.execute(
1131
+ "SELECT * FROM memories "
1132
+ "WHERE agent_id = ? AND external_id = ? "
1133
+ "ORDER BY created_at DESC LIMIT 1",
1134
+ (agent_id, external_id),
1135
+ ).fetchone()
1136
+ else:
1137
+ row = c.execute(
1138
+ "SELECT * FROM memories "
1139
+ "WHERE agent_id = ? AND user_id = ? AND external_id = ? "
1140
+ "ORDER BY created_at DESC LIMIT 1",
1141
+ (agent_id, user_id, external_id),
1142
+ ).fetchone()
1143
+ return self._row_to_memory(row) if row else None
1144
+
1145
+ # ---- Unified recall across memories + wiki + entities -----------
1146
+ @staticmethod
1147
+ def _tokenize(query: str) -> list[str]:
1148
+ """Split a query into overlapping tokens.
1149
+
1150
+ Handles:
1151
+ * English words split on whitespace + punctuation
1152
+ * Chinese/Japanese/Korean characters split into individual
1153
+ unigrams + 2-grams (so "知识图谱" becomes [知, 识, 图, 谱,
1154
+ 知识, 识图, 图谱])
1155
+ * Lower-cases ASCII
1156
+
1157
+ Returns up to 24 tokens. Empty list for empty input.
1158
+ """
1159
+ import re as _re
1160
+ if not query:
1161
+ return []
1162
+ toks: list[str] = []
1163
+ # English words
1164
+ for w in _re.findall(r"[A-Za-z0-9_]+", query):
1165
+ w = w.lower()
1166
+ if len(w) >= 2:
1167
+ toks.append(w)
1168
+ # CJK bi-grams first (high signal), then 3-grams for longer
1169
+ # tokens. Single-character tokens are intentionally NOT added
1170
+ # by default — "图" matches too much unrelated text. We fall
1171
+ # back to unigrams only when the CJK span is exactly 1 char.
1172
+ cjk_runs = _re.findall(r"[\u3040-\u30ff\u3400-\u4dbf\u4e00-\u9fff\uac00-\ud7af]+", query)
1173
+ for word in cjk_runs:
1174
+ if len(word) == 1:
1175
+ toks.append(word)
1176
+ continue
1177
+ # 2-grams
1178
+ for i in range(len(word) - 1):
1179
+ toks.append(word[i:i+2])
1180
+ # 3-grams (lower priority — appended after 2-grams so they
1181
+ # don't displace them)
1182
+ for i in range(len(word) - 2):
1183
+ toks.append(word[i:i+3])
1184
+ # Strip common Chinese stopwords so they don't dilute scores
1185
+ stops = {"的", "了", "是", "在", "和", "与", "或", "我", "你", "他", "她", "它",
1186
+ "把", "被", "给", "从", "到", "为", "对", "及", "而", "也", "都",
1187
+ "就", "还", "但", "并", "如", "若", "则", "此", "那", "哪", "什么", "怎么"}
1188
+ out: list[str] = []
1189
+ seen: set[str] = set()
1190
+ for t in toks:
1191
+ if not t or t in stops or len(t) < 1:
1192
+ continue
1193
+ if t in seen:
1194
+ continue
1195
+ seen.add(t)
1196
+ out.append(t)
1197
+ if len(out) >= 24:
1198
+ break
1199
+ return out
1200
+
1201
+ @staticmethod
1202
+ def _like_clause(col: str, tokens: list[str]) -> tuple[str, list[str]]:
1203
+ """Build a SQL ``(col LIKE ? OR col LIKE ? ...)`` clause and
1204
+ the matching parameter list for the given tokens."""
1205
+ if not tokens:
1206
+ return "0", []
1207
+ parts = []
1208
+ params: list[str] = []
1209
+ for t in tokens:
1210
+ parts.append(f"{col} LIKE ?")
1211
+ params.append(f"%{t}%")
1212
+ return "(" + " OR ".join(parts) + ")", params
1213
+
1214
+ def recall(self, query: str, limit: int = 12,
1215
+ include: tuple[str, ...] = ("memories", "wiki", "entities"),
1216
+ bump_signals: bool = True,
1217
+ source: str | None = None) -> dict[str, list[dict]]:
1218
+ """Unified recall — returns a dict with three ranked lists.
1219
+
1220
+ Each result is tagged with its ``kind`` ("memory" | "wiki" |
1221
+ "entity") and a numeric ``score`` so callers can render a
1222
+ single ranked stream. Bumps ``memory_signals.recall_count`` on
1223
+ any memory that gets surfaced, so the dashboard's "Most
1224
+ recalled memories" widget stays accurate.
1225
+
1226
+ ``include`` lets the MCP server / CLI pick which sources to
1227
+ surface; default is all three for the broadest recall.
1228
+ """
1229
+ import time as _time
1230
+ tokens = self._tokenize(query)
1231
+ if not tokens:
1232
+ return {"memories": [], "wiki": [], "entities": [], "tokens": []}
1233
+ out: dict[str, list[dict]] = {"memories": [], "wiki": [], "entities": [], "tokens": tokens}
1234
+ with self._conn() as c:
1235
+ if "memories" in include:
1236
+ clause, params = self._like_clause("text", tokens)
1237
+ tag_clause, tag_params = self._like_clause("tags", tokens)
1238
+ sql = (
1239
+ "SELECT m.id, m.kind, m.text, m.importance, m.score, m.source, "
1240
+ "m.tags, m.created_at, m.updated_at, "
1241
+ "COALESCE(s.recall_count, 0) AS recall_count "
1242
+ "FROM memories m "
1243
+ "LEFT JOIN memory_signals s ON s.memory_id = m.id "
1244
+ f"WHERE {clause} OR {tag_clause} "
1245
+ "ORDER BY m.score DESC, m.importance DESC, m.created_at DESC "
1246
+ "LIMIT ?"
1247
+ )
1248
+ rows = c.execute(sql, (*params, *tag_params, limit * 3)).fetchall()
1249
+ for r in rows:
1250
+ tags = []
1251
+ try:
1252
+ tags = json.loads(r["tags"]) if r["tags"] else []
1253
+ except Exception:
1254
+ tags = []
1255
+ txt = (r["text"] or "").lower()
1256
+ tag_lc = ",".join(tags).lower()
1257
+ body_hits = sum(txt.count(t) for t in tokens)
1258
+ tag_hits = sum(tag_lc.count(t) for t in tokens)
1259
+ score = body_hits + 2 * tag_hits
1260
+ score *= 0.5 + float(r["importance"] or 0) * 0.8
1261
+ score *= 0.7 + float(r["score"] or 0) * 0.6
1262
+ out["memories"].append({
1263
+ "id": r["id"],
1264
+ "kind": "memory",
1265
+ "text": r["text"],
1266
+ "importance": float(r["importance"] or 0),
1267
+ "score_field": float(r["score"] or 0),
1268
+ "source": r["source"],
1269
+ "tags": tags,
1270
+ "created_at": float(r["created_at"] or 0),
1271
+ "recall_count": int(r["recall_count"] or 0),
1272
+ "score": round(score, 3),
1273
+ "preview": (r["text"] or "")[:240],
1274
+ })
1275
+ out["memories"].sort(key=lambda m: -m["score"])
1276
+ out["memories"] = out["memories"][:limit]
1277
+
1278
+ if "wiki" in include:
1279
+ clause, params = self._like_clause("title", tokens)
1280
+ body_clause, body_params = self._like_clause("body", tokens)
1281
+ sum_clause, sum_params = self._like_clause("summary", tokens)
1282
+ tag_clause, tag_params = self._like_clause("tags", tokens)
1283
+ scope_sql = ""
1284
+ scope_params: list[str] = []
1285
+ if source:
1286
+ scope_token = self._source_token(source)
1287
+ if scope_token:
1288
+ scope_sql = " AND (scope IN ('global', 'all') OR instr(','||scope||',', ?) > 0)"
1289
+ scope_params.append("," + scope_token + ",")
1290
+ sql = (
1291
+ "SELECT id, slug, title, body, summary, importance, tags, "
1292
+ "updated_at, version "
1293
+ "FROM wiki_pages "
1294
+ f"WHERE ({clause} OR {body_clause} OR {sum_clause} OR {tag_clause})"
1295
+ f"{scope_sql} "
1296
+ "ORDER BY importance DESC, updated_at DESC "
1297
+ "LIMIT ?"
1298
+ )
1299
+ rows = c.execute(
1300
+ sql,
1301
+ (*params, *body_params, *sum_params, *tag_params, *scope_params, limit * 3),
1302
+ ).fetchall()
1303
+ for r in rows:
1304
+ tags = []
1305
+ try:
1306
+ tags = json.loads(r["tags"]) if r["tags"] else []
1307
+ except Exception:
1308
+ tags = []
1309
+ t_lc = (r["title"] or "").lower()
1310
+ b_lc = (r["body"] or "").lower()
1311
+ s_lc = (r["summary"] or "").lower()
1312
+ g_lc = ",".join(tags).lower()
1313
+ hits = (
1314
+ sum(t_lc.count(t) for t in tokens) * 5
1315
+ + sum(s_lc.count(t) for t in tokens) * 3
1316
+ + sum(b_lc.count(t) for t in tokens)
1317
+ + sum(g_lc.count(t) for t in tokens) * 2
1318
+ )
1319
+ score = hits * (0.5 + float(r["importance"] or 0) * 1.5)
1320
+ out["wiki"].append({
1321
+ "id": r["id"],
1322
+ "slug": r["slug"],
1323
+ "title": r["title"],
1324
+ "body": r["body"],
1325
+ "summary": r["summary"] or "",
1326
+ "tags": tags,
1327
+ "importance": float(r["importance"] or 0),
1328
+ "version": int(r["version"] or 1),
1329
+ "updated_at": float(r["updated_at"] or 0),
1330
+ "kind": "wiki",
1331
+ "score": round(score, 3),
1332
+ "preview": ((r["summary"] or r["body"]) or "")[:240],
1333
+ })
1334
+ out["wiki"].sort(key=lambda m: -m["score"])
1335
+ out["wiki"] = out["wiki"][:limit]
1336
+
1337
+ if "entities" in include:
1338
+ clause, params = self._like_clause("name", tokens)
1339
+ sql = (
1340
+ "SELECT id, name, kind, mention_count, weight "
1341
+ f"FROM entities WHERE {clause} "
1342
+ "ORDER BY weight DESC, mention_count DESC LIMIT ?"
1343
+ )
1344
+ rows = c.execute(sql, (*params, limit)).fetchall()
1345
+ for r in rows:
1346
+ n_lc = (r["name"] or "").lower()
1347
+ hits = sum(n_lc.count(t) for t in tokens)
1348
+ score = hits * (0.5 + float(r["weight"] or 0) * 1.2 + float(r["mention_count"] or 0) * 0.05)
1349
+ out["entities"].append({
1350
+ "id": r["id"],
1351
+ "name": r["name"],
1352
+ "kind": "entity",
1353
+ "entity_kind": r["kind"],
1354
+ "mention_count": int(r["mention_count"] or 0),
1355
+ "weight": float(r["weight"] or 0),
1356
+ "score": round(score, 3),
1357
+ })
1358
+ out["entities"].sort(key=lambda m: -m["score"])
1359
+ out["entities"] = out["entities"][:limit]
1360
+
1361
+ # Bump recall_count on surfaced memories (so dashboard tracks usage)
1362
+ if bump_signals and out["memories"]:
1363
+ now = _time.time()
1364
+ ids = [m["id"] for m in out["memories"]]
1365
+ with self._conn() as c:
1366
+ for mid in ids:
1367
+ c.execute(
1368
+ "INSERT INTO memory_signals (memory_id, recall_count, last_recalled_at, updated_at) "
1369
+ "VALUES (?, 1, ?, ?) "
1370
+ "ON CONFLICT(memory_id) DO UPDATE SET "
1371
+ "recall_count = recall_count + 1, "
1372
+ "last_recalled_at = excluded.last_recalled_at, "
1373
+ "updated_at = excluded.updated_at",
1374
+ (mid, now, now),
1375
+ )
1376
+ return out
1377
+
1378
+ # ----------------------------------------------------------------
1379
+ # Hybrid recall: BM25 (FTS5) + semantic (cosine) + entity overlap
1380
+ # fused via Reciprocal Rank Fusion (RRF).
1381
+ #
1382
+ # Mem0 (April 2026) showed that fusing BM25 keyword + semantic
1383
+ # + entity signal is worth ~20 points on LoCoMo / LongMemEval.
1384
+ # SQLite FTS5 ships with the kernel (no new dep), so the cost
1385
+ # of the keyword channel is effectively zero.
1386
+ #
1387
+ # Output is the same shape as the existing ``recall()`` method
1388
+ # so the API + dashboard don't need to change to consume it.
1389
+ # ----------------------------------------------------------------
1390
+ def recall_hybrid(
1391
+ self,
1392
+ query: str,
1393
+ limit: int = 12,
1394
+ source: str | None = None,
1395
+ rrf_k: int = 60,
1396
+ bm25_pool: int = 50,
1397
+ embed_pool: int = 50,
1398
+ include: tuple[str, ...] = ("memories", "wiki", "entities"),
1399
+ bump_signals: bool = True,
1400
+ level: int = 1,
1401
+ adaptive: bool = False,
1402
+ ) -> dict[str, list[dict]]:
1403
+ """RRF-fused recall across BM25 + semantic + entity channels.
1404
+
1405
+ ``adaptive=True`` blends the 4D AdaptiveScore (importance +
1406
+ recency + usage + graph_degree) and applies the graph boost
1407
+ from ``jobs.graph.graph_boost``. Off by default to keep the
1408
+ existing dashboard + MCP behaviour byte-identical.
1409
+ RRF-fused recall across BM25 + semantic + entity channels.
1410
+
1411
+ ``source`` enables per-source scope: only wiki pages whose
1412
+ scope is 'global' OR contains this source are returned. If
1413
+ ``source`` is None, no scope filter is applied (the dashboard
1414
+ + admin recall see everything).
1415
+
1416
+ ``level`` is the OpenViking-style tiered-loader knob:
1417
+
1418
+ * 0 → L0 (titles + tags + preview, never the raw body / full
1419
+ text). Smallest payload, suitable for sidebar chips.
1420
+ * 1 → L1 (default): summary + first 800 chars of body;
1421
+ full text of memory rows. Recommended for the Timeline.
1422
+ * 2 → L2: full body, full text. Use this when the UI is
1423
+ explicitly expanding a wiki page or memory, not for
1424
+ bulk recall.
1425
+ """
1426
+ import time as _time
1427
+ from .retrieval import (
1428
+ fuse_rrf,
1429
+ bm25_search,
1430
+ detect_temporal_intent,
1431
+ temporal_score,
1432
+ )
1433
+ out: dict[str, list[dict]] = {"memories": [], "wiki": [], "entities": [], "tokens": self._tokenize(query)}
1434
+ if not (query or "").strip():
1435
+ return out
1436
+
1437
+ # --- channel 1: BM25 (FTS5) ---------------------------------
1438
+ bm25_mem: list[dict] = []
1439
+ bm25_wiki: list[dict] = []
1440
+ if "memories" in include:
1441
+ bm25_mem = bm25_search(self, query, kind="memories", limit=bm25_pool,
1442
+ source_filter=source)
1443
+ if "wiki" in include:
1444
+ bm25_wiki = bm25_search(self, query, kind="wiki", limit=bm25_pool,
1445
+ source_filter=source)
1446
+ # Each result has a positive bm25 score and the row's primary key.
1447
+
1448
+ # --- channel 2: semantic (cosine) ---------------------------
1449
+ # We do this through the existing search_by_embedding helper,
1450
+ # but we need a query embedding. If the embedder isn't set up
1451
+ # at the store level, the helper gracefully returns [].
1452
+ sem_mem: list[dict] = []
1453
+ sem_wiki: list[dict] = []
1454
+ try:
1455
+ q_emb = self._embed_query(query)
1456
+ if q_emb:
1457
+ sem_mem_rows = self.search_by_embedding(q_emb, top_k=embed_pool)
1458
+ sem_mem = [{"id": r.id, "_score": float(r.score or 0)} for r in sem_mem_rows]
1459
+ sem_wiki = self.search_wiki_by_embedding(q_emb, top_k=embed_pool)
1460
+ sem_wiki = [{"id": r["id"], "_score": float(r.get("importance", 0))} for r in sem_wiki]
1461
+ except Exception:
1462
+ pass
1463
+
1464
+ # --- channel 3: entity overlap -----------------------------
1465
+ ent_mem: list[dict] = []
1466
+ ent_entities: list[dict] = []
1467
+ if "entities" in include:
1468
+ try:
1469
+ from ..graph.extract import extract_entities
1470
+ ents = extract_entities(query, min_count=1)
1471
+ if ents:
1472
+ names = [n for (n, _k) in ents]
1473
+ ent_rows = self.search_entities_by_names(names, limit=bm25_pool)
1474
+ ent_entities = [{"id": r["id"], "_score": float(r.get("weight", 0))} for r in ent_rows]
1475
+ ent_mem = self.search_memories_by_entity_names(
1476
+ names, limit=bm25_pool, source_filter=source
1477
+ )
1478
+ except Exception:
1479
+ pass
1480
+
1481
+ # --- fuse ---------------------------------------------------
1482
+ fused_mem = fuse_rrf([bm25_mem, sem_mem, ent_mem], k=rrf_k)
1483
+ fused_wiki = fuse_rrf([bm25_wiki, sem_wiki], k=rrf_k)
1484
+ fused_ent = fuse_rrf([ent_entities], k=rrf_k)
1485
+
1486
+ # --- temporal reasoning -------------------------------------
1487
+ # Mem0 v3 showed that detecting "what is the current X" vs
1488
+ # "the X I shipped last week" in the query and reranking by
1489
+ # date relevance is the single biggest recall improvement
1490
+ # (~27 points on LongMemEval). The primitives below are in
1491
+ # ``retrieval.py``; we apply them here as a multiplier on the
1492
+ # fused RRF score (added to each row, not multiplied with the
1493
+ # RRF, so it ranks the same direction).
1494
+ t_intent, t_conf = detect_temporal_intent(query)
1495
+ _now_ts = _time.time()
1496
+ if t_intent != "any" and t_conf > 0:
1497
+ # We don't multiply here: the per-row hydration step in
1498
+ # _hydrate_memories / _hydrate_wiki reads the actual
1499
+ # created_at / updated_at from SQLite and recomputes
1500
+ # the temporal score with that timestamp. The fused
1501
+ # rows only need the intent + confidence carried through
1502
+ # so the hydration helpers can pick them up.
1503
+ for r in fused_mem:
1504
+ r.setdefault("_t_intent", t_intent)
1505
+ r.setdefault("_t_conf", t_conf)
1506
+ for r in fused_wiki:
1507
+ r.setdefault("_t_intent", t_intent)
1508
+ r.setdefault("_t_conf", t_conf)
1509
+ else:
1510
+ for r in fused_mem + fused_wiki:
1511
+ r.setdefault("_t_intent", "any")
1512
+ r.setdefault("_t_conf", 0.0)
1513
+
1514
+ # --- materialise (re-hydrate the rows) ---------------------
1515
+ mem_ids = [r["id"] for r in fused_mem[:limit * 2]]
1516
+ wiki_ids = [r["id"] for r in fused_wiki[:limit * 2]]
1517
+ ent_ids = [r["id"] for r in fused_ent[:limit * 2]]
1518
+ if mem_ids:
1519
+ out["memories"] = self._hydrate_memories(
1520
+ mem_ids, fused_mem, source=source,
1521
+ t_intent=t_intent, t_conf=t_conf, now=_now_ts,
1522
+ level=level,
1523
+ )
1524
+ if wiki_ids:
1525
+ out["wiki"] = self._hydrate_wiki(
1526
+ wiki_ids, fused_wiki,
1527
+ t_intent=t_intent, t_conf=t_conf, now=_now_ts,
1528
+ level=level,
1529
+ )
1530
+ if ent_ids:
1531
+ out["entities"] = self._hydrate_entities(ent_ids, fused_ent)
1532
+
1533
+ # Trim
1534
+ out["memories"] = out["memories"][:limit]
1535
+ out["wiki"] = out["wiki"][:limit]
1536
+ out["entities"] = out["entities"][:limit]
1537
+
1538
+ # Surface intent in the result so the UI can show it.
1539
+ out["temporal_intent"] = t_intent
1540
+ out["temporal_confidence"] = round(t_conf, 2)
1541
+
1542
+ # --- 3D adaptive scoring + graph boost ----------------------
1543
+ # ``adaptive=True`` blends the 4D AdaptiveScore (importance +
1544
+ # recency + usage + graph_degree) with the existing RRF
1545
+ # score. The graph boost is a separate multiplier in
1546
+ # [0, 1.5] computed from the query's entity neighbourhood;
1547
+ # it can dominate when the memory shares multiple entities
1548
+ # with the query. Implementation: 60% RRF + 40% adaptive
1549
+ # blend, multiplied by (1 + graph_boost). This is the
1550
+ # "third dimension" the article calls out as Mem0's
1551
+ # differentiator from plain RAG.
1552
+ if adaptive and out["memories"]:
1553
+ try:
1554
+ from ..jobs.graph import (
1555
+ graph_boost as _gb,
1556
+ adaptive_score as _as,
1557
+ ) # type: ignore
1558
+ except Exception:
1559
+ _gb = _as = None # type: ignore
1560
+ if _gb is not None and _as is not None:
1561
+ mem_ids = [m["id"] for m in out["memories"]]
1562
+ boosts = _gb(self, query, mem_ids)
1563
+ now_ts = _time.time()
1564
+ for m in out["memories"]:
1565
+ last_recalled = m.get("last_recalled_at")
1566
+ s_ = _as(
1567
+ importance=m.get("importance") or 0.5,
1568
+ created_at=m.get("created_at") or now_ts,
1569
+ now=now_ts,
1570
+ recall_count=int(m.get("recall_count") or 0),
1571
+ last_recalled_at=last_recalled,
1572
+ graph_degree=len(boosts[m["id"]].matched_entities)
1573
+ if m["id"] in boosts else 0,
1574
+ )
1575
+ g_boost = boosts[m["id"]].boost if m["id"] in boosts else 0.0
1576
+ blended = (0.6 * (m.get("score") or 0) + 0.4 * s_.blended)
1577
+ new_score = blended * (1.0 + g_boost)
1578
+ m["score"] = round(new_score, 4)
1579
+ m["_adaptive"] = s_.to_dict()
1580
+ m["_graph_boost"] = g_boost
1581
+ if m["id"] in boosts:
1582
+ m["_graph_entities"] = boosts[m["id"]].matched_entities
1583
+ out["memories"].sort(key=lambda m: -m["score"])
1584
+ out["adaptive"] = True
1585
+
1586
+ if bump_signals and out["memories"]:
1587
+ now = _time.time()
1588
+ with self._conn() as c:
1589
+ for m in out["memories"]:
1590
+ c.execute(
1591
+ "INSERT INTO memory_signals (memory_id, recall_count, last_recalled_at, updated_at) "
1592
+ "VALUES (?, 1, ?, ?) "
1593
+ "ON CONFLICT(memory_id) DO UPDATE SET "
1594
+ "recall_count = recall_count + 1, "
1595
+ "last_recalled_at = excluded.last_recalled_at, "
1596
+ "updated_at = excluded.updated_at",
1597
+ (m["id"], now, now),
1598
+ )
1599
+ return out
1600
+
1601
+ # --- Hybrid recall helpers -------------------------------------
1602
+
1603
+ def _embed_query(self, query: str) -> list[float] | None:
1604
+ """Return a query embedding if an embedder is configured.
1605
+
1606
+ The store does not own an embedder directly, but the app layer
1607
+ often does — we expose a hook so a wrapper can attach one.
1608
+ For now, returns None unless ``self._embedder`` is set, which
1609
+ keeps this file dependency-free.
1610
+ """
1611
+ emb = getattr(self, "_embedder", None)
1612
+ if emb is None:
1613
+ return None
1614
+ try:
1615
+ return list(emb.embed_query(query) or [])
1616
+ except Exception:
1617
+ return None
1618
+
1619
+ def set_embedder(self, embedder) -> None:
1620
+ """Attach a query embedder for hybrid recall."""
1621
+ self._embedder = embedder
1622
+
1623
+ def search_wiki_by_embedding(self, query_embedding, top_k=20) -> list[dict]:
1624
+ """Stub for the *semantic* wiki channel.
1625
+
1626
+ We do not yet store embeddings on ``wiki_pages``; this channel
1627
+ is therefore a poor-man's proxy ordered by a blend of
1628
+ importance and recency. The shape matches the rest of the
1629
+ hybrid pipeline (``{"id", "_score"}``) so the fusion step
1630
+ can consume it.
1631
+
1632
+ ``source_filter`` is applied here so out-of-scope pages never
1633
+ make it into recall — fixing a long-standing bug where a
1634
+ literal ``?`` was being passed as a scope token.
1635
+ """
1636
+ # ``query_embedding`` is currently unused; once wiki embeddings
1637
+ # are added (see roadmap), this becomes a real cosine rank.
1638
+ del query_embedding
1639
+ tok = self._source_token(source=None) # placeholder
1640
+ with self._conn() as c:
1641
+ if tok:
1642
+ # When a source filter is set, return only 'global' OR
1643
+ # scope tokens that match.
1644
+ rows = c.execute(
1645
+ "SELECT id, title, body, summary, importance, updated_at, scope "
1646
+ "FROM wiki_pages "
1647
+ "WHERE scope IN ('global', 'all') OR instr(','||scope||',', ?) > 0 "
1648
+ "ORDER BY (COALESCE(importance,0)*0.6 + 0.4) DESC, updated_at DESC LIMIT ?",
1649
+ ("," + tok + ",", top_k),
1650
+ ).fetchall()
1651
+ else:
1652
+ rows = c.execute(
1653
+ "SELECT id, title, body, summary, importance, updated_at, scope "
1654
+ "FROM wiki_pages "
1655
+ "ORDER BY (COALESCE(importance,0)*0.6 + 0.4) DESC, updated_at DESC LIMIT ?",
1656
+ (top_k,),
1657
+ ).fetchall()
1658
+ return [{"id": r["id"], "_score": float(r["importance"] or 0)} for r in rows]
1659
+
1660
+ @staticmethod
1661
+ def _source_token(name: str) -> str:
1662
+ return (name or '').strip().lower().replace(' ', '-')
1663
+
1664
+ def _hydrate_memories(self, ids: list[str], scored: list[dict], source: str | None = None,
1665
+ t_intent: str = "any", t_conf: float = 0.0, now: float = 0.0,
1666
+ level: int = 1) -> list[dict]:
1667
+ if not ids:
1668
+ return []
1669
+ score_map = {r["id"]: r.get("_rrf", 0) for r in scored}
1670
+ placeholders = ",".join("?" * len(ids))
1671
+ with self._conn() as c:
1672
+ rows = c.execute(
1673
+ f"""SELECT m.id, m.kind, m.text, m.importance, m.score, m.source,
1674
+ m.tags, m.created_at, m.updated_at,
1675
+ m.agent_id, m.user_id, m.external_id,
1676
+ COALESCE(s.recall_count, 0) AS recall_count
1677
+ FROM memories m
1678
+ LEFT JOIN memory_signals s ON s.memory_id = m.id
1679
+ WHERE m.id IN ({placeholders}) """,
1680
+ ids,
1681
+ ).fetchall()
1682
+ if not now:
1683
+ import time as _t
1684
+ now = _t.time()
1685
+ out = []
1686
+ for r in rows:
1687
+ tags = []
1688
+ try:
1689
+ tags = json.loads(r["tags"]) if r["tags"] else []
1690
+ except Exception:
1691
+ tags = []
1692
+ base_score = score_map.get(r["id"], 0)
1693
+ t_mult = temporal_score(
1694
+ created_at=float(r["created_at"] or now),
1695
+ updated_at=float(r["updated_at"] or r["created_at"] or now),
1696
+ intent=t_intent, now=now, confidence=t_conf,
1697
+ )
1698
+ full_text = r["text"] or ""
1699
+ # Tiered payload (OpenViking pattern): level<=0 trims
1700
+ # the full text down to the preview and tags only.
1701
+ # level>=1 keeps the full text. level<=2 is the default
1702
+ # ("L1") which keeps the text but trims the body in the
1703
+ # corresponding wiki hydration.
1704
+ if level <= 0:
1705
+ payload_text = ""
1706
+ else:
1707
+ payload_text = full_text
1708
+ out.append({
1709
+ "id": r["id"],
1710
+ "kind": "memory",
1711
+ "text": payload_text,
1712
+ "importance": float(r["importance"] or 0),
1713
+ "score_field": float(r["score"] or 0),
1714
+ "source": r["source"],
1715
+ "tags": tags,
1716
+ "created_at": float(r["created_at"] or 0),
1717
+ "recall_count": int(r["recall_count"] or 0),
1718
+ "agent_id": r["agent_id"] if "agent_id" in r.keys() else None,
1719
+ "user_id": r["user_id"] if "user_id" in r.keys() else None,
1720
+ "external_id": r["external_id"] if "external_id" in r.keys() else None,
1721
+ "score": round(base_score * t_mult, 4),
1722
+ "_temporal_multiplier": round(t_mult, 3),
1723
+ "preview": full_text[:240],
1724
+ "_level": level,
1725
+ })
1726
+ # Filter by source if a scope applies. Memories are not
1727
+ # scoped (only wiki pages are), but we still respect the
1728
+ # source filter for memories that came from a different
1729
+ # client when the caller asks for a specific source.
1730
+ if source:
1731
+ tok = self._source_token(source)
1732
+ out = [m for m in out if (m.get("source") or "").split("/")[0] in (tok, "all")]
1733
+ out.sort(key=lambda m: -m["score"])
1734
+ return out
1735
+
1736
+ def _hydrate_wiki(self, ids: list[str], scored: list[dict],
1737
+ t_intent: str = "any", t_conf: float = 0.0, now: float = 0.0,
1738
+ level: int = 1) -> list[dict]:
1739
+ if not ids:
1740
+ return []
1741
+ score_map = {r["id"]: r.get("_rrf", 0) for r in scored}
1742
+ placeholders = ",".join("?" * len(ids))
1743
+ with self._conn() as c:
1744
+ rows = c.execute(
1745
+ f"""SELECT id, slug, title, body, summary, importance, tags, updated_at, version, scope, created_at
1746
+ FROM wiki_pages WHERE id IN ({placeholders}) """,
1747
+ ids,
1748
+ ).fetchall()
1749
+ if not now:
1750
+ import time as _t
1751
+ now = _t.time()
1752
+ out = []
1753
+ for r in rows:
1754
+ tags = []
1755
+ try:
1756
+ tags = json.loads(r["tags"]) if r["tags"] else []
1757
+ except Exception:
1758
+ tags = []
1759
+ base_score = score_map.get(r["id"], 0)
1760
+ t_mult = temporal_score(
1761
+ created_at=float(r["created_at"] or now),
1762
+ updated_at=float(r["updated_at"] or r["created_at"] or now),
1763
+ intent=t_intent, now=now, confidence=t_conf,
1764
+ )
1765
+ full_body = r["body"] or ""
1766
+ summary_txt = r["summary"] or ""
1767
+ # Tiered payload for wiki pages:
1768
+ # L0 (level<=0): title + tags + preview only — body and
1769
+ # summary dropped entirely.
1770
+ # L1 (default, level<=1): keep summary; cap body at 800
1771
+ # chars so the prompt still fits cheaply.
1772
+ # L2 (level>=2): full body.
1773
+ if level <= 0:
1774
+ payload_summary = ""
1775
+ payload_body = ""
1776
+ elif level == 1:
1777
+ payload_summary = summary_txt
1778
+ payload_body = full_body[:800]
1779
+ else:
1780
+ payload_summary = summary_txt
1781
+ payload_body = full_body
1782
+ out.append({
1783
+ "id": r["id"],
1784
+ "kind": "wiki",
1785
+ "slug": r["slug"],
1786
+ "title": r["title"],
1787
+ "summary": payload_summary,
1788
+ "body": payload_body,
1789
+ "importance": float(r["importance"] or 0),
1790
+ "tags": tags,
1791
+ "scope": r["scope"] or "global",
1792
+ "updated_at": float(r["updated_at"] or 0),
1793
+ "version": int(r["version"] or 1),
1794
+ "score": round(base_score * t_mult, 4),
1795
+ "_temporal_multiplier": round(t_mult, 3),
1796
+ "preview": (summary_txt or full_body or "")[:240],
1797
+ "_level": level,
1798
+ })
1799
+ out.sort(key=lambda m: -m["score"])
1800
+ return out
1801
+
1802
+ def _hydrate_entities(self, ids: list[str], scored: list[dict]) -> list[dict]:
1803
+ if not ids:
1804
+ return []
1805
+ score_map = {r["id"]: r.get("_rrf", 0) for r in scored}
1806
+ placeholders = ",".join("?" * len(ids))
1807
+ with self._conn() as c:
1808
+ rows = c.execute(
1809
+ f"""SELECT id, name, kind, mention_count, weight
1810
+ FROM entities WHERE id IN ({placeholders}) """,
1811
+ ids,
1812
+ ).fetchall()
1813
+ out = []
1814
+ for r in rows:
1815
+ out.append({
1816
+ "id": r["id"],
1817
+ "kind": "entity",
1818
+ "name": r["name"],
1819
+ "entity_kind": r["kind"],
1820
+ "mention_count": int(r["mention_count"] or 0),
1821
+ "weight": float(r["weight"] or 0),
1822
+ "score": round(score_map.get(r["id"], 0), 4),
1823
+ })
1824
+ out.sort(key=lambda m: -m["score"])
1825
+ return out
1826
+
1827
+ # ----------------------------------------------------------------
1828
+ # Entity-lookup helpers used by the entity channel of hybrid recall.
1829
+ #
1830
+ # Stored entity names use a kind prefix (``concept:Codex``,
1831
+ # ``tag:auto``, ``wiki:foo``) so the same surface string can refer
1832
+ # to several kinds without colliding on the UNIQUE(name,kind)
1833
+ # constraint. Caller code typically only has the bare token
1834
+ # (e.g. extracted by ``graph.extract.extract_entities``), so we
1835
+ # match against both the full prefixed name and the suffix after
1836
+ # the colon.
1837
+ # ----------------------------------------------------------------
1838
+ def entity_by_name(self, name: str) -> dict | None:
1839
+ """Look up a single entity row by its canonical name.
1840
+
1841
+ Returns ``None`` if the entity is unknown. Used by the graph
1842
+ job (``subgraph_for``) to attach ``kind`` / ``weight`` to
1843
+ nodes it materialises from a query.
1844
+ """
1845
+ n = (name or "").strip()
1846
+ if not n:
1847
+ return None
1848
+ with self._conn() as c:
1849
+ row = c.execute(
1850
+ "SELECT * FROM entities WHERE name = ? LIMIT 1",
1851
+ (n,),
1852
+ ).fetchone()
1853
+ if not row:
1854
+ return None
1855
+ return dict(row)
1856
+
1857
+ def related_entities(self, name: str, limit: int = 32) -> list[str]:
1858
+ """Return the entity names connected to ``name`` via a relation.
1859
+
1860
+ Walks both ``src = name`` and ``dst = name`` so the caller
1861
+ doesn't have to know which side the entity landed on.
1862
+ """
1863
+ n = (name or "").strip()
1864
+ if not n:
1865
+ return []
1866
+ with self._conn() as c:
1867
+ rows = c.execute(
1868
+ "SELECT src, dst FROM relations WHERE src = ? OR dst = ? LIMIT ?",
1869
+ (n, n, limit),
1870
+ ).fetchall()
1871
+ out: list[str] = []
1872
+ for r in rows:
1873
+ other = r["dst"] if r["src"] == n else r["src"]
1874
+ if other and other != n and other not in out:
1875
+ out.append(other)
1876
+ return out[:limit]
1877
+
1878
+ def upsert_entity_mention(self, memory_id: str, entity_name: str,
1879
+ *, weight: float = 0.5) -> bool:
1880
+ """Record that ``memory_id`` mentions ``entity_name``.
1881
+
1882
+ Idempotent: (memory_id, entity_id) is the primary key. Returns
1883
+ True if a new row was inserted, False if it already existed
1884
+ (in which case the existing weight is left alone — the first
1885
+ mention is the strongest signal).
1886
+ """
1887
+ n = (entity_name or "").strip()
1888
+ if not memory_id or not n:
1889
+ return False
1890
+ with self._conn() as c:
1891
+ row = c.execute(
1892
+ "SELECT id FROM entities WHERE name = ?", (n,),
1893
+ ).fetchone()
1894
+ if not row:
1895
+ return False
1896
+ try:
1897
+ c.execute(
1898
+ "INSERT INTO entity_mentions (memory_id, entity_id, weight, created_at) "
1899
+ "VALUES (?, ?, ?, ?)",
1900
+ (memory_id, row["id"], float(weight), time.time()),
1901
+ )
1902
+ return True
1903
+ except sqlite3.IntegrityError:
1904
+ return False
1905
+
1906
+ def rebuild_entity_mentions(self) -> int:
1907
+ """Re-extract entities from every memory text and write the
1908
+ (memory_id, entity_id) rows needed by ``graph_boost`` and the
1909
+ knowledge-graph UI.
1910
+
1911
+ Idempotent: re-running clears the previous mentions first.
1912
+ Returns the number of new mention rows.
1913
+ """
1914
+ # We reuse the lightweight graph extractor. Keeping the
1915
+ # import local avoids a circular import at module load.
1916
+ from ..graph.extract import extract_entities
1917
+ n_inserted = 0
1918
+ with self._conn() as c:
1919
+ c.execute("DELETE FROM entity_mentions")
1920
+ rows = c.execute(
1921
+ "SELECT id, text, tags, source FROM memories"
1922
+ ).fetchall()
1923
+ for r in rows:
1924
+ ents = extract_entities(r["text"] or "")
1925
+ for name, _kind in ents:
1926
+ ent = c.execute(
1927
+ "SELECT id FROM entities WHERE name = ?", (name,),
1928
+ ).fetchone()
1929
+ if not ent:
1930
+ continue
1931
+ try:
1932
+ c.execute(
1933
+ "INSERT INTO entity_mentions (memory_id, entity_id, weight, created_at) "
1934
+ "VALUES (?, ?, 0.5, ?)",
1935
+ (r["id"], ent["id"], time.time()),
1936
+ )
1937
+ n_inserted += 1
1938
+ except sqlite3.IntegrityError:
1939
+ pass
1940
+ return n_inserted
1941
+
1942
+ def memory_ids_for_entity(self, name: str, limit: int = 64) -> list[str]:
1943
+ """Return the memory ids that mention the given entity.
1944
+
1945
+ Joins ``entity_mentions`` → ``entities`` so callers can look
1946
+ up the backing memories of a graph node in O(1) without
1947
+ re-running entity extraction on the original text.
1948
+ """
1949
+ n = (name or "").strip()
1950
+ if not n:
1951
+ return []
1952
+ with self._conn() as c:
1953
+ rows = c.execute(
1954
+ """SELECT em.memory_id
1955
+ FROM entity_mentions em
1956
+ JOIN entities e ON e.id = em.entity_id
1957
+ WHERE e.name = ?
1958
+ ORDER BY em.weight DESC
1959
+ LIMIT ?""",
1960
+ (n, limit),
1961
+ ).fetchall()
1962
+ return [r["memory_id"] for r in rows if r["memory_id"]]
1963
+
1964
+ def graph_subgraph_for_query(
1965
+ self,
1966
+ query: str,
1967
+ *,
1968
+ max_hops: int = 1,
1969
+ max_nodes: int = 32,
1970
+ max_edges: int = 64,
1971
+ ) -> dict:
1972
+ """Convenience wrapper: same as
1973
+ ``loop_memory.jobs.graph.subgraph_for`` but inlined so the
1974
+ store can serve it from a single SQL path. The graph job
1975
+ uses this when it wants to stay inside the store's
1976
+ transaction boundary.
1977
+ """
1978
+ from ..jobs.graph import subgraph_for as _sg # type: ignore
1979
+ sg = _sg(self, query, max_hops=max_hops,
1980
+ max_nodes=max_nodes, max_edges=max_edges)
1981
+ return sg.to_dict()
1982
+
1983
+ def search_entities_by_names(self, names: list[str], limit: int = 20) -> list[dict]:
1984
+ if not names:
1985
+ return []
1986
+ # Build a list of every candidate form for each input name.
1987
+ candidates: list[str] = []
1988
+ seen: set[str] = set()
1989
+ for n in names:
1990
+ base = (n or "").strip().lower()
1991
+ if not base:
1992
+ continue
1993
+ for form in (base, base.split(":")[-1] if ":" in base else base):
1994
+ if form and form not in seen:
1995
+ candidates.append(form)
1996
+ seen.add(form)
1997
+ if not candidates:
1998
+ return []
1999
+ with self._conn() as c:
2000
+ qmarks = ",".join("?" * len(candidates))
2001
+ # Match either the full prefixed name OR the suffix
2002
+ # after the last ':'. LIKE on the lower-cased name covers
2003
+ # both, and we dedupe in Python at the end.
2004
+ rows = c.execute(
2005
+ f"""SELECT id, name, kind, mention_count, weight
2006
+ FROM entities
2007
+ WHERE LOWER(name) IN ({qmarks})
2008
+ OR LOWER(name) LIKE '%:' || ?
2009
+ OR LOWER(name) = ?
2010
+ GROUP BY id
2011
+ ORDER BY weight DESC, mention_count DESC LIMIT ?""",
2012
+ (*candidates, candidates[0], candidates[0], limit),
2013
+ ).fetchall()
2014
+ return [dict(r) for r in rows]
2015
+
2016
+ def search_memories_by_entity_names(
2017
+ self, names: list[str], limit: int = 50, source_filter: str | None = None
2018
+ ) -> list[dict]:
2019
+ if not names:
2020
+ return []
2021
+ candidates: list[str] = []
2022
+ seen: set[str] = set()
2023
+ for n in names:
2024
+ base = (n or "").strip().lower()
2025
+ if not base:
2026
+ continue
2027
+ for form in (base, base.split(":")[-1] if ":" in base else base):
2028
+ if form and form not in seen:
2029
+ candidates.append(form)
2030
+ seen.add(form)
2031
+ if not candidates:
2032
+ return []
2033
+ with self._conn() as c:
2034
+ qmarks = ",".join("?" * len(candidates))
2035
+ sql = (
2036
+ f"""SELECT m.id, MAX(m.score) AS mem_score
2037
+ FROM memories m
2038
+ JOIN entity_mentions em ON em.memory_id = m.id
2039
+ JOIN entities e ON e.id = em.entity_id
2040
+ WHERE LOWER(e.name) IN ({qmarks})
2041
+ OR LOWER(e.name) LIKE '%:' || ?
2042
+ OR LOWER(e.name) = ?
2043
+ """
2044
+ )
2045
+ params: list = list(candidates) + [candidates[0], candidates[0]]
2046
+ if source_filter:
2047
+ sql += " AND (m.source LIKE ? OR m.source LIKE ?) "
2048
+ tok = self._source_token(source_filter)
2049
+ params.extend([f"{tok}/%", tok])
2050
+ sql += " GROUP BY m.id ORDER BY SUM(em.weight) DESC LIMIT ?"
2051
+ params.append(limit)
2052
+ rows = c.execute(sql, params).fetchall()
2053
+ return [{"id": r["id"], "_score": float(r["mem_score"] or 0)} for r in rows]
2054
+
2055
+ def search_by_embedding(
2056
+ self, query_embedding: list[float], top_k: int = 20
2057
+ ) -> list[StoredMemory]:
2058
+ """Brute-force cosine over every row. Fine up to ~10k items."""
2059
+ if not query_embedding:
2060
+ return []
2061
+ with self._conn() as c:
2062
+ rows = c.execute(
2063
+ "SELECT * FROM memories WHERE embedding IS NOT NULL"
2064
+ ).fetchall()
2065
+ scored: list[tuple[float, StoredMemory]] = []
2066
+ for r in rows:
2067
+ mem = self._row_to_memory(r)
2068
+ if mem.embedding is None:
2069
+ continue
2070
+ scored.append((_cosine(query_embedding, mem.embedding), mem))
2071
+ scored.sort(key=lambda x: x[0], reverse=True)
2072
+ return [m for _, m in scored[:top_k]]
2073
+
2074
+ def _row_to_memory(self, row: sqlite3.Row) -> StoredMemory:
2075
+ import json
2076
+
2077
+ tags = []
2078
+ if row["tags"]:
2079
+ try:
2080
+ tags = json.loads(row["tags"])
2081
+ except (ValueError, TypeError):
2082
+ logger.warning("corrupt tags for memory %s; resetting", row["id"])
2083
+ tags = []
2084
+ return StoredMemory(
2085
+ id=row["id"],
2086
+ kind=row["kind"],
2087
+ text=row["text"],
2088
+ importance=row["importance"],
2089
+ source=row["source"],
2090
+ session_id=row["session_id"],
2091
+ created_at=row["created_at"],
2092
+ updated_at=row["updated_at"],
2093
+ score=row["score"],
2094
+ ttl=row["ttl"],
2095
+ tags=tags,
2096
+ embedding=_from_blob(row["embedding"]),
2097
+ agent_id=row["agent_id"] if "agent_id" in row.keys() else None,
2098
+ user_id=row["user_id"] if "user_id" in row.keys() else None,
2099
+ external_id=row["external_id"] if "external_id" in row.keys() else None,
2100
+ )
2101
+
2102
+ # --- wiki pages -------------------------------------------------------
2103
+
2104
+ def upsert_wiki_page(
2105
+ self,
2106
+ *,
2107
+ slug: str,
2108
+ title: str,
2109
+ body: str,
2110
+ summary: str | None = None,
2111
+ tags: list[str] | None = None,
2112
+ importance: float = 0.5,
2113
+ evidence_ids: list[str] | None = None,
2114
+ run_id: str | None = None,
2115
+ scope: str | None = None,
2116
+ key_facts: list[str] | None = None,
2117
+ contradicting_ids: list[str] | None = None,
2118
+ auto_classification: dict | str | None = None,
2119
+ source_hint: str | None = None,
2120
+ ) -> Dict[str, Any]:
2121
+ """Create-or-update a wiki page by slug.
2122
+
2123
+ Returns the full row as a dict so the API can hand it back to
2124
+ the UI without an extra round-trip.
2125
+
2126
+ ``key_facts`` and ``contradicting_ids`` are optional JSON-array
2127
+ columns (see ``_init_schema``). Older callers pass neither
2128
+ and the columns stay NULL. When a new row omits ``scope``, the
2129
+ local wiki classifier chooses ``global`` only for universal
2130
+ security guidance; otherwise it derives a client scope from the
2131
+ evidence or falls back to ``codex``. Existing rows preserve scope.
2132
+ """
2133
+ import json as _json
2134
+ import uuid as _uuid
2135
+ now = time.time()
2136
+ tags_json = _json.dumps(tags or [], ensure_ascii=False)
2137
+ evid_json = _json.dumps(evidence_ids or [], ensure_ascii=False)
2138
+ kf_json = _json.dumps(key_facts or [], ensure_ascii=False) if key_facts is not None else None
2139
+ ci_json = _json.dumps(contradicting_ids or [], ensure_ascii=False) if contradicting_ids is not None else None
2140
+ if isinstance(scope, str) and not scope.strip():
2141
+ scope = None
2142
+ if isinstance(scope, str) and scope.strip().lower() == "auto":
2143
+ scope = None
2144
+ if isinstance(auto_classification, dict) and scope is None:
2145
+ applied_scope = auto_classification.get("scope_applied")
2146
+ if applied_scope:
2147
+ scope = str(applied_scope).strip().lower() or None
2148
+ with self._conn() as c:
2149
+ existing = c.execute(
2150
+ "SELECT id, version, scope, auto_classification FROM wiki_pages WHERE slug=?", (slug,)
2151
+ ).fetchone()
2152
+ ac_json = None
2153
+ if isinstance(auto_classification, str):
2154
+ ac_json = auto_classification
2155
+ elif auto_classification is not None:
2156
+ ac_json = _json.dumps(auto_classification, ensure_ascii=False)
2157
+ if ac_json is None:
2158
+ from ..wiki.classifier import classify_page
2159
+ from ..wiki.scope import (
2160
+ auto_scope_config,
2161
+ build_scope_audit,
2162
+ derive_scope_from_sources,
2163
+ )
2164
+
2165
+ scope_cfg = auto_scope_config(self)
2166
+ evidence_sources: list[str] = []
2167
+ evidence_values = [str(value) for value in (evidence_ids or []) if str(value).strip()]
2168
+ if evidence_values:
2169
+ placeholders = ",".join("?" for _ in evidence_values)
2170
+ source_rows = c.execute(
2171
+ f"SELECT source FROM memories WHERE id IN ({placeholders})",
2172
+ evidence_values,
2173
+ ).fetchall()
2174
+ evidence_sources = [
2175
+ str(row["source"] or "").strip()
2176
+ for row in source_rows
2177
+ if row["source"]
2178
+ ]
2179
+ classification = classify_page(
2180
+ title=title,
2181
+ body=body,
2182
+ summary=summary or "",
2183
+ tags=tags or [],
2184
+ evidence_sources=evidence_sources,
2185
+ mode=scope_cfg["mode"] if scope_cfg["enabled"] else "off",
2186
+ )
2187
+ if existing is None:
2188
+ if scope is not None:
2189
+ effective_scope = scope
2190
+ decision = "explicit"
2191
+ elif scope_cfg["enabled"] and classification.auto_global:
2192
+ effective_scope = "global"
2193
+ decision = "auto-global"
2194
+ else:
2195
+ effective_scope = derive_scope_from_sources(
2196
+ source_hint=source_hint,
2197
+ evidence_sources=evidence_sources,
2198
+ )
2199
+ decision = "default-source"
2200
+ if scope is None:
2201
+ scope = effective_scope
2202
+ else:
2203
+ effective_scope = scope if scope is not None else (existing["scope"] or "global")
2204
+ decision = "explicit" if scope is not None else "preserved-existing"
2205
+ existing_audit = None
2206
+ if existing is not None and existing["auto_classification"]:
2207
+ try:
2208
+ parsed_audit = _json.loads(existing["auto_classification"])
2209
+ if isinstance(parsed_audit, dict):
2210
+ existing_audit = parsed_audit
2211
+ except (ValueError, TypeError):
2212
+ existing_audit = None
2213
+ audit = build_scope_audit(
2214
+ classification,
2215
+ scope=effective_scope,
2216
+ decision=decision,
2217
+ source_hint=source_hint,
2218
+ enabled=bool(scope_cfg["enabled"]),
2219
+ mode=str(scope_cfg["mode"]),
2220
+ existing={"auto_classification": existing_audit} if existing_audit else None,
2221
+ )
2222
+ ac_json = _json.dumps(audit, ensure_ascii=False)
2223
+ if existing is None:
2224
+ pid = _uuid.uuid4().hex
2225
+ version = 1
2226
+ c.execute(
2227
+ "INSERT INTO wiki_pages(id, slug, title, body, summary, tags, "
2228
+ "importance, evidence_ids, run_id, version, created_at, updated_at, scope, "
2229
+ "key_facts, contradicting_ids, auto_classification) "
2230
+ "VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)",
2231
+ (pid, slug, title, body, summary or "", tags_json,
2232
+ float(importance), evid_json, run_id, version, now, now,
2233
+ scope or "global", kf_json, ci_json, ac_json),
2234
+ )
2235
+ else:
2236
+ pid = existing["id"]
2237
+ version = (existing["version"] or 1) + 1
2238
+ # Only override key_facts / contradicting_ids when the
2239
+ # caller explicitly passes them — preserves lists
2240
+ # built up by the contradiction detector across edits.
2241
+ if key_facts is not None:
2242
+ c.execute("UPDATE wiki_pages SET key_facts=? WHERE id=?",
2243
+ (kf_json, pid))
2244
+ if contradicting_ids is not None:
2245
+ c.execute("UPDATE wiki_pages SET contradicting_ids=? WHERE id=?",
2246
+ (ci_json, pid))
2247
+ c.execute(
2248
+ "UPDATE wiki_pages SET title=?, body=?, summary=?, tags=?, "
2249
+ "importance=?, evidence_ids=?, run_id=?, version=?, updated_at=?, scope=?, "
2250
+ "auto_classification=? "
2251
+ "WHERE id=?",
2252
+ (title, body, summary or "", tags_json,
2253
+ float(importance), evid_json, run_id, version, now,
2254
+ scope if scope is not None else (existing["scope"] or "global"),
2255
+ ac_json if ac_json is not None else existing["auto_classification"],
2256
+ pid),
2257
+ )
2258
+ row = c.execute(
2259
+ "SELECT * FROM wiki_pages WHERE id=?", (pid,)
2260
+ ).fetchone()
2261
+ return self._row_to_wiki(row)
2262
+
2263
+ def list_wiki_pages(
2264
+ self,
2265
+ limit: int = 200,
2266
+ min_importance: float | None = None,
2267
+ query: str | None = None,
2268
+ scope: str | None = None,
2269
+ ) -> list[Dict[str, Any]]:
2270
+ clauses: list[str] = []
2271
+ params: list = []
2272
+ if min_importance is not None:
2273
+ clauses.append("importance >= ?")
2274
+ params.append(float(min_importance))
2275
+ if query:
2276
+ clauses.append("(title LIKE ? OR body LIKE ? OR summary LIKE ?)")
2277
+ like = f"%{query}%"
2278
+ params.extend([like, like, like])
2279
+ if scope:
2280
+ clauses.append("scope = ?")
2281
+ params.append(scope)
2282
+ where = ("WHERE " + " AND ".join(clauses)) if clauses else ""
2283
+ with self._conn() as c:
2284
+ rows = c.execute(
2285
+ f"SELECT * FROM wiki_pages {where} ORDER BY updated_at DESC LIMIT ?",
2286
+ (*params, limit),
2287
+ ).fetchall()
2288
+ return [self._row_to_wiki(r) for r in rows]
2289
+
2290
+ def merge_wiki_pages(
2291
+ self,
2292
+ *,
2293
+ winner_id: str,
2294
+ loser_id: str,
2295
+ merged_body: str | None = None,
2296
+ merged_summary: str | None = None,
2297
+ merged_key_facts: list[str] | None = None,
2298
+ merged_importance: float | None = None,
2299
+ merged_tags: list[str] | None = None,
2300
+ ) -> dict[str, Any]:
2301
+ """Merge two wiki pages into one and archive the loser.
2302
+
2303
+ The winner keeps its id; the loser is deleted (its evidence
2304
+ ids are preserved as a record in ``merged_into`` so any
2305
+ later re-scan can see what was absorbed). The winner's
2306
+ body/summary/key_facts are replaced with the caller-supplied
2307
+ merged values. Returns a small dict describing what changed.
2308
+
2309
+ Used by the contradiction UI: the user previews a side-by-side
2310
+ diff, edits a merged body, and posts it here. The loser is
2311
+ gone in the same transaction so the UI can refresh once.
2312
+ """
2313
+ if winner_id == loser_id:
2314
+ raise ValueError("merge_wiki_pages needs two distinct ids")
2315
+ winner = self.get_wiki_page(winner_id)
2316
+ loser = self.get_wiki_page(loser_id)
2317
+ if not winner or not loser:
2318
+ raise ValueError("both pages must exist")
2319
+ # Carry the loser's evidence_ids forward — they're a record
2320
+ # of which raw memories contributed to the merged topic.
2321
+ winner_evidence = list(winner.get("evidence_ids") or [])
2322
+ winner_evidence.extend(loser.get("evidence_ids") or [])
2323
+ # Dedup but keep order.
2324
+ seen = set()
2325
+ merged_evidence = []
2326
+ for x in winner_evidence:
2327
+ if x in seen:
2328
+ continue
2329
+ seen.add(x)
2330
+ merged_evidence.append(x)
2331
+ body = merged_body if merged_body is not None else winner.get("body") or ""
2332
+ summary = merged_summary if merged_summary is not None else winner.get("summary") or ""
2333
+ facts = merged_key_facts if merged_key_facts is not None else winner.get("key_facts") or []
2334
+ tags = merged_tags if merged_tags is not None else winner.get("tags") or []
2335
+ imp = merged_importance if merged_importance is not None else max(
2336
+ float(winner.get("importance") or 0),
2337
+ float(loser.get("importance") or 0),
2338
+ )
2339
+ # Clear the winner's contradicting_ids — once merged, there's
2340
+ # nothing left to flag.
2341
+ with self._conn() as c:
2342
+ c.execute(
2343
+ "UPDATE wiki_pages SET body=?, summary=?, key_facts=?, "
2344
+ "tags=?, importance=?, evidence_ids=?, contradicting_ids=?, "
2345
+ "updated_at=? WHERE id=?",
2346
+ (
2347
+ body,
2348
+ summary,
2349
+ json.dumps(facts, ensure_ascii=False) if facts is not None else None,
2350
+ json.dumps(tags, ensure_ascii=False),
2351
+ imp,
2352
+ json.dumps(merged_evidence, ensure_ascii=False),
2353
+ json.dumps([], ensure_ascii=False),
2354
+ time.time(),
2355
+ winner_id,
2356
+ ),
2357
+ )
2358
+ c.execute("DELETE FROM wiki_pages WHERE id=?", (loser_id,))
2359
+ # Also drop the loser from any other page's contradicting_ids
2360
+ c.execute(
2361
+ "UPDATE wiki_pages SET contradicting_ids="
2362
+ "REPLACE(REPLACE(contradicting_ids, ?, ''), ?, '') "
2363
+ "WHERE contradicting_ids LIKE ?",
2364
+ (
2365
+ f'"{loser_id}"',
2366
+ f',"{loser_id}"',
2367
+ f'%{loser_id}%',
2368
+ ),
2369
+ )
2370
+ return {
2371
+ "winner_id": winner_id,
2372
+ "loser_id": loser_id,
2373
+ "winner_title": winner.get("title") or "",
2374
+ "loser_title": loser.get("title") or "",
2375
+ "merged": {
2376
+ "body_len": len(body),
2377
+ "summary_len": len(summary),
2378
+ "facts": len(facts),
2379
+ "evidence_ids": len(merged_evidence),
2380
+ "importance": imp,
2381
+ },
2382
+ }
2383
+
2384
+ def resolve_contradiction(self, page_id: str) -> bool:
2385
+ """Clear a page's ``contradicting_ids`` so it disappears from
2386
+ the contradiction list. Use when the user inspects and
2387
+ decides there is no real conflict (e.g. the two pages are
2388
+ about different facets of the same topic)."""
2389
+ with self._conn() as c:
2390
+ cur = c.execute(
2391
+ "UPDATE wiki_pages SET contradicting_ids=? WHERE id=?",
2392
+ (json.dumps([], ensure_ascii=False), page_id),
2393
+ )
2394
+ return cur.rowcount > 0
2395
+
2396
+ def get_wiki_page(self, page_id: str) -> Dict[str, Any] | None:
2397
+ with self._conn() as c:
2398
+ row = c.execute(
2399
+ "SELECT * FROM wiki_pages WHERE id=?", (page_id,)
2400
+ ).fetchone()
2401
+ return self._row_to_wiki(row) if row else None
2402
+
2403
+ def get_wiki_page_by_slug(self, slug: str) -> Dict[str, Any] | None:
2404
+ with self._conn() as c:
2405
+ row = c.execute(
2406
+ "SELECT * FROM wiki_pages WHERE slug=?", (slug,)
2407
+ ).fetchone()
2408
+ return self._row_to_wiki(row) if row else None
2409
+
2410
+ def delete_wiki_page(self, page_id: str) -> bool:
2411
+ with self._conn() as c:
2412
+ cur = c.execute(
2413
+ "DELETE FROM wiki_pages WHERE id=?", (page_id,)
2414
+ )
2415
+ return cur.rowcount > 0
2416
+
2417
+ def delete_wiki_pages_for_run(self, run_id: str) -> int:
2418
+ """Helper used by tests and by re-runs of a specific run id."""
2419
+ with self._conn() as c:
2420
+ cur = c.execute(
2421
+ "DELETE FROM wiki_pages WHERE run_id=?", (run_id,)
2422
+ )
2423
+ return cur.rowcount
2424
+
2425
+ def count_memories(self) -> int:
2426
+ with self._conn() as c:
2427
+ return c.execute("SELECT COUNT(*) c FROM memories").fetchone()["c"]
2428
+
2429
+ def count_sessions(self) -> int:
2430
+ with self._conn() as c:
2431
+ return c.execute("SELECT COUNT(*) c FROM sessions").fetchone()["c"]
2432
+
2433
+ def count_entities(self) -> int:
2434
+ with self._conn() as c:
2435
+ return c.execute("SELECT COUNT(*) c FROM entities").fetchone()["c"]
2436
+
2437
+ def count_wiki_pages(self) -> int:
2438
+ with self._conn() as c:
2439
+ row = c.execute("SELECT COUNT(*) AS n FROM wiki_pages").fetchone()
2440
+ return int(row["n"] or 0) if row else 0
2441
+
2442
+ def _row_to_wiki(self, row: sqlite3.Row) -> Dict[str, Any]:
2443
+ tags = []
2444
+ if row["tags"]:
2445
+ try:
2446
+ tags = json.loads(row["tags"])
2447
+ except (ValueError, TypeError):
2448
+ logger.warning("corrupt tags for relation %s; resetting", row["id"])
2449
+ tags = []
2450
+ evidence = []
2451
+ if row["evidence_ids"]:
2452
+ try:
2453
+ evidence = json.loads(row["evidence_ids"])
2454
+ except (ValueError, TypeError):
2455
+ logger.warning("corrupt evidence_ids for relation %s; resetting", row["id"])
2456
+ evidence = []
2457
+ key_facts: list[str] = []
2458
+ if row["key_facts"]:
2459
+ try:
2460
+ parsed = json.loads(row["key_facts"])
2461
+ if isinstance(parsed, list):
2462
+ key_facts = [str(x) for x in parsed if x]
2463
+ except (ValueError, TypeError):
2464
+ logger.warning("corrupt key_facts for wiki page %s; resetting", row["id"])
2465
+ key_facts = []
2466
+ contradicting_ids: list[str] = []
2467
+ if row["contradicting_ids"]:
2468
+ try:
2469
+ parsed = json.loads(row["contradicting_ids"])
2470
+ if isinstance(parsed, list):
2471
+ contradicting_ids = [str(x) for x in parsed if x]
2472
+ except (ValueError, TypeError):
2473
+ logger.warning("corrupt contradicting_ids for wiki page %s; resetting", row["id"])
2474
+ contradicting_ids = []
2475
+ auto_classification = None
2476
+ if "auto_classification" in row.keys() and row["auto_classification"]:
2477
+ try:
2478
+ parsed = json.loads(row["auto_classification"])
2479
+ if isinstance(parsed, dict):
2480
+ auto_classification = parsed
2481
+ except (ValueError, TypeError):
2482
+ logger.warning("corrupt auto_classification for wiki page %s; resetting", row["id"])
2483
+ auto_classification = None
2484
+ return {
2485
+ "id": row["id"],
2486
+ "slug": row["slug"],
2487
+ "title": row["title"],
2488
+ "body": row["body"],
2489
+ "summary": row["summary"] or "",
2490
+ "tags": tags,
2491
+ "importance": float(row["importance"] or 0.0),
2492
+ "evidence_ids": evidence,
2493
+ "run_id": row["run_id"],
2494
+ "version": int(row["version"] or 1),
2495
+ "created_at": float(row["created_at"] or 0.0),
2496
+ "updated_at": float(row["updated_at"] or 0.0),
2497
+ "key_facts": key_facts,
2498
+ "contradicting_ids": contradicting_ids,
2499
+ "auto_classification": auto_classification,
2500
+ "scope": row["scope"] or "global",
2501
+ }
2502
+
2503
+ # --- scoring ----------------------------------------------------------
2504
+
2505
+ # Weights are class-level so tests can pin them down. The blend is
2506
+ # normalized to [0, 1].
2507
+ #
2508
+ # Why this shape: recency alone fades everything; usage alone lets
2509
+ # an old junk memory float back. Combining both with feedback as a
2510
+ # bias lets a memory stay useful only while it is actually being
2511
+ # consulted. ``positive`` events are sticky (no time decay) — the
2512
+ # user explicitly said "this is good"; ``negative`` events are
2513
+ # sticky too, but pull down.
2514
+ _SCORE_WEIGHTS = {
2515
+ "importance": 0.40, # LLM/original importance
2516
+ "recency": 0.25, # time-decay (newer = higher)
2517
+ "usage": 0.25, # recall_count × last_recalled_at decay
2518
+ "feedback": 0.10, # +/- thumbs
2519
+ }
2520
+
2521
+ @classmethod
2522
+ def score_components(
2523
+ cls,
2524
+ importance: float,
2525
+ created_at: float,
2526
+ now: float | None = None,
2527
+ recall_count: int = 0,
2528
+ last_recalled_at: float | None = None,
2529
+ positive: int = 0,
2530
+ negative: int = 0,
2531
+ half_life_days: float = 30.0,
2532
+ ) -> Dict[str, float]:
2533
+ """Return the four score components plus the blended score, all in
2534
+ [0, 1]. Pure function — no DB access — so it can be unit-tested
2535
+ and reused by the UI."""
2536
+ now = now if now is not None else time.time()
2537
+ age = max(0.0, now - created_at)
2538
+ half_life = half_life_days * 86400.0
2539
+ recency = (0.5 ** (age / half_life)) if half_life else 1.0
2540
+
2541
+ # Usage: log-saturated recall_count × a recency factor on when
2542
+ # it was last recalled. A memory recalled 1× today scores
2543
+ # ~0.30 on usage; 10× today → ~0.65; 100× today → ~0.95.
2544
+ import math
2545
+ usage = 0.0
2546
+ if recall_count > 0:
2547
+ log_recall = math.log1p(recall_count) / math.log1p(100) # 0..1
2548
+ log_recall = max(0.0, min(1.0, log_recall))
2549
+ if last_recalled_at:
2550
+ age_recall = max(0.0, now - last_recalled_at)
2551
+ usage_recency = (0.5 ** (age_recall / half_life)) if half_life else 1.0
2552
+ else:
2553
+ usage_recency = 1.0
2554
+ usage = log_recall * (0.25 + 0.75 * usage_recency)
2555
+
2556
+ # Feedback: positive/negative are sticky (no time decay). We
2557
+ # tanh-clamp so a flood of thumbs can't push the score to ±∞.
2558
+ import math as _m
2559
+ feedback = _m.tanh((positive - negative) / 3.0) # -1..1
2560
+
2561
+ w = cls._SCORE_WEIGHTS
2562
+ score = (
2563
+ w["importance"] * max(0.0, min(1.0, importance or 0.0))
2564
+ + w["recency"] * recency
2565
+ + w["usage"] * usage
2566
+ + w["feedback"] * max(-0.5, min(0.5, feedback)) # half-weight negative path
2567
+ )
2568
+ return {
2569
+ "importance": importance or 0.0,
2570
+ "recency": recency,
2571
+ "usage": usage,
2572
+ "feedback": feedback,
2573
+ "score": max(0.0, min(1.0, score)),
2574
+ }
2575
+
2576
+ @staticmethod
2577
+ def compute_score(
2578
+ importance: float,
2579
+ created_at: float,
2580
+ now: float | None = None,
2581
+ half_life_days: float = 30.0,
2582
+ ) -> float:
2583
+ """Legacy single-blend score. Kept for callers that don't have
2584
+ signal data yet. New code should prefer ``score_components``."""
2585
+ now = now if now is not None else time.time()
2586
+ age_seconds = max(0.0, now - created_at)
2587
+ half_life_seconds = half_life_days * 86400.0
2588
+ recency = 0.5 ** (age_seconds / half_life_seconds) if half_life_seconds else 1.0
2589
+ blended = 0.35 * importance + 0.65 * recency
2590
+ return max(0.0, min(1.0, blended))
2591
+
2592
+ def rescore_all(self, half_life_days: float = 30.0) -> int:
2593
+ """Recompute score for every memory using v2 (importance × recency
2594
+ × usage × feedback). One row at a time keeps WAL writes small
2595
+ and lets us skip rows whose components didn't change."""
2596
+ updated = 0
2597
+ now = time.time()
2598
+ with self._conn() as c:
2599
+ rows = c.execute(
2600
+ """SELECT m.id, m.importance, m.created_at,
2601
+ COALESCE(s.recall_count, 0) AS recall_count,
2602
+ s.last_recalled_at,
2603
+ COALESCE(s.positive, 0) AS positive,
2604
+ COALESCE(s.negative, 0) AS negative
2605
+ FROM memories m
2606
+ LEFT JOIN memory_signals s ON s.memory_id = m.id"""
2607
+ ).fetchall()
2608
+ for r in rows:
2609
+ comps = self.score_components(
2610
+ importance=r["importance"],
2611
+ created_at=r["created_at"],
2612
+ now=now,
2613
+ recall_count=r["recall_count"],
2614
+ last_recalled_at=r["last_recalled_at"],
2615
+ positive=r["positive"],
2616
+ negative=r["negative"],
2617
+ half_life_days=half_life_days,
2618
+ )
2619
+ new = comps["score"]
2620
+ # Cheap change check (3 decimals): avoid WAL churn.
2621
+ cur = c.execute(
2622
+ "SELECT score FROM memories WHERE id=?", (r["id"],)
2623
+ ).fetchone()
2624
+ if cur is None or abs((cur["score"] or 0) - new) > 1e-3:
2625
+ c.execute(
2626
+ "UPDATE memories SET score=? WHERE id=?",
2627
+ (new, r["id"]),
2628
+ )
2629
+ updated += 1
2630
+ return updated
2631
+
2632
+ # --- graph ------------------------------------------------------------
2633
+
2634
+ def upsert_entity(
2635
+ self,
2636
+ name: str,
2637
+ kind: str = "concept",
2638
+ bump_weight: float = 0.0,
2639
+ ) -> GraphEntity:
2640
+ name = (name or "").strip()
2641
+ if not name:
2642
+ raise ValueError("empty entity name")
2643
+ now = time.time()
2644
+ with self._conn() as c:
2645
+ row = c.execute(
2646
+ "SELECT id, mention_count, weight FROM entities WHERE name=? AND kind=?",
2647
+ (name, kind),
2648
+ ).fetchone()
2649
+ if row is None:
2650
+ eid = uuid.uuid4().hex
2651
+ weight = max(0.05, min(1.0, 0.5 + bump_weight))
2652
+ c.execute(
2653
+ """INSERT INTO entities(id, name, kind, mention_count, weight, created_at, updated_at)
2654
+ VALUES (?,?,?,?,?,?,?)""",
2655
+ (eid, name, kind, 1, weight, now, now),
2656
+ )
2657
+ return GraphEntity(id=eid, name=name, kind=kind, mention_count=1, weight=weight)
2658
+ new_count = row["mention_count"] + 1
2659
+ new_weight = min(1.0, row["weight"] + bump_weight)
2660
+ c.execute(
2661
+ "UPDATE entities SET mention_count=?, weight=?, updated_at=? WHERE id=?",
2662
+ (new_count, new_weight, now, row["id"]),
2663
+ )
2664
+ return GraphEntity(
2665
+ id=row["id"], name=name, kind=kind,
2666
+ mention_count=new_count, weight=new_weight,
2667
+ )
2668
+
2669
+ def upsert_relation(
2670
+ self,
2671
+ src: str,
2672
+ dst: str,
2673
+ kind: str = "related",
2674
+ weight: float = 0.5,
2675
+ evidence_id: str | None = None,
2676
+ ) -> GraphRelation:
2677
+ if not src or not dst or src == dst:
2678
+ raise ValueError("bad relation")
2679
+ rid = uuid.uuid4().hex
2680
+ now = time.time()
2681
+ with self._conn() as c:
2682
+ row = c.execute(
2683
+ "SELECT id, weight, evidence_ids FROM relations WHERE src=? AND dst=? AND kind=?",
2684
+ (src, dst, kind),
2685
+ ).fetchone()
2686
+ if row is not None:
2687
+ existing = []
2688
+ if row["evidence_ids"]:
2689
+ try:
2690
+ import json as _json_local
2691
+ existing = _json_local.loads(row["evidence_ids"])
2692
+ except (ValueError, TypeError):
2693
+ logger.warning("corrupt evidence_ids for row; resetting")
2694
+ existing = []
2695
+ if evidence_id and evidence_id not in existing:
2696
+ existing.append(evidence_id)
2697
+ new_weight = min(1.0, (row["weight"] or 0.0) + 0.05)
2698
+ c.execute(
2699
+ "UPDATE relations SET weight=?, evidence_ids=? WHERE id=?",
2700
+ (
2701
+ new_weight,
2702
+ __import__("json").dumps(existing),
2703
+ row["id"],
2704
+ ),
2705
+ )
2706
+ return GraphRelation(
2707
+ id=row["id"], src=src, dst=dst, kind=kind,
2708
+ weight=new_weight, evidence_ids=existing,
2709
+ )
2710
+ evidence = [evidence_id] if evidence_id else []
2711
+ c.execute(
2712
+ """INSERT INTO relations(id, src, dst, kind, weight, evidence_ids, created_at)
2713
+ VALUES (?,?,?,?,?,?,?)""",
2714
+ (rid, src, dst, kind, max(0.05, min(1.0, weight)),
2715
+ __import__("json").dumps(evidence), now),
2716
+ )
2717
+ return GraphRelation(id=rid, src=src, dst=dst, kind=kind,
2718
+ weight=weight, evidence_ids=evidence)
2719
+
2720
+ def list_entities(self, limit: int = 500, kind: str | None = None) -> list[GraphEntity]:
2721
+ with self._conn() as c:
2722
+ if kind:
2723
+ rows = c.execute(
2724
+ "SELECT * FROM entities WHERE kind=? ORDER BY weight DESC LIMIT ?",
2725
+ (kind, limit),
2726
+ ).fetchall()
2727
+ else:
2728
+ rows = c.execute(
2729
+ "SELECT * FROM entities ORDER BY weight DESC LIMIT ?",
2730
+ (limit,),
2731
+ ).fetchall()
2732
+ return [GraphEntity(
2733
+ id=r["id"], name=r["name"], kind=r["kind"],
2734
+ mention_count=r["mention_count"], weight=r["weight"],
2735
+ ) for r in rows]
2736
+
2737
+ def list_relations(self, limit: int = 2000) -> list[GraphRelation]:
2738
+ import json
2739
+ with self._conn() as c:
2740
+ rows = c.execute(
2741
+ "SELECT * FROM relations ORDER BY weight DESC LIMIT ?",
2742
+ (limit,),
2743
+ ).fetchall()
2744
+ out = []
2745
+ for r in rows:
2746
+ ev = []
2747
+ if r["evidence_ids"]:
2748
+ try:
2749
+ ev = json.loads(r["evidence_ids"])
2750
+ except (ValueError, TypeError):
2751
+ logger.warning("corrupt evidence_ids in graph query; skipping relation")
2752
+ ev = []
2753
+ out.append(GraphRelation(
2754
+ id=r["id"], src=r["src"], dst=r["dst"],
2755
+ kind=r["kind"], weight=r["weight"], evidence_ids=ev,
2756
+ ))
2757
+ return out
2758
+
2759
+ def graph_stats(self) -> dict:
2760
+ with self._conn() as c:
2761
+ ne = c.execute("SELECT COUNT(*) c FROM entities").fetchone()["c"]
2762
+ nr = c.execute("SELECT COUNT(*) c FROM relations").fetchone()["c"]
2763
+ return {"entities": ne, "relations": nr}
2764
+
2765
+ def delete_graph(self) -> int:
2766
+ with self._conn() as c:
2767
+ r1 = c.execute("DELETE FROM entities").rowcount
2768
+ c.execute("DELETE FROM relations")
2769
+ return r1
2770
+
2771
+ # --- Universal Agent Memory v7 ----------------------------------
2772
+ # Methods for the three new tables (wiki_versions, cognitive_audit,
2773
+ # auth_tokens). All keep the same return-shape conventions as the
2774
+ # rest of the file: dataclasses for memory-shaped rows, dicts for
2775
+ # raw query results.
2776
+
2777
+ # ----- wiki_versions ----------------------------------------------
2778
+
2779
+ def snapshot_wiki_version(
2780
+ self,
2781
+ page_id: str,
2782
+ *,
2783
+ branch_tag: str | None = None,
2784
+ ) -> dict | None:
2785
+ """Snapshot the current state of a wiki page into ``wiki_versions``.
2786
+
2787
+ Called on every ``upsert_wiki_page`` and whenever the user
2788
+ runs ``MemoryClient.fork(branch_tag=...)``. Returns the new
2789
+ version row, or ``None`` if the page doesn't exist.
2790
+ """
2791
+ page = self.get_wiki_page(page_id)
2792
+ if page is None:
2793
+ return None
2794
+ import json
2795
+ with self._conn() as c:
2796
+ row = c.execute(
2797
+ "SELECT COALESCE(MAX(version), 0) AS v FROM wiki_versions WHERE page_id=?",
2798
+ (page_id,),
2799
+ ).fetchone()
2800
+ next_v = int(row["v"] or 0) + 1
2801
+ wid = uuid.uuid4().hex
2802
+ c.execute(
2803
+ """INSERT INTO wiki_versions
2804
+ (id, page_id, version, title, body, summary, tags,
2805
+ importance, key_facts, scope, branched_at, branch_tag)
2806
+ VALUES (?,?,?,?,?,?,?,?,?,?,?,?)""",
2807
+ (
2808
+ wid,
2809
+ page_id,
2810
+ next_v,
2811
+ page.get("title") or "",
2812
+ page.get("body") or "",
2813
+ page.get("summary") or "",
2814
+ json.dumps(page.get("tags") or []),
2815
+ float(page.get("importance") or 0.5),
2816
+ json.dumps(page.get("key_facts") or []),
2817
+ page.get("scope") or "global",
2818
+ time.time(),
2819
+ branch_tag,
2820
+ ),
2821
+ )
2822
+ return {
2823
+ "id": wid,
2824
+ "page_id": page_id,
2825
+ "version": next_v,
2826
+ "title": page.get("title") or "",
2827
+ "summary": page.get("summary") or "",
2828
+ "tags": page.get("tags") or [],
2829
+ "importance": float(page.get("importance") or 0.5),
2830
+ "scope": page.get("scope") or "global",
2831
+ "branch_tag": branch_tag,
2832
+ }
2833
+
2834
+ def list_wiki_versions(
2835
+ self,
2836
+ page_id: str | None = None,
2837
+ *,
2838
+ branch_tag: str | None = None,
2839
+ limit: int = 200,
2840
+ ) -> list[dict]:
2841
+ """Return version history, newest first."""
2842
+ clauses: list[str] = []
2843
+ params: list = []
2844
+ if page_id is not None:
2845
+ clauses.append("page_id = ?")
2846
+ params.append(page_id)
2847
+ if branch_tag is not None:
2848
+ clauses.append("branch_tag = ?")
2849
+ params.append(branch_tag)
2850
+ where = ("WHERE " + " AND ".join(clauses)) if clauses else ""
2851
+ import json
2852
+ with self._conn() as c:
2853
+ rows = c.execute(
2854
+ f"SELECT * FROM wiki_versions {where} "
2855
+ "ORDER BY branched_at DESC LIMIT ?",
2856
+ (*params, limit),
2857
+ ).fetchall()
2858
+ out: list[dict] = []
2859
+ for r in rows:
2860
+ try:
2861
+ tags = json.loads(r["tags"]) if r["tags"] else []
2862
+ except Exception:
2863
+ tags = []
2864
+ try:
2865
+ kf = json.loads(r["key_facts"]) if r["key_facts"] else []
2866
+ except Exception:
2867
+ kf = []
2868
+ out.append({
2869
+ "id": r["id"],
2870
+ "page_id": r["page_id"],
2871
+ "version": int(r["version"] or 1),
2872
+ "title": r["title"] or "",
2873
+ "summary": r["summary"] or "",
2874
+ "tags": tags,
2875
+ "importance": float(r["importance"] or 0.5),
2876
+ "key_facts": kf,
2877
+ "scope": r["scope"] or "global",
2878
+ "branched_at": float(r["branched_at"] or 0),
2879
+ "branch_tag": r["branch_tag"],
2880
+ })
2881
+ return out
2882
+
2883
+ def get_wiki_version(self, version_id: str) -> dict | None:
2884
+ import json
2885
+ with self._conn() as c:
2886
+ r = c.execute(
2887
+ "SELECT * FROM wiki_versions WHERE id=?", (version_id,),
2888
+ ).fetchone()
2889
+ if not r:
2890
+ return None
2891
+ try:
2892
+ tags = json.loads(r["tags"]) if r["tags"] else []
2893
+ except Exception:
2894
+ tags = []
2895
+ try:
2896
+ kf = json.loads(r["key_facts"]) if r["key_facts"] else []
2897
+ except Exception:
2898
+ kf = []
2899
+ return {
2900
+ "id": r["id"],
2901
+ "page_id": r["page_id"],
2902
+ "version": int(r["version"] or 1),
2903
+ "title": r["title"] or "",
2904
+ "body": r["body"] or "",
2905
+ "summary": r["summary"] or "",
2906
+ "tags": tags,
2907
+ "importance": float(r["importance"] or 0.5),
2908
+ "key_facts": kf,
2909
+ "scope": r["scope"] or "global",
2910
+ "branched_at": float(r["branched_at"] or 0),
2911
+ "branch_tag": r["branch_tag"],
2912
+ }
2913
+
2914
+ # ----- cognitive_audit -------------------------------------------
2915
+
2916
+ def record_audit(
2917
+ self,
2918
+ *,
2919
+ kind: str,
2920
+ action: str,
2921
+ target_kind: str,
2922
+ target_id: str | None = None,
2923
+ target_text: str | None = None,
2924
+ reason: str | None = None,
2925
+ score: float | None = None,
2926
+ payload: dict | None = None,
2927
+ ) -> dict:
2928
+ """Append one row to ``cognitive_audit``.
2929
+
2930
+ ``kind`` is the trigger category — ``forget``, ``merge``,
2931
+ ``contradict``, ``stale``, ``low_value``. ``action`` is the
2932
+ disposition — ``suggest`` (we proposed it but didn't touch
2933
+ data), ``applied`` (the SDK / CLI ran the cleanup), or
2934
+ ``reverted`` (the user undid it).
2935
+ """
2936
+ import json
2937
+ aid = uuid.uuid4().hex
2938
+ ts = time.time()
2939
+ with self._conn() as c:
2940
+ c.execute(
2941
+ """INSERT INTO cognitive_audit
2942
+ (id, ts, kind, action, target_kind, target_id,
2943
+ target_text, reason, score, payload)
2944
+ VALUES (?,?,?,?,?,?,?,?,?,?)""",
2945
+ (
2946
+ aid,
2947
+ ts,
2948
+ kind,
2949
+ action,
2950
+ target_kind,
2951
+ target_id,
2952
+ target_text,
2953
+ reason,
2954
+ score,
2955
+ json.dumps(payload or {}),
2956
+ ),
2957
+ )
2958
+ return {
2959
+ "id": aid,
2960
+ "ts": ts,
2961
+ "kind": kind,
2962
+ "action": action,
2963
+ "target_kind": target_kind,
2964
+ "target_id": target_id,
2965
+ "target_text": target_text,
2966
+ "reason": reason,
2967
+ "score": score,
2968
+ "payload": payload or {},
2969
+ }
2970
+
2971
+ def list_audit(
2972
+ self,
2973
+ *,
2974
+ kind: str | None = None,
2975
+ action: str | None = None,
2976
+ limit: int = 200,
2977
+ ) -> list[dict]:
2978
+ clauses: list[str] = []
2979
+ params: list = []
2980
+ if kind:
2981
+ clauses.append("kind = ?")
2982
+ params.append(kind)
2983
+ if action:
2984
+ clauses.append("action = ?")
2985
+ params.append(action)
2986
+ where = ("WHERE " + " AND ".join(clauses)) if clauses else ""
2987
+ import json
2988
+ with self._conn() as c:
2989
+ rows = c.execute(
2990
+ f"SELECT * FROM cognitive_audit {where} "
2991
+ "ORDER BY ts DESC LIMIT ?",
2992
+ (*params, limit),
2993
+ ).fetchall()
2994
+ out: list[dict] = []
2995
+ for r in rows:
2996
+ try:
2997
+ pj = json.loads(r["payload"]) if r["payload"] else {}
2998
+ except Exception:
2999
+ pj = {}
3000
+ out.append({
3001
+ "id": r["id"],
3002
+ "ts": float(r["ts"] or 0),
3003
+ "kind": r["kind"],
3004
+ "action": r["action"],
3005
+ "target_kind": r["target_kind"],
3006
+ "target_id": r["target_id"],
3007
+ "target_text": r["target_text"],
3008
+ "reason": r["reason"],
3009
+ "score": r["score"],
3010
+ "payload": pj,
3011
+ })
3012
+ return out
3013
+
3014
+ # ----- auth_tokens -----------------------------------------------
3015
+
3016
+ def issue_token(
3017
+ self,
3018
+ *,
3019
+ user_id: str | None = None,
3020
+ agent_id: str | None = None,
3021
+ label: str | None = None,
3022
+ expires_in: float | None = None,
3023
+ ) -> dict:
3024
+ """Mint a bearer token scoped to ``(user_id, agent_id)``.
3025
+
3026
+ Returns ``{"id", "token", "user_id", "agent_id", "label",
3027
+ "created_at", "expires_at"}``. The token is only available
3028
+ at issue time — the store keeps a SHA-256 hash so it can be
3029
+ verified but never recovered.
3030
+ """
3031
+ import hashlib
3032
+ import secrets
3033
+ token = secrets.token_urlsafe(32)
3034
+ token_hash = hashlib.sha256(token.encode("utf-8")).hexdigest()
3035
+ tid = uuid.uuid4().hex
3036
+ now = time.time()
3037
+ exp = (now + expires_in) if expires_in else None
3038
+ with self._conn() as c:
3039
+ c.execute(
3040
+ """INSERT INTO auth_tokens
3041
+ (id, user_id, agent_id, label, token_hash,
3042
+ created_at, expires_at, revoked)
3043
+ VALUES (?,?,?,?,?,?,?,0)""",
3044
+ (tid, user_id, agent_id, label, token_hash, now, exp),
3045
+ )
3046
+ return {
3047
+ "id": tid,
3048
+ "token": token,
3049
+ "user_id": user_id,
3050
+ "agent_id": agent_id,
3051
+ "label": label,
3052
+ "created_at": now,
3053
+ "expires_at": exp,
3054
+ }
3055
+
3056
+ def verify_token(self, token: str) -> dict | None:
3057
+ """Return the token row if ``token`` is valid, else ``None``."""
3058
+ import hashlib
3059
+ if not token:
3060
+ return None
3061
+ h = hashlib.sha256(token.encode("utf-8")).hexdigest()
3062
+ now = time.time()
3063
+ with self._conn() as c:
3064
+ r = c.execute(
3065
+ "SELECT * FROM auth_tokens WHERE token_hash=? AND revoked=0",
3066
+ (h,),
3067
+ ).fetchone()
3068
+ if not r:
3069
+ return None
3070
+ if r["expires_at"] and float(r["expires_at"]) < now:
3071
+ return None
3072
+ # Best-effort: bump last_used_at. We don't fail if it errors
3073
+ # — verification is the contract.
3074
+ try:
3075
+ with self._conn() as c:
3076
+ c.execute(
3077
+ "UPDATE auth_tokens SET last_used_at=? WHERE id=?",
3078
+ (now, r["id"]),
3079
+ )
3080
+ except Exception:
3081
+ pass
3082
+ return {
3083
+ "id": r["id"],
3084
+ "user_id": r["user_id"],
3085
+ "agent_id": r["agent_id"],
3086
+ "label": r["label"],
3087
+ "created_at": float(r["created_at"] or 0),
3088
+ "expires_at": r["expires_at"],
3089
+ }
3090
+
3091
+ def revoke_token(self, token_id: str) -> bool:
3092
+ with self._conn() as c:
3093
+ cur = c.execute(
3094
+ "UPDATE auth_tokens SET revoked=1 WHERE id=? AND revoked=0",
3095
+ (token_id,),
3096
+ )
3097
+ return cur.rowcount > 0
3098
+
3099
+ def list_tokens(self) -> list[dict]:
3100
+ with self._conn() as c:
3101
+ rows = c.execute(
3102
+ "SELECT id, user_id, agent_id, label, created_at, "
3103
+ "last_used_at, expires_at, revoked FROM auth_tokens "
3104
+ "ORDER BY created_at DESC"
3105
+ ).fetchall()
3106
+ return [
3107
+ {
3108
+ "id": r["id"],
3109
+ "user_id": r["user_id"],
3110
+ "agent_id": r["agent_id"],
3111
+ "label": r["label"],
3112
+ "created_at": float(r["created_at"] or 0),
3113
+ "last_used_at": r["last_used_at"],
3114
+ "expires_at": r["expires_at"],
3115
+ "revoked": bool(r["revoked"]),
3116
+ }
3117
+ for r in rows
3118
+ ]
3119
+
3120
+ # --- maintenance ------------------------------------------------------
3121
+
3122
+ def delete_memory(self, mid: str) -> int:
3123
+ with self._conn() as c:
3124
+ cur = c.execute("DELETE FROM memories WHERE id=?", (mid,))
3125
+ return cur.rowcount
3126
+
3127
+ def merge_memories(self, a_id: str, b_id: str) -> dict:
3128
+ """True memory-pair merge.
3129
+
3130
+ Behaviour:
3131
+ - The higher-scored memory wins (ties go to ``a_id``).
3132
+ - The loser's text is appended to the winner's text (de-duplicated
3133
+ if the loser's text is already a substring of the winner's).
3134
+ - The winner's ``importance`` and ``score`` are bumped to the max
3135
+ of the two so the fused memory keeps the strongest signal.
3136
+ - The loser is deleted in the same transaction.
3137
+ - The pair is recorded in ``contradiction_ignored`` so the pulse
3138
+ does not surface it again.
3139
+
3140
+ Returns a small dict describing what changed so the API layer can
3141
+ report it back to the UI (UI then shows a 'merged' toast).
3142
+ """
3143
+ a_id = str(a_id or "")
3144
+ b_id = str(b_id or "")
3145
+ if not a_id or not b_id or a_id == b_id:
3146
+ raise ValueError("merge_memories needs two distinct ids")
3147
+ now = time.time()
3148
+ with self._conn() as c:
3149
+ a_row = c.execute(
3150
+ "SELECT id, text, importance, score FROM memories WHERE id=?",
3151
+ (a_id,),
3152
+ ).fetchone()
3153
+ b_row = c.execute(
3154
+ "SELECT id, text, importance, score FROM memories WHERE id=?",
3155
+ (b_id,),
3156
+ ).fetchone()
3157
+ if a_row is None and b_row is None:
3158
+ return {"merged": False, "reason": "neither_exists"}
3159
+ if a_row is None:
3160
+ # Only B exists — silently delete the missing A and keep B.
3161
+ c.execute("DELETE FROM memories WHERE id=?", (a_id,))
3162
+ return {"merged": False, "kept": b_id, "lost": a_id, "reason": "a_missing"}
3163
+ if b_row is None:
3164
+ c.execute("DELETE FROM memories WHERE id=?", (b_id,))
3165
+ return {"merged": False, "kept": a_id, "lost": b_id, "reason": "b_missing"}
3166
+
3167
+ # Pick winner = higher score; ties go to a_id.
3168
+ a_score = a_row["score"] or 0.0
3169
+ b_score = b_row["score"] or 0.0
3170
+ winner_is_a = a_score >= b_score
3171
+ winner_id = a_id if winner_is_a else b_id
3172
+ loser_id = b_id if winner_is_a else a_id
3173
+ winner_text = (a_row["text"] if winner_is_a else b_row["text"]) or ""
3174
+ loser_text = (b_row["text"] if winner_is_a else a_row["text"]) or ""
3175
+ importance_max = max(a_row["importance"] or 0.0, b_row["importance"] or 0.0)
3176
+ score_max = max(a_score, b_score)
3177
+
3178
+ # Decide whether the loser's content needs to be appended. If
3179
+ # the winner already contains it (string containment is fine for
3180
+ # the plain-text payloads we have here), skip the append.
3181
+ needs_append = bool(loser_text.strip()) and loser_text.strip() not in winner_text
3182
+ if needs_append:
3183
+ # Triple-dash rule marks the boundary between the two
3184
+ # original sources of a merged memory.
3185
+ sep = "\n\n---\n\n"
3186
+ merged_text = winner_text.rstrip() + sep + loser_text.strip()
3187
+ else:
3188
+ merged_text = winner_text
3189
+
3190
+ c.execute(
3191
+ "UPDATE memories "
3192
+ "SET text=?, importance=?, score=?, updated_at=? "
3193
+ "WHERE id=?",
3194
+ (merged_text, importance_max, score_max, now, winner_id),
3195
+ )
3196
+ c.execute("DELETE FROM memories WHERE id=?", (loser_id,))
3197
+
3198
+ # Suppress the pair so the pulse does not resurface it.
3199
+ lo, hi = sorted([a_id, b_id])
3200
+ key = f"{lo}|{hi}"
3201
+ c.execute(
3202
+ "INSERT INTO contradiction_ignored(pair_key, ignored_at) VALUES(?, ?) "
3203
+ "ON CONFLICT(pair_key) DO NOTHING",
3204
+ (key, now),
3205
+ )
3206
+
3207
+ return {
3208
+ "merged": True,
3209
+ "kept": winner_id,
3210
+ "lost": loser_id,
3211
+ "appended": needs_append,
3212
+ "winner_was_a": winner_is_a,
3213
+ "new_length": len(merged_text),
3214
+ }
3215
+
3216
+ def delete_session(self, session_id: str) -> int:
3217
+ with self._conn() as c:
3218
+ cur = c.execute("DELETE FROM memories WHERE session_id=?", (session_id,))
3219
+ c.execute("DELETE FROM sessions WHERE id=?", (session_id,))
3220
+ return cur.rowcount
3221
+
3222
+ def gc(self) -> int:
3223
+ now = time.time()
3224
+ with self._conn() as c:
3225
+ cur = c.execute(
3226
+ "DELETE FROM memories WHERE ttl IS NOT NULL AND (? - created_at) > ttl",
3227
+ (now,),
3228
+ )
3229
+ return cur.rowcount
3230
+
3231
+ def stats(self) -> dict:
3232
+ with self._conn() as c:
3233
+ n_mem = c.execute("SELECT COUNT(*) c FROM memories").fetchone()["c"]
3234
+ n_ses = c.execute("SELECT COUNT(*) c FROM sessions").fetchone()["c"]
3235
+ n_wiki = c.execute("SELECT COUNT(*) c FROM wiki_pages").fetchone()["c"]
3236
+ n_entities = c.execute("SELECT COUNT(*) c FROM entities").fetchone()["c"]
3237
+ n_relations = c.execute("SELECT COUNT(*) c FROM relations").fetchone()["c"]
3238
+ avg = c.execute("SELECT AVG(score) a FROM memories").fetchone()["a"] or 0.0
3239
+ wiki_avg = c.execute("SELECT AVG(importance) a FROM wiki_pages").fetchone()["a"] or 0.0
3240
+ return {
3241
+ "memories": n_mem,
3242
+ "sessions": n_ses,
3243
+ "wiki_pages": n_wiki,
3244
+ "entities": n_entities,
3245
+ "relations": n_relations,
3246
+ "wiki_avg_importance": round(float(wiki_avg), 4),
3247
+ "avg_score": round(avg, 4),
3248
+ "path": str(self.path),
3249
+ "db_size_bytes": self.db_size_bytes(),
3250
+ }
3251
+
3252
+ def db_size_bytes(self) -> int:
3253
+ """Return the on-disk size of the SQLite file in bytes.
3254
+
3255
+ Cheap (one stat call) so the dashboard can poll it freely.
3256
+ """
3257
+ try:
3258
+ return int(self.path.stat().st_size)
3259
+ except OSError:
3260
+ return 0
3261
+
3262
+ def list_low_value_memories(self, limit: int = 500) -> list[StoredMemory]:
3263
+ """Memories ranked by *combined* importance × score, ascending.
3264
+
3265
+ Used by the compactor to drop the least-useful rows when the
3266
+ store exceeds its hard ceiling. Excludes rows that have ever
3267
+ been recalled — we never evict demonstrably-useful memories
3268
+ without an explicit user action.
3269
+ """
3270
+ with self._conn() as c:
3271
+ rows = c.execute(
3272
+ """
3273
+ SELECT m.*
3274
+ FROM memories m
3275
+ LEFT JOIN memory_signals s ON s.memory_id = m.id
3276
+ WHERE COALESCE(s.recall_count, 0) = 0
3277
+ AND m.kind != 'digest'
3278
+ ORDER BY (COALESCE(m.score, 0) * COALESCE(m.importance, 0)) ASC,
3279
+ COALESCE(m.created_at, 0) ASC
3280
+ LIMIT ?
3281
+ """,
3282
+ (limit,),
3283
+ ).fetchall()
3284
+ return [self._row_to_memory(r) for r in rows]
3285
+
3286
+ def storage_breakdown(self) -> dict[str, int]:
3287
+ """Per-table row counts and approximate bytes-on-disk.
3288
+
3289
+ The on-disk byte estimate is from SQLite's ``dbstat`` virtual
3290
+ table when available; falls back to a row-count × avg-size
3291
+ heuristic otherwise.
3292
+ """
3293
+ out: dict[str, int] = {}
3294
+ with self._conn() as c:
3295
+ for tbl in ("memories", "sessions", "wiki_pages", "entities", "relations",
3296
+ "memory_signals", "contradiction_pairs", "drops", "settings"):
3297
+ try:
3298
+ row = c.execute(f"SELECT COUNT(*) c FROM {tbl}").fetchone()
3299
+ out[tbl] = int(row["c"] or 0)
3300
+ except sqlite3.OperationalError:
3301
+ out[tbl] = 0
3302
+ out["db_size_bytes"] = self.db_size_bytes()
3303
+ return out
3304
+
3305
+
3306
+ # --- settings ---------------------------------------------------------
3307
+
3308
+ def get_setting(self, key: str, default=None):
3309
+ """Read a single setting key, returning ``default`` if absent."""
3310
+ with self._conn() as c:
3311
+ row = c.execute("SELECT v FROM settings WHERE k=?", (key,)).fetchone()
3312
+ if row is None:
3313
+ return default
3314
+ try:
3315
+ return json.loads(row["v"])
3316
+ except (ValueError, TypeError):
3317
+ logger.warning("corrupt setting %s; returning default", key)
3318
+ return default
3319
+
3320
+ def set_setting(self, key: str, value) -> None:
3321
+ """Persist a setting value (JSON-encoded)."""
3322
+ import json
3323
+ payload = json.dumps(value, ensure_ascii=False)
3324
+ now = time.time()
3325
+ with self._conn() as c:
3326
+ c.execute(
3327
+ "INSERT INTO settings(k,v,updated_at) VALUES (?,?,?) "
3328
+ "ON CONFLICT(k) DO UPDATE SET v=excluded.v, updated_at=excluded.updated_at",
3329
+ (key, payload, now),
3330
+ )
3331
+
3332
+ def get_all_settings(self) -> dict:
3333
+ with self._conn() as c:
3334
+ rows = c.execute("SELECT k, v FROM settings").fetchall()
3335
+ import json
3336
+ out: dict = {}
3337
+ for r in rows:
3338
+ try:
3339
+ out[r["k"]] = json.loads(r["v"])
3340
+ except (ValueError, TypeError):
3341
+ logger.warning("corrupt setting %s; keeping raw value", r["k"])
3342
+ out[r["k"]] = r["v"]
3343
+ return out
3344
+
3345
+ # --- consolidation run history ---------------------------------------
3346
+
3347
+ def start_consolidation_run(self, trigger: str, model=None) -> str:
3348
+ import uuid
3349
+ rid = uuid.uuid4().hex
3350
+ now = time.time()
3351
+ with self._conn() as c:
3352
+ c.execute(
3353
+ "INSERT INTO consolidation_runs(id,started_at,trigger,status,model) "
3354
+ "VALUES (?,?,?,?,?)",
3355
+ (rid, now, trigger, "running", model),
3356
+ )
3357
+ return rid
3358
+
3359
+ def finish_consolidation_run(
3360
+ self,
3361
+ run_id: str,
3362
+ status: str,
3363
+ stats=None,
3364
+ error=None,
3365
+ ) -> None:
3366
+ import json
3367
+ now = time.time()
3368
+ with self._conn() as c:
3369
+ c.execute(
3370
+ "UPDATE consolidation_runs "
3371
+ "SET finished_at=?, status=?, stats_json=?, error=? WHERE id=?",
3372
+ (now, status, json.dumps(stats) if stats else None, error, run_id),
3373
+ )
3374
+
3375
+ def list_consolidation_runs(self, limit: int = 20) -> list:
3376
+ with self._conn() as c:
3377
+ rows = c.execute(
3378
+ "SELECT * FROM consolidation_runs ORDER BY started_at DESC LIMIT ?",
3379
+ (limit,),
3380
+ ).fetchall()
3381
+ import json
3382
+ out = []
3383
+ for r in rows:
3384
+ d = dict(r)
3385
+ if d.get("stats_json"):
3386
+ try:
3387
+ d["stats"] = json.loads(d["stats_json"])
3388
+ except (ValueError, TypeError):
3389
+ logger.warning("corrupt stats_json for run %s; dropping stats", d.get("id"))
3390
+ d.pop("stats_json", None)
3391
+ out.append(d)
3392
+ return out
3393
+
3394
+
3395
+
3396
+
3397
+
3398
+
3399
+ # ---- Contradiction pair ignore list --------------------------------
3400
+
3401
+ @staticmethod
3402
+ def pair_key(a: str, b: str) -> str:
3403
+ """Canonical hash for a (memory_a, memory_b) pair.
3404
+
3405
+ Order-independent so the user can ignore the pair from either
3406
+ side and we still find it again later.
3407
+ """
3408
+ lo, hi = sorted([str(a or ""), str(b or "")])
3409
+ return f"{lo}|{hi}"
3410
+
3411
+ def ignore_contradiction(self, a: str, b: str) -> bool:
3412
+ """Mark a contradiction pair as ignored. Returns True if newly inserted."""
3413
+ key = self.pair_key(a, b)
3414
+ now = time.time()
3415
+ with self._conn() as c:
3416
+ cur = c.execute(
3417
+ "INSERT INTO contradiction_ignored(pair_key, ignored_at) VALUES(?, ?) "
3418
+ "ON CONFLICT(pair_key) DO NOTHING",
3419
+ (key, now),
3420
+ )
3421
+ return cur.rowcount > 0
3422
+
3423
+ def unignore_contradiction(self, a: str, b: str) -> bool:
3424
+ """Reverse an ignore. Returns True if a row was actually deleted."""
3425
+ key = self.pair_key(a, b)
3426
+ with self._conn() as c:
3427
+ cur = c.execute("DELETE FROM contradiction_ignored WHERE pair_key=?", (key,))
3428
+ return cur.rowcount > 0
3429
+
3430
+ def is_contradiction_ignored(self, a: str, b: str) -> bool:
3431
+ key = self.pair_key(a, b)
3432
+ with self._conn() as c:
3433
+ r = c.execute("SELECT 1 FROM contradiction_ignored WHERE pair_key=?", (key,)).fetchone()
3434
+ return bool(r)
3435
+
3436
+ def list_ignored_pairs(self) -> set[str]:
3437
+ with self._conn() as c:
3438
+ return {r["pair_key"] for r in c.execute("SELECT pair_key FROM contradiction_ignored")}
3439
+ class LLMAuditStore:
3440
+ """Append-only audit log for every LLM provider call.
3441
+
3442
+ Backed by the ``llm_audit`` table. Inserts are fire-and-forget;
3443
+ the API intentionally never raises to keep consolidation paths
3444
+ robust against audit-write failures.
3445
+ """
3446
+
3447
+ def __init__(self, store: MemoryStore) -> None:
3448
+ self._store = store
3449
+
3450
+ def record(
3451
+ self,
3452
+ *,
3453
+ provider: str,
3454
+ model: str,
3455
+ kind: str,
3456
+ prompt: str,
3457
+ response: str,
3458
+ prompt_tokens: int = 0,
3459
+ completion_tokens: int = 0,
3460
+ cost_usd: float = 0.0,
3461
+ latency_ms: int = 0,
3462
+ ok: bool = True,
3463
+ error: str | None = None,
3464
+ run_id: str | None = None,
3465
+ ) -> str | None:
3466
+ import hashlib
3467
+ import json
3468
+ import time
3469
+ import uuid
3470
+ try:
3471
+ aid = uuid.uuid4().hex
3472
+ prompt_hash = hashlib.sha1((prompt or "").encode("utf-8"), usedforsecurity=False).hexdigest()[:16]
3473
+ total = (prompt_tokens or 0) + (completion_tokens or 0)
3474
+ with self._store._conn() as c:
3475
+ c.execute(
3476
+ """INSERT INTO llm_audit
3477
+ (id, ts, provider, model, kind, run_id, prompt_hash,
3478
+ prompt_text, response_text,
3479
+ prompt_tokens, completion_tokens, total_tokens,
3480
+ cost_usd, latency_ms, ok, error)
3481
+ VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)""",
3482
+ (
3483
+ aid, time.time(), provider, model, kind, run_id,
3484
+ prompt_hash,
3485
+ (prompt or "")[:8000], (response or "")[:8000],
3486
+ prompt_tokens, completion_tokens, total,
3487
+ cost_usd, latency_ms,
3488
+ 1 if ok else 0, (error or "")[:1000],
3489
+ ),
3490
+ )
3491
+ return aid
3492
+ except Exception:
3493
+ log.exception("llm_audit insert failed")
3494
+ return None
3495
+
3496
+ def recent(self, limit: int = 50, kind: str | None = None) -> list[dict]:
3497
+ try:
3498
+ with self._store._conn() as c:
3499
+ sql = "SELECT * FROM llm_audit"
3500
+ params: list = []
3501
+ if kind:
3502
+ sql += " WHERE kind = ?"
3503
+ params.append(kind)
3504
+ sql += " ORDER BY ts DESC LIMIT ?"
3505
+ params.append(limit)
3506
+ rows = c.execute(sql, params).fetchall()
3507
+ return [dict(r) for r in rows]
3508
+ except Exception:
3509
+ return []
3510
+
3511
+ def stats(self, since_ts: float | None = None) -> dict:
3512
+ """Aggregate token / cost / latency / failure counts."""
3513
+ try:
3514
+ with self._store._conn() as c:
3515
+ clauses = []
3516
+ params: list = []
3517
+ if since_ts is not None:
3518
+ clauses.append("ts >= ?")
3519
+ params.append(since_ts)
3520
+ where = ("WHERE " + " AND ".join(clauses)) if clauses else ""
3521
+ row = c.execute(
3522
+ f"""SELECT
3523
+ COUNT(*) AS calls,
3524
+ COALESCE(SUM(total_tokens), 0) AS total_tokens,
3525
+ COALESCE(SUM(prompt_tokens), 0) AS prompt_tokens,
3526
+ COALESCE(SUM(completion_tokens), 0) AS completion_tokens,
3527
+ COALESCE(SUM(cost_usd), 0.0) AS cost_usd,
3528
+ COALESCE(AVG(latency_ms), 0) AS avg_latency_ms,
3529
+ COALESCE(SUM(CASE WHEN ok=0 THEN 1 ELSE 0 END), 0) AS failures
3530
+ FROM llm_audit {where}""",
3531
+ params,
3532
+ ).fetchone()
3533
+ return dict(row)
3534
+ except Exception:
3535
+ return {}
3536
+
3537
+
3538
+
3539
+ class WriteGuardDropStore:
3540
+ """Tiny counter table for items rejected by the WriteGuard.
3541
+
3542
+ The pipeline stores one row per drop so the dashboard can show live
3543
+ counts and last-rejected timestamps per rejection kind / source.
3544
+ """
3545
+
3546
+ def __init__(self, store: MemoryStore) -> None:
3547
+ self.store = store
3548
+
3549
+ def record(self, *, source: str, kind: str, text_preview: str = "",
3550
+ matched_id: str | None = None, matched_score: float = 0.0,
3551
+ ts: float | None = None) -> None:
3552
+ import time as _t
3553
+ ts = ts if ts is not None else _t.time()
3554
+ try:
3555
+ with self.store._conn() as c:
3556
+ c.execute(
3557
+ "INSERT INTO write_guard_drops(ts, source, kind, text_preview, matched_id, matched_score) "
3558
+ "VALUES (?,?,?,?,?,?)",
3559
+ (ts, source or "unknown", kind, (text_preview or "")[:160],
3560
+ matched_id, float(matched_score or 0.0)),
3561
+ )
3562
+ except Exception:
3563
+ # Never let a metrics write block ingestion.
3564
+ pass
3565
+
3566
+ def summary(self, *, window_hours: float = 24 * 7) -> dict:
3567
+ """Aggregate drop counts by source + kind, plus last-seen timestamps.
3568
+
3569
+ Returned shape::
3570
+
3571
+ {
3572
+ "totals": {"duplicate": 12, "too_short": 3, ...},
3573
+ "by_source": {"codex": {"duplicate": 9, ...}, ...},
3574
+ "last": {"duplicate": 1784297800.1, ...},
3575
+ "window_hours": 168,
3576
+ "threshold": {"duplicate_threshold": 0.85, "min_len": 25, "max_len": 1200, "min_imp": 0.4},
3577
+ }
3578
+ """
3579
+ import time as _t
3580
+ since = _t.time() - float(window_hours) * 3600.0
3581
+ with self.store._conn() as c:
3582
+ rows = c.execute(
3583
+ "SELECT source, kind, COUNT(*) AS n, MAX(ts) AS last_ts "
3584
+ "FROM write_guard_drops WHERE ts >= ? GROUP BY source, kind",
3585
+ (since,),
3586
+ ).fetchall()
3587
+ totals: dict[str, int] = {}
3588
+ by_source: dict[str, dict[str, int]] = {}
3589
+ last: dict[str, float] = {}
3590
+ for r in rows:
3591
+ n = int(r["n"])
3592
+ totals[r["kind"]] = totals.get(r["kind"], 0) + n
3593
+ by_source.setdefault(r["source"], {})[r["kind"]] = n
3594
+ last[r["kind"]] = max(last.get(r["kind"], 0.0), float(r["last_ts"] or 0.0))
3595
+ # Read thresholds from the running WriteGuard if one exists.
3596
+ threshold = {
3597
+ "duplicate_threshold": 0.85,
3598
+ "min_len": 25,
3599
+ "max_len": 1200,
3600
+ "min_imp": 0.4,
3601
+ }
3602
+ try:
3603
+ from ..ingest.pipeline import WriteGuard as _WG
3604
+ wg = _WG(self.store)
3605
+ threshold = {
3606
+ "duplicate_threshold": float(wg.duplicate_threshold),
3607
+ "min_len": int(wg.min_len),
3608
+ "max_len": int(wg.max_len),
3609
+ "min_imp": float(wg.min_importance),
3610
+ }
3611
+ except Exception:
3612
+ pass
3613
+ return {
3614
+ "totals": totals,
3615
+ "by_source": by_source,
3616
+ "last": last,
3617
+ "window_hours": float(window_hours),
3618
+ "threshold": threshold,
3619
+ }
3620
+
3621
+ def _cosine(a: list[float], b: list[float]) -> float:
3622
+ if not a or not b or len(a) != len(b):
3623
+ return 0.0
3624
+ dot = sum(x * y for x, y in zip(a, b, strict=False))
3625
+ na = math.sqrt(sum(x * x for x in a)) or 1e-12
3626
+ nb = math.sqrt(sum(x * x for x in b)) or 1e-12
3627
+ return dot / (na * nb)