agent-memory-brain 0.2.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 (54) hide show
  1. agent_memory_brain-0.2.0.dist-info/METADATA +98 -0
  2. agent_memory_brain-0.2.0.dist-info/RECORD +54 -0
  3. agent_memory_brain-0.2.0.dist-info/WHEEL +4 -0
  4. agent_memory_brain-0.2.0.dist-info/entry_points.txt +3 -0
  5. agent_memory_brain-0.2.0.dist-info/licenses/LICENSE +67 -0
  6. devmem/__init__.py +3 -0
  7. devmem/ado.py +433 -0
  8. devmem/agent_colors.py +74 -0
  9. devmem/cli.py +500 -0
  10. devmem/db.py +1069 -0
  11. devmem/dispatch.py +478 -0
  12. devmem/embed.py +111 -0
  13. devmem/filelock.py +53 -0
  14. devmem/git_sync.py +261 -0
  15. devmem/guard.py +214 -0
  16. devmem/hook_log.py +49 -0
  17. devmem/ingest/__init__.py +1 -0
  18. devmem/ingest/claude.py +87 -0
  19. devmem/ingest/codex.py +175 -0
  20. devmem/ingest/runner.py +318 -0
  21. devmem/ingest/subagents.py +250 -0
  22. devmem/maintenance.py +801 -0
  23. devmem/mcp_server.py +542 -0
  24. devmem/messages.py +684 -0
  25. devmem/panel/__init__.py +1 -0
  26. devmem/panel/app.py +971 -0
  27. devmem/panel/state.py +358 -0
  28. devmem/panel/static/base.css +27 -0
  29. devmem/panel/static/brain.css +473 -0
  30. devmem/panel/static/brain.js +1118 -0
  31. devmem/panel/static/brain3d.js +600 -0
  32. devmem/panel/static/chat.js +18 -0
  33. devmem/panel/static/common.js +208 -0
  34. devmem/panel/static/site.css +508 -0
  35. devmem/panel/static/tokens.css +19 -0
  36. devmem/panel/templates/ado.html +62 -0
  37. devmem/panel/templates/agents.html +35 -0
  38. devmem/panel/templates/audit.html +19 -0
  39. devmem/panel/templates/base.html +42 -0
  40. devmem/panel/templates/brain.html +101 -0
  41. devmem/panel/templates/maintenance.html +32 -0
  42. devmem/panel/templates/memory.html +71 -0
  43. devmem/panel/templates/message_thread.html +49 -0
  44. devmem/panel/templates/messages.html +8 -0
  45. devmem/panel/templates/overview.html +109 -0
  46. devmem/panel/templates/project.html +48 -0
  47. devmem/panel/templates/projects.html +21 -0
  48. devmem/panel/templates/session.html +49 -0
  49. devmem/panel/templates/sessions.html +67 -0
  50. devmem/panel/templates/subagent.html +27 -0
  51. devmem/procs.py +10 -0
  52. devmem/projects.py +17 -0
  53. devmem/secrets.py +9 -0
  54. devmem/skills.py +105 -0
devmem/db.py ADDED
@@ -0,0 +1,1069 @@
1
+ """SQLite storage for devmem. All dates are UTC ISO-8601 strings."""
2
+ from __future__ import annotations
3
+
4
+ import os
5
+ import re
6
+ import sqlite3
7
+ import hashlib
8
+ import sys
9
+ from contextlib import contextmanager
10
+ from datetime import UTC, date, datetime, timedelta
11
+ from functools import lru_cache
12
+ from pathlib import Path
13
+ from typing import Iterator
14
+
15
+ import numpy as np
16
+
17
+ from .embed import EMBEDDING_DIM, EMBEDDING_MODEL, get_embedder
18
+ from .projects import project_for_cwd
19
+
20
+ SCHEMA_VERSION = 14
21
+ DEFAULT_MAX_CONTENT = 2000
22
+ # bge-small-en-v1.5 scores unrelated text ~0.45-0.59 and real matches ~0.65+ (measured on ~40 memories, 2026-10).
23
+ MIN_COSINE = 0.6
24
+ # measured 2026-10-27 on 56 memories: real duplicates scored 0.86-0.92, related-but-distinct memories up to 0.89, so this is a hint, not a block.
25
+ SIMILAR_COSINE = 0.80
26
+
27
+
28
+ # Version 1 schema, kept verbatim: later versions migrate from it additively.
29
+ V1_SCHEMA = """
30
+ CREATE TABLE memories (
31
+ id INTEGER PRIMARY KEY, project TEXT, kind TEXT NOT NULL CHECK(kind IN ('note','decision','preference','snippet','todo')),
32
+ title TEXT NOT NULL, content TEXT NOT NULL, tags TEXT NOT NULL DEFAULT '',
33
+ created_at TEXT NOT NULL, updated_at TEXT NOT NULL, source_agent TEXT, source_session TEXT
34
+ );
35
+ CREATE VIRTUAL TABLE memories_fts USING fts5(title, content, tags, content='memories', content_rowid='id');
36
+ CREATE TRIGGER memories_ai AFTER INSERT ON memories BEGIN
37
+ INSERT INTO memories_fts(rowid,title,content,tags) VALUES(new.id,new.title,new.content,new.tags);
38
+ END;
39
+ CREATE TRIGGER memories_ad AFTER DELETE ON memories BEGIN
40
+ INSERT INTO memories_fts(memories_fts,rowid,title,content,tags) VALUES('delete',old.id,old.title,old.content,old.tags);
41
+ END;
42
+ CREATE TRIGGER memories_au AFTER UPDATE ON memories BEGIN
43
+ INSERT INTO memories_fts(memories_fts,rowid,title,content,tags) VALUES('delete',old.id,old.title,old.content,old.tags);
44
+ INSERT INTO memories_fts(rowid,title,content,tags) VALUES(new.id,new.title,new.content,new.tags);
45
+ END;
46
+ CREATE TABLE agent_clients (
47
+ id INTEGER PRIMARY KEY, client_name TEXT, client_version TEXT, pid INTEGER, cwd TEXT, project TEXT,
48
+ started_at TEXT NOT NULL, last_seen_at TEXT NOT NULL, ended_at TEXT
49
+ );
50
+ CREATE TABLE tool_calls (
51
+ id INTEGER PRIMARY KEY, client_id INTEGER REFERENCES agent_clients(id), tool TEXT NOT NULL, args_summary TEXT,
52
+ result_count INTEGER, duration_ms INTEGER, ok INTEGER NOT NULL, error TEXT, at TEXT NOT NULL
53
+ );
54
+ CREATE TABLE usage_events (
55
+ id INTEGER PRIMARY KEY, agent TEXT NOT NULL, session_id TEXT, project TEXT, model TEXT, ts TEXT,
56
+ input_tokens INTEGER NOT NULL DEFAULT 0, output_tokens INTEGER NOT NULL DEFAULT 0,
57
+ cache_read_tokens INTEGER NOT NULL DEFAULT 0, cache_write_tokens INTEGER NOT NULL DEFAULT 0,
58
+ reasoning_tokens INTEGER NOT NULL DEFAULT 0, source_file TEXT NOT NULL, source_offset INTEGER NOT NULL,
59
+ UNIQUE(source_file, source_offset)
60
+ );
61
+ CREATE TABLE rate_limits (
62
+ id INTEGER PRIMARY KEY, agent TEXT NOT NULL, ts TEXT NOT NULL, window_minutes INTEGER,
63
+ used_percent REAL, resets_at TEXT
64
+ );
65
+ CREATE TABLE ingest_offsets (source_file TEXT PRIMARY KEY, byte_offset INTEGER NOT NULL DEFAULT 0);
66
+ CREATE TABLE usage_dedup (agent TEXT NOT NULL, dedup_key TEXT NOT NULL, PRIMARY KEY(agent,dedup_key));
67
+ CREATE INDEX memories_project_updated ON memories(project, updated_at DESC);
68
+ CREATE INDEX usage_project_ts ON usage_events(project, ts);
69
+ CREATE INDEX usage_session_ts ON usage_events(session_id, ts);
70
+ CREATE INDEX tool_calls_client_at ON tool_calls(client_id, at DESC);
71
+ """
72
+
73
+
74
+ def now() -> str:
75
+ return datetime.now(UTC).isoformat()
76
+
77
+
78
+ def db_path() -> Path:
79
+ return Path(os.environ.get("DEVMEM_DB", "~/.devmem/devmem.db")).expanduser()
80
+
81
+
82
+ def max_content() -> int:
83
+ """Return the configured content cap, falling back to a safe default."""
84
+ try:
85
+ return max(1, int(os.environ.get("DEVMEM_MAX_CONTENT", DEFAULT_MAX_CONTENT)))
86
+ except ValueError:
87
+ return DEFAULT_MAX_CONTENT
88
+
89
+
90
+ def validate_content(content: str) -> None:
91
+ """Reject content that would make a memory unsafe to retrieve in full."""
92
+ if len(content) > max_content():
93
+ raise ValueError(f"content exceeds the {max_content()} character limit; put long material in a file and save a short summary plus the file path")
94
+
95
+
96
+ def save_memory(conn: sqlite3.Connection, *, content: str, title: str | None = None, kind: str = "note", tags: str = "", project: str | None = None, pinned: bool = False, review_after: str | None = None, source_agent: str = "mcp-client") -> int:
97
+ """Insert one validated memory and return its id."""
98
+ if kind not in {"note", "decision", "preference", "snippet", "todo"}: raise ValueError("invalid kind")
99
+ validate_content(content); review_after = validate_review_after(review_after)
100
+ return conn.execute("INSERT INTO memories(project,kind,title,content,tags,pinned,review_after,created_at,updated_at,source_agent) VALUES (?,?,?,?,?,?,?,?,?,?)",
101
+ (project, kind, title or content[:80], content, tags, int(pinned), review_after, now(), now(), source_agent)).lastrowid
102
+
103
+
104
+ def update_memory(conn: sqlite3.Connection, id: int, *, content: str | None = None, title: str | None = None, tags: str | None = None, kind: str | None = None, pinned: bool | None = None, review_after: str | None = None) -> bool:
105
+ """Update one memory; the revision trigger retains its prior content."""
106
+ fields = {k: v for k, v in {"content": content, "title": title, "tags": tags, "kind": kind, "pinned": pinned, "review_after": review_after}.items() if v is not None}
107
+ if kind and kind not in {"note", "decision", "preference", "snippet", "todo"}: raise ValueError("invalid kind")
108
+ if content is not None: validate_content(content)
109
+ if review_after is not None: fields["review_after"] = validate_review_after(review_after)
110
+ if not fields: return bool(conn.execute("SELECT 1 FROM memories WHERE id=?", (id,)).fetchone())
111
+ fields["updated_at"] = now()
112
+ return bool(conn.execute("UPDATE memories SET " + ", ".join(f"{key}=?" for key in fields) + " WHERE id=?", (*fields.values(), id)).rowcount)
113
+
114
+
115
+ _BOARD_SECTION = re.compile(r"\b(NOW|NEXT|LATER):")
116
+ _BOARD_ENTRY = re.compile(r"(?:(?<!\S)|(?<=NOW:)|(?<=NEXT:)|(?<=LATER:))(?P<n>\d+)\.\s+(?P<task>\d+(?:\.\d+)+)(?=\s)")
117
+ _BOARD_STATUS = re.compile(r"\bStatus as of (?P<date>.*?)(?:\.(?=\s|$)|(?=\n|$))")
118
+
119
+
120
+ def parse_task_board(content: str) -> tuple[list[dict], str | None]:
121
+ """Parse a board memory without changing its free-text layout."""
122
+ sections = list(_BOARD_SECTION.finditer(content))
123
+ entries = list(_BOARD_ENTRY.finditer(content))
124
+ stops = sorted([*sections, *entries, *_BOARD_STATUS.finditer(content)], key=lambda match: match.start())
125
+ parsed = []
126
+ for entry in entries:
127
+ end = next((marker.start() for marker in stops if marker.start() > entry.start()), len(content))
128
+ text = content[entry.start():end].strip()
129
+ body = text[entry.end() - entry.start():].strip()
130
+ title, separator, phase = body.partition("→ PHASE:")
131
+ title = title.rstrip()
132
+ if title.endswith(("—", "-")): title = title[:-1].rstrip()
133
+ section = next((match.group(1) for match in reversed(sections) if match.start() < entry.start()), "")
134
+ parsed.append({"section": section, "n": int(entry.group("n")), "task": entry.group("task"),
135
+ "title": title, "phase": phase.strip() if separator else "", "_start": entry.start(), "_end": end})
136
+ status = _BOARD_STATUS.search(content)
137
+ return parsed, status.group("date") if status else None
138
+
139
+
140
+ def task_board(conn: sqlite3.Connection, board_id: int) -> dict:
141
+ """Return one board's parsed entries and its status date."""
142
+ row = conn.execute("SELECT content FROM memories WHERE id=?", (board_id,)).fetchone()
143
+ if not row: return {"ok": False, "error": "board not found"}
144
+ entries, status_as_of = parse_task_board(row["content"])
145
+ if not entries: return {"ok": False, "error": f"no board entries found in #{board_id}"}
146
+ return {"entries": [{key: value for key, value in entry.items() if not key.startswith("_")} for entry in entries],
147
+ "status_as_of": status_as_of}
148
+
149
+
150
+ def _board_today() -> tuple[str, str]:
151
+ current = datetime.now().astimezone()
152
+ return f"{current.day} {current:%b %Y}", f"{current.day} {current:%b}"
153
+
154
+
155
+ def task_status_set(conn: sqlite3.Connection, board_id: int, task: str, phase: str, *, status: str | None = None,
156
+ entry_id: int | None = None, entry_title_prefix: str = "Phase 2 task", dry_run: bool = False) -> dict:
157
+ """Update a board entry and its optional task memory as one transaction; commits on success."""
158
+ from .secrets import SECRET
159
+ released = started = False
160
+ try:
161
+ if entry_id == board_id: raise ValueError("entry_id must differ from board_id")
162
+ if not dry_run and not conn.in_transaction: conn.execute("BEGIN IMMEDIATE")
163
+ conn.execute("SAVEPOINT task_status_set"); started = True # failure or dry run undoes only this call's writes; success commits, including any work the caller had open
164
+ board = conn.execute("SELECT id,content,project FROM memories WHERE id=?", (board_id,)).fetchone()
165
+ if not board: raise ValueError("board not found")
166
+ entries, _ = parse_task_board(board["content"])
167
+ matches = [entry for entry in entries if entry["task"] == task]
168
+ if len(matches) != 1: raise ValueError(f"expected exactly one board entry for {task}, found {len(matches)}")
169
+ selected = matches[0]
170
+ raw = board["content"][selected["_start"]:selected["_end"]]
171
+ old_line = raw.strip()
172
+ body = old_line[_BOARD_ENTRY.match(old_line).end():].strip()
173
+ before, marker, _ = body.partition("→ PHASE:")
174
+ new_body = before.rstrip() + f" → PHASE: {phase}" if marker else body.rstrip().removesuffix(".") + f" → PHASE: {phase}"
175
+ new_line = old_line[:_BOARD_ENTRY.match(old_line).end()] + " " + new_body
176
+ new_board = board["content"][:selected["_start"]] + new_line + raw[len(raw.rstrip()):] + board["content"][selected["_end"]:]
177
+ today, short_today = _board_today()
178
+ new_board = _BOARD_STATUS.sub(f"Status as of {today}.", new_board, count=1)
179
+ entry_result = None
180
+ entry = None
181
+ if status is not None:
182
+ if entry_id is not None:
183
+ entry = conn.execute("SELECT id,content FROM memories WHERE id=?", (entry_id,)).fetchone()
184
+ if not entry: raise ValueError("entry memory not found")
185
+ else:
186
+ prefix = f"{entry_title_prefix} {task} "
187
+ escaped = prefix.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_")
188
+ found = conn.execute(f"SELECT id,content FROM memories m WHERE project IS ? AND title LIKE ? ESCAPE '\\' AND {not_superseded_any_sql()} ORDER BY id",
189
+ (board["project"], escaped + "%")).fetchall()
190
+ if len(found) != 1: raise ValueError(f"expected exactly one entry memory for {task}, found {len(found)}")
191
+ entry = found[0]
192
+ old_status = entry["content"].split("\n", 1)[0] if entry["content"].startswith("STATUS") else None
193
+ new_status = f"STATUS ({short_today}): {status}"
194
+ new_entry = new_status + ("\n" + entry["content"] if old_status is None else entry["content"][len(old_status):])
195
+ entry_result = {"id": entry["id"], "old_status": old_status, "new_status": new_status}
196
+ if SECRET.search(phase) or (status is not None and SECRET.search(status)):
197
+ raise ValueError("secret detected")
198
+ validate_content(new_board)
199
+ if entry is not None: validate_content(new_entry)
200
+ result = {"ok": True, "board": {"id": board_id, "old_line": old_line, "new_line": new_line}, "entry": entry_result}
201
+ if dry_run:
202
+ conn.execute("ROLLBACK TO task_status_set"); conn.execute("RELEASE task_status_set"); released = True; return result
203
+ update_memory(conn, board_id, content=new_board)
204
+ if entry is not None: update_memory(conn, entry["id"], content=new_entry)
205
+ conn.execute("RELEASE task_status_set"); released = True; conn.commit()
206
+ return result
207
+ except Exception as exc:
208
+ if started and not released:
209
+ try:
210
+ conn.execute("ROLLBACK TO task_status_set"); conn.execute("RELEASE task_status_set")
211
+ except Exception as rollback_exc:
212
+ raise exc from rollback_exc
213
+ if isinstance(exc, sqlite3.OperationalError) and "locked" in str(exc).lower(): return {"ok": False, "error": "database busy"}
214
+ if isinstance(exc, ValueError): return {"ok": False, "error": str(exc)}
215
+ raise
216
+
217
+
218
+ def validate_review_after(value: str | None) -> str | None:
219
+ """Validate an optional ISO calendar date and return its canonical form."""
220
+ if value is None or value == "":
221
+ return None
222
+ try:
223
+ return date.fromisoformat(value).isoformat()
224
+ except (TypeError, ValueError) as exc:
225
+ raise ValueError("review_after must be an ISO date (YYYY-MM-DD)") from exc
226
+
227
+
228
+ def today_utc() -> str:
229
+ return datetime.now(UTC).date().isoformat()
230
+
231
+
232
+ def is_stale(review_after: str | None) -> bool:
233
+ """Whether a review date has passed in UTC."""
234
+ return bool(review_after and review_after < today_utc())
235
+
236
+
237
+ def backup_dir() -> Path:
238
+ return Path(os.environ.get("DEVMEM_BACKUP_DIR", "~/.devmem/backups")).expanduser()
239
+
240
+
241
+ def connect(path: Path | None = None) -> sqlite3.Connection:
242
+ path = path or db_path()
243
+ path.parent.mkdir(parents=True, exist_ok=True)
244
+ conn = sqlite3.connect(path, timeout=10, check_same_thread=False)
245
+ conn.row_factory = sqlite3.Row
246
+ conn.execute("PRAGMA journal_mode=WAL")
247
+ conn.execute("PRAGMA foreign_keys=ON")
248
+ migrate(conn)
249
+ return conn
250
+
251
+
252
+ @contextmanager
253
+ def connection(path: Path | None = None) -> Iterator[sqlite3.Connection]:
254
+ conn = connect(path)
255
+ try:
256
+ yield conn
257
+ conn.commit()
258
+ finally:
259
+ conn.close()
260
+
261
+
262
+ @contextmanager
263
+ def readonly_connection(path: Path | None = None) -> Iterator[sqlite3.Connection]:
264
+ """Open an existing database without creating it or applying migrations."""
265
+ path = path or db_path()
266
+ if not path.is_file():
267
+ raise FileNotFoundError(path)
268
+ conn = sqlite3.connect(f"{path.resolve().as_uri()}?mode=ro", uri=True, timeout=1,
269
+ check_same_thread=False)
270
+ conn.row_factory = sqlite3.Row
271
+ try:
272
+ yield conn
273
+ finally:
274
+ conn.close()
275
+
276
+
277
+ def migrate(conn: sqlite3.Connection) -> None:
278
+ conn.execute("CREATE TABLE IF NOT EXISTS schema_version (version INTEGER NOT NULL)")
279
+ row = conn.execute("SELECT version FROM schema_version LIMIT 1").fetchone()
280
+ if row is None:
281
+ conn.execute("INSERT INTO schema_version VALUES (0)")
282
+ version = 0
283
+ else:
284
+ version = row[0]
285
+ if 0 < version < SCHEMA_VERSION:
286
+ # Every upgrade of an existing database starts from a recoverable snapshot, before any step runs.
287
+ _backup_before_upgrade(conn)
288
+ version = conn.execute("SELECT version FROM schema_version LIMIT 1").fetchone()[0]
289
+ if version < 1:
290
+ conn.executescript(V1_SCHEMA)
291
+ conn.execute("UPDATE schema_version SET version=1")
292
+ conn.commit()
293
+ if version < 2:
294
+ _migrate_v2(conn)
295
+ if version < 3:
296
+ _migrate_v3(conn)
297
+ if version < 4:
298
+ _migrate_v4(conn)
299
+ if version < 5:
300
+ _migrate_v5(conn)
301
+ if version < 6:
302
+ _migrate_v6(conn)
303
+ if version < 7:
304
+ _migrate_v7(conn)
305
+ if version < 8:
306
+ _migrate_v8(conn)
307
+ if version < 9:
308
+ _migrate_v9(conn)
309
+ if version < 10:
310
+ _migrate_v10(conn)
311
+ if version < 11:
312
+ _migrate_v11(conn)
313
+ if version < 12:
314
+ _migrate_v12(conn)
315
+ if version < 13:
316
+ _migrate_v13(conn)
317
+ if version < 14:
318
+ _migrate_v14(conn)
319
+
320
+
321
+ _V2_STATEMENTS = (
322
+ # Additive only: no existing row or column is removed.
323
+ "ALTER TABLE rate_limits ADD COLUMN label TEXT",
324
+ # Last time the (unchanged) values were observed; rows are only inserted when values change.
325
+ "ALTER TABLE rate_limits ADD COLUMN observed_at TEXT",
326
+ "UPDATE rate_limits SET label = CASE window_minutes WHEN 300 THEN 'primary' WHEN 10080 THEN 'secondary' END "
327
+ "WHERE label IS NULL AND agent='codex'",
328
+ # Parser state (e.g. Codex session/model) so resumed files need not be re-read from byte 0.
329
+ "ALTER TABLE ingest_offsets ADD COLUMN context TEXT",
330
+ """CREATE TABLE activity_events (
331
+ id INTEGER PRIMARY KEY, agent TEXT NOT NULL, session_id TEXT, project TEXT, ts TEXT,
332
+ kind TEXT NOT NULL, name TEXT, summary TEXT,
333
+ source_file TEXT NOT NULL, source_offset INTEGER NOT NULL,
334
+ UNIQUE(source_file, source_offset)
335
+ )""",
336
+ "CREATE INDEX activity_ts ON activity_events(ts)",
337
+ "CREATE INDEX activity_session_ts ON activity_events(session_id, ts)",
338
+ "CREATE INDEX activity_project_ts ON activity_events(project, ts)",
339
+ "CREATE INDEX rate_limits_agent_label ON rate_limits(agent, label, id)",
340
+ "CREATE INDEX usage_ts ON usage_events(ts)",
341
+ )
342
+
343
+
344
+ def _migrate_v2(conn: sqlite3.Connection) -> None:
345
+ """v1 -> v2. Runs in one IMMEDIATE transaction and re-checks the version, so concurrent processes are safe."""
346
+ conn.commit()
347
+ conn.execute("BEGIN IMMEDIATE")
348
+ try:
349
+ if conn.execute("SELECT version FROM schema_version LIMIT 1").fetchone()[0] < 2:
350
+ for statement in _V2_STATEMENTS:
351
+ conn.execute(statement)
352
+ conn.execute("UPDATE schema_version SET version=2")
353
+ conn.commit()
354
+ except Exception:
355
+ conn.rollback()
356
+ raise
357
+
358
+
359
+ _V3_STATEMENTS = (
360
+ """CREATE TABLE memory_embeddings (
361
+ memory_id INTEGER PRIMARY KEY REFERENCES memories(id) ON DELETE CASCADE,
362
+ model TEXT NOT NULL, dim INTEGER NOT NULL, content_hash TEXT NOT NULL, vec BLOB NOT NULL
363
+ )""",
364
+ """CREATE TRIGGER memories_embeddings_ad AFTER DELETE ON memories BEGIN
365
+ DELETE FROM memory_embeddings WHERE memory_id=old.id;
366
+ END""",
367
+ )
368
+
369
+
370
+ def _migrate_v3(conn: sqlite3.Connection) -> None:
371
+ """v2 -> v3. Embeddings are deliberately independent of the foreign_keys pragma."""
372
+ conn.commit()
373
+ conn.execute("BEGIN IMMEDIATE")
374
+ try:
375
+ if conn.execute("SELECT version FROM schema_version LIMIT 1").fetchone()[0] < 3:
376
+ for statement in _V3_STATEMENTS:
377
+ conn.execute(statement)
378
+ conn.execute("UPDATE schema_version SET version=3")
379
+ conn.commit()
380
+ except Exception:
381
+ conn.rollback()
382
+ raise
383
+
384
+
385
+ _V4_STATEMENTS = (
386
+ "ALTER TABLE memories ADD COLUMN pinned INTEGER NOT NULL DEFAULT 0",
387
+ """CREATE TABLE memory_revisions (
388
+ id INTEGER PRIMARY KEY, memory_id INTEGER NOT NULL, project TEXT, kind TEXT NOT NULL,
389
+ title TEXT NOT NULL, content TEXT NOT NULL, tags TEXT NOT NULL, pinned INTEGER NOT NULL DEFAULT 0, created_at TEXT,
390
+ op TEXT NOT NULL CHECK(op IN ('update','delete')), revised_at TEXT NOT NULL
391
+ )""",
392
+ "CREATE INDEX memory_revisions_memory_id ON memory_revisions(memory_id, id DESC)",
393
+ """CREATE TRIGGER memories_revisions_bu BEFORE UPDATE ON memories
394
+ WHEN old.title IS NOT new.title OR old.content IS NOT new.content OR old.tags IS NOT new.tags
395
+ OR old.kind IS NOT new.kind OR old.project IS NOT new.project OR old.pinned IS NOT new.pinned
396
+ BEGIN
397
+ INSERT INTO memory_revisions(memory_id,project,kind,title,content,tags,pinned,created_at,op,revised_at)
398
+ VALUES(old.id,old.project,old.kind,old.title,old.content,old.tags,old.pinned,old.created_at,'update',strftime('%Y-%m-%dT%H:%M:%fZ','now'));
399
+ END""",
400
+ """CREATE TRIGGER memories_revisions_bd BEFORE DELETE ON memories BEGIN
401
+ INSERT INTO memory_revisions(memory_id,project,kind,title,content,tags,pinned,created_at,op,revised_at)
402
+ VALUES(old.id,old.project,old.kind,old.title,old.content,old.tags,old.pinned,old.created_at,'delete',strftime('%Y-%m-%dT%H:%M:%fZ','now'));
403
+ END""",
404
+ )
405
+
406
+
407
+ def _migrate_v4(conn: sqlite3.Connection) -> None:
408
+ """v3 -> v4. History is additive and survives deleted memories."""
409
+ conn.commit()
410
+ conn.execute("BEGIN IMMEDIATE")
411
+ try:
412
+ if conn.execute("SELECT version FROM schema_version LIMIT 1").fetchone()[0] < 4:
413
+ for statement in _V4_STATEMENTS:
414
+ conn.execute(statement)
415
+ conn.execute("UPDATE schema_version SET version=4")
416
+ conn.commit()
417
+ except Exception:
418
+ conn.rollback()
419
+ raise
420
+
421
+
422
+ _V5_STATEMENTS = (
423
+ """CREATE TABLE memory_links (
424
+ id INTEGER PRIMARY KEY, from_id INTEGER NOT NULL, to_id INTEGER NOT NULL,
425
+ type TEXT NOT NULL CHECK(type IN ('blocks','depends_on','relates_to','overlaps','fixes','supersedes')),
426
+ note TEXT NOT NULL DEFAULT '', created_at TEXT NOT NULL,
427
+ UNIQUE(from_id,to_id,type), CHECK(from_id != to_id)
428
+ )""",
429
+ "CREATE INDEX memory_links_from_id ON memory_links(from_id)",
430
+ "CREATE INDEX memory_links_to_id ON memory_links(to_id)",
431
+ """CREATE TABLE memory_link_revisions (
432
+ id INTEGER PRIMARY KEY, link_id INTEGER NOT NULL, from_id INTEGER NOT NULL, to_id INTEGER NOT NULL,
433
+ type TEXT NOT NULL, note TEXT NOT NULL, created_at TEXT NOT NULL, removed_at TEXT NOT NULL, memory_revision_id INTEGER
434
+ )""",
435
+ "CREATE INDEX memory_link_revisions_from_id ON memory_link_revisions(from_id)",
436
+ "CREATE INDEX memory_link_revisions_to_id ON memory_link_revisions(to_id)",
437
+ """CREATE TRIGGER memory_links_revisions_bd BEFORE DELETE ON memory_links BEGIN
438
+ INSERT INTO memory_link_revisions(link_id,from_id,to_id,type,note,created_at,removed_at,memory_revision_id)
439
+ VALUES(old.id,old.from_id,old.to_id,old.type,old.note,old.created_at,strftime('%Y-%m-%dT%H:%M:%fZ','now'),NULL);
440
+ END""",
441
+ """CREATE TRIGGER memories_links_ad AFTER DELETE ON memories BEGIN
442
+ DELETE FROM memory_links WHERE from_id=old.id OR to_id=old.id;
443
+ UPDATE memory_link_revisions SET memory_revision_id=(SELECT MAX(id) FROM memory_revisions WHERE memory_id=old.id AND op='delete')
444
+ WHERE memory_revision_id IS NULL AND (from_id=old.id OR to_id=old.id)
445
+ AND removed_at=(SELECT revised_at FROM memory_revisions WHERE memory_id=old.id AND op='delete' ORDER BY id DESC LIMIT 1);
446
+ END""",
447
+ )
448
+
449
+
450
+ def _migrate_v5(conn: sqlite3.Connection) -> None:
451
+ """v4 -> v5. Links and their delete history remain independent of deleted memories."""
452
+ conn.commit()
453
+ conn.execute("BEGIN IMMEDIATE")
454
+ try:
455
+ if conn.execute("SELECT version FROM schema_version LIMIT 1").fetchone()[0] < 5:
456
+ for statement in _V5_STATEMENTS:
457
+ conn.execute(statement)
458
+ conn.execute("UPDATE schema_version SET version=5")
459
+ conn.commit()
460
+ except Exception:
461
+ conn.rollback()
462
+ raise
463
+
464
+
465
+ def _backup_before_migration(conn: sqlite3.Connection, new_version: int, old_version: int) -> None:
466
+ """Make a WAL-safe SQLite snapshot before upgrading an existing database."""
467
+ destination_dir = backup_dir()
468
+ stamp = datetime.now(UTC).strftime("%Y%m%dT%H%M%SZ")
469
+ destination = destination_dir / f"devmem-before-v{new_version}-from-v{old_version}-{stamp}.db"
470
+ try:
471
+ destination_dir.mkdir(parents=True, exist_ok=True)
472
+ if destination.exists():
473
+ raise FileExistsError(destination)
474
+ source_path = next(row[2] for row in conn.execute("PRAGMA database_list") if row[1] == "main")
475
+ # fix(2026-10-28): write to .tmp and rename, so a half-written file never matches the "backup exists" glob.
476
+ partial = destination.with_name(destination.name + ".tmp")
477
+ try:
478
+ source, backup = sqlite3.connect(source_path), sqlite3.connect(partial)
479
+ try: source.backup(backup)
480
+ finally: source.close(); backup.close()
481
+ os.replace(partial, destination)
482
+ except BaseException:
483
+ partial.unlink(missing_ok=True); raise
484
+ except Exception as exc:
485
+ raise RuntimeError(f"devmem migration backup failed ({destination}): {exc}") from exc
486
+
487
+
488
+ def _backup_before_upgrade(conn: sqlite3.Connection) -> None:
489
+ """Snapshot an existing database once per upgrade, under the upgrade lock so racing processes do not both back up."""
490
+ conn.commit()
491
+ conn.execute("BEGIN IMMEDIATE")
492
+ try:
493
+ version = conn.execute("SELECT version FROM schema_version LIMIT 1").fetchone()[0]
494
+ if 0 < version < SCHEMA_VERSION and not any(backup_dir().glob(f"devmem-before-v{SCHEMA_VERSION}-from-v{version}-*.db")):
495
+ _backup_before_migration(conn, SCHEMA_VERSION, version)
496
+ conn.commit()
497
+ except Exception:
498
+ conn.rollback()
499
+ raise
500
+
501
+
502
+ def _migrate_v6(conn: sqlite3.Connection) -> None:
503
+ """v5 -> v6. Adds review dates; the backup is taken by migrate() before any upgrade step."""
504
+ conn.commit()
505
+ conn.execute("BEGIN IMMEDIATE")
506
+ try:
507
+ version = conn.execute("SELECT version FROM schema_version LIMIT 1").fetchone()[0]
508
+ if version < 6:
509
+ conn.execute("ALTER TABLE memories ADD COLUMN review_after TEXT")
510
+ conn.execute("UPDATE schema_version SET version=6")
511
+ conn.commit()
512
+ except Exception:
513
+ conn.rollback()
514
+ raise
515
+
516
+
517
+ _V7_STATEMENTS = (
518
+ """CREATE TABLE IF NOT EXISTS maintenance_runs (
519
+ id INTEGER PRIMARY KEY, trigger TEXT NOT NULL CHECK(trigger IN ('startup','session_end','manual')),
520
+ project TEXT, started_at TEXT NOT NULL, finished_at TEXT, status TEXT NOT NULL,
521
+ engine TEXT, model TEXT, checked INTEGER DEFAULT 0, applied INTEGER DEFAULT 0, flagged INTEGER DEFAULT 0, error TEXT
522
+ )""",
523
+ """CREATE TABLE IF NOT EXISTS maintenance_changes (
524
+ id INTEGER PRIMARY KEY, run_id INTEGER NOT NULL, memory_id INTEGER NOT NULL, target_id INTEGER,
525
+ action TEXT NOT NULL CHECK(action IN ('update','merge','supersede','keep','flag')),
526
+ applied INTEGER NOT NULL, summary TEXT NOT NULL, evidence TEXT NOT NULL,
527
+ old_title TEXT, old_content TEXT, new_title TEXT, new_content TEXT,
528
+ revision_id INTEGER, link_id INTEGER, blocked_by TEXT,
529
+ reverted_at TEXT, created_at TEXT NOT NULL
530
+ )""",
531
+ "CREATE INDEX IF NOT EXISTS maintenance_changes_run ON maintenance_changes(run_id, id DESC)",
532
+ "CREATE INDEX IF NOT EXISTS maintenance_changes_memory ON maintenance_changes(memory_id, created_at DESC)",
533
+ )
534
+
535
+
536
+ def _migrate_v7(conn: sqlite3.Connection) -> None:
537
+ """v6 -> v7. Maintenance history is additive; migrate() took the backup."""
538
+ conn.commit(); conn.execute("BEGIN IMMEDIATE")
539
+ try:
540
+ if conn.execute("SELECT version FROM schema_version LIMIT 1").fetchone()[0] < 7:
541
+ for statement in _V7_STATEMENTS: conn.execute(statement)
542
+ conn.execute("UPDATE schema_version SET version=7")
543
+ conn.commit()
544
+ except Exception:
545
+ conn.rollback(); raise
546
+
547
+
548
+ _V8_COLUMNS = (
549
+ # full=1 marks an unlimited, project-less, non-dry run: only those reset the startup window.
550
+ ("maintenance_runs", "full", "INTEGER NOT NULL DEFAULT 0"),
551
+ ("maintenance_runs", "options", "TEXT"), # JSON for queued requests, e.g. the transcript path
552
+ ("maintenance_runs", "note", "TEXT"),
553
+ ("maintenance_changes", "old_review_after", "TEXT"),
554
+ ("maintenance_changes", "new_review_after", "TEXT"),
555
+ ("maintenance_changes", "seen_at", "TEXT"),
556
+ )
557
+
558
+
559
+ def _migrate_v8(conn: sqlite3.Connection) -> None:
560
+ """v7 -> v8. Additive maintenance bookkeeping; skips columns that exist (v7 tables are CREATE IF NOT EXISTS)."""
561
+ conn.commit(); conn.execute("BEGIN IMMEDIATE")
562
+ try:
563
+ if conn.execute("SELECT version FROM schema_version LIMIT 1").fetchone()[0] < 8:
564
+ for table, column, declaration in _V8_COLUMNS:
565
+ if column not in {row[1] for row in conn.execute(f"PRAGMA table_info({table})")}:
566
+ conn.execute(f"ALTER TABLE {table} ADD COLUMN {column} {declaration}")
567
+ conn.execute("UPDATE schema_version SET version=8")
568
+ conn.commit()
569
+ except Exception:
570
+ conn.rollback(); raise
571
+
572
+
573
+ _V9_STATEMENTS = (
574
+ # thread_id is the root message's id; a thread's status is its root's status.
575
+ """CREATE TABLE IF NOT EXISTS messages (
576
+ id INTEGER PRIMARY KEY, thread_id INTEGER, reply_to INTEGER, project TEXT, sender TEXT NOT NULL,
577
+ sender_kind TEXT NOT NULL CHECK(sender_kind IN ('claude','subagent','codex','user','system')),
578
+ sender_declared INTEGER NOT NULL DEFAULT 0, recipient TEXT NOT NULL DEFAULT '*',
579
+ kind TEXT NOT NULL CHECK(kind IN ('message','question','finding','event')), subject TEXT, body TEXT NOT NULL,
580
+ status TEXT NOT NULL DEFAULT 'open' CHECK(status IN ('open','answered','closed')),
581
+ created_at TEXT NOT NULL, closed_at TEXT, agent_id TEXT
582
+ )""",
583
+ """CREATE TABLE IF NOT EXISTS message_reads (
584
+ message_id INTEGER NOT NULL REFERENCES messages(id) ON DELETE CASCADE, reader TEXT NOT NULL, read_at TEXT NOT NULL,
585
+ PRIMARY KEY(message_id, reader)
586
+ )""",
587
+ # Claude Code subagents seen in ~/.claude/projects/<proj>/<session>/subagents; offset is this table's own read position.
588
+ """CREATE TABLE IF NOT EXISTS subagents (
589
+ agent_id TEXT PRIMARY KEY, session_id TEXT, project TEXT, agent_type TEXT, description TEXT, tool_use_id TEXT,
590
+ spawn_depth INTEGER, started_at TEXT, last_activity_at TEXT, finished INTEGER NOT NULL DEFAULT 0,
591
+ task TEXT, last_text TEXT, source_file TEXT, byte_offset INTEGER NOT NULL DEFAULT 0, meta_mtime REAL,
592
+ spawn_msg_id INTEGER, report_msg_id INTEGER
593
+ )""",
594
+ """CREATE TABLE IF NOT EXISTS subagent_tools (
595
+ id INTEGER PRIMARY KEY, agent_id TEXT NOT NULL, ts TEXT, name TEXT NOT NULL, arg TEXT NOT NULL DEFAULT '',
596
+ source_offset INTEGER NOT NULL, UNIQUE(agent_id, source_offset)
597
+ )""",
598
+ "CREATE INDEX IF NOT EXISTS messages_thread ON messages(thread_id, id)",
599
+ "CREATE INDEX IF NOT EXISTS messages_sender_created ON messages(sender, created_at)",
600
+ "CREATE INDEX IF NOT EXISTS subagents_session ON subagents(session_id, last_activity_at)",
601
+ )
602
+
603
+
604
+ def _migrate_v9(conn: sqlite3.Connection) -> None:
605
+ """v8 -> v9. Agent messages and live subagents; additive, migrate() took the backup."""
606
+ conn.commit(); conn.execute("BEGIN IMMEDIATE")
607
+ try:
608
+ if conn.execute("SELECT version FROM schema_version LIMIT 1").fetchone()[0] < 9:
609
+ for statement in _V9_STATEMENTS: conn.execute(statement)
610
+ conn.execute("UPDATE schema_version SET version=9")
611
+ conn.commit()
612
+ except Exception:
613
+ conn.rollback(); raise
614
+
615
+
616
+ _V10_COLUMNS = (
617
+ # last_terminal: the newest record ends the run (SubagentHandback, its result, or an end_turn text); meta "finished" is often never written.
618
+ ("subagents", "last_terminal", "INTEGER NOT NULL DEFAULT 0"),
619
+ ("subagents", "tail_partial", "INTEGER NOT NULL DEFAULT 0"), # the log ended in an unterminated line at the last read
620
+ ("subagents", "ended", "TEXT"), # finished | stopped, once the report event is posted
621
+ )
622
+ _V10_STATEMENTS = (
623
+ # Assistant remarks between tool calls (role agent) and later parent messages (role parent); never thinking or tool results.
624
+ """CREATE TABLE IF NOT EXISTS subagent_texts (
625
+ id INTEGER PRIMARY KEY, agent_id TEXT NOT NULL, ts TEXT, role TEXT NOT NULL CHECK(role IN ('agent','parent')), body TEXT NOT NULL,
626
+ source_offset INTEGER NOT NULL, UNIQUE(agent_id, source_offset)
627
+ )""",
628
+ "CREATE INDEX IF NOT EXISTS messages_agent ON messages(agent_id)",
629
+ # Re-read every subagent log once so remarks and the end-of-run marker exist for agents scanned under v9.
630
+ "UPDATE subagents SET byte_offset=0",
631
+ )
632
+
633
+
634
+ def _migrate_v10(conn: sqlite3.Connection) -> None:
635
+ """v9 -> v10. Subagent remarks and end-of-run detection; additive, migrate() took the backup."""
636
+ conn.commit(); conn.execute("BEGIN IMMEDIATE")
637
+ try:
638
+ if conn.execute("SELECT version FROM schema_version LIMIT 1").fetchone()[0] < 10:
639
+ for table, column, declaration in _V10_COLUMNS:
640
+ if column not in {row[1] for row in conn.execute(f"PRAGMA table_info({table})")}:
641
+ conn.execute(f"ALTER TABLE {table} ADD COLUMN {column} {declaration}")
642
+ for statement in _V10_STATEMENTS: conn.execute(statement)
643
+ conn.execute("UPDATE schema_version SET version=10")
644
+ conn.commit()
645
+ except Exception:
646
+ conn.rollback(); raise
647
+
648
+
649
+ _V11_COLUMNS = (
650
+ ("subagents", "agent_kind", "TEXT NOT NULL DEFAULT 'subagent'"),
651
+ ("subagents", "model", "TEXT"),
652
+ ("subagents", "handoff_id", "TEXT"),
653
+ ("subagents", "handoff_session_id", "TEXT"),
654
+ )
655
+ _V11_STATEMENTS = (
656
+ "CREATE INDEX IF NOT EXISTS subagents_handoff ON subagents(handoff_session_id, last_activity_at)",
657
+ "UPDATE ingest_offsets SET byte_offset=0 WHERE source_file LIKE '%/sessions/%'",
658
+ )
659
+
660
+
661
+ def _migrate_v11(conn: sqlite3.Connection) -> None:
662
+ """v10 -> v11. Codex rollout identities and conservative Claude handoff links; migrate() took the backup."""
663
+ conn.commit(); conn.execute("BEGIN IMMEDIATE")
664
+ try:
665
+ if conn.execute("SELECT version FROM schema_version LIMIT 1").fetchone()[0] < 11:
666
+ for table, column, declaration in _V11_COLUMNS:
667
+ if column not in {row[1] for row in conn.execute(f"PRAGMA table_info({table})")}:
668
+ conn.execute(f"ALTER TABLE {table} ADD COLUMN {column} {declaration}")
669
+ for statement in _V11_STATEMENTS: conn.execute(statement)
670
+ conn.execute("UPDATE schema_version SET version=11")
671
+ conn.commit()
672
+ except Exception:
673
+ conn.rollback(); raise
674
+
675
+
676
+ def _migrate_v12(conn: sqlite3.Connection) -> None:
677
+ """Add durable user-message dispatch jobs; migrate() took the backup."""
678
+ conn.commit(); conn.execute("BEGIN IMMEDIATE")
679
+ try:
680
+ conn.execute("""CREATE TABLE IF NOT EXISTS message_jobs (
681
+ id INTEGER PRIMARY KEY, message_id INTEGER NOT NULL UNIQUE REFERENCES messages(id) ON DELETE CASCADE,
682
+ chat_key TEXT NOT NULL, state TEXT NOT NULL DEFAULT 'queued', agent TEXT, attempts INTEGER NOT NULL DEFAULT 0,
683
+ created_at TEXT NOT NULL, started_at TEXT, finished_at TEXT, error TEXT
684
+ )""")
685
+ conn.execute("CREATE INDEX IF NOT EXISTS message_jobs_queue ON message_jobs(state,id)")
686
+ conn.execute("UPDATE schema_version SET version=12")
687
+ conn.commit()
688
+ except Exception:
689
+ conn.rollback(); raise
690
+
691
+
692
+ def _migrate_v13(conn: sqlite3.Connection) -> None:
693
+ """Add opt-in live-room memberships; migrate() took the backup."""
694
+ conn.commit(); conn.execute("BEGIN IMMEDIATE")
695
+ try:
696
+ conn.execute("""CREATE TABLE IF NOT EXISTS room_connections (
697
+ agent_key TEXT PRIMARY KEY, since_id INTEGER NOT NULL, connected_at TEXT NOT NULL
698
+ )""")
699
+ conn.execute("UPDATE schema_version SET version=13")
700
+ conn.commit()
701
+ except Exception:
702
+ conn.rollback(); raise
703
+
704
+
705
+ def _migrate_v14(conn: sqlite3.Connection) -> None:
706
+ """Add durable live-agent palette assignments; migrate() took the backup."""
707
+ conn.commit(); conn.execute("BEGIN IMMEDIATE")
708
+ try:
709
+ conn.execute("""CREATE TABLE IF NOT EXISTS agent_colors (
710
+ agent_key TEXT PRIMARY KEY, color TEXT NOT NULL, last_seen TEXT NOT NULL
711
+ )""")
712
+ conn.execute("UPDATE schema_version SET version=14")
713
+ conn.commit()
714
+ except Exception:
715
+ conn.rollback(); raise
716
+
717
+
718
+ def maintenance_failing_since(conn: sqlite3.Connection) -> dict | None:
719
+ """{since, error} while the newest maintenance runs failed (or one is stuck running for 6 h+); None when healthy."""
720
+ from .maintenance import config
721
+ if not config()["enabled"]:
722
+ return None
723
+ try:
724
+ rows = conn.execute("SELECT status,started_at,error FROM maintenance_runs WHERE status IN ('success','error','running') ORDER BY id DESC LIMIT 100").fetchall()
725
+ except sqlite3.OperationalError: # read-only connection to a pre-v7 database
726
+ return None
727
+ failing = None
728
+ for row in rows:
729
+ started = parse_ts(row["started_at"])
730
+ if row["status"] == "success": break
731
+ if row["status"] == "running":
732
+ if not started or datetime.now(UTC) - started < timedelta(hours=6): continue
733
+ row = {"started_at": row["started_at"], "error": "a run has been stuck for over 6 hours"}
734
+ failing = {"since": row["started_at"], "error": (failing or {}).get("error") or row["error"] or "unknown error"}
735
+ return failing
736
+
737
+
738
+ def restore_memory(conn: sqlite3.Connection, id: int, revision_id: int, source_agent: str) -> None:
739
+ """Restore a revision using the same safeguards for MCP and maintenance reverts."""
740
+ row = conn.execute("SELECT * FROM memory_revisions WHERE id=? AND memory_id=?", (revision_id, id)).fetchone()
741
+ if not row:
742
+ raise ValueError("revision does not belong to memory")
743
+ existing = conn.execute("SELECT created_at FROM memories WHERE id=?", (id,)).fetchone()
744
+ if existing and row["created_at"] is not None and existing["created_at"] != row["created_at"]:
745
+ # SQLite reuses the highest id after a delete, so this id may now be another memory.
746
+ raise ValueError("memory id was reused by a newer memory; save the revision as a new memory instead")
747
+ if existing:
748
+ conn.execute("UPDATE memories SET project=?,kind=?,title=?,content=?,tags=?,pinned=?,updated_at=? WHERE id=?",
749
+ (row["project"], row["kind"], row["title"], row["content"], row["tags"], row["pinned"], now(), id))
750
+ else:
751
+ conn.execute("""INSERT INTO memories(id,project,kind,title,content,tags,pinned,created_at,updated_at,source_agent)
752
+ VALUES(?,?,?,?,?,?,?,?,?,?)""", (id, row["project"], row["kind"], row["title"], row["content"], row["tags"],
753
+ row["pinned"], row["created_at"] or now(), now(), source_agent))
754
+ if row["op"] == "delete":
755
+ conn.execute("""INSERT OR IGNORE INTO memory_links(from_id,to_id,type,note,created_at)
756
+ SELECT from_id,to_id,type,note,created_at FROM memory_link_revisions
757
+ WHERE memory_revision_id=? AND (from_id=? OR to_id=?)
758
+ AND EXISTS(SELECT 1 FROM memories WHERE id=CASE WHEN from_id=? THEN to_id ELSE from_id END)""",
759
+ (revision_id, id, id, id))
760
+
761
+
762
+ def parse_ts(value) -> datetime | None:
763
+ """Parse the mixed timestamp formats found in logs (ISO with Z/offset/space, or unix epoch) to aware UTC."""
764
+ if value is None or value == "":
765
+ return None
766
+ try:
767
+ if isinstance(value, (int, float)) or (isinstance(value, str) and value.strip().replace(".", "", 1).isdigit()):
768
+ return datetime.fromtimestamp(float(value), UTC)
769
+ text = str(value).strip().replace(" ", "T", 1)
770
+ if text.endswith("Z"):
771
+ text = text[:-1] + "+00:00"
772
+ parsed = datetime.fromisoformat(text)
773
+ return parsed if parsed.tzinfo else parsed.replace(tzinfo=UTC)
774
+ except (ValueError, OverflowError, OSError):
775
+ return None
776
+
777
+
778
+ def iso(value) -> str | None:
779
+ parsed = parse_ts(value)
780
+ return parsed.astimezone(UTC).isoformat() if parsed else None
781
+
782
+
783
+ def fts_query(text: str) -> str:
784
+ """Turn free text into a safe FTS5 query: quoted word tokens joined by OR (bm25 ranks multi-term hits first)."""
785
+ return " OR ".join(f'"{token}"' for token in re.findall(r"\w+", text))
786
+
787
+
788
+ def embedding_text(title: str, content: str, tags: str) -> str:
789
+ return f"{title}\n{content}\n{tags}"
790
+
791
+
792
+ def embedding_hash(title: str, content: str, tags: str, model: str = EMBEDDING_MODEL) -> str:
793
+ return hashlib.sha256((embedding_text(title, content, tags) + model).encode()).hexdigest()
794
+
795
+
796
+ def _normalise(vectors: np.ndarray) -> np.ndarray | None:
797
+ vectors = np.asarray(vectors, dtype=np.float32)
798
+ if vectors.ndim != 2 or vectors.shape[1] != EMBEDDING_DIM:
799
+ return None
800
+ lengths = np.linalg.norm(vectors, axis=1)
801
+ if np.any(lengths == 0):
802
+ return None
803
+ return vectors / lengths[:, None]
804
+
805
+
806
+ def ensure_embeddings(conn: sqlite3.Connection, limit: int = 200) -> dict[str, int]:
807
+ """Fill at most ``limit`` stale rows; embedding-model failures are silent (embedded=0); database errors propagate."""
808
+ rows = conn.execute("""SELECT m.id,m.title,m.content,m.tags,e.model,e.content_hash,e.dim
809
+ FROM memories m LEFT JOIN memory_embeddings e ON e.memory_id=m.id ORDER BY m.id""").fetchall()
810
+ all_stale = [row for row in rows if row["model"] != EMBEDDING_MODEL or row["dim"] != EMBEDDING_DIM
811
+ or row["content_hash"] != embedding_hash(row["title"], row["content"], row["tags"])]
812
+ stale = all_stale[:max(0, limit)]
813
+ if not stale:
814
+ return {"embedded": 0, "pending": 0}
815
+ try:
816
+ vectors = get_embedder().embed(
817
+ [embedding_text(row["title"], row["content"], row["tags"]) for row in stale]
818
+ )
819
+ normalised = _normalise(vectors) if vectors is not None else None
820
+ except Exception as exc:
821
+ print(f"devmem: embedding normalisation failed: {type(exc).__name__}: {exc}"[:250], file=sys.stderr)
822
+ normalised = None
823
+ if normalised is None:
824
+ return {"embedded": 0, "pending": len(all_stale)}
825
+ embedded = 0
826
+ for row, vector in zip(stale, normalised, strict=True):
827
+ cur = conn.execute("""INSERT INTO memory_embeddings(memory_id,model,dim,content_hash,vec)
828
+ SELECT ?,?,?,?,? WHERE EXISTS (SELECT 1 FROM memories WHERE id=?)
829
+ ON CONFLICT(memory_id) DO UPDATE SET model=excluded.model,dim=excluded.dim,
830
+ content_hash=excluded.content_hash,vec=excluded.vec""",
831
+ (row["id"], EMBEDDING_MODEL, EMBEDDING_DIM,
832
+ embedding_hash(row["title"], row["content"], row["tags"]), vector.astype("<f4").tobytes(), row["id"]))
833
+ embedded += cur.rowcount
834
+ # A skipped row disappeared between the read and conditional upsert, so it is not pending.
835
+ return {"embedded": embedded, "pending": len(all_stale) - len(stale)}
836
+
837
+
838
+ def embed_memory(conn: sqlite3.Connection, memory_id: int) -> bool:
839
+ """Embed one memory immediately; callers handle unavailable embeddings."""
840
+ row = conn.execute("SELECT id,title,content,tags FROM memories WHERE id=?", (memory_id,)).fetchone()
841
+ if not row:
842
+ return False
843
+ vectors = get_embedder().embed([embedding_text(row["title"], row["content"], row["tags"])])
844
+ normalised = _normalise(vectors) if vectors is not None else None
845
+ if normalised is None:
846
+ return False
847
+ conn.execute("""INSERT INTO memory_embeddings(memory_id,model,dim,content_hash,vec) VALUES(?,?,?,?,?)
848
+ ON CONFLICT(memory_id) DO UPDATE SET model=excluded.model,dim=excluded.dim,
849
+ content_hash=excluded.content_hash,vec=excluded.vec""",
850
+ (memory_id, EMBEDDING_MODEL, EMBEDDING_DIM,
851
+ embedding_hash(row["title"], row["content"], row["tags"]), normalised[0].astype("<f4").tobytes()))
852
+ return True
853
+
854
+
855
+ def similar_memories(conn: sqlite3.Connection, memory_id: int, project: str | None) -> list[dict]:
856
+ """Return nearby embedded memories in the same scope."""
857
+ row = conn.execute("SELECT vec FROM memory_embeddings WHERE memory_id=? AND model=? AND dim=?",
858
+ (memory_id, EMBEDDING_MODEL, EMBEDDING_DIM)).fetchone()
859
+ if not row:
860
+ return []
861
+ where, params = ("m.project IS NULL", ()) if project is None else ("m.project=?", (project,))
862
+ rows = conn.execute(f"""SELECT m.id,m.title,e.vec FROM memories m JOIN memory_embeddings e ON e.memory_id=m.id
863
+ WHERE {where} AND m.id!=? AND e.model=? AND e.dim=?""",
864
+ (*params, memory_id, EMBEDDING_MODEL, EMBEDDING_DIM)).fetchall()
865
+ vector = np.frombuffer(row["vec"], dtype="<f4")
866
+ similar = []
867
+ for candidate in rows:
868
+ other = np.frombuffer(candidate["vec"], dtype="<f4")
869
+ if vector.size != EMBEDDING_DIM or other.size != EMBEDDING_DIM:
870
+ continue
871
+ score = float(np.dot(vector, other))
872
+ if score >= SIMILAR_COSINE:
873
+ similar.append({"id": candidate["id"], "title": candidate["title"], "score": round(score, 3)})
874
+ return sorted(similar, key=lambda item: (-item["score"], item["id"]))[:3]
875
+
876
+
877
+ def not_superseded_sql(alias: str = "m") -> str:
878
+ return f"NOT EXISTS(SELECT 1 FROM memory_links sl WHERE sl.to_id={alias}.id AND sl.type='supersedes' AND sl.note='automatic maintenance')"
879
+
880
+
881
+ def not_superseded_any_sql(alias: str = "m") -> str:
882
+ return f"NOT EXISTS(SELECT 1 FROM memory_links sl WHERE sl.to_id={alias}.id AND sl.type='supersedes')"
883
+
884
+
885
+ def _bm25_candidates(conn: sqlite3.Connection, query: str, where_sql: str, params: tuple, kind: str | None) -> list[dict]:
886
+ match = fts_query(query)
887
+ if not match:
888
+ return []
889
+ clauses, values = [f"({where_sql})", "memories_fts MATCH ?", not_superseded_sql()], list(params) + [match]
890
+ if kind:
891
+ clauses.append("m.kind=?")
892
+ values.append(kind)
893
+ rows = conn.execute(f"""SELECT m.id,m.title,m.kind,m.project,m.updated_at,m.review_after,
894
+ snippet(memories_fts,1,'<b>','</b>','…',18) snippet
895
+ FROM memories_fts JOIN memories m ON m.id=memories_fts.rowid
896
+ WHERE {' AND '.join(clauses)} ORDER BY bm25(memories_fts) LIMIT 50""", values).fetchall()
897
+ return [{**dict(row), "stale": is_stale(row["review_after"])} for row in rows]
898
+
899
+
900
+ def hybrid_search(conn: sqlite3.Connection, query: str, *, where_sql: str, params: tuple = (),
901
+ kind: str | None = None, limit: int = 8) -> list[dict]:
902
+ """Fuse FTS and local-vector rankings using Reciprocal Rank Fusion (k=60)."""
903
+ keyword = _bm25_candidates(conn, query, where_sql, params, kind)
904
+ for row in keyword:
905
+ row["match"] = "keyword" # same result shape on the BM25-only fallback paths
906
+ if not fts_query(query):
907
+ return keyword[:limit]
908
+ if os.environ.get("DEVMEM_EMBED") == "0":
909
+ return keyword[:limit]
910
+
911
+ # These may populate stale rows and load the local model. Both are best-effort.
912
+ try:
913
+ ensure_embeddings(conn)
914
+ except sqlite3.Error as exc:
915
+ print(f"devmem: embedding backfill skipped: {type(exc).__name__}: {exc}", file=sys.stderr)
916
+ try:
917
+ query_vector = get_embedder().embed([query], query=True)
918
+ normalised_query = _normalise(query_vector) if query_vector is not None else None
919
+ except Exception as exc:
920
+ print(f"devmem: query embedding normalisation failed: {type(exc).__name__}: {exc}"[:250], file=sys.stderr)
921
+ normalised_query = None
922
+ if normalised_query is None:
923
+ return keyword[:limit]
924
+
925
+ clauses, values = [f"({where_sql})", "e.model=?", "e.dim=?", not_superseded_sql()], list(params) + [EMBEDDING_MODEL, EMBEDDING_DIM]
926
+ if kind:
927
+ clauses.append("m.kind=?")
928
+ values.append(kind)
929
+ rows = conn.execute(f"""SELECT m.id,m.title,m.kind,m.project,m.updated_at,m.review_after,m.content,e.vec
930
+ FROM memories m JOIN memory_embeddings e ON e.memory_id=m.id
931
+ WHERE {' AND '.join(clauses)}""", values).fetchall()
932
+ semantic: list[dict] = []
933
+ q = normalised_query[0]
934
+ for row in rows:
935
+ vector = np.frombuffer(row["vec"], dtype="<f4")
936
+ if vector.size != EMBEDDING_DIM:
937
+ continue
938
+ cosine = float(np.dot(q, vector))
939
+ if cosine >= MIN_COSINE:
940
+ item = dict(row)
941
+ item["snippet"] = item.pop("content")[:160]
942
+ item.pop("vec")
943
+ item["cosine"] = cosine
944
+ semantic.append(item)
945
+ semantic.sort(key=lambda row: (-row["cosine"], row["id"]))
946
+ semantic = semantic[:50]
947
+
948
+ merged: dict[int, dict] = {}
949
+ for rank, row in enumerate(keyword, 1):
950
+ merged[row["id"]] = {**row, "_score": 1 / (60 + rank), "_keyword": True, "_semantic": False}
951
+ for rank, row in enumerate(semantic, 1):
952
+ current = merged.get(row["id"])
953
+ if current is None:
954
+ merged[row["id"]] = {**row, "_score": 1 / (60 + rank), "_keyword": False, "_semantic": True}
955
+ else:
956
+ current["_score"] += 1 / (60 + rank)
957
+ current["_semantic"] = True
958
+ ordered = sorted(merged.values(), key=lambda row: (-row["_score"], row["id"]))[:limit]
959
+ out = []
960
+ for row in ordered:
961
+ keyword_match, semantic_match = row.pop("_keyword"), row.pop("_semantic")
962
+ row.pop("_score")
963
+ row.pop("cosine", None)
964
+ row["match"] = "both" if keyword_match and semantic_match else "keyword" if keyword_match else "semantic"
965
+ row["stale"] = is_stale(row.get("review_after"))
966
+ out.append(row)
967
+ return out
968
+
969
+
970
+ def context_text(conn: sqlite3.Connection, project: str | None) -> str:
971
+ """Render the compact project and global-memory briefing."""
972
+ from .guard import rules_data
973
+
974
+ current_date = today_utc()
975
+ visible = not_superseded_sql("memories")
976
+ pinned_rows = conn.execute(f"SELECT id,kind,title,content,project FROM memories WHERE pinned=1 AND ({visible}) AND (review_after IS NULL OR review_after>=?) ORDER BY updated_at DESC", (current_date,)).fetchall()
977
+ pinned_text, cap, last_id = "", 6000 - len("## Pinned\n"), None
978
+ for row in pinned_rows:
979
+ name = (Path(row["project"]).name or row["project"]) if row["project"] else "global"
980
+ item = f"[{row['kind']} #{row['id']} · {name}] {row['title']}\n{row['content']}"
981
+ note = f"… (truncated; memory_get {row['id']} for the rest)"
982
+ separator = "\n" if pinned_text else ""
983
+ if len(pinned_text) + len(separator) + len(item) <= cap:
984
+ pinned_text += separator + item
985
+ last_id = row["id"]
986
+ continue
987
+ if len(pinned_text) + len(separator) + len(note) > cap:
988
+ note = f"… (truncated; memory_get {last_id} for the rest)"
989
+ pinned_text = pinned_text[:cap - len(note) - 1]
990
+ separator = "\n"
991
+ available = cap - len(pinned_text) - len(separator) - len(note) - 1
992
+ if available > 0:
993
+ pinned_text += separator + item[:available] + "\n"
994
+ elif pinned_text:
995
+ pinned_text += "\n"
996
+ pinned_text += note
997
+ break
998
+ rows = conn.execute(f"""SELECT kind,title,content FROM memories WHERE pinned=0 AND project=? AND kind IN ('decision','preference','todo','note') AND ({visible})
999
+ AND (review_after IS NULL OR review_after>=?) ORDER BY kind='note', updated_at DESC LIMIT 20""", (project, current_date)).fetchall()
1000
+ global_rows = conn.execute(f"""SELECT kind,title,content FROM memories WHERE pinned=0 AND project IS NULL AND kind IN ('decision','preference') AND ({visible})
1001
+ AND (review_after IS NULL OR review_after>=?) ORDER BY updated_at DESC LIMIT 40""", (current_date,)).fetchall()
1002
+ text = "\n".join(f"[{r['kind']}] {r['title']}: {r['content'][:200]}" for r in rows)[:1500]
1003
+ global_text = "\n".join(f"[{r['kind']}] {r['title']}" for r in global_rows)[:3000] # titles state the rule; memory_get has the detail
1004
+ failing = maintenance_failing_since(conn)
1005
+ failing_text = f"Automatic maintenance failing since {failing['since'][:16]}: {failing['error'][:160]} (see the panel's Maintenance page)" if failing else ""
1006
+ from .skills import briefing_text
1007
+ guidelines = briefing_text()
1008
+ text_rules = rules_data(conn, project)["text_rules"]
1009
+ rules_text = "\n".join(f"- {rule['text']} (devmem #{rule['id']}: {rule['title']})" for rule in text_rules)
1010
+ parts = [f"## Rules\n{rules_text}" if rules_text else "", f"## Pinned\n{pinned_text}" if pinned_text else "", guidelines, f"## Project\n{text}" if text else "", f"## Global preferences\n{global_text}" if global_text else "", f"## Maintenance\n{failing_text}" if failing_text else ""]
1011
+ return "\n\n".join(p for p in parts if p) or "No saved project context."
1012
+
1013
+
1014
+ def _outdated_terms() -> list[str]:
1015
+ """Read optional audit wording without making it a required configuration file."""
1016
+ path = Path(os.environ.get("DEVMEM_AUDIT", "~/.devmem/audit.json")).expanduser()
1017
+ try:
1018
+ import json
1019
+ data = json.loads(path.read_text(encoding="utf-8"))
1020
+ terms = data.get("outdated_terms", []) if isinstance(data, dict) else []
1021
+ return [term.casefold() for term in terms if isinstance(term, str) and term.strip()]
1022
+ except FileNotFoundError:
1023
+ return []
1024
+ except Exception:
1025
+ return []
1026
+
1027
+
1028
+ def audit_memories(conn: sqlite3.Connection) -> list[dict]:
1029
+ """Return report-only context-rot findings, sorted by category then largest memory."""
1030
+ cap, today = max_content(), today_utc()
1031
+ terms = _outdated_terms()
1032
+ rows = [dict(row) for row in conn.execute(f"""SELECT id,title,content,project,updated_at,review_after FROM memories
1033
+ WHERE {not_superseded_sql('memories')}""").fetchall()]
1034
+ findings = []
1035
+ for row in rows:
1036
+ base = {key: row[key] for key in ("id", "title", "updated_at")}
1037
+ base["size"] = len(row["content"])
1038
+ if base["size"] > cap:
1039
+ findings.append({**base, "reason": f"oversized (over {cap} characters)"})
1040
+ if row["review_after"] and row["review_after"] < today:
1041
+ findings.append({**base, "reason": "review date has passed"})
1042
+ parsed = parse_ts(row["updated_at"])
1043
+ if parsed and (datetime.now(UTC) - parsed).days >= 30:
1044
+ findings.append({**base, "reason": "not updated in 30+ days"})
1045
+ haystack = (row["title"] + "\n" + row["content"]).casefold()
1046
+ matched = [term for term in terms if term in haystack]
1047
+ if matched:
1048
+ findings.append({**base, "reason": "outdated wording: " + ", ".join(matched)})
1049
+
1050
+ embedded = conn.execute(f"""SELECT m.id,m.title,m.content,m.project,m.updated_at,e.vec
1051
+ FROM memories m JOIN memory_embeddings e ON e.memory_id=m.id WHERE e.model=? AND e.dim=?
1052
+ AND {not_superseded_sql()} ORDER BY m.id""",
1053
+ (EMBEDDING_MODEL, EMBEDDING_DIM)).fetchall()
1054
+ for index, left in enumerate(embedded):
1055
+ left_vector = np.frombuffer(left["vec"], dtype="<f4")
1056
+ if left_vector.size != EMBEDDING_DIM:
1057
+ continue
1058
+ for right in embedded[index + 1:]:
1059
+ if left["project"] != right["project"]:
1060
+ continue
1061
+ right_vector = np.frombuffer(right["vec"], dtype="<f4")
1062
+ if right_vector.size != EMBEDDING_DIM:
1063
+ continue
1064
+ score = float(np.dot(left_vector, right_vector))
1065
+ if score >= 0.86:
1066
+ findings.append({"id": left["id"], "title": left["title"], "size": len(left["content"]),
1067
+ "updated_at": left["updated_at"], "reason": f"likely duplicate of #{right['id']} ({score:.2f})",
1068
+ "other_id": right["id"], "other_title": right["title"], "score": round(score, 3)})
1069
+ return sorted(findings, key=lambda item: (item["reason"], -item["size"], item["id"]))