opencode-semantic-memory 0.1.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.
@@ -0,0 +1,1608 @@
1
+ """SQLite storage for entities, memories, and sessions."""
2
+
3
+ import json
4
+ import sqlite3
5
+ import threading
6
+ from datetime import UTC, datetime, timedelta
7
+ from pathlib import Path
8
+ from typing import Any
9
+
10
+ from opencode_memory.models import (
11
+ Entity,
12
+ EntityType,
13
+ LinkType,
14
+ Memory,
15
+ MemoryCategory,
16
+ MemoryLink,
17
+ Session,
18
+ )
19
+
20
+
21
+ def _format_age_from_iso(iso_str: str) -> str:
22
+ """Format age from ISO datetime string."""
23
+ created_at = datetime.fromisoformat(iso_str)
24
+ if created_at.tzinfo is None:
25
+ created_at = created_at.replace(tzinfo=UTC)
26
+
27
+ now = datetime.now(UTC)
28
+ delta = now - created_at
29
+ days = delta.days
30
+
31
+ if days == 0:
32
+ hours = delta.seconds // 3600
33
+ if hours == 0:
34
+ minutes = delta.seconds // 60
35
+ return f"{minutes}m" if minutes > 0 else "now"
36
+ return f"{hours}h"
37
+ elif days == 1:
38
+ return "1d"
39
+ elif days < 7:
40
+ return f"{days}d"
41
+ elif days < 30:
42
+ return f"{days // 7}w"
43
+ elif days < 365:
44
+ return f"{days // 30}mo"
45
+ else:
46
+ return f"{days // 365}y"
47
+
48
+
49
+ SCHEMA = """
50
+ CREATE TABLE IF NOT EXISTS entities (
51
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
52
+ type TEXT NOT NULL,
53
+ ref TEXT NOT NULL,
54
+ project TEXT,
55
+ title TEXT,
56
+ metadata TEXT,
57
+ created_at TEXT NOT NULL,
58
+ updated_at TEXT NOT NULL,
59
+ last_mentioned_at TEXT,
60
+ last_enriched_at TEXT,
61
+ UNIQUE(type, ref, project)
62
+ );
63
+
64
+ CREATE TABLE IF NOT EXISTS memories (
65
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
66
+ source_file TEXT,
67
+ source_line INTEGER,
68
+ project TEXT,
69
+ category TEXT NOT NULL,
70
+ content TEXT NOT NULL,
71
+ what TEXT,
72
+ why TEXT,
73
+ learned TEXT,
74
+ created_at TEXT NOT NULL,
75
+ expires_at TEXT,
76
+ resolved_at TEXT,
77
+ embedding_id TEXT
78
+ );
79
+
80
+ CREATE TABLE IF NOT EXISTS entity_memories (
81
+ entity_id INTEGER NOT NULL,
82
+ memory_id INTEGER NOT NULL,
83
+ relationship TEXT DEFAULT 'about',
84
+ PRIMARY KEY (entity_id, memory_id),
85
+ FOREIGN KEY (entity_id) REFERENCES entities(id) ON DELETE CASCADE,
86
+ FOREIGN KEY (memory_id) REFERENCES memories(id) ON DELETE CASCADE
87
+ );
88
+
89
+ CREATE TABLE IF NOT EXISTS sessions (
90
+ id TEXT PRIMARY KEY,
91
+ started_at TEXT NOT NULL,
92
+ last_heartbeat TEXT NOT NULL,
93
+ working_on TEXT,
94
+ claimed_items TEXT
95
+ );
96
+
97
+ CREATE TABLE IF NOT EXISTS ingest_state (
98
+ source TEXT PRIMARY KEY,
99
+ last_processed TEXT,
100
+ last_id TEXT,
101
+ metadata TEXT
102
+ );
103
+
104
+ CREATE VIRTUAL TABLE IF NOT EXISTS memories_fts USING fts5(
105
+ content, what, why, learned,
106
+ content='memories',
107
+ content_rowid='id'
108
+ );
109
+
110
+ CREATE TRIGGER IF NOT EXISTS memories_ai AFTER INSERT ON memories BEGIN
111
+ INSERT INTO memories_fts(rowid, content, what, why, learned)
112
+ VALUES (new.id, new.content, new.what, new.why, new.learned);
113
+ END;
114
+
115
+ CREATE TRIGGER IF NOT EXISTS memories_ad AFTER DELETE ON memories BEGIN
116
+ INSERT INTO memories_fts(memories_fts, rowid, content, what, why, learned)
117
+ VALUES ('delete', old.id, old.content, old.what, old.why, old.learned);
118
+ END;
119
+
120
+ CREATE TRIGGER IF NOT EXISTS memories_au AFTER UPDATE ON memories BEGIN
121
+ INSERT INTO memories_fts(memories_fts, rowid, content, what, why, learned)
122
+ VALUES ('delete', old.id, old.content, old.what, old.why, old.learned);
123
+ INSERT INTO memories_fts(rowid, content, what, why, learned)
124
+ VALUES (new.id, new.content, new.what, new.why, new.learned);
125
+ END;
126
+
127
+ CREATE INDEX IF NOT EXISTS idx_entities_type ON entities(type);
128
+ CREATE INDEX IF NOT EXISTS idx_entities_ref ON entities(ref);
129
+ CREATE INDEX IF NOT EXISTS idx_entities_last_mentioned ON entities(last_mentioned_at);
130
+ CREATE INDEX IF NOT EXISTS idx_entities_last_enriched ON entities(last_enriched_at);
131
+ CREATE INDEX IF NOT EXISTS idx_memories_category ON memories(category);
132
+ CREATE INDEX IF NOT EXISTS idx_memories_created ON memories(created_at);
133
+ CREATE INDEX IF NOT EXISTS idx_memories_resolved ON memories(resolved_at);
134
+ CREATE INDEX IF NOT EXISTS idx_memories_expires ON memories(expires_at);
135
+ CREATE INDEX IF NOT EXISTS idx_memories_project ON memories(project);
136
+
137
+ CREATE TABLE IF NOT EXISTS archived_memories (
138
+ id INTEGER PRIMARY KEY,
139
+ source_file TEXT,
140
+ source_line INTEGER,
141
+ category TEXT NOT NULL,
142
+ content TEXT NOT NULL,
143
+ what TEXT,
144
+ why TEXT,
145
+ learned TEXT,
146
+ created_at TEXT NOT NULL,
147
+ expires_at TEXT,
148
+ resolved_at TEXT,
149
+ archived_at TEXT NOT NULL,
150
+ archive_reason TEXT
151
+ );
152
+
153
+ CREATE TABLE IF NOT EXISTS memory_links (
154
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
155
+ source_memory_id INTEGER NOT NULL,
156
+ target_memory_id INTEGER NOT NULL,
157
+ link_type TEXT NOT NULL,
158
+ strength REAL DEFAULT 0.5,
159
+ reason TEXT,
160
+ created_at TEXT NOT NULL,
161
+ FOREIGN KEY (source_memory_id) REFERENCES memories(id) ON DELETE CASCADE,
162
+ FOREIGN KEY (target_memory_id) REFERENCES memories(id) ON DELETE CASCADE,
163
+ UNIQUE(source_memory_id, target_memory_id, link_type)
164
+ );
165
+
166
+ CREATE INDEX IF NOT EXISTS idx_memory_links_source ON memory_links(source_memory_id);
167
+ CREATE INDEX IF NOT EXISTS idx_memory_links_target ON memory_links(target_memory_id);
168
+ CREATE INDEX IF NOT EXISTS idx_memory_links_type ON memory_links(link_type);
169
+
170
+ -- Composite index for common query pattern: category + project filtering
171
+ CREATE INDEX IF NOT EXISTS idx_memories_category_project ON memories(category, project);
172
+ """
173
+
174
+
175
+ class SQLiteStorage:
176
+ """SQLite-based storage for memories and entities.
177
+
178
+ Uses thread-local connections to avoid "database is locked" errors
179
+ under concurrent access while maintaining connection reuse.
180
+ """
181
+
182
+ def __init__(self, db_path: Path):
183
+ self.db_path = db_path
184
+ self.db_path.parent.mkdir(parents=True, exist_ok=True)
185
+ self._local = threading.local()
186
+ self._init_db()
187
+
188
+ def _init_db(self) -> None:
189
+ """Initialize database schema."""
190
+ with sqlite3.connect(self.db_path) as conn:
191
+ self._run_migrations(conn)
192
+ conn.executescript(SCHEMA)
193
+
194
+ def _run_migrations(self, conn: sqlite3.Connection) -> None:
195
+ """Run any needed schema migrations before full schema creation."""
196
+ # Migrate memories table
197
+ try:
198
+ cursor = conn.execute("PRAGMA table_info(memories)")
199
+ columns = {row[1] for row in cursor.fetchall()}
200
+ except Exception:
201
+ columns = set()
202
+
203
+ if columns and "project" not in columns:
204
+ conn.execute("ALTER TABLE memories ADD COLUMN project TEXT")
205
+ conn.execute("CREATE INDEX IF NOT EXISTS idx_memories_project ON memories(project)")
206
+
207
+ # Migrate entities table
208
+ try:
209
+ cursor = conn.execute("PRAGMA table_info(entities)")
210
+ entity_columns = {row[1] for row in cursor.fetchall()}
211
+ except Exception:
212
+ entity_columns = set()
213
+
214
+ if entity_columns:
215
+ if "last_mentioned_at" not in entity_columns:
216
+ conn.execute("ALTER TABLE entities ADD COLUMN last_mentioned_at TEXT")
217
+ conn.execute(
218
+ "CREATE INDEX IF NOT EXISTS idx_entities_last_mentioned ON entities(last_mentioned_at)"
219
+ )
220
+ # Initialize from most recent memory mention
221
+ conn.execute("""
222
+ UPDATE entities SET last_mentioned_at = (
223
+ SELECT MAX(m.created_at) FROM memories m
224
+ JOIN entity_memories em ON em.memory_id = m.id
225
+ WHERE em.entity_id = entities.id
226
+ )
227
+ """)
228
+
229
+ if "last_enriched_at" not in entity_columns:
230
+ conn.execute("ALTER TABLE entities ADD COLUMN last_enriched_at TEXT")
231
+ conn.execute(
232
+ "CREATE INDEX IF NOT EXISTS idx_entities_last_enriched ON entities(last_enriched_at)"
233
+ )
234
+ # Initialize from updated_at for entities with metadata
235
+ conn.execute("""
236
+ UPDATE entities SET last_enriched_at = updated_at
237
+ WHERE metadata IS NOT NULL
238
+ """)
239
+
240
+ def _get_conn(self) -> sqlite3.Connection:
241
+ """Get a thread-local database connection.
242
+
243
+ Reuses connections per-thread to avoid overhead and lock contention.
244
+ """
245
+ conn = getattr(self._local, "conn", None)
246
+ if conn is None:
247
+ conn = sqlite3.connect(self.db_path, timeout=30.0)
248
+ conn.row_factory = sqlite3.Row
249
+ conn.execute("PRAGMA journal_mode=WAL") # Better concurrency
250
+ conn.execute("PRAGMA busy_timeout=30000") # 30s timeout
251
+ self._local.conn = conn
252
+ return conn
253
+
254
+ def upsert_entity(self, entity: Entity) -> int:
255
+ """Insert or update an entity, return its ID."""
256
+ metadata_json = json.dumps(entity.metadata) if entity.metadata else None
257
+ with self._get_conn() as conn:
258
+ cursor = conn.execute(
259
+ """
260
+ INSERT INTO entities (type, ref, project, title, metadata, created_at, updated_at)
261
+ VALUES (?, ?, ?, ?, ?, ?, ?)
262
+ ON CONFLICT(type, ref, project) DO UPDATE SET
263
+ title = COALESCE(excluded.title, entities.title),
264
+ metadata = COALESCE(excluded.metadata, entities.metadata),
265
+ updated_at = excluded.updated_at
266
+ RETURNING id
267
+ """,
268
+ (
269
+ entity.type.value,
270
+ entity.ref,
271
+ entity.project,
272
+ entity.title,
273
+ metadata_json,
274
+ entity.created_at.isoformat(),
275
+ entity.updated_at.isoformat(),
276
+ ),
277
+ )
278
+ row = cursor.fetchone()
279
+ return int(row[0])
280
+
281
+ def get_entity(self, ref: str, entity_type: EntityType | None = None) -> Entity | None:
282
+ """Get an entity by reference."""
283
+ with self._get_conn() as conn:
284
+ if entity_type:
285
+ cursor = conn.execute(
286
+ "SELECT * FROM entities WHERE ref = ? AND type = ?",
287
+ (ref, entity_type.value),
288
+ )
289
+ else:
290
+ cursor = conn.execute("SELECT * FROM entities WHERE ref = ?", (ref,))
291
+ row = cursor.fetchone()
292
+ if row:
293
+ metadata = {}
294
+ if row["metadata"]:
295
+ try:
296
+ metadata = json.loads(row["metadata"])
297
+ except json.JSONDecodeError:
298
+ pass
299
+ return Entity(
300
+ id=row["id"],
301
+ type=EntityType(row["type"]),
302
+ ref=row["ref"],
303
+ project=row["project"],
304
+ title=row["title"],
305
+ metadata=metadata,
306
+ created_at=datetime.fromisoformat(row["created_at"]),
307
+ updated_at=datetime.fromisoformat(row["updated_at"]),
308
+ )
309
+ return None
310
+
311
+ def insert_memory(self, memory: Memory, entity_ids: list[int] | None = None) -> int:
312
+ """Insert a memory and link to entities.
313
+
314
+ Returns existing memory ID if duplicate content exists (same category + content prefix).
315
+ """
316
+ with self._get_conn() as conn:
317
+ # Check for duplicate: same category and content prefix (first 500 chars)
318
+ content_prefix = memory.content[:500] if memory.content else ""
319
+ cursor = conn.execute(
320
+ """
321
+ SELECT id FROM memories
322
+ WHERE category = ? AND SUBSTR(content, 1, 500) = ?
323
+ LIMIT 1
324
+ """,
325
+ (memory.category.value, content_prefix),
326
+ )
327
+ existing = cursor.fetchone()
328
+ if existing:
329
+ # Silently return existing ID instead of creating duplicate
330
+ return int(existing[0])
331
+
332
+ cursor = conn.execute(
333
+ """
334
+ INSERT INTO memories
335
+ (source_file, source_line, project, category, content, what, why, learned,
336
+ created_at, expires_at, resolved_at, embedding_id)
337
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
338
+ RETURNING id
339
+ """,
340
+ (
341
+ memory.source_file,
342
+ memory.source_line,
343
+ memory.project,
344
+ memory.category.value,
345
+ memory.content,
346
+ memory.what,
347
+ memory.why,
348
+ memory.learned,
349
+ memory.created_at.isoformat(),
350
+ memory.expires_at.isoformat() if memory.expires_at else None,
351
+ memory.resolved_at.isoformat() if memory.resolved_at else None,
352
+ memory.embedding_id,
353
+ ),
354
+ )
355
+ memory_id = int(cursor.fetchone()[0])
356
+
357
+ if entity_ids:
358
+ now = datetime.now(UTC).isoformat()
359
+ for entity_id in entity_ids:
360
+ conn.execute(
361
+ "INSERT OR IGNORE INTO entity_memories (entity_id, memory_id) VALUES (?, ?)",
362
+ (entity_id, memory_id),
363
+ )
364
+ # Update last_mentioned_at for all linked entities
365
+ conn.execute(
366
+ f"""
367
+ UPDATE entities SET last_mentioned_at = ?
368
+ WHERE id IN ({",".join("?" * len(entity_ids))})
369
+ """,
370
+ [now] + list(entity_ids),
371
+ )
372
+
373
+ return memory_id
374
+
375
+ def _sanitize_fts_term(self, word: str) -> str:
376
+ """Escape quotes in FTS5 search terms to prevent injection."""
377
+ return '"' + word.replace('"', '""') + '"'
378
+
379
+ def search_fts(self, query: str, limit: int = 20, project: str | None = None) -> list[Memory]:
380
+ """Full-text search across memories, optionally filtered by project.
381
+
382
+ Automatically excludes expired and archived memories.
383
+ """
384
+ fts_query = " OR ".join(self._sanitize_fts_term(word) for word in query.split() if word)
385
+ with self._get_conn() as conn:
386
+ if project:
387
+ cursor = conn.execute(
388
+ """
389
+ SELECT m.*, bm25(memories_fts) as score
390
+ FROM memories_fts
391
+ JOIN memories m ON memories_fts.rowid = m.id
392
+ WHERE memories_fts MATCH ?
393
+ AND m.project = ?
394
+ AND (m.expires_at IS NULL OR m.expires_at > datetime('now'))
395
+ AND m.id NOT IN (SELECT id FROM archived_memories)
396
+ ORDER BY score
397
+ LIMIT ?
398
+ """,
399
+ (fts_query, project, limit),
400
+ )
401
+ else:
402
+ cursor = conn.execute(
403
+ """
404
+ SELECT m.*, bm25(memories_fts) as score
405
+ FROM memories_fts
406
+ JOIN memories m ON memories_fts.rowid = m.id
407
+ WHERE memories_fts MATCH ?
408
+ AND (m.expires_at IS NULL OR m.expires_at > datetime('now'))
409
+ AND m.id NOT IN (SELECT id FROM archived_memories)
410
+ ORDER BY score
411
+ LIMIT ?
412
+ """,
413
+ (fts_query, limit),
414
+ )
415
+ return [self._row_to_memory(row) for row in cursor.fetchall()]
416
+
417
+ def get_memory_by_id(self, memory_id: int) -> Memory | None:
418
+ """Get a single memory by ID."""
419
+ with self._get_conn() as conn:
420
+ cursor = conn.execute(
421
+ "SELECT * FROM memories WHERE id = ?",
422
+ (memory_id,),
423
+ )
424
+ row = cursor.fetchone()
425
+ return self._row_to_memory(row) if row else None
426
+
427
+ def delete_memory(self, memory_id: int) -> bool:
428
+ """Permanently delete a memory and its associations.
429
+
430
+ This is a hard delete - use archive_memory for soft delete.
431
+ Also removes from FTS index, entity links, and memory links.
432
+ """
433
+ with self._get_conn() as conn:
434
+ # Check if exists
435
+ cursor = conn.execute("SELECT id FROM memories WHERE id = ?", (memory_id,))
436
+ if not cursor.fetchone():
437
+ return False
438
+
439
+ # Delete entity links first
440
+ conn.execute("DELETE FROM entity_memories WHERE memory_id = ?", (memory_id,))
441
+
442
+ # Delete memory links (both directions)
443
+ conn.execute(
444
+ "DELETE FROM memory_links WHERE source_memory_id = ? OR target_memory_id = ?",
445
+ (memory_id, memory_id),
446
+ )
447
+
448
+ # Delete the memory itself (FTS is handled by trigger)
449
+ cursor = conn.execute("DELETE FROM memories WHERE id = ?", (memory_id,))
450
+ return cursor.rowcount > 0
451
+
452
+ def update_memory(
453
+ self,
454
+ memory_id: int,
455
+ content: str | None = None,
456
+ what: str | None = None,
457
+ why: str | None = None,
458
+ learned: str | None = None,
459
+ category: str | None = None,
460
+ ) -> bool:
461
+ """Update a memory's content or metadata.
462
+
463
+ Only provided (non-None) fields are updated.
464
+ Also updates FTS index if content changes.
465
+ """
466
+ memory = self.get_memory_by_id(memory_id)
467
+ if not memory:
468
+ return False
469
+
470
+ updates = []
471
+ params = []
472
+
473
+ if content is not None:
474
+ updates.append("content = ?")
475
+ params.append(content)
476
+ if what is not None:
477
+ updates.append("what = ?")
478
+ params.append(what)
479
+ if why is not None:
480
+ updates.append("why = ?")
481
+ params.append(why)
482
+ if learned is not None:
483
+ updates.append("learned = ?")
484
+ params.append(learned)
485
+ if category is not None:
486
+ updates.append("category = ?")
487
+ params.append(category)
488
+
489
+ if not updates:
490
+ return True # Nothing to update
491
+
492
+ params.append(memory_id)
493
+
494
+ with self._get_conn() as conn:
495
+ conn.execute(
496
+ f"UPDATE memories SET {', '.join(updates)} WHERE id = ?",
497
+ params,
498
+ )
499
+
500
+ # Update FTS index if content changed
501
+ if content is not None:
502
+ conn.execute("DELETE FROM memories_fts WHERE rowid = ?", (memory_id,))
503
+ conn.execute(
504
+ "INSERT INTO memories_fts(rowid, content) VALUES (?, ?)",
505
+ (memory_id, content),
506
+ )
507
+
508
+ return True
509
+
510
+ def get_memories_by_ids(
511
+ self, memory_ids: list[int], exclude_expired: bool = True
512
+ ) -> dict[int, Memory]:
513
+ """Get multiple memories by IDs in a single query. Returns dict of id -> Memory.
514
+
515
+ By default excludes expired and archived memories.
516
+ """
517
+ if not memory_ids:
518
+ return {}
519
+ with self._get_conn() as conn:
520
+ placeholders = ",".join("?" * len(memory_ids))
521
+ if exclude_expired:
522
+ cursor = conn.execute(
523
+ f"""
524
+ SELECT * FROM memories
525
+ WHERE id IN ({placeholders})
526
+ AND (expires_at IS NULL OR expires_at > datetime('now'))
527
+ AND id NOT IN (SELECT id FROM archived_memories)
528
+ """,
529
+ memory_ids,
530
+ )
531
+ else:
532
+ cursor = conn.execute(
533
+ f"SELECT * FROM memories WHERE id IN ({placeholders})",
534
+ memory_ids,
535
+ )
536
+ return {row["id"]: self._row_to_memory(row) for row in cursor.fetchall()}
537
+
538
+ def get_memories_for_entity(self, entity_id: int) -> list[Memory]:
539
+ """Get all memories linked to an entity."""
540
+ with self._get_conn() as conn:
541
+ cursor = conn.execute(
542
+ """
543
+ SELECT m.* FROM memories m
544
+ JOIN entity_memories em ON m.id = em.memory_id
545
+ WHERE em.entity_id = ?
546
+ ORDER BY m.created_at DESC
547
+ """,
548
+ (entity_id,),
549
+ )
550
+ return [self._row_to_memory(row) for row in cursor.fetchall()]
551
+
552
+ def get_all_memories(
553
+ self, project: str | None = None, include_archived: bool = False, limit: int = 1000
554
+ ) -> list[Memory]:
555
+ """Get all memories, optionally filtered by project."""
556
+ with self._get_conn() as conn:
557
+ if project:
558
+ if include_archived:
559
+ cursor = conn.execute(
560
+ "SELECT * FROM memories WHERE project = ? ORDER BY created_at DESC LIMIT ?",
561
+ (project, limit),
562
+ )
563
+ else:
564
+ cursor = conn.execute(
565
+ """
566
+ SELECT * FROM memories
567
+ WHERE project = ? AND id NOT IN (SELECT id FROM archived_memories)
568
+ ORDER BY created_at DESC LIMIT ?
569
+ """,
570
+ (project, limit),
571
+ )
572
+ else:
573
+ if include_archived:
574
+ cursor = conn.execute(
575
+ "SELECT * FROM memories ORDER BY created_at DESC LIMIT ?",
576
+ (limit,),
577
+ )
578
+ else:
579
+ cursor = conn.execute(
580
+ """
581
+ SELECT * FROM memories
582
+ WHERE id NOT IN (SELECT id FROM archived_memories)
583
+ ORDER BY created_at DESC LIMIT ?
584
+ """,
585
+ (limit,),
586
+ )
587
+ return [self._row_to_memory(row) for row in cursor.fetchall()]
588
+
589
+ def get_memories_by_category(
590
+ self,
591
+ category: MemoryCategory,
592
+ limit: int = 50,
593
+ include_resolved: bool = False,
594
+ project: str | None = None,
595
+ ) -> list[Memory]:
596
+ """Get memories by category, optionally filtered by project."""
597
+ with self._get_conn() as conn:
598
+ conditions = ["category = ?"]
599
+ params: list[Any] = [category.value]
600
+
601
+ if not include_resolved:
602
+ conditions.append("resolved_at IS NULL")
603
+
604
+ if project:
605
+ conditions.append("project = ?")
606
+ params.append(project)
607
+
608
+ params.append(limit)
609
+ query = f"""
610
+ SELECT * FROM memories
611
+ WHERE {" AND ".join(conditions)}
612
+ ORDER BY created_at DESC
613
+ LIMIT ?
614
+ """
615
+ cursor = conn.execute(query, params)
616
+ return [self._row_to_memory(row) for row in cursor.fetchall()]
617
+
618
+ def get_directives_for_context(
619
+ self, current_project: str | None, limit: int = 20
620
+ ) -> list[Memory]:
621
+ """Get directives: global (project IS NULL) + current project specific.
622
+
623
+ Returns directives that either:
624
+ - Have no project (global, apply everywhere)
625
+ - Match the current project exactly
626
+ """
627
+ with self._get_conn() as conn:
628
+ if current_project:
629
+ query = """
630
+ SELECT * FROM memories
631
+ WHERE category = ? AND resolved_at IS NULL
632
+ AND (project IS NULL OR project = ?)
633
+ ORDER BY
634
+ CASE WHEN project IS NULL THEN 1 ELSE 0 END,
635
+ created_at DESC
636
+ LIMIT ?
637
+ """
638
+ params = [MemoryCategory.DIRECTIVE.value, current_project, limit]
639
+ else:
640
+ # No project context - only return global directives
641
+ query = """
642
+ SELECT * FROM memories
643
+ WHERE category = ? AND resolved_at IS NULL AND project IS NULL
644
+ ORDER BY created_at DESC
645
+ LIMIT ?
646
+ """
647
+ params = [MemoryCategory.DIRECTIVE.value, limit]
648
+
649
+ cursor = conn.execute(query, params)
650
+ return [self._row_to_memory(row) for row in cursor.fetchall()]
651
+
652
+ def get_plan_summaries(self) -> list[dict[str, Any]]:
653
+ """Get summary of active plans grouped by project.
654
+
655
+ Returns list of {project, count, plans: [{id, what, age}]}
656
+ """
657
+ with self._get_conn() as conn:
658
+ cursor = conn.execute(
659
+ """
660
+ SELECT id, project, what, created_at FROM memories
661
+ WHERE category = 'plan'
662
+ AND resolved_at IS NULL
663
+ AND id NOT IN (SELECT id FROM archived_memories)
664
+ ORDER BY project, created_at DESC
665
+ """
666
+ )
667
+ rows = cursor.fetchall()
668
+
669
+ # Group by project
670
+ by_project: dict[str | None, list[dict]] = {}
671
+ for row in rows:
672
+ project = row["project"]
673
+ if project not in by_project:
674
+ by_project[project] = []
675
+ by_project[project].append(
676
+ {
677
+ "id": row["id"],
678
+ "what": row["what"],
679
+ "age": _format_age_from_iso(row["created_at"]),
680
+ }
681
+ )
682
+
683
+ return [
684
+ {
685
+ "project": project or "global",
686
+ "count": len(plans),
687
+ "plans": plans[:3], # Show max 3 per project
688
+ }
689
+ for project, plans in sorted(by_project.items(), key=lambda x: (x[0] is None, x[0]))
690
+ ]
691
+
692
+ def resolve_memory(self, memory_id: int) -> bool:
693
+ """Mark a memory as resolved."""
694
+ with self._get_conn() as conn:
695
+ cursor = conn.execute(
696
+ "UPDATE memories SET resolved_at = ? WHERE id = ? AND resolved_at IS NULL",
697
+ (datetime.now(UTC).isoformat(), memory_id),
698
+ )
699
+ return cursor.rowcount > 0
700
+
701
+ def unresolve_memory(self, memory_id: int) -> bool:
702
+ """Mark a memory as unresolved."""
703
+ with self._get_conn() as conn:
704
+ cursor = conn.execute(
705
+ "UPDATE memories SET resolved_at = NULL WHERE id = ?",
706
+ (memory_id,),
707
+ )
708
+ return cursor.rowcount > 0
709
+
710
+ def _row_to_memory(self, row: sqlite3.Row) -> Memory:
711
+ """Convert a database row to a Memory object."""
712
+ resolved_at = None
713
+ if "resolved_at" in row.keys() and row["resolved_at"]:
714
+ resolved_at = datetime.fromisoformat(row["resolved_at"])
715
+ project = row["project"] if "project" in row.keys() else None
716
+ return Memory(
717
+ id=row["id"],
718
+ source_file=row["source_file"],
719
+ source_line=row["source_line"],
720
+ project=project,
721
+ category=MemoryCategory(row["category"]),
722
+ content=row["content"],
723
+ what=row["what"],
724
+ why=row["why"],
725
+ learned=row["learned"],
726
+ created_at=datetime.fromisoformat(row["created_at"]),
727
+ expires_at=datetime.fromisoformat(row["expires_at"]) if row["expires_at"] else None,
728
+ resolved_at=resolved_at,
729
+ embedding_id=row["embedding_id"],
730
+ )
731
+
732
+ def upsert_session(self, session: Session) -> None:
733
+ """Insert or update a session."""
734
+ with self._get_conn() as conn:
735
+ conn.execute(
736
+ """
737
+ INSERT INTO sessions (id, started_at, last_heartbeat, working_on, claimed_items)
738
+ VALUES (?, ?, ?, ?, ?)
739
+ ON CONFLICT(id) DO UPDATE SET
740
+ last_heartbeat = excluded.last_heartbeat,
741
+ working_on = excluded.working_on,
742
+ claimed_items = excluded.claimed_items
743
+ """,
744
+ (
745
+ session.id,
746
+ session.started_at.isoformat(),
747
+ session.last_heartbeat.isoformat(),
748
+ session.working_on,
749
+ ",".join(session.claimed_items) if session.claimed_items else None,
750
+ ),
751
+ )
752
+
753
+ def get_active_sessions(self, stale_minutes: int = 30) -> list[Session]:
754
+ """Get sessions with recent heartbeats."""
755
+ cutoff = datetime.now(UTC).timestamp() - (stale_minutes * 60)
756
+ with self._get_conn() as conn:
757
+ cursor = conn.execute(
758
+ """
759
+ SELECT * FROM sessions
760
+ WHERE datetime(last_heartbeat) > datetime(?, 'unixepoch')
761
+ """,
762
+ (cutoff,),
763
+ )
764
+ sessions = []
765
+ for row in cursor.fetchall():
766
+ sessions.append(
767
+ Session(
768
+ id=row["id"],
769
+ started_at=datetime.fromisoformat(row["started_at"]),
770
+ last_heartbeat=datetime.fromisoformat(row["last_heartbeat"]),
771
+ working_on=row["working_on"],
772
+ claimed_items=row["claimed_items"].split(",")
773
+ if row["claimed_items"]
774
+ else [],
775
+ )
776
+ )
777
+ return sessions
778
+
779
+ def delete_session(self, session_id: str) -> None:
780
+ """Delete a session."""
781
+ with self._get_conn() as conn:
782
+ conn.execute("DELETE FROM sessions WHERE id = ?", (session_id,))
783
+
784
+ def atomic_claim_item(self, session_id: str, item_ref: str) -> tuple[bool, str | None]:
785
+ """Atomically claim an item for a session.
786
+
787
+ Returns (success, current_owner). If success is False, current_owner
788
+ contains the session_id that owns the item.
789
+
790
+ Uses BEGIN IMMEDIATE with retry logic to handle database contention
791
+ without blocking for long periods.
792
+ """
793
+ import time
794
+
795
+ max_retries = 3
796
+ retry_delay = 0.1 # 100ms between retries
797
+
798
+ for attempt in range(max_retries):
799
+ conn = self._get_conn()
800
+ try:
801
+ # Set a short busy timeout for this transaction
802
+ conn.execute("PRAGMA busy_timeout=1000") # 1 second
803
+
804
+ # BEGIN IMMEDIATE acquires write lock immediately
805
+ conn.execute("BEGIN IMMEDIATE")
806
+
807
+ # Check if any other session has this item claimed
808
+ cursor = conn.execute(
809
+ """
810
+ SELECT id, claimed_items FROM sessions
811
+ WHERE claimed_items LIKE ? AND id != ?
812
+ """,
813
+ (f"%{item_ref}%", session_id),
814
+ )
815
+ for row in cursor.fetchall():
816
+ claimed = row["claimed_items"].split(",") if row["claimed_items"] else []
817
+ if item_ref in claimed:
818
+ conn.execute("ROLLBACK")
819
+ # Restore default timeout
820
+ conn.execute("PRAGMA busy_timeout=30000")
821
+ return False, row["id"]
822
+
823
+ # Get current session's claims
824
+ cursor = conn.execute(
825
+ "SELECT claimed_items FROM sessions WHERE id = ?",
826
+ (session_id,),
827
+ )
828
+ row = cursor.fetchone()
829
+
830
+ if row:
831
+ current_claims = row["claimed_items"].split(",") if row["claimed_items"] else []
832
+ if item_ref not in current_claims:
833
+ current_claims.append(item_ref)
834
+ conn.execute(
835
+ "UPDATE sessions SET claimed_items = ? WHERE id = ?",
836
+ (",".join(current_claims), session_id),
837
+ )
838
+ else:
839
+ # Session doesn't exist, create it
840
+ now = datetime.now(UTC).isoformat()
841
+ conn.execute(
842
+ """
843
+ INSERT INTO sessions (id, started_at, last_heartbeat, claimed_items)
844
+ VALUES (?, ?, ?, ?)
845
+ """,
846
+ (session_id, now, now, item_ref),
847
+ )
848
+
849
+ conn.execute("COMMIT")
850
+ # Restore default timeout
851
+ conn.execute("PRAGMA busy_timeout=30000")
852
+ return True, None
853
+
854
+ except sqlite3.OperationalError as e:
855
+ try:
856
+ conn.execute("ROLLBACK")
857
+ except Exception:
858
+ pass
859
+ # Restore default timeout
860
+ try:
861
+ conn.execute("PRAGMA busy_timeout=30000")
862
+ except Exception:
863
+ pass
864
+
865
+ if "database is locked" in str(e) and attempt < max_retries - 1:
866
+ time.sleep(retry_delay * (attempt + 1)) # Exponential backoff
867
+ continue
868
+ raise
869
+
870
+ except Exception:
871
+ try:
872
+ conn.execute("ROLLBACK")
873
+ conn.execute("PRAGMA busy_timeout=30000")
874
+ except Exception:
875
+ pass
876
+ raise
877
+
878
+ # Should not reach here, but just in case
879
+ return True, None
880
+
881
+ def atomic_release_item(self, session_id: str, item_ref: str) -> bool:
882
+ """Atomically release an item from a session.
883
+
884
+ Uses BEGIN IMMEDIATE with retry logic for consistency with atomic_claim_item.
885
+ """
886
+ import time
887
+
888
+ max_retries = 3
889
+ retry_delay = 0.1
890
+
891
+ for attempt in range(max_retries):
892
+ conn = self._get_conn()
893
+ try:
894
+ conn.execute("PRAGMA busy_timeout=1000")
895
+ conn.execute("BEGIN IMMEDIATE")
896
+
897
+ cursor = conn.execute(
898
+ "SELECT claimed_items FROM sessions WHERE id = ?",
899
+ (session_id,),
900
+ )
901
+ row = cursor.fetchone()
902
+ if not row:
903
+ conn.execute("ROLLBACK")
904
+ conn.execute("PRAGMA busy_timeout=30000")
905
+ return False
906
+
907
+ current_claims = row["claimed_items"].split(",") if row["claimed_items"] else []
908
+ if item_ref in current_claims:
909
+ current_claims.remove(item_ref)
910
+ conn.execute(
911
+ "UPDATE sessions SET claimed_items = ? WHERE id = ?",
912
+ (",".join(current_claims) if current_claims else None, session_id),
913
+ )
914
+ conn.execute("COMMIT")
915
+ conn.execute("PRAGMA busy_timeout=30000")
916
+ return True
917
+
918
+ conn.execute("ROLLBACK")
919
+ conn.execute("PRAGMA busy_timeout=30000")
920
+ return False
921
+
922
+ except sqlite3.OperationalError as e:
923
+ try:
924
+ conn.execute("ROLLBACK")
925
+ except Exception:
926
+ pass
927
+ try:
928
+ conn.execute("PRAGMA busy_timeout=30000")
929
+ except Exception:
930
+ pass
931
+
932
+ if "database is locked" in str(e) and attempt < max_retries - 1:
933
+ time.sleep(retry_delay * (attempt + 1))
934
+ continue
935
+ raise
936
+
937
+ except Exception:
938
+ try:
939
+ conn.execute("ROLLBACK")
940
+ conn.execute("PRAGMA busy_timeout=30000")
941
+ except Exception:
942
+ pass
943
+ raise
944
+
945
+ return False
946
+
947
+ def get_item_owner(self, item_ref: str, stale_minutes: int = 30) -> str | None:
948
+ """Get the session that owns an item (if any active session)."""
949
+ cutoff = datetime.now(UTC).timestamp() - (stale_minutes * 60)
950
+ with self._get_conn() as conn:
951
+ cursor = conn.execute(
952
+ """
953
+ SELECT id, claimed_items FROM sessions
954
+ WHERE claimed_items LIKE ?
955
+ AND datetime(last_heartbeat) > datetime(?, 'unixepoch')
956
+ """,
957
+ (f"%{item_ref}%", cutoff),
958
+ )
959
+ for row in cursor.fetchall():
960
+ claimed = row["claimed_items"].split(",") if row["claimed_items"] else []
961
+ if item_ref in claimed:
962
+ return row["id"]
963
+ return None
964
+
965
+ def get_ingest_state(self, source: str) -> dict[str, Any] | None:
966
+ """Get ingestion state for a source."""
967
+ with self._get_conn() as conn:
968
+ cursor = conn.execute("SELECT * FROM ingest_state WHERE source = ?", (source,))
969
+ row = cursor.fetchone()
970
+ if row:
971
+ return {
972
+ "source": row["source"],
973
+ "last_processed": row["last_processed"],
974
+ "last_id": row["last_id"],
975
+ "metadata": row["metadata"],
976
+ }
977
+ return None
978
+
979
+ def set_ingest_state(
980
+ self, source: str, last_processed: str, last_id: str | None = None
981
+ ) -> None:
982
+ """Update ingestion state for a source."""
983
+ with self._get_conn() as conn:
984
+ conn.execute(
985
+ """
986
+ INSERT INTO ingest_state (source, last_processed, last_id)
987
+ VALUES (?, ?, ?)
988
+ ON CONFLICT(source) DO UPDATE SET
989
+ last_processed = excluded.last_processed,
990
+ last_id = excluded.last_id
991
+ """,
992
+ (source, last_processed, last_id),
993
+ )
994
+
995
+ def get_ingested_session_ids(self) -> set[str]:
996
+ """Get all source_file values for ingested OpenCode sessions."""
997
+ with self._get_conn() as conn:
998
+ cursor = conn.execute(
999
+ "SELECT DISTINCT source_file FROM memories WHERE source_file LIKE 'opencode:session:%'"
1000
+ )
1001
+ return {row["source_file"] for row in cursor.fetchall()}
1002
+
1003
+ def delete_memories_for_session(self, session_id: str) -> int:
1004
+ """Delete all memories for a session (for re-ingestion)."""
1005
+ source_file = f"opencode:session:{session_id}"
1006
+ with self._get_conn() as conn:
1007
+ cursor = conn.execute(
1008
+ "DELETE FROM memories WHERE source_file = ?",
1009
+ (source_file,),
1010
+ )
1011
+ return cursor.rowcount
1012
+
1013
+ def get_session_memory_count(self, session_id: str) -> int:
1014
+ """Get the number of memories for a session."""
1015
+ source_file = f"opencode:session:{session_id}"
1016
+ with self._get_conn() as conn:
1017
+ cursor = conn.execute(
1018
+ "SELECT COUNT(*) FROM memories WHERE source_file = ?",
1019
+ (source_file,),
1020
+ )
1021
+ return cursor.fetchone()[0]
1022
+
1023
+ def get_stale_entities(self, max_age_hours: int = 24, limit: int = 50) -> list[Entity]:
1024
+ """Get entities that haven't been enriched or are stale (legacy method)."""
1025
+ return self.get_entities_for_refresh(limit=limit)
1026
+
1027
+ def get_entities_for_refresh(self, limit: int = 50) -> list[Entity]:
1028
+ """Get entities due for refresh based on tiered scheduling.
1029
+
1030
+ Refresh frequency based on activity (last_mentioned_at):
1031
+ - Hot (mentioned in last 24h): refresh if not enriched in last 1 hour
1032
+ - Warm (mentioned in last 7 days): refresh if not enriched in last 6 hours
1033
+ - Cool (mentioned in last 30 days): refresh if not enriched in last 24 hours
1034
+ - Cold (older or never mentioned): refresh if not enriched in last 7 days
1035
+
1036
+ Also includes entities that have never been enriched (no title/metadata).
1037
+ """
1038
+ with self._get_conn() as conn:
1039
+ cursor = conn.execute(
1040
+ """
1041
+ SELECT *,
1042
+ CASE
1043
+ WHEN last_mentioned_at > datetime('now', '-1 day') THEN 'hot'
1044
+ WHEN last_mentioned_at > datetime('now', '-7 days') THEN 'warm'
1045
+ WHEN last_mentioned_at > datetime('now', '-30 days') THEN 'cool'
1046
+ ELSE 'cold'
1047
+ END as heat_tier
1048
+ FROM entities
1049
+ WHERE
1050
+ -- Never enriched
1051
+ (title IS NULL OR metadata IS NULL OR last_enriched_at IS NULL)
1052
+ OR
1053
+ -- Hot: refresh hourly
1054
+ (last_mentioned_at > datetime('now', '-1 day')
1055
+ AND (last_enriched_at IS NULL OR last_enriched_at < datetime('now', '-1 hour')))
1056
+ OR
1057
+ -- Warm: refresh every 6 hours
1058
+ (last_mentioned_at > datetime('now', '-7 days')
1059
+ AND last_mentioned_at <= datetime('now', '-1 day')
1060
+ AND (last_enriched_at IS NULL OR last_enriched_at < datetime('now', '-6 hours')))
1061
+ OR
1062
+ -- Cool: refresh daily
1063
+ (last_mentioned_at > datetime('now', '-30 days')
1064
+ AND last_mentioned_at <= datetime('now', '-7 days')
1065
+ AND (last_enriched_at IS NULL OR last_enriched_at < datetime('now', '-1 day')))
1066
+ OR
1067
+ -- Cold: refresh weekly
1068
+ ((last_mentioned_at IS NULL OR last_mentioned_at <= datetime('now', '-30 days'))
1069
+ AND (last_enriched_at IS NULL OR last_enriched_at < datetime('now', '-7 days')))
1070
+ ORDER BY
1071
+ -- Prioritize: never enriched, then hot, warm, cool, cold
1072
+ CASE
1073
+ WHEN title IS NULL OR metadata IS NULL THEN 0
1074
+ WHEN last_mentioned_at > datetime('now', '-1 day') THEN 1
1075
+ WHEN last_mentioned_at > datetime('now', '-7 days') THEN 2
1076
+ WHEN last_mentioned_at > datetime('now', '-30 days') THEN 3
1077
+ ELSE 4
1078
+ END,
1079
+ last_enriched_at ASC NULLS FIRST
1080
+ LIMIT ?
1081
+ """,
1082
+ (limit,),
1083
+ )
1084
+ entities = []
1085
+ for row in cursor.fetchall():
1086
+ metadata = {}
1087
+ if row["metadata"]:
1088
+ try:
1089
+ metadata = json.loads(row["metadata"])
1090
+ except json.JSONDecodeError:
1091
+ pass
1092
+ entities.append(
1093
+ Entity(
1094
+ id=row["id"],
1095
+ type=EntityType(row["type"]),
1096
+ ref=row["ref"],
1097
+ project=row["project"],
1098
+ title=row["title"],
1099
+ metadata=metadata,
1100
+ created_at=datetime.fromisoformat(row["created_at"]),
1101
+ updated_at=datetime.fromisoformat(row["updated_at"]),
1102
+ )
1103
+ )
1104
+ return entities
1105
+
1106
+ def update_entity_enriched(self, entity_id: int) -> None:
1107
+ """Mark an entity as just enriched."""
1108
+ with self._get_conn() as conn:
1109
+ conn.execute(
1110
+ "UPDATE entities SET last_enriched_at = ? WHERE id = ?",
1111
+ (datetime.now(UTC).isoformat(), entity_id),
1112
+ )
1113
+
1114
+ def bump_entity_mention(self, entity_id: int) -> None:
1115
+ """Bump an entity to 'hot' status by updating last_mentioned_at."""
1116
+ with self._get_conn() as conn:
1117
+ conn.execute(
1118
+ "UPDATE entities SET last_mentioned_at = ? WHERE id = ?",
1119
+ (datetime.now(UTC).isoformat(), entity_id),
1120
+ )
1121
+
1122
+ def archive_memory(self, memory_id: int, reason: str) -> bool:
1123
+ """Archive a memory (move to archive table)."""
1124
+ with self._get_conn() as conn:
1125
+ cursor = conn.execute("SELECT * FROM memories WHERE id = ?", (memory_id,))
1126
+ row = cursor.fetchone()
1127
+ if not row:
1128
+ return False
1129
+
1130
+ conn.execute(
1131
+ """
1132
+ INSERT INTO archived_memories
1133
+ (id, source_file, source_line, category, content, what, why, learned,
1134
+ created_at, expires_at, resolved_at, archived_at, archive_reason)
1135
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
1136
+ """,
1137
+ (
1138
+ row["id"],
1139
+ row["source_file"],
1140
+ row["source_line"],
1141
+ row["category"],
1142
+ row["content"],
1143
+ row["what"],
1144
+ row["why"],
1145
+ row["learned"],
1146
+ row["created_at"],
1147
+ row["expires_at"],
1148
+ row["resolved_at"],
1149
+ datetime.now(UTC).isoformat(),
1150
+ reason,
1151
+ ),
1152
+ )
1153
+
1154
+ conn.execute("DELETE FROM memories WHERE id = ?", (memory_id,))
1155
+ return True
1156
+
1157
+ def archive_expired_memories(self) -> int:
1158
+ """Archive all memories past their expiration date."""
1159
+ now = datetime.now(UTC).isoformat()
1160
+ with self._get_conn() as conn:
1161
+ cursor = conn.execute(
1162
+ """
1163
+ SELECT id FROM memories
1164
+ WHERE expires_at IS NOT NULL AND expires_at < ?
1165
+ """,
1166
+ (now,),
1167
+ )
1168
+ expired_ids = [row["id"] for row in cursor.fetchall()]
1169
+
1170
+ archived = 0
1171
+ for memory_id in expired_ids:
1172
+ if self.archive_memory(memory_id, "expired"):
1173
+ archived += 1
1174
+ return archived
1175
+
1176
+ def archive_old_resolved_blockers(self, days_old: int = 90) -> int:
1177
+ """Archive blockers that were resolved more than N days ago.
1178
+
1179
+ Note: This is for storage management only. Resolved blockers are
1180
+ already excluded from boot context. Use a conservative threshold.
1181
+ """
1182
+ cutoff = (datetime.now(UTC) - timedelta(days=days_old)).isoformat()
1183
+ with self._get_conn() as conn:
1184
+ cursor = conn.execute(
1185
+ """
1186
+ SELECT id FROM memories
1187
+ WHERE category = 'blocker'
1188
+ AND resolved_at IS NOT NULL
1189
+ AND resolved_at < ?
1190
+ """,
1191
+ (cutoff,),
1192
+ )
1193
+ old_ids = [row["id"] for row in cursor.fetchall()]
1194
+
1195
+ archived = 0
1196
+ for memory_id in old_ids:
1197
+ if self.archive_memory(memory_id, "old_resolved_blocker"):
1198
+ archived += 1
1199
+ return archived
1200
+
1201
+ def archive_old_conversations(self, days_old: int = 180) -> int:
1202
+ """Archive conversation summaries older than N days.
1203
+
1204
+ Note: This is for storage management on very old data (6+ months).
1205
+ Archived conversations remain searchable via search_archived().
1206
+ """
1207
+ cutoff = (datetime.now(UTC) - timedelta(days=days_old)).isoformat()
1208
+ with self._get_conn() as conn:
1209
+ cursor = conn.execute(
1210
+ """
1211
+ SELECT id FROM memories
1212
+ WHERE category = 'conversation'
1213
+ AND created_at < ?
1214
+ """,
1215
+ (cutoff,),
1216
+ )
1217
+ old_ids = [row["id"] for row in cursor.fetchall()]
1218
+
1219
+ archived = 0
1220
+ for memory_id in old_ids:
1221
+ if self.archive_memory(memory_id, "old_conversation"):
1222
+ archived += 1
1223
+ return archived
1224
+
1225
+ def resolve_blockers_for_entity(self, entity_ref: str) -> int:
1226
+ """Mark all blockers mentioning an entity as resolved."""
1227
+ now = datetime.now(UTC).isoformat()
1228
+ with self._get_conn() as conn:
1229
+ cursor = conn.execute(
1230
+ """
1231
+ UPDATE memories
1232
+ SET resolved_at = ?
1233
+ WHERE category = 'blocker'
1234
+ AND resolved_at IS NULL
1235
+ AND (content LIKE ? OR content LIKE ?)
1236
+ """,
1237
+ (now, f"%{entity_ref}%", f"%{entity_ref.lstrip('!#&@')}%"),
1238
+ )
1239
+ return cursor.rowcount
1240
+
1241
+ def get_archived_memories(self, limit: int = 50) -> list[dict]:
1242
+ """Get archived memories for audit purposes."""
1243
+ with self._get_conn() as conn:
1244
+ cursor = conn.execute(
1245
+ """
1246
+ SELECT * FROM archived_memories
1247
+ ORDER BY archived_at DESC
1248
+ LIMIT ?
1249
+ """,
1250
+ (limit,),
1251
+ )
1252
+ return [dict(row) for row in cursor.fetchall()]
1253
+
1254
+ def get_cleanup_stats(self) -> dict:
1255
+ """Get statistics about memory cleanup candidates."""
1256
+ with self._get_conn() as conn:
1257
+ stats = {}
1258
+
1259
+ cursor = conn.execute(
1260
+ "SELECT COUNT(*) FROM memories WHERE expires_at IS NOT NULL AND expires_at < datetime('now')"
1261
+ )
1262
+ stats["expired"] = cursor.fetchone()[0]
1263
+
1264
+ cursor = conn.execute(
1265
+ "SELECT COUNT(*) FROM memories WHERE category = 'blocker' AND resolved_at IS NOT NULL"
1266
+ )
1267
+ stats["resolved_blockers"] = cursor.fetchone()[0]
1268
+
1269
+ cursor = conn.execute(
1270
+ "SELECT COUNT(*) FROM memories WHERE category = 'conversation' AND created_at < datetime('now', '-90 days')"
1271
+ )
1272
+ stats["old_conversations"] = cursor.fetchone()[0]
1273
+
1274
+ cursor = conn.execute("SELECT COUNT(*) FROM archived_memories")
1275
+ stats["archived_total"] = cursor.fetchone()[0]
1276
+
1277
+ return stats
1278
+
1279
+ # ==================== Memory Links ====================
1280
+
1281
+ def insert_link(self, link: MemoryLink) -> int | None:
1282
+ """Insert a memory link. Returns link ID or None if duplicate."""
1283
+ with self._get_conn() as conn:
1284
+ try:
1285
+ cursor = conn.execute(
1286
+ """
1287
+ INSERT INTO memory_links
1288
+ (source_memory_id, target_memory_id, link_type, strength, reason, created_at)
1289
+ VALUES (?, ?, ?, ?, ?, ?)
1290
+ RETURNING id
1291
+ """,
1292
+ (
1293
+ link.source_memory_id,
1294
+ link.target_memory_id,
1295
+ link.link_type.value,
1296
+ link.strength,
1297
+ link.reason,
1298
+ link.created_at.isoformat(),
1299
+ ),
1300
+ )
1301
+ row = cursor.fetchone()
1302
+ return int(row[0]) if row else None
1303
+ except sqlite3.IntegrityError:
1304
+ return None
1305
+
1306
+ def get_links_for_memory(self, memory_id: int, as_source: bool = True) -> list[MemoryLink]:
1307
+ """Get all links where memory is source (outgoing) or target (incoming)."""
1308
+ with self._get_conn() as conn:
1309
+ if as_source:
1310
+ cursor = conn.execute(
1311
+ "SELECT * FROM memory_links WHERE source_memory_id = ?",
1312
+ (memory_id,),
1313
+ )
1314
+ else:
1315
+ cursor = conn.execute(
1316
+ "SELECT * FROM memory_links WHERE target_memory_id = ?",
1317
+ (memory_id,),
1318
+ )
1319
+ return [self._row_to_link(row) for row in cursor.fetchall()]
1320
+
1321
+ def get_all_links_for_memory(self, memory_id: int) -> list[MemoryLink]:
1322
+ """Get all links (both directions) for a memory."""
1323
+ with self._get_conn() as conn:
1324
+ cursor = conn.execute(
1325
+ """
1326
+ SELECT * FROM memory_links
1327
+ WHERE source_memory_id = ? OR target_memory_id = ?
1328
+ """,
1329
+ (memory_id, memory_id),
1330
+ )
1331
+ return [self._row_to_link(row) for row in cursor.fetchall()]
1332
+
1333
+ def get_linked_memory_ids(
1334
+ self, memory_id: int, link_types: list[LinkType] | None = None
1335
+ ) -> list[int]:
1336
+ """Get IDs of memories linked to this one (both directions)."""
1337
+ with self._get_conn() as conn:
1338
+ if link_types:
1339
+ type_placeholders = ",".join("?" * len(link_types))
1340
+ type_values = [lt.value for lt in link_types]
1341
+ cursor = conn.execute(
1342
+ f"""
1343
+ SELECT DISTINCT
1344
+ CASE
1345
+ WHEN source_memory_id = ? THEN target_memory_id
1346
+ ELSE source_memory_id
1347
+ END as linked_id
1348
+ FROM memory_links
1349
+ WHERE (source_memory_id = ? OR target_memory_id = ?)
1350
+ AND link_type IN ({type_placeholders})
1351
+ """,
1352
+ [memory_id, memory_id, memory_id] + type_values,
1353
+ )
1354
+ else:
1355
+ cursor = conn.execute(
1356
+ """
1357
+ SELECT DISTINCT
1358
+ CASE
1359
+ WHEN source_memory_id = ? THEN target_memory_id
1360
+ ELSE source_memory_id
1361
+ END as linked_id
1362
+ FROM memory_links
1363
+ WHERE source_memory_id = ? OR target_memory_id = ?
1364
+ """,
1365
+ (memory_id, memory_id, memory_id),
1366
+ )
1367
+ return [row["linked_id"] for row in cursor.fetchall()]
1368
+
1369
+ def get_unlinked_memories(self, limit: int = 100) -> list[Memory]:
1370
+ """Get memories that have no links yet (for background linking).
1371
+
1372
+ Prioritizes high-value categories (directive, procedure, fact, decision)
1373
+ over conversations which are numerous but lower value for linking.
1374
+ """
1375
+ with self._get_conn() as conn:
1376
+ cursor = conn.execute(
1377
+ """
1378
+ SELECT m.* FROM memories m
1379
+ LEFT JOIN memory_links ml_src ON m.id = ml_src.source_memory_id
1380
+ LEFT JOIN memory_links ml_tgt ON m.id = ml_tgt.target_memory_id
1381
+ WHERE ml_src.id IS NULL AND ml_tgt.id IS NULL
1382
+ AND m.id NOT IN (SELECT id FROM archived_memories)
1383
+ ORDER BY
1384
+ CASE m.category
1385
+ WHEN 'directive' THEN 1
1386
+ WHEN 'plan' THEN 2
1387
+ WHEN 'procedure' THEN 3
1388
+ WHEN 'fact' THEN 4
1389
+ WHEN 'decision' THEN 5
1390
+ WHEN 'blocker' THEN 6
1391
+ WHEN 'event' THEN 7
1392
+ WHEN 'conversation' THEN 8
1393
+ ELSE 9
1394
+ END,
1395
+ m.created_at DESC
1396
+ LIMIT ?
1397
+ """,
1398
+ (limit,),
1399
+ )
1400
+ return [self._row_to_memory(row) for row in cursor.fetchall()]
1401
+
1402
+ def get_memories_needing_links(self, since_hours: int = 24, limit: int = 50) -> list[Memory]:
1403
+ """Get recent memories that may need linking (created recently, few links)."""
1404
+ cutoff = (datetime.now(UTC) - timedelta(hours=since_hours)).isoformat()
1405
+ with self._get_conn() as conn:
1406
+ cursor = conn.execute(
1407
+ """
1408
+ SELECT m.*, COUNT(ml.id) as link_count
1409
+ FROM memories m
1410
+ LEFT JOIN memory_links ml ON m.id = ml.source_memory_id OR m.id = ml.target_memory_id
1411
+ WHERE m.created_at > ?
1412
+ AND m.id NOT IN (SELECT id FROM archived_memories)
1413
+ GROUP BY m.id
1414
+ HAVING link_count < 3
1415
+ ORDER BY m.created_at DESC
1416
+ LIMIT ?
1417
+ """,
1418
+ (cutoff, limit),
1419
+ )
1420
+ return [self._row_to_memory(row) for row in cursor.fetchall()]
1421
+
1422
+ def link_exists(self, source_id: int, target_id: int, link_type: LinkType) -> bool:
1423
+ """Check if a specific link already exists."""
1424
+ with self._get_conn() as conn:
1425
+ cursor = conn.execute(
1426
+ """
1427
+ SELECT 1 FROM memory_links
1428
+ WHERE source_memory_id = ? AND target_memory_id = ? AND link_type = ?
1429
+ """,
1430
+ (source_id, target_id, link_type.value),
1431
+ )
1432
+ return cursor.fetchone() is not None
1433
+
1434
+ def any_link_exists(self, source_id: int, target_id: int) -> bool:
1435
+ """Check if any link exists between two memories (in either direction)."""
1436
+ with self._get_conn() as conn:
1437
+ cursor = conn.execute(
1438
+ """
1439
+ SELECT 1 FROM memory_links
1440
+ WHERE (source_memory_id = ? AND target_memory_id = ?)
1441
+ OR (source_memory_id = ? AND target_memory_id = ?)
1442
+ """,
1443
+ (source_id, target_id, target_id, source_id),
1444
+ )
1445
+ return cursor.fetchone() is not None
1446
+
1447
+ def delete_link(self, link_id: int) -> bool:
1448
+ """Delete a memory link."""
1449
+ with self._get_conn() as conn:
1450
+ cursor = conn.execute("DELETE FROM memory_links WHERE id = ?", (link_id,))
1451
+ return cursor.rowcount > 0
1452
+
1453
+ def get_link_stats(self) -> dict:
1454
+ """Get statistics about memory links."""
1455
+ with self._get_conn() as conn:
1456
+ stats: dict[str, Any] = {}
1457
+
1458
+ cursor = conn.execute("SELECT COUNT(*) FROM memory_links")
1459
+ stats["total_links"] = cursor.fetchone()[0]
1460
+
1461
+ cursor = conn.execute(
1462
+ "SELECT link_type, COUNT(*) as cnt FROM memory_links GROUP BY link_type"
1463
+ )
1464
+ stats["by_type"] = {row["link_type"]: row["cnt"] for row in cursor.fetchall()}
1465
+
1466
+ cursor = conn.execute(
1467
+ """
1468
+ SELECT COUNT(DISTINCT m.id) as linked_count
1469
+ FROM memories m
1470
+ INNER JOIN memory_links ml ON m.id = ml.source_memory_id OR m.id = ml.target_memory_id
1471
+ """
1472
+ )
1473
+ stats["memories_with_links"] = cursor.fetchone()[0]
1474
+
1475
+ cursor = conn.execute(
1476
+ "SELECT COUNT(*) FROM memories WHERE id NOT IN (SELECT id FROM archived_memories)"
1477
+ )
1478
+ stats["total_memories"] = cursor.fetchone()[0]
1479
+
1480
+ return stats
1481
+
1482
+ def get_consolidation_stats(self, project: str | None, days_stale: int) -> dict[str, Any]:
1483
+ """Get consolidation stats using efficient SQL queries."""
1484
+ stale_threshold = (datetime.now(UTC) - timedelta(days=days_stale)).isoformat()
1485
+
1486
+ with self._get_conn() as conn:
1487
+ # Total count
1488
+ if project:
1489
+ cursor = conn.execute(
1490
+ """SELECT COUNT(*) FROM memories
1491
+ WHERE project = ? AND id NOT IN (SELECT id FROM archived_memories)""",
1492
+ (project,),
1493
+ )
1494
+ else:
1495
+ cursor = conn.execute(
1496
+ "SELECT COUNT(*) FROM memories WHERE id NOT IN (SELECT id FROM archived_memories)"
1497
+ )
1498
+ total = cursor.fetchone()[0]
1499
+
1500
+ # By category
1501
+ if project:
1502
+ cursor = conn.execute(
1503
+ """SELECT category, COUNT(*) FROM memories
1504
+ WHERE project = ? AND id NOT IN (SELECT id FROM archived_memories)
1505
+ GROUP BY category""",
1506
+ (project,),
1507
+ )
1508
+ else:
1509
+ cursor = conn.execute(
1510
+ """SELECT category, COUNT(*) FROM memories
1511
+ WHERE id NOT IN (SELECT id FROM archived_memories)
1512
+ GROUP BY category"""
1513
+ )
1514
+ by_category = {row[0]: row[1] for row in cursor.fetchall()}
1515
+
1516
+ # Stale records (facts/procedures older than threshold)
1517
+ if project:
1518
+ cursor = conn.execute(
1519
+ """SELECT id, category, content, created_at FROM memories
1520
+ WHERE project = ?
1521
+ AND category IN ('fact', 'procedure')
1522
+ AND created_at < ?
1523
+ AND id NOT IN (SELECT id FROM archived_memories)
1524
+ ORDER BY created_at ASC
1525
+ LIMIT 50""",
1526
+ (project, stale_threshold),
1527
+ )
1528
+ else:
1529
+ cursor = conn.execute(
1530
+ """SELECT id, category, content, created_at FROM memories
1531
+ WHERE category IN ('fact', 'procedure')
1532
+ AND created_at < ?
1533
+ AND id NOT IN (SELECT id FROM archived_memories)
1534
+ ORDER BY created_at ASC
1535
+ LIMIT 50""",
1536
+ (stale_threshold,),
1537
+ )
1538
+ stale_records = [
1539
+ {
1540
+ "id": row["id"],
1541
+ "category": row["category"],
1542
+ "content": row["content"][:100] + "..."
1543
+ if len(row["content"]) > 100
1544
+ else row["content"],
1545
+ "age": _format_age_from_iso(row["created_at"]),
1546
+ }
1547
+ for row in cursor.fetchall()
1548
+ ]
1549
+
1550
+ return {
1551
+ "total": total,
1552
+ "by_category": by_category,
1553
+ "stale_records": stale_records,
1554
+ }
1555
+
1556
+ def get_recent_consolidation_report(self, max_age_hours: int = 24) -> Memory | None:
1557
+ """Get the most recent consolidation report if one exists within max_age."""
1558
+ cutoff = (datetime.now(UTC) - timedelta(hours=max_age_hours)).isoformat()
1559
+ with self._get_conn() as conn:
1560
+ cursor = conn.execute(
1561
+ """SELECT * FROM memories
1562
+ WHERE what = 'Memory consolidation report'
1563
+ AND created_at > ?
1564
+ AND id NOT IN (SELECT id FROM archived_memories)
1565
+ ORDER BY created_at DESC
1566
+ LIMIT 1""",
1567
+ (cutoff,),
1568
+ )
1569
+ row = cursor.fetchone()
1570
+ return self._row_to_memory(row) if row else None
1571
+
1572
+ def get_recent_memories_for_dedup(
1573
+ self, project: str | None, limit: int, days: int
1574
+ ) -> list[Memory]:
1575
+ """Get recent memories for duplicate detection."""
1576
+ cutoff = (datetime.now(UTC) - timedelta(days=days)).isoformat()
1577
+ with self._get_conn() as conn:
1578
+ if project:
1579
+ cursor = conn.execute(
1580
+ """SELECT * FROM memories
1581
+ WHERE project = ? AND created_at > ?
1582
+ AND id NOT IN (SELECT id FROM archived_memories)
1583
+ ORDER BY created_at DESC
1584
+ LIMIT ?""",
1585
+ (project, cutoff, limit),
1586
+ )
1587
+ else:
1588
+ cursor = conn.execute(
1589
+ """SELECT * FROM memories
1590
+ WHERE created_at > ?
1591
+ AND id NOT IN (SELECT id FROM archived_memories)
1592
+ ORDER BY created_at DESC
1593
+ LIMIT ?""",
1594
+ (cutoff, limit),
1595
+ )
1596
+ return [self._row_to_memory(row) for row in cursor.fetchall()]
1597
+
1598
+ def _row_to_link(self, row: sqlite3.Row) -> MemoryLink:
1599
+ """Convert a database row to a MemoryLink object."""
1600
+ return MemoryLink(
1601
+ id=row["id"],
1602
+ source_memory_id=row["source_memory_id"],
1603
+ target_memory_id=row["target_memory_id"],
1604
+ link_type=LinkType(row["link_type"]),
1605
+ strength=row["strength"],
1606
+ reason=row["reason"],
1607
+ created_at=datetime.fromisoformat(row["created_at"]),
1608
+ )