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.
- code_context_engine-0.4.0.dist-info/METADATA +389 -0
- code_context_engine-0.4.0.dist-info/RECORD +63 -0
- code_context_engine-0.4.0.dist-info/WHEEL +5 -0
- code_context_engine-0.4.0.dist-info/entry_points.txt +4 -0
- code_context_engine-0.4.0.dist-info/licenses/LICENSE +21 -0
- code_context_engine-0.4.0.dist-info/top_level.txt +1 -0
- context_engine/__init__.py +3 -0
- context_engine/cli.py +2848 -0
- context_engine/cli_style.py +66 -0
- context_engine/compression/__init__.py +0 -0
- context_engine/compression/compressor.py +144 -0
- context_engine/compression/ollama_client.py +33 -0
- context_engine/compression/output_rules.py +77 -0
- context_engine/compression/prompts.py +9 -0
- context_engine/compression/quality.py +37 -0
- context_engine/config.py +198 -0
- context_engine/dashboard/__init__.py +0 -0
- context_engine/dashboard/_page.py +1548 -0
- context_engine/dashboard/server.py +429 -0
- context_engine/editors.py +265 -0
- context_engine/event_bus.py +24 -0
- context_engine/indexer/__init__.py +0 -0
- context_engine/indexer/chunker.py +147 -0
- context_engine/indexer/embedder.py +154 -0
- context_engine/indexer/embedding_cache.py +168 -0
- context_engine/indexer/git_hooks.py +73 -0
- context_engine/indexer/git_indexer.py +136 -0
- context_engine/indexer/ignorefile.py +96 -0
- context_engine/indexer/manifest.py +78 -0
- context_engine/indexer/pipeline.py +624 -0
- context_engine/indexer/secrets.py +332 -0
- context_engine/indexer/watcher.py +109 -0
- context_engine/integration/__init__.py +0 -0
- context_engine/integration/bootstrap.py +76 -0
- context_engine/integration/git_context.py +132 -0
- context_engine/integration/mcp_server.py +1825 -0
- context_engine/integration/session_capture.py +306 -0
- context_engine/memory/__init__.py +6 -0
- context_engine/memory/compressor.py +344 -0
- context_engine/memory/db.py +922 -0
- context_engine/memory/extractive.py +106 -0
- context_engine/memory/grammar.py +419 -0
- context_engine/memory/hook_installer.py +258 -0
- context_engine/memory/hook_server.py +83 -0
- context_engine/memory/hooks.py +327 -0
- context_engine/memory/migrate.py +268 -0
- context_engine/models.py +96 -0
- context_engine/pricing.py +104 -0
- context_engine/project_commands.py +296 -0
- context_engine/retrieval/__init__.py +0 -0
- context_engine/retrieval/confidence.py +47 -0
- context_engine/retrieval/query_parser.py +105 -0
- context_engine/retrieval/retriever.py +199 -0
- context_engine/serve_http.py +208 -0
- context_engine/services.py +252 -0
- context_engine/storage/__init__.py +0 -0
- context_engine/storage/backend.py +39 -0
- context_engine/storage/fts_store.py +112 -0
- context_engine/storage/graph_store.py +219 -0
- context_engine/storage/local_backend.py +109 -0
- context_engine/storage/remote_backend.py +117 -0
- context_engine/storage/vector_store.py +357 -0
- 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
|