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.
- opencode_memory/__init__.py +3 -0
- opencode_memory/cache.py +261 -0
- opencode_memory/cli.py +794 -0
- opencode_memory/config.py +89 -0
- opencode_memory/daemon.py +879 -0
- opencode_memory/enrichment/__init__.py +0 -0
- opencode_memory/enrichment/gitlab.py +237 -0
- opencode_memory/extraction.py +225 -0
- opencode_memory/historical_ingest.py +142 -0
- opencode_memory/http_server.py +464 -0
- opencode_memory/ingestion/__init__.py +7 -0
- opencode_memory/ingestion/embeddings.py +211 -0
- opencode_memory/ingestion/extractors.py +287 -0
- opencode_memory/ingestion/opencode_db.py +448 -0
- opencode_memory/ingestion/parser.py +344 -0
- opencode_memory/ingestion/watcher.py +88 -0
- opencode_memory/linking/__init__.py +5 -0
- opencode_memory/linking/linker.py +323 -0
- opencode_memory/metrics.py +273 -0
- opencode_memory/models.py +171 -0
- opencode_memory/project.py +86 -0
- opencode_memory/query/__init__.py +5 -0
- opencode_memory/query/hybrid.py +196 -0
- opencode_memory/server.py +2795 -0
- opencode_memory/session/__init__.py +5 -0
- opencode_memory/session/registry.py +57 -0
- opencode_memory/storage/__init__.py +6 -0
- opencode_memory/storage/sqlite.py +1608 -0
- opencode_memory/storage/vectors.py +199 -0
- opencode_semantic_memory-0.1.0.dist-info/METADATA +531 -0
- opencode_semantic_memory-0.1.0.dist-info/RECORD +33 -0
- opencode_semantic_memory-0.1.0.dist-info/WHEEL +4 -0
- opencode_semantic_memory-0.1.0.dist-info/entry_points.txt +3 -0
|
@@ -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
|
+
)
|