code-context-engine 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 (63) hide show
  1. code_context_engine-0.4.0.dist-info/METADATA +389 -0
  2. code_context_engine-0.4.0.dist-info/RECORD +63 -0
  3. code_context_engine-0.4.0.dist-info/WHEEL +5 -0
  4. code_context_engine-0.4.0.dist-info/entry_points.txt +4 -0
  5. code_context_engine-0.4.0.dist-info/licenses/LICENSE +21 -0
  6. code_context_engine-0.4.0.dist-info/top_level.txt +1 -0
  7. context_engine/__init__.py +3 -0
  8. context_engine/cli.py +2848 -0
  9. context_engine/cli_style.py +66 -0
  10. context_engine/compression/__init__.py +0 -0
  11. context_engine/compression/compressor.py +144 -0
  12. context_engine/compression/ollama_client.py +33 -0
  13. context_engine/compression/output_rules.py +77 -0
  14. context_engine/compression/prompts.py +9 -0
  15. context_engine/compression/quality.py +37 -0
  16. context_engine/config.py +198 -0
  17. context_engine/dashboard/__init__.py +0 -0
  18. context_engine/dashboard/_page.py +1548 -0
  19. context_engine/dashboard/server.py +429 -0
  20. context_engine/editors.py +265 -0
  21. context_engine/event_bus.py +24 -0
  22. context_engine/indexer/__init__.py +0 -0
  23. context_engine/indexer/chunker.py +147 -0
  24. context_engine/indexer/embedder.py +154 -0
  25. context_engine/indexer/embedding_cache.py +168 -0
  26. context_engine/indexer/git_hooks.py +73 -0
  27. context_engine/indexer/git_indexer.py +136 -0
  28. context_engine/indexer/ignorefile.py +96 -0
  29. context_engine/indexer/manifest.py +78 -0
  30. context_engine/indexer/pipeline.py +624 -0
  31. context_engine/indexer/secrets.py +332 -0
  32. context_engine/indexer/watcher.py +109 -0
  33. context_engine/integration/__init__.py +0 -0
  34. context_engine/integration/bootstrap.py +76 -0
  35. context_engine/integration/git_context.py +132 -0
  36. context_engine/integration/mcp_server.py +1825 -0
  37. context_engine/integration/session_capture.py +306 -0
  38. context_engine/memory/__init__.py +6 -0
  39. context_engine/memory/compressor.py +344 -0
  40. context_engine/memory/db.py +922 -0
  41. context_engine/memory/extractive.py +106 -0
  42. context_engine/memory/grammar.py +419 -0
  43. context_engine/memory/hook_installer.py +258 -0
  44. context_engine/memory/hook_server.py +83 -0
  45. context_engine/memory/hooks.py +327 -0
  46. context_engine/memory/migrate.py +268 -0
  47. context_engine/models.py +96 -0
  48. context_engine/pricing.py +104 -0
  49. context_engine/project_commands.py +296 -0
  50. context_engine/retrieval/__init__.py +0 -0
  51. context_engine/retrieval/confidence.py +47 -0
  52. context_engine/retrieval/query_parser.py +105 -0
  53. context_engine/retrieval/retriever.py +199 -0
  54. context_engine/serve_http.py +208 -0
  55. context_engine/services.py +252 -0
  56. context_engine/storage/__init__.py +0 -0
  57. context_engine/storage/backend.py +39 -0
  58. context_engine/storage/fts_store.py +112 -0
  59. context_engine/storage/graph_store.py +219 -0
  60. context_engine/storage/local_backend.py +109 -0
  61. context_engine/storage/remote_backend.py +117 -0
  62. context_engine/storage/vector_store.py +357 -0
  63. context_engine/utils.py +72 -0
@@ -0,0 +1,922 @@
1
+ """Per-project memory.db bootstrap and connection helper.
2
+
3
+ Schema version 3 — see docs/specs/2026-04-28-memory-claude-mem-parity-design.md.
4
+
5
+ v1: core memory tables + FTS5 virtual tables for lexical recall.
6
+ v2: adds sqlite-vec `vec0` virtual tables for semantic recall on
7
+ decisions and turn_summaries (the two surfaces session_recall reads).
8
+ v3: adds `savings_log` — append-only ledger of token savings per bucket
9
+ (retrieval, chunk_compression, output_compression, memory_recall,
10
+ grammar, turn_summarization, progressive_disclosure). Feeds the
11
+ `cce savings` per-bucket breakdown.
12
+
13
+ Idempotent: opening an existing db is a no-op; opening an empty file creates
14
+ the schema and stamps version=3. Older dbs are upgraded in place additively.
15
+ """
16
+ from __future__ import annotations
17
+
18
+ import logging
19
+ import sqlite3
20
+ import struct
21
+ import time
22
+ from pathlib import Path
23
+
24
+ log = logging.getLogger(__name__)
25
+
26
+ CURRENT_VERSION = 3
27
+
28
+ # bge-small-en-v1.5 — the default embedder used everywhere else in cce.
29
+ # If the project's embedder swaps to a different model, vec tables are
30
+ # rebuilt on first access (see `_ensure_vec_dim`).
31
+ _VEC_DIM = 384
32
+
33
+ _SCHEMA_V1 = [
34
+ """
35
+ CREATE TABLE sessions (
36
+ id TEXT PRIMARY KEY,
37
+ project TEXT NOT NULL,
38
+ started_at_epoch INTEGER NOT NULL,
39
+ started_at TEXT NOT NULL,
40
+ ended_at_epoch INTEGER,
41
+ ended_at TEXT,
42
+ exit_reason TEXT,
43
+ prompt_count INTEGER DEFAULT 0,
44
+ status TEXT CHECK(status IN ('active','completed','failed')) NOT NULL DEFAULT 'active',
45
+ rollup_summary TEXT,
46
+ rollup_summary_at_epoch INTEGER
47
+ )
48
+ """,
49
+ "CREATE INDEX idx_sessions_started ON sessions(started_at_epoch DESC)",
50
+
51
+ """
52
+ CREATE TABLE prompts (
53
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
54
+ session_id TEXT NOT NULL REFERENCES sessions(id) ON DELETE CASCADE,
55
+ prompt_number INTEGER NOT NULL,
56
+ prompt_text TEXT NOT NULL,
57
+ created_at_epoch INTEGER NOT NULL,
58
+ created_at TEXT NOT NULL,
59
+ UNIQUE(session_id, prompt_number)
60
+ )
61
+ """,
62
+ "CREATE INDEX idx_prompts_session ON prompts(session_id, prompt_number)",
63
+
64
+ """
65
+ CREATE TABLE tool_event_payloads (
66
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
67
+ raw_input TEXT NOT NULL,
68
+ raw_output TEXT,
69
+ size_bytes INTEGER NOT NULL
70
+ )
71
+ """,
72
+
73
+ """
74
+ CREATE TABLE tool_events (
75
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
76
+ session_id TEXT NOT NULL REFERENCES sessions(id) ON DELETE CASCADE,
77
+ prompt_number INTEGER NOT NULL,
78
+ tool_name TEXT NOT NULL,
79
+ payload_id INTEGER REFERENCES tool_event_payloads(id) ON DELETE SET NULL,
80
+ summary TEXT,
81
+ created_at_epoch INTEGER NOT NULL,
82
+ created_at TEXT NOT NULL
83
+ )
84
+ """,
85
+ "CREATE INDEX idx_events_session_turn ON tool_events(session_id, prompt_number)",
86
+
87
+ """
88
+ CREATE TABLE turn_summaries (
89
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
90
+ session_id TEXT NOT NULL REFERENCES sessions(id) ON DELETE CASCADE,
91
+ prompt_number INTEGER NOT NULL,
92
+ summary TEXT NOT NULL,
93
+ tier TEXT NOT NULL,
94
+ created_at_epoch INTEGER NOT NULL,
95
+ UNIQUE(session_id, prompt_number)
96
+ )
97
+ """,
98
+
99
+ """
100
+ CREATE TABLE decisions (
101
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
102
+ session_id TEXT REFERENCES sessions(id) ON DELETE SET NULL,
103
+ decision TEXT NOT NULL,
104
+ reason TEXT NOT NULL,
105
+ source TEXT NOT NULL CHECK(source IN ('manual','migrated','auto')) DEFAULT 'manual',
106
+ created_at_epoch INTEGER NOT NULL,
107
+ created_at TEXT NOT NULL
108
+ )
109
+ """,
110
+ "CREATE INDEX idx_decisions_created ON decisions(created_at_epoch DESC)",
111
+ "CREATE INDEX idx_decisions_source ON decisions(source)",
112
+
113
+ """
114
+ CREATE TABLE code_areas (
115
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
116
+ session_id TEXT REFERENCES sessions(id) ON DELETE SET NULL,
117
+ file_path TEXT NOT NULL,
118
+ description TEXT NOT NULL,
119
+ source TEXT NOT NULL CHECK(source IN ('manual','migrated','auto')) DEFAULT 'manual',
120
+ created_at_epoch INTEGER NOT NULL
121
+ )
122
+ """,
123
+ "CREATE INDEX idx_code_areas_file ON code_areas(file_path)",
124
+
125
+ """
126
+ CREATE TABLE pending_compressions (
127
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
128
+ kind TEXT NOT NULL CHECK(kind IN ('turn','session_rollup')),
129
+ session_id TEXT NOT NULL,
130
+ prompt_number INTEGER,
131
+ enqueued_at_epoch INTEGER NOT NULL,
132
+ attempts INTEGER NOT NULL DEFAULT 0,
133
+ last_error TEXT,
134
+ UNIQUE(kind, session_id, prompt_number)
135
+ )
136
+ """,
137
+
138
+ # Tracks files consumed by `cce sessions migrate` so reruns are idempotent.
139
+ """
140
+ CREATE TABLE migrated_files (
141
+ source_path TEXT PRIMARY KEY,
142
+ imported_at_epoch INTEGER NOT NULL
143
+ )
144
+ """,
145
+
146
+ # FTS5 virtual tables — search index for session_recall.
147
+ "CREATE VIRTUAL TABLE prompts_fts USING fts5(prompt_text, content='prompts', content_rowid='id')",
148
+ "CREATE VIRTUAL TABLE decisions_fts USING fts5(decision, reason, content='decisions', content_rowid='id')",
149
+ "CREATE VIRTUAL TABLE turn_summaries_fts USING fts5(summary, content='turn_summaries', content_rowid='id')",
150
+
151
+ # Triggers keep the FTS shadow tables in sync with their source tables.
152
+ """
153
+ CREATE TRIGGER prompts_ai AFTER INSERT ON prompts BEGIN
154
+ INSERT INTO prompts_fts(rowid, prompt_text) VALUES (new.id, new.prompt_text);
155
+ END
156
+ """,
157
+ """
158
+ CREATE TRIGGER prompts_ad AFTER DELETE ON prompts BEGIN
159
+ INSERT INTO prompts_fts(prompts_fts, rowid, prompt_text) VALUES('delete', old.id, old.prompt_text);
160
+ END
161
+ """,
162
+ """
163
+ CREATE TRIGGER prompts_au AFTER UPDATE ON prompts BEGIN
164
+ INSERT INTO prompts_fts(prompts_fts, rowid, prompt_text) VALUES('delete', old.id, old.prompt_text);
165
+ INSERT INTO prompts_fts(rowid, prompt_text) VALUES (new.id, new.prompt_text);
166
+ END
167
+ """,
168
+
169
+ """
170
+ CREATE TRIGGER decisions_ai AFTER INSERT ON decisions BEGIN
171
+ INSERT INTO decisions_fts(rowid, decision, reason) VALUES (new.id, new.decision, new.reason);
172
+ END
173
+ """,
174
+ """
175
+ CREATE TRIGGER decisions_ad AFTER DELETE ON decisions BEGIN
176
+ INSERT INTO decisions_fts(decisions_fts, rowid, decision, reason) VALUES('delete', old.id, old.decision, old.reason);
177
+ END
178
+ """,
179
+ """
180
+ CREATE TRIGGER decisions_au AFTER UPDATE ON decisions BEGIN
181
+ INSERT INTO decisions_fts(decisions_fts, rowid, decision, reason) VALUES('delete', old.id, old.decision, old.reason);
182
+ INSERT INTO decisions_fts(rowid, decision, reason) VALUES (new.id, new.decision, new.reason);
183
+ END
184
+ """,
185
+
186
+ """
187
+ CREATE TRIGGER turn_summaries_ai AFTER INSERT ON turn_summaries BEGIN
188
+ INSERT INTO turn_summaries_fts(rowid, summary) VALUES (new.id, new.summary);
189
+ END
190
+ """,
191
+ """
192
+ CREATE TRIGGER turn_summaries_ad AFTER DELETE ON turn_summaries BEGIN
193
+ INSERT INTO turn_summaries_fts(turn_summaries_fts, rowid, summary) VALUES('delete', old.id, old.summary);
194
+ END
195
+ """,
196
+ """
197
+ CREATE TRIGGER turn_summaries_au AFTER UPDATE ON turn_summaries BEGIN
198
+ INSERT INTO turn_summaries_fts(turn_summaries_fts, rowid, summary) VALUES('delete', old.id, old.summary);
199
+ INSERT INTO turn_summaries_fts(rowid, summary) VALUES (new.id, new.summary);
200
+ END
201
+ """,
202
+
203
+ """
204
+ CREATE TABLE schema_versions (
205
+ version INTEGER PRIMARY KEY,
206
+ applied_at_epoch INTEGER NOT NULL
207
+ )
208
+ """,
209
+ ]
210
+
211
+
212
+ _SCHEMA_V3 = [
213
+ # Append-only savings ledger. Each row is one accounting event from a
214
+ # bucket (retrieval, grammar, memory_recall, etc.) with baseline (what
215
+ # would have been spent without CCE) and served (what was actually
216
+ # spent). `meta` carries bucket-specific context as JSON — e.g.
217
+ # {"level": "max"} for output_compression.
218
+ """
219
+ CREATE TABLE IF NOT EXISTS savings_log (
220
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
221
+ bucket TEXT NOT NULL,
222
+ baseline INTEGER NOT NULL,
223
+ served INTEGER NOT NULL,
224
+ meta TEXT,
225
+ ts INTEGER NOT NULL
226
+ )
227
+ """,
228
+ "CREATE INDEX IF NOT EXISTS idx_savings_bucket_ts ON savings_log(bucket, ts)",
229
+ ]
230
+
231
+
232
+ def _vec_table_stmts(dim: int) -> list[str]:
233
+ """vec0 virtual tables for the two surfaces session_recall actually reads.
234
+
235
+ We don't add vec for prompts (too noisy — the user's raw text is rarely
236
+ the right semantic anchor) or code_areas (already keyed by file path,
237
+ which a substring filter handles well enough).
238
+ """
239
+ return [
240
+ f"CREATE VIRTUAL TABLE IF NOT EXISTS decisions_vec USING vec0(embedding float[{dim}])",
241
+ f"CREATE VIRTUAL TABLE IF NOT EXISTS turn_summaries_vec USING vec0(embedding float[{dim}])",
242
+ ]
243
+
244
+
245
+ def _vec_trigger_stmts() -> list[str]:
246
+ """Cleanup triggers — when a source row is deleted, drop its vec row too.
247
+
248
+ Without these, FK cascades / explicit deletes would leak rows in the vec
249
+ tables (FTS gets cleaned up by its own existing triggers).
250
+ """
251
+ return [
252
+ """
253
+ CREATE TRIGGER IF NOT EXISTS decisions_vec_ad AFTER DELETE ON decisions BEGIN
254
+ DELETE FROM decisions_vec WHERE rowid = old.id;
255
+ END
256
+ """,
257
+ """
258
+ CREATE TRIGGER IF NOT EXISTS turn_summaries_vec_ad AFTER DELETE ON turn_summaries BEGIN
259
+ DELETE FROM turn_summaries_vec WHERE rowid = old.id;
260
+ END
261
+ """,
262
+ ]
263
+
264
+
265
+ def _serialize_vec(vec) -> bytes:
266
+ """Pack a float vector into bytes for sqlite-vec."""
267
+ v = list(vec) if not isinstance(vec, list) else vec
268
+ return struct.pack(f"{len(v)}f", *v)
269
+
270
+
271
+ def _try_load_vec(conn: sqlite3.Connection) -> bool:
272
+ """Load the sqlite-vec extension. Returns False if unavailable.
273
+
274
+ A False return means the db opens fine but the v2 vec tables can't be
275
+ created or queried. Callers that need semantic recall should treat this
276
+ as a soft degradation and fall back to FTS5-only.
277
+ """
278
+ try:
279
+ import sqlite_vec
280
+ conn.enable_load_extension(True)
281
+ sqlite_vec.load(conn)
282
+ conn.enable_load_extension(False)
283
+ return True
284
+ except Exception as exc:
285
+ log.warning("sqlite-vec load failed; semantic recall disabled: %s", exc)
286
+ return False
287
+
288
+
289
+ def connect(db_path: str | Path) -> sqlite3.Connection:
290
+ """Open (or create) the per-project memory.db at `db_path`.
291
+
292
+ Bootstraps the schema if the file is empty, upgrades v1 → v2 in place,
293
+ and loads the sqlite-vec extension. Idempotent.
294
+ """
295
+ db_path = Path(db_path)
296
+ db_path.parent.mkdir(parents=True, exist_ok=True)
297
+ conn = sqlite3.connect(str(db_path))
298
+ conn.row_factory = sqlite3.Row
299
+ # Foreign keys must be enabled per-connection in SQLite.
300
+ conn.execute("PRAGMA foreign_keys = ON")
301
+ # WAL gives concurrent readers (the dashboard) decent isolation while the
302
+ # MCP server writes; no impact on single-process use.
303
+ conn.execute("PRAGMA journal_mode = WAL")
304
+ has_vec = _try_load_vec(conn)
305
+ _ensure_schema(conn, has_vec=has_vec)
306
+ return conn
307
+
308
+
309
+ def _ensure_schema(conn: sqlite3.Connection, *, has_vec: bool) -> None:
310
+ cur = conn.cursor()
311
+ bootstrap_row = cur.execute(
312
+ "SELECT name FROM sqlite_master WHERE type='table' AND name='schema_versions'"
313
+ ).fetchone()
314
+
315
+ if bootstrap_row is None:
316
+ cur.execute("BEGIN")
317
+ try:
318
+ for stmt in _SCHEMA_V1:
319
+ cur.execute(stmt)
320
+ if has_vec:
321
+ for stmt in _vec_table_stmts(_VEC_DIM):
322
+ cur.execute(stmt)
323
+ for stmt in _vec_trigger_stmts():
324
+ cur.execute(stmt)
325
+ for stmt in _SCHEMA_V3:
326
+ cur.execute(stmt)
327
+ cur.execute(
328
+ "INSERT INTO schema_versions (version, applied_at_epoch) "
329
+ "VALUES (?, strftime('%s','now'))",
330
+ (CURRENT_VERSION,),
331
+ )
332
+ conn.commit()
333
+ except Exception:
334
+ conn.rollback()
335
+ raise
336
+ return
337
+
338
+ # Existing db — apply additive upgrades up to CURRENT_VERSION.
339
+ # v1 → v2: add vec tables + cleanup triggers (needs sqlite-vec).
340
+ # v2 → v3: add savings_log (no extension dependency).
341
+ # If sqlite-vec is unavailable we can still apply v3, but we don't
342
+ # stamp the version row so a future connection with vec loaded will
343
+ # complete the v1 → v2 step.
344
+ current = schema_version(conn)
345
+ if current >= CURRENT_VERSION:
346
+ return
347
+ cur.execute("BEGIN")
348
+ try:
349
+ if current < 2 and has_vec:
350
+ for stmt in _vec_table_stmts(_VEC_DIM):
351
+ cur.execute(stmt)
352
+ for stmt in _vec_trigger_stmts():
353
+ cur.execute(stmt)
354
+ if current < 3:
355
+ for stmt in _SCHEMA_V3:
356
+ cur.execute(stmt)
357
+ if current < 2 and not has_vec:
358
+ # No version bump — vec step still pending.
359
+ conn.commit()
360
+ return
361
+ cur.execute(
362
+ "INSERT INTO schema_versions (version, applied_at_epoch) "
363
+ "VALUES (?, strftime('%s','now'))",
364
+ (CURRENT_VERSION,),
365
+ )
366
+ conn.commit()
367
+ except Exception:
368
+ conn.rollback()
369
+ raise
370
+
371
+
372
+ def schema_version(conn: sqlite3.Connection) -> int:
373
+ row = conn.execute(
374
+ "SELECT MAX(version) AS v FROM schema_versions"
375
+ ).fetchone()
376
+ return int(row["v"]) if row and row["v"] is not None else 0
377
+
378
+
379
+ def memory_db_path(storage_base: str | Path) -> Path:
380
+ """Canonical location of the memory db inside a project's storage dir."""
381
+ return Path(storage_base) / "memory.db"
382
+
383
+
384
+ # ── Vector helpers ──────────────────────────────────────────────────────────
385
+
386
+ def has_vec_tables(conn: sqlite3.Connection) -> bool:
387
+ """True iff the v2 vec tables exist (extension loaded + schema upgraded)."""
388
+ rows = conn.execute(
389
+ "SELECT name FROM sqlite_master WHERE type='table' "
390
+ "AND name IN ('decisions_vec','turn_summaries_vec')"
391
+ ).fetchall()
392
+ return len(rows) == 2
393
+
394
+
395
+ def _decision_vec_text(decision: str, reason: str) -> str:
396
+ if decision and reason:
397
+ return f"{decision} — {reason}"
398
+ return decision or reason or ""
399
+
400
+
401
+ def _write_vec_row(conn, table: str, rowid: int, vec) -> None:
402
+ """Best-effort vec write. Swallows dim mismatches so a swapped embedder
403
+ doesn't break inserts on the source table — the failed row simply won't
404
+ be semantically searchable until the vec tables are rebuilt.
405
+ """
406
+ try:
407
+ conn.execute(f"DELETE FROM {table} WHERE rowid = ?", (rowid,))
408
+ conn.execute(
409
+ f"INSERT INTO {table}(rowid, embedding) VALUES (?, ?)",
410
+ (rowid, _serialize_vec(vec)),
411
+ )
412
+ except sqlite3.OperationalError as exc:
413
+ log.debug("vec write skipped on %s rowid=%s: %s", table, rowid, exc)
414
+
415
+
416
+ def record_decision_vec(conn, embedder, *, decision_id: int, decision: str, reason: str) -> None:
417
+ """Embed a decision row and write it to decisions_vec. Idempotent on rowid."""
418
+ if not has_vec_tables(conn):
419
+ return
420
+ text = _decision_vec_text(decision, reason)
421
+ if not text.strip():
422
+ return
423
+ try:
424
+ vec = embedder.embed_query(text)
425
+ except Exception:
426
+ log.exception("embedder failed for decision %s", decision_id)
427
+ return
428
+ _write_vec_row(conn, "decisions_vec", decision_id, vec)
429
+
430
+
431
+ def record_turn_summary_vec(conn, embedder, *, turn_id: int, summary: str) -> None:
432
+ """Embed a turn summary and write it to turn_summaries_vec."""
433
+ if not has_vec_tables(conn):
434
+ return
435
+ if not summary.strip():
436
+ return
437
+ try:
438
+ vec = embedder.embed_query(summary)
439
+ except Exception:
440
+ log.exception("embedder failed for turn_summary %s", turn_id)
441
+ return
442
+ _write_vec_row(conn, "turn_summaries_vec", turn_id, vec)
443
+
444
+
445
+ def backfill_vec_tables(conn, embedder) -> dict[str, int]:
446
+ """Embed any source rows that don't yet have a vec entry.
447
+
448
+ Idempotent and incremental — runs at MCP startup so:
449
+ - Projects upgrading from v1 get their full backlog embedded.
450
+ - Decisions imported by `cce sessions migrate` (which runs without
451
+ an embedder) pick up semantic recall on next `cce serve`.
452
+ - Sessions captured while the vec extension was unavailable get
453
+ retroactively indexed once it loads.
454
+
455
+ The previous "only run if the vec table is empty" guard meant a single
456
+ manually-recorded decision permanently disabled all future backfill,
457
+ so any subsequent migrated rows were invisible to semantic recall.
458
+ """
459
+ counts = {"decisions": 0, "turn_summaries": 0}
460
+ if not has_vec_tables(conn):
461
+ return counts
462
+ for row in conn.execute(
463
+ "SELECT d.id, d.decision, d.reason FROM decisions d "
464
+ "WHERE NOT EXISTS ("
465
+ " SELECT 1 FROM decisions_vec v WHERE v.rowid = d.id"
466
+ ")"
467
+ ):
468
+ record_decision_vec(
469
+ conn, embedder,
470
+ decision_id=row["id"],
471
+ decision=row["decision"] or "",
472
+ reason=row["reason"] or "",
473
+ )
474
+ counts["decisions"] += 1
475
+ for row in conn.execute(
476
+ "SELECT t.id, t.summary FROM turn_summaries t "
477
+ "WHERE NOT EXISTS ("
478
+ " SELECT 1 FROM turn_summaries_vec v WHERE v.rowid = t.id"
479
+ ")"
480
+ ):
481
+ record_turn_summary_vec(
482
+ conn, embedder,
483
+ turn_id=row["id"],
484
+ summary=row["summary"] or "",
485
+ )
486
+ counts["turn_summaries"] += 1
487
+ if counts["decisions"] or counts["turn_summaries"]:
488
+ conn.commit()
489
+ log.info("vec backfill: decisions=%d turn_summaries=%d",
490
+ counts["decisions"], counts["turn_summaries"])
491
+ return counts
492
+
493
+
494
+ # Maximum L2 distance accepted from sqlite-vec MATCH. bge-small produces
495
+ # unit-normalised vectors, so L2² = 2·(1 - cosine_sim). Empirically bge-small's
496
+ # *noise floor* on short English text is around cosine_sim ≈ 0.50 — random
497
+ # unrelated queries land there. So we set the threshold at cosine_sim ≥ 0.58
498
+ # (L2 ≤ √(2·0.42) ≈ 0.917) to keep paraphrases ("risk management" ↔ "Risk
499
+ # limit at 2% per trade", measured at 0.638) while rejecting "how is the
500
+ # weather today" (max 0.535 against the same corpus).
501
+ _VEC_MAX_DISTANCE = 0.92
502
+
503
+
504
+ def search_decisions_vec(
505
+ conn, embedder, topic: str, *, k: int = 20,
506
+ max_distance: float = _VEC_MAX_DISTANCE,
507
+ ) -> list[int]:
508
+ """Return decision rowids ranked by semantic similarity to `topic`,
509
+ filtered by `max_distance` (default `_VEC_MAX_DISTANCE`). Empty list
510
+ on failure or no good match. Tests can pass a permissive max_distance
511
+ to use a deterministic fake embedder whose vectors don't satisfy
512
+ bge-small-tuned thresholds.
513
+ """
514
+ if not has_vec_tables(conn) or not topic.strip():
515
+ return []
516
+ try:
517
+ vec = embedder.embed_query(topic)
518
+ except Exception:
519
+ return []
520
+ try:
521
+ rows = conn.execute(
522
+ "SELECT rowid, distance FROM decisions_vec "
523
+ "WHERE embedding MATCH ? "
524
+ "ORDER BY distance LIMIT ?",
525
+ (_serialize_vec(vec), k),
526
+ ).fetchall()
527
+ except sqlite3.OperationalError as exc:
528
+ log.debug("decisions_vec search failed: %s", exc)
529
+ return []
530
+ return [r["rowid"] for r in rows if r["distance"] <= max_distance]
531
+
532
+
533
+ def search_turn_summaries_vec(
534
+ conn, embedder, topic: str, *, k: int = 20,
535
+ max_distance: float = _VEC_MAX_DISTANCE,
536
+ ) -> list[int]:
537
+ """Return turn_summary rowids ranked by semantic similarity, distance-filtered."""
538
+ if not has_vec_tables(conn) or not topic.strip():
539
+ return []
540
+ try:
541
+ vec = embedder.embed_query(topic)
542
+ except Exception:
543
+ return []
544
+ try:
545
+ rows = conn.execute(
546
+ "SELECT rowid, distance FROM turn_summaries_vec "
547
+ "WHERE embedding MATCH ? "
548
+ "ORDER BY distance LIMIT ?",
549
+ (_serialize_vec(vec), k),
550
+ ).fetchall()
551
+ except sqlite3.OperationalError as exc:
552
+ log.debug("turn_summaries_vec search failed: %s", exc)
553
+ return []
554
+ return [r["rowid"] for r in rows if r["distance"] <= max_distance]
555
+
556
+
557
+ # ── PII redaction toggle ────────────────────────────────────────────────────
558
+ # Set at process start by the MCP server / CLI from `Config.memory_redact_pii`.
559
+ # Defaults to True so a misconfigured caller errs on the side of redaction.
560
+ # Stored as module-level state because the write helpers are called from
561
+ # many entry points (mcp_server, compressor, migrate) without easy access
562
+ # to the live Config — and the value never changes within a process.
563
+ _PII_REDACTION_ENABLED = True
564
+
565
+
566
+ def set_pii_redaction(enabled: bool) -> None:
567
+ """Toggle PII scrubbing globally for memory.db writes."""
568
+ global _PII_REDACTION_ENABLED
569
+ _PII_REDACTION_ENABLED = bool(enabled)
570
+
571
+
572
+ def scrub_pii(text: str) -> str:
573
+ """Apply PII redaction (emails / IPs / SSNs / cards / phones) when
574
+ enabled. Returns the original text unchanged when off, or the input
575
+ is empty. Centralised so every memory.db write goes through one
576
+ place — wrapping each INSERT site directly was error-prone.
577
+ """
578
+ if not text or not _PII_REDACTION_ENABLED:
579
+ return text
580
+ from context_engine.indexer.secrets import redact_pii as _redact_pii
581
+ out, fired = _redact_pii(text)
582
+ if fired:
583
+ log.debug("memory: scrubbed %s from incoming text", ",".join(sorted(set(fired))))
584
+ return out
585
+
586
+
587
+ # ── Savings ledger ──────────────────────────────────────────────────────────
588
+
589
+ # Canonical bucket names — keep in sync with the renderer in cli.py.
590
+ BUCKETS = (
591
+ "retrieval",
592
+ "chunk_compression",
593
+ "output_compression",
594
+ "memory_recall",
595
+ "grammar",
596
+ "turn_summarization",
597
+ "progressive_disclosure",
598
+ )
599
+
600
+
601
+ def record_savings(
602
+ conn: sqlite3.Connection,
603
+ *,
604
+ bucket: str,
605
+ baseline: int,
606
+ served: int,
607
+ meta: dict | None = None,
608
+ ) -> None:
609
+ """Append one savings event. Best-effort — swallows write errors so a
610
+ misbehaving instrumentation point can never break a tool response.
611
+ """
612
+ if bucket not in BUCKETS:
613
+ log.warning("record_savings: unknown bucket %r — skipping", bucket)
614
+ return
615
+ try:
616
+ import json as _json
617
+ conn.execute(
618
+ "INSERT INTO savings_log (bucket, baseline, served, meta, ts) "
619
+ "VALUES (?, ?, ?, ?, ?)",
620
+ (
621
+ bucket,
622
+ int(baseline),
623
+ int(served),
624
+ _json.dumps(meta) if meta else None,
625
+ int(time.time()),
626
+ ),
627
+ )
628
+ conn.commit()
629
+ except sqlite3.Error as exc:
630
+ log.debug("record_savings(%s) failed: %s", bucket, exc)
631
+
632
+
633
+ def aggregate_savings(conn: sqlite3.Connection) -> dict[str, dict]:
634
+ """Roll up `savings_log` into per-bucket totals for the savings report.
635
+
636
+ Returns a dict keyed by bucket name with `{baseline, served, calls}`.
637
+ Missing buckets are filled with zeros so the renderer can iterate
638
+ over the canonical BUCKETS tuple unconditionally.
639
+ """
640
+ out = {b: {"baseline": 0, "served": 0, "calls": 0} for b in BUCKETS}
641
+ try:
642
+ rows = conn.execute(
643
+ "SELECT bucket, SUM(baseline) AS baseline, SUM(served) AS served, "
644
+ "COUNT(*) AS calls FROM savings_log GROUP BY bucket"
645
+ ).fetchall()
646
+ except sqlite3.Error:
647
+ return out
648
+ for r in rows:
649
+ b = r["bucket"]
650
+ if b in out:
651
+ out[b] = {
652
+ "baseline": int(r["baseline"] or 0),
653
+ "served": int(r["served"] or 0),
654
+ "calls": int(r["calls"] or 0),
655
+ }
656
+ return out
657
+
658
+
659
+ def aggregate_output_compression_levels(conn: sqlite3.Connection) -> dict[str, int]:
660
+ """Histogram of output_compression levels seen in the ledger.
661
+
662
+ Reads `meta.level` from each output_compression row. Used by the
663
+ renderer to show "max=21 calls, standard=4 calls" alongside the
664
+ estimated savings.
665
+ """
666
+ out: dict[str, int] = {}
667
+ try:
668
+ import json as _json
669
+ rows = conn.execute(
670
+ "SELECT meta FROM savings_log WHERE bucket = 'output_compression'"
671
+ ).fetchall()
672
+ except sqlite3.Error:
673
+ return out
674
+ for r in rows:
675
+ if not r["meta"]:
676
+ continue
677
+ try:
678
+ meta = _json.loads(r["meta"])
679
+ level = meta.get("level")
680
+ if level:
681
+ out[level] = out.get(level, 0) + 1
682
+ except (ValueError, TypeError):
683
+ continue
684
+ return out
685
+
686
+
687
+ # ── Retention ───────────────────────────────────────────────────────────────
688
+
689
+ def prune_old_payloads(conn, *, days: int = 30) -> dict[str, int]:
690
+ """NULL-out raw_input/raw_output on tool_event_payloads older than `days`.
691
+
692
+ The summary lives on `tool_events.summary` (or as a turn_summary), so
693
+ callers can still get the gist of an aged-out event — the raw payload
694
+ is the expensive part and the only thing that grows unbounded. The
695
+ `session_event` MCP tool already has a "raw payload aged out of the
696
+ retention window" branch; this is what makes that branch reachable.
697
+
698
+ Returns counts: {"payloads_pruned", "bytes_freed_estimate"}.
699
+ """
700
+ cutoff = conn.execute(
701
+ "SELECT strftime('%s','now') - ? * 86400 AS cutoff", (days,),
702
+ ).fetchone()["cutoff"]
703
+ # tool_event_payloads has no created_at of its own — it inherits time
704
+ # from tool_events. Find payloads referenced only by old events, where
705
+ # the raw fields aren't already nulled.
706
+ # raw_input has NOT NULL in v1 schema, so we use '' as the aged-out
707
+ # sentinel for it; raw_output is already nullable. Callers detect aged
708
+ # rows via "not raw_input and raw_output is None".
709
+ rows = conn.execute(
710
+ "SELECT p.id, p.size_bytes "
711
+ "FROM tool_event_payloads p "
712
+ "WHERE p.raw_input != '' "
713
+ "AND NOT EXISTS ("
714
+ " SELECT 1 FROM tool_events te "
715
+ " WHERE te.payload_id = p.id "
716
+ " AND te.created_at_epoch >= ?"
717
+ ")",
718
+ (cutoff,),
719
+ ).fetchall()
720
+ if not rows:
721
+ return {"payloads_pruned": 0, "bytes_freed_estimate": 0}
722
+ ids = [r["id"] for r in rows]
723
+ bytes_freed = sum(r["size_bytes"] or 0 for r in rows)
724
+ placeholders = ",".join("?" * len(ids))
725
+ conn.execute(
726
+ f"UPDATE tool_event_payloads "
727
+ f"SET raw_input = '', raw_output = NULL, size_bytes = 0 "
728
+ f"WHERE id IN ({placeholders})",
729
+ tuple(ids),
730
+ )
731
+ conn.commit()
732
+ log.info("pruned %d tool payloads older than %dd (~%d bytes freed)",
733
+ len(ids), days, bytes_freed)
734
+ return {"payloads_pruned": len(ids), "bytes_freed_estimate": bytes_freed}
735
+
736
+
737
+ # ── Row-level retention for memory tables ──────────────────────────────────
738
+ # Defaults err on the generous side — a 6-month-old decision can still be
739
+ # valuable, but unbounded growth eventually drops recall quality. Override
740
+ # via config (memory_decision_retention_days, etc.) or by passing different
741
+ # values to prune_old_rows() in tests.
742
+ DEFAULT_TURN_RETENTION_DAYS = 180 # 6 months
743
+ DEFAULT_DECISION_RETENTION_DAYS = 365 # 1 year — decisions tend to be load-bearing
744
+ DEFAULT_CODE_AREA_RETENTION_DAYS = 180 # 6 months
745
+ DEFAULT_AUTO_ARCHIVE = True # write rows to a json file before delete
746
+
747
+
748
+ def prune_old_rows(
749
+ conn: sqlite3.Connection,
750
+ *,
751
+ storage_base,
752
+ turn_days: int = DEFAULT_TURN_RETENTION_DAYS,
753
+ decision_days: int = DEFAULT_DECISION_RETENTION_DAYS,
754
+ code_area_days: int = DEFAULT_CODE_AREA_RETENTION_DAYS,
755
+ archive: bool = DEFAULT_AUTO_ARCHIVE,
756
+ ) -> dict[str, int]:
757
+ """Delete decisions / turn_summaries / code_areas older than the
758
+ configured TTLs. Optionally archives deleted rows to a JSON file
759
+ under `storage_base/archives/` before deletion, so power users can
760
+ grep history that's no longer indexed.
761
+
762
+ Returns counts: {"decisions_pruned", "turns_pruned", "code_areas_pruned"}.
763
+
764
+ Recall guard: rows referenced by a `decisions_vec` / `turn_summaries_vec`
765
+ entry are NOT skipped — vec triggers (see `_vec_trigger_stmts`) cascade
766
+ the delete cleanly. The bigger risk is deleting a row that just
767
+ surfaced in a recall hit, but we don't track per-row recall timestamps;
768
+ the long retention defaults make that vanishingly unlikely.
769
+ """
770
+ import json as _json
771
+ from pathlib import Path as _Path
772
+ counts = {"decisions_pruned": 0, "turns_pruned": 0, "code_areas_pruned": 0}
773
+ cutoffs = {
774
+ "turn_summaries": int(time.time()) - turn_days * 86400,
775
+ "decisions": int(time.time()) - decision_days * 86400,
776
+ "code_areas": int(time.time()) - code_area_days * 86400,
777
+ }
778
+
779
+ archive_dir = _Path(storage_base) / "archives"
780
+ if archive:
781
+ archive_dir.mkdir(parents=True, exist_ok=True)
782
+ ts = time.strftime("%Y%m%dT%H%M%S", time.gmtime())
783
+ archive_path = archive_dir / f"pruned-{ts}.json"
784
+ else:
785
+ archive_path = None
786
+
787
+ archived: dict[str, list[dict]] = {}
788
+
789
+ def _harvest_and_delete(table: str, columns: list[str], cutoff: int) -> int:
790
+ col_list = ", ".join(columns)
791
+ rows = conn.execute(
792
+ f"SELECT {col_list} FROM {table} WHERE created_at_epoch < ?",
793
+ (cutoff,),
794
+ ).fetchall()
795
+ if not rows:
796
+ return 0
797
+ if archive:
798
+ archived[table] = [dict(r) for r in rows]
799
+ conn.execute(
800
+ f"DELETE FROM {table} WHERE created_at_epoch < ?",
801
+ (cutoff,),
802
+ )
803
+ return len(rows)
804
+
805
+ counts["turns_pruned"] = _harvest_and_delete(
806
+ "turn_summaries",
807
+ ["id", "session_id", "prompt_number", "summary", "tier", "created_at_epoch"],
808
+ cutoffs["turn_summaries"],
809
+ )
810
+ counts["decisions_pruned"] = _harvest_and_delete(
811
+ "decisions",
812
+ ["id", "session_id", "decision", "reason", "source",
813
+ "created_at_epoch", "created_at"],
814
+ cutoffs["decisions"],
815
+ )
816
+ counts["code_areas_pruned"] = _harvest_and_delete(
817
+ "code_areas",
818
+ ["id", "session_id", "file_path", "description", "source", "created_at_epoch"],
819
+ cutoffs["code_areas"],
820
+ )
821
+ conn.commit()
822
+
823
+ if archive and archived and archive_path is not None:
824
+ try:
825
+ archive_path.write_text(_json.dumps(archived, indent=2, default=str))
826
+ log.info("memory: archived pruned rows to %s", archive_path)
827
+ except OSError as exc:
828
+ log.warning("memory: archive write failed (%s); rows still deleted", exc)
829
+
830
+ total = sum(counts.values())
831
+ if total:
832
+ log.info(
833
+ "memory: pruned %d row(s) across decisions/turns/code_areas",
834
+ total,
835
+ )
836
+ return counts
837
+
838
+
839
+ # Defaults exposed so tests can inject smaller values without monkey-patching.
840
+ AUTO_PRUNE_INITIAL_DELAY_SECONDS = 120 # stagger past vec backfill / compress
841
+ AUTO_PRUNE_INTERVAL_SECONDS = 86_400 # one pass per day
842
+
843
+
844
+ async def auto_prune_loop(
845
+ storage_base,
846
+ *,
847
+ days: int = 30,
848
+ initial_delay: float = AUTO_PRUNE_INITIAL_DELAY_SECONDS,
849
+ interval: float = AUTO_PRUNE_INTERVAL_SECONDS,
850
+ stop_event=None,
851
+ ) -> None:
852
+ """Background task: periodically age out old raw tool payloads.
853
+
854
+ Runs forever, sleeping `interval` between passes. Each pass opens its
855
+ own SQLite connection (so we don't pin a long-lived conn across the
856
+ day-long sleep) and dispatches the actual prune to a worker thread.
857
+ Cancellable via `stop_event` (preferred) or `task.cancel()`.
858
+
859
+ Extracted from `cli._run_serve` so it's testable without spinning up
860
+ the whole MCP server. Exposed defaults for `initial_delay` and
861
+ `interval` let tests run iterations in milliseconds.
862
+ """
863
+ import asyncio
864
+ from pathlib import Path
865
+ db_path = memory_db_path(Path(storage_base))
866
+
867
+ if initial_delay > 0:
868
+ try:
869
+ if stop_event is not None:
870
+ await asyncio.wait_for(stop_event.wait(), timeout=initial_delay)
871
+ return # stop_event fired during stagger
872
+ else:
873
+ await asyncio.sleep(initial_delay)
874
+ except asyncio.TimeoutError:
875
+ pass # normal: timeout means stagger elapsed without stop
876
+
877
+ while True:
878
+ if stop_event is not None and stop_event.is_set():
879
+ return
880
+ try:
881
+ def _do_prune():
882
+ conn = connect(db_path)
883
+ try:
884
+ payload = prune_old_payloads(conn, days=days)
885
+ rows = prune_old_rows(conn, storage_base=Path(storage_base))
886
+ return {**payload, **rows}
887
+ finally:
888
+ conn.close()
889
+ out = await asyncio.to_thread(_do_prune)
890
+ if out.get("payloads_pruned"):
891
+ log.info(
892
+ "auto-prune: aged out %d raw payloads (~%d KB)",
893
+ out["payloads_pruned"],
894
+ out["bytes_freed_estimate"] // 1024,
895
+ )
896
+ row_total = (
897
+ out.get("decisions_pruned", 0)
898
+ + out.get("turns_pruned", 0)
899
+ + out.get("code_areas_pruned", 0)
900
+ )
901
+ if row_total:
902
+ log.info(
903
+ "auto-prune: removed %d expired memory rows "
904
+ "(decisions=%d turns=%d code_areas=%d)",
905
+ row_total,
906
+ out.get("decisions_pruned", 0),
907
+ out.get("turns_pruned", 0),
908
+ out.get("code_areas_pruned", 0),
909
+ )
910
+ except asyncio.CancelledError:
911
+ raise
912
+ except Exception:
913
+ log.exception("auto-prune iteration failed; backing off")
914
+
915
+ try:
916
+ if stop_event is not None:
917
+ await asyncio.wait_for(stop_event.wait(), timeout=interval)
918
+ return
919
+ else:
920
+ await asyncio.sleep(interval)
921
+ except asyncio.TimeoutError:
922
+ pass