docugraph 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.
- docugraph/__init__.py +24 -0
- docugraph/_version.py +28 -0
- docugraph/agents/__init__.py +27 -0
- docugraph/agents/coding_agent.py +532 -0
- docugraph/agents/tools/__init__.py +62 -0
- docugraph/agents/tools/graph_query.py +203 -0
- docugraph/agents/tools/memory_ops.py +305 -0
- docugraph/agents/tools/search_code.py +305 -0
- docugraph/agents/tools/search_docs.py +148 -0
- docugraph/core/__init__.py +34 -0
- docugraph/core/config.py +183 -0
- docugraph/core/embeddings.py +394 -0
- docugraph/core/llm.py +892 -0
- docugraph/core/models.py +145 -0
- docugraph/ingestion/__init__.py +64 -0
- docugraph/ingestion/api_docs.py +646 -0
- docugraph/ingestion/chunker.py +382 -0
- docugraph/ingestion/crawler.py +305 -0
- docugraph/ingestion/git_indexer.py +556 -0
- docugraph/ingestion/local_files.py +516 -0
- docugraph/interfaces/__init__.py +1 -0
- docugraph/interfaces/cli.py +983 -0
- docugraph/interfaces/mcp_server.py +918 -0
- docugraph/retrieval/__init__.py +54 -0
- docugraph/retrieval/context_builder.py +522 -0
- docugraph/retrieval/hybrid_search.py +534 -0
- docugraph/retrieval/reranker.py +532 -0
- docugraph/storage/__init__.py +14 -0
- docugraph/storage/graph_store.py +602 -0
- docugraph/storage/memory_store.py +529 -0
- docugraph/storage/vector_store.py +367 -0
- docugraph-0.1.0.dist-info/METADATA +354 -0
- docugraph-0.1.0.dist-info/RECORD +37 -0
- docugraph-0.1.0.dist-info/WHEEL +4 -0
- docugraph-0.1.0.dist-info/entry_points.txt +3 -0
- docugraph-0.1.0.dist-info/licenses/LICENSE +201 -0
- docugraph-0.1.0.dist-info/licenses/NOTICE +10 -0
|
@@ -0,0 +1,529 @@
|
|
|
1
|
+
"""Memory store for agent session state with repository/branch awareness."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import sqlite3
|
|
7
|
+
from collections.abc import Generator
|
|
8
|
+
from contextlib import contextmanager
|
|
9
|
+
from datetime import UTC, datetime
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
from docugraph.core.config import get_config
|
|
14
|
+
from docugraph.core.models import MemoryEntry
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class MemoryStore:
|
|
18
|
+
"""Agent memory store backed by SQLite.
|
|
19
|
+
|
|
20
|
+
Provides persistent key-value storage with:
|
|
21
|
+
- Repository and branch awareness
|
|
22
|
+
- Access time tracking (for LRU eviction)
|
|
23
|
+
- Arbitrary value and context storage
|
|
24
|
+
- Pattern-based search
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
def __init__(self, db_path: Path | str | None = None) -> None:
|
|
28
|
+
"""Initialize the memory store.
|
|
29
|
+
|
|
30
|
+
Args:
|
|
31
|
+
db_path: Path to SQLite database. If None, uses config default.
|
|
32
|
+
"""
|
|
33
|
+
if db_path is None:
|
|
34
|
+
config = get_config()
|
|
35
|
+
db_path = config.storage.data_dir / "memory.db"
|
|
36
|
+
|
|
37
|
+
self._db_path = Path(db_path)
|
|
38
|
+
self._db_path.parent.mkdir(parents=True, exist_ok=True)
|
|
39
|
+
self._init_db()
|
|
40
|
+
|
|
41
|
+
def _init_db(self) -> None:
|
|
42
|
+
"""Initialize the database schema."""
|
|
43
|
+
with self._get_connection() as conn:
|
|
44
|
+
conn.execute("""
|
|
45
|
+
CREATE TABLE IF NOT EXISTS memories (
|
|
46
|
+
id TEXT PRIMARY KEY,
|
|
47
|
+
repository TEXT NOT NULL,
|
|
48
|
+
branch TEXT NOT NULL,
|
|
49
|
+
key TEXT NOT NULL,
|
|
50
|
+
value TEXT NOT NULL,
|
|
51
|
+
context TEXT DEFAULT '{}',
|
|
52
|
+
created_at TEXT NOT NULL,
|
|
53
|
+
accessed_at TEXT NOT NULL,
|
|
54
|
+
UNIQUE(repository, branch, key)
|
|
55
|
+
)
|
|
56
|
+
""")
|
|
57
|
+
# Create indices for common query patterns
|
|
58
|
+
conn.execute("""
|
|
59
|
+
CREATE INDEX IF NOT EXISTS idx_memories_repo_branch
|
|
60
|
+
ON memories(repository, branch)
|
|
61
|
+
""")
|
|
62
|
+
conn.execute("""
|
|
63
|
+
CREATE INDEX IF NOT EXISTS idx_memories_accessed
|
|
64
|
+
ON memories(accessed_at DESC)
|
|
65
|
+
""")
|
|
66
|
+
conn.execute("""
|
|
67
|
+
CREATE INDEX IF NOT EXISTS idx_memories_key_pattern
|
|
68
|
+
ON memories(repository, branch, key)
|
|
69
|
+
""")
|
|
70
|
+
conn.commit()
|
|
71
|
+
|
|
72
|
+
@contextmanager
|
|
73
|
+
def _get_connection(self) -> Generator[sqlite3.Connection, None, None]:
|
|
74
|
+
"""Get a database connection with proper cleanup."""
|
|
75
|
+
conn = sqlite3.connect(str(self._db_path))
|
|
76
|
+
conn.row_factory = sqlite3.Row
|
|
77
|
+
try:
|
|
78
|
+
yield conn
|
|
79
|
+
finally:
|
|
80
|
+
conn.close()
|
|
81
|
+
|
|
82
|
+
def set(
|
|
83
|
+
self,
|
|
84
|
+
key: str,
|
|
85
|
+
value: Any,
|
|
86
|
+
repository: str = "default",
|
|
87
|
+
branch: str = "main",
|
|
88
|
+
context: dict[str, Any] | None = None,
|
|
89
|
+
) -> MemoryEntry:
|
|
90
|
+
"""Store a value in memory.
|
|
91
|
+
|
|
92
|
+
Args:
|
|
93
|
+
key: Memory key
|
|
94
|
+
value: Value to store (will be JSON serialized)
|
|
95
|
+
repository: Repository scope
|
|
96
|
+
branch: Branch scope
|
|
97
|
+
context: Optional context metadata
|
|
98
|
+
|
|
99
|
+
Returns:
|
|
100
|
+
The created/updated MemoryEntry
|
|
101
|
+
"""
|
|
102
|
+
now = datetime.now(UTC)
|
|
103
|
+
context = context or {}
|
|
104
|
+
|
|
105
|
+
# Serialize value and context
|
|
106
|
+
value_json = json.dumps(value)
|
|
107
|
+
context_json = json.dumps(context)
|
|
108
|
+
|
|
109
|
+
with self._get_connection() as conn:
|
|
110
|
+
# Try to get existing entry
|
|
111
|
+
cursor = conn.execute(
|
|
112
|
+
"SELECT id FROM memories WHERE repository = ? AND branch = ? AND key = ?",
|
|
113
|
+
(repository, branch, key),
|
|
114
|
+
)
|
|
115
|
+
row = cursor.fetchone()
|
|
116
|
+
|
|
117
|
+
if row:
|
|
118
|
+
# Update existing
|
|
119
|
+
entry_id = row["id"]
|
|
120
|
+
conn.execute(
|
|
121
|
+
"""
|
|
122
|
+
UPDATE memories
|
|
123
|
+
SET value = ?, context = ?, accessed_at = ?
|
|
124
|
+
WHERE id = ?
|
|
125
|
+
""",
|
|
126
|
+
(value_json, context_json, now.isoformat(), entry_id),
|
|
127
|
+
)
|
|
128
|
+
else:
|
|
129
|
+
# Insert new
|
|
130
|
+
from docugraph.core.models import generate_id
|
|
131
|
+
|
|
132
|
+
entry_id = generate_id()
|
|
133
|
+
conn.execute(
|
|
134
|
+
"""
|
|
135
|
+
INSERT INTO memories (id, repository, branch, key, value, context, created_at, accessed_at)
|
|
136
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
|
137
|
+
""",
|
|
138
|
+
(
|
|
139
|
+
entry_id,
|
|
140
|
+
repository,
|
|
141
|
+
branch,
|
|
142
|
+
key,
|
|
143
|
+
value_json,
|
|
144
|
+
context_json,
|
|
145
|
+
now.isoformat(),
|
|
146
|
+
now.isoformat(),
|
|
147
|
+
),
|
|
148
|
+
)
|
|
149
|
+
|
|
150
|
+
conn.commit()
|
|
151
|
+
|
|
152
|
+
return MemoryEntry(
|
|
153
|
+
id=entry_id,
|
|
154
|
+
repository=repository,
|
|
155
|
+
branch=branch,
|
|
156
|
+
key=key,
|
|
157
|
+
value=value,
|
|
158
|
+
context=context,
|
|
159
|
+
created_at=now,
|
|
160
|
+
accessed_at=now,
|
|
161
|
+
)
|
|
162
|
+
|
|
163
|
+
def get(
|
|
164
|
+
self,
|
|
165
|
+
key: str,
|
|
166
|
+
repository: str = "default",
|
|
167
|
+
branch: str = "main",
|
|
168
|
+
default: Any = None,
|
|
169
|
+
) -> Any:
|
|
170
|
+
"""Retrieve a value from memory.
|
|
171
|
+
|
|
172
|
+
Updates the accessed_at timestamp for LRU tracking.
|
|
173
|
+
|
|
174
|
+
Args:
|
|
175
|
+
key: Memory key
|
|
176
|
+
repository: Repository scope
|
|
177
|
+
branch: Branch scope
|
|
178
|
+
default: Default value if not found
|
|
179
|
+
|
|
180
|
+
Returns:
|
|
181
|
+
The stored value or default
|
|
182
|
+
"""
|
|
183
|
+
with self._get_connection() as conn:
|
|
184
|
+
cursor = conn.execute(
|
|
185
|
+
"SELECT id, value FROM memories WHERE repository = ? AND branch = ? AND key = ?",
|
|
186
|
+
(repository, branch, key),
|
|
187
|
+
)
|
|
188
|
+
row = cursor.fetchone()
|
|
189
|
+
|
|
190
|
+
if row is None:
|
|
191
|
+
return default
|
|
192
|
+
|
|
193
|
+
# Update accessed_at
|
|
194
|
+
now = datetime.now(UTC)
|
|
195
|
+
conn.execute(
|
|
196
|
+
"UPDATE memories SET accessed_at = ? WHERE id = ?",
|
|
197
|
+
(now.isoformat(), row["id"]),
|
|
198
|
+
)
|
|
199
|
+
conn.commit()
|
|
200
|
+
|
|
201
|
+
return json.loads(row["value"])
|
|
202
|
+
|
|
203
|
+
def get_entry(
|
|
204
|
+
self,
|
|
205
|
+
key: str,
|
|
206
|
+
repository: str = "default",
|
|
207
|
+
branch: str = "main",
|
|
208
|
+
) -> MemoryEntry | None:
|
|
209
|
+
"""Retrieve a full MemoryEntry from memory.
|
|
210
|
+
|
|
211
|
+
Args:
|
|
212
|
+
key: Memory key
|
|
213
|
+
repository: Repository scope
|
|
214
|
+
branch: Branch scope
|
|
215
|
+
|
|
216
|
+
Returns:
|
|
217
|
+
MemoryEntry or None if not found
|
|
218
|
+
"""
|
|
219
|
+
with self._get_connection() as conn:
|
|
220
|
+
cursor = conn.execute(
|
|
221
|
+
"SELECT * FROM memories WHERE repository = ? AND branch = ? AND key = ?",
|
|
222
|
+
(repository, branch, key),
|
|
223
|
+
)
|
|
224
|
+
row = cursor.fetchone()
|
|
225
|
+
|
|
226
|
+
if row is None:
|
|
227
|
+
return None
|
|
228
|
+
|
|
229
|
+
# Update accessed_at
|
|
230
|
+
now = datetime.now(UTC)
|
|
231
|
+
conn.execute(
|
|
232
|
+
"UPDATE memories SET accessed_at = ? WHERE id = ?",
|
|
233
|
+
(now.isoformat(), row["id"]),
|
|
234
|
+
)
|
|
235
|
+
conn.commit()
|
|
236
|
+
|
|
237
|
+
return self._row_to_entry(row)
|
|
238
|
+
|
|
239
|
+
def _row_to_entry(self, row: sqlite3.Row) -> MemoryEntry:
|
|
240
|
+
"""Convert a database row to a MemoryEntry."""
|
|
241
|
+
return MemoryEntry(
|
|
242
|
+
id=row["id"],
|
|
243
|
+
repository=row["repository"],
|
|
244
|
+
branch=row["branch"],
|
|
245
|
+
key=row["key"],
|
|
246
|
+
value=json.loads(row["value"]),
|
|
247
|
+
context=json.loads(row["context"]) if row["context"] else {},
|
|
248
|
+
created_at=datetime.fromisoformat(row["created_at"]),
|
|
249
|
+
accessed_at=datetime.fromisoformat(row["accessed_at"]),
|
|
250
|
+
)
|
|
251
|
+
|
|
252
|
+
def delete(
|
|
253
|
+
self,
|
|
254
|
+
key: str,
|
|
255
|
+
repository: str = "default",
|
|
256
|
+
branch: str = "main",
|
|
257
|
+
) -> bool:
|
|
258
|
+
"""Delete a memory entry.
|
|
259
|
+
|
|
260
|
+
Args:
|
|
261
|
+
key: Memory key
|
|
262
|
+
repository: Repository scope
|
|
263
|
+
branch: Branch scope
|
|
264
|
+
|
|
265
|
+
Returns:
|
|
266
|
+
True if entry was deleted, False if not found
|
|
267
|
+
"""
|
|
268
|
+
with self._get_connection() as conn:
|
|
269
|
+
cursor = conn.execute(
|
|
270
|
+
"DELETE FROM memories WHERE repository = ? AND branch = ? AND key = ?",
|
|
271
|
+
(repository, branch, key),
|
|
272
|
+
)
|
|
273
|
+
conn.commit()
|
|
274
|
+
return cursor.rowcount > 0
|
|
275
|
+
|
|
276
|
+
def search(
|
|
277
|
+
self,
|
|
278
|
+
pattern: str,
|
|
279
|
+
repository: str = "default",
|
|
280
|
+
branch: str = "main",
|
|
281
|
+
limit: int = 100,
|
|
282
|
+
) -> list[MemoryEntry]:
|
|
283
|
+
"""Search memories by key pattern.
|
|
284
|
+
|
|
285
|
+
Args:
|
|
286
|
+
pattern: SQL LIKE pattern (use % for wildcard)
|
|
287
|
+
repository: Repository scope
|
|
288
|
+
branch: Branch scope
|
|
289
|
+
limit: Maximum results
|
|
290
|
+
|
|
291
|
+
Returns:
|
|
292
|
+
List of matching MemoryEntry objects
|
|
293
|
+
"""
|
|
294
|
+
with self._get_connection() as conn:
|
|
295
|
+
cursor = conn.execute(
|
|
296
|
+
"""
|
|
297
|
+
SELECT * FROM memories
|
|
298
|
+
WHERE repository = ? AND branch = ? AND key LIKE ?
|
|
299
|
+
ORDER BY accessed_at DESC
|
|
300
|
+
LIMIT ?
|
|
301
|
+
""",
|
|
302
|
+
(repository, branch, pattern, limit),
|
|
303
|
+
)
|
|
304
|
+
return [self._row_to_entry(row) for row in cursor.fetchall()]
|
|
305
|
+
|
|
306
|
+
def list_keys(
|
|
307
|
+
self,
|
|
308
|
+
repository: str = "default",
|
|
309
|
+
branch: str = "main",
|
|
310
|
+
limit: int = 100,
|
|
311
|
+
) -> list[str]:
|
|
312
|
+
"""List all keys for a repository/branch.
|
|
313
|
+
|
|
314
|
+
Args:
|
|
315
|
+
repository: Repository scope
|
|
316
|
+
branch: Branch scope
|
|
317
|
+
limit: Maximum results
|
|
318
|
+
|
|
319
|
+
Returns:
|
|
320
|
+
List of keys
|
|
321
|
+
"""
|
|
322
|
+
with self._get_connection() as conn:
|
|
323
|
+
cursor = conn.execute(
|
|
324
|
+
"""
|
|
325
|
+
SELECT key FROM memories
|
|
326
|
+
WHERE repository = ? AND branch = ?
|
|
327
|
+
ORDER BY accessed_at DESC
|
|
328
|
+
LIMIT ?
|
|
329
|
+
""",
|
|
330
|
+
(repository, branch, limit),
|
|
331
|
+
)
|
|
332
|
+
return [row["key"] for row in cursor.fetchall()]
|
|
333
|
+
|
|
334
|
+
def get_recent(
|
|
335
|
+
self,
|
|
336
|
+
repository: str = "default",
|
|
337
|
+
branch: str = "main",
|
|
338
|
+
limit: int = 10,
|
|
339
|
+
) -> list[MemoryEntry]:
|
|
340
|
+
"""Get most recently accessed memories.
|
|
341
|
+
|
|
342
|
+
Args:
|
|
343
|
+
repository: Repository scope
|
|
344
|
+
branch: Branch scope
|
|
345
|
+
limit: Maximum results
|
|
346
|
+
|
|
347
|
+
Returns:
|
|
348
|
+
List of MemoryEntry objects ordered by access time
|
|
349
|
+
"""
|
|
350
|
+
with self._get_connection() as conn:
|
|
351
|
+
cursor = conn.execute(
|
|
352
|
+
"""
|
|
353
|
+
SELECT * FROM memories
|
|
354
|
+
WHERE repository = ? AND branch = ?
|
|
355
|
+
ORDER BY accessed_at DESC
|
|
356
|
+
LIMIT ?
|
|
357
|
+
""",
|
|
358
|
+
(repository, branch, limit),
|
|
359
|
+
)
|
|
360
|
+
return [self._row_to_entry(row) for row in cursor.fetchall()]
|
|
361
|
+
|
|
362
|
+
def get_all(
|
|
363
|
+
self,
|
|
364
|
+
repository: str = "default",
|
|
365
|
+
branch: str = "main",
|
|
366
|
+
) -> dict[str, Any]:
|
|
367
|
+
"""Get all memories as a key-value dict.
|
|
368
|
+
|
|
369
|
+
Args:
|
|
370
|
+
repository: Repository scope
|
|
371
|
+
branch: Branch scope
|
|
372
|
+
|
|
373
|
+
Returns:
|
|
374
|
+
Dict mapping keys to values
|
|
375
|
+
"""
|
|
376
|
+
with self._get_connection() as conn:
|
|
377
|
+
cursor = conn.execute(
|
|
378
|
+
"SELECT key, value FROM memories WHERE repository = ? AND branch = ?",
|
|
379
|
+
(repository, branch),
|
|
380
|
+
)
|
|
381
|
+
return {row["key"]: json.loads(row["value"]) for row in cursor.fetchall()}
|
|
382
|
+
|
|
383
|
+
def clear(
|
|
384
|
+
self,
|
|
385
|
+
repository: str | None = None,
|
|
386
|
+
branch: str | None = None,
|
|
387
|
+
) -> int:
|
|
388
|
+
"""Clear memories.
|
|
389
|
+
|
|
390
|
+
Args:
|
|
391
|
+
repository: If provided, only clear this repository
|
|
392
|
+
branch: If provided with repository, only clear this branch
|
|
393
|
+
|
|
394
|
+
Returns:
|
|
395
|
+
Number of entries deleted
|
|
396
|
+
"""
|
|
397
|
+
with self._get_connection() as conn:
|
|
398
|
+
if repository is None:
|
|
399
|
+
# Clear all
|
|
400
|
+
cursor = conn.execute("DELETE FROM memories")
|
|
401
|
+
elif branch is None:
|
|
402
|
+
# Clear repository
|
|
403
|
+
cursor = conn.execute(
|
|
404
|
+
"DELETE FROM memories WHERE repository = ?",
|
|
405
|
+
(repository,),
|
|
406
|
+
)
|
|
407
|
+
else:
|
|
408
|
+
# Clear specific branch
|
|
409
|
+
cursor = conn.execute(
|
|
410
|
+
"DELETE FROM memories WHERE repository = ? AND branch = ?",
|
|
411
|
+
(repository, branch),
|
|
412
|
+
)
|
|
413
|
+
conn.commit()
|
|
414
|
+
return cursor.rowcount
|
|
415
|
+
|
|
416
|
+
def count(
|
|
417
|
+
self,
|
|
418
|
+
repository: str | None = None,
|
|
419
|
+
branch: str | None = None,
|
|
420
|
+
) -> int:
|
|
421
|
+
"""Count memory entries.
|
|
422
|
+
|
|
423
|
+
Args:
|
|
424
|
+
repository: If provided, only count this repository
|
|
425
|
+
branch: If provided with repository, only count this branch
|
|
426
|
+
|
|
427
|
+
Returns:
|
|
428
|
+
Number of entries
|
|
429
|
+
"""
|
|
430
|
+
with self._get_connection() as conn:
|
|
431
|
+
if repository is None:
|
|
432
|
+
cursor = conn.execute("SELECT COUNT(*) as cnt FROM memories")
|
|
433
|
+
elif branch is None:
|
|
434
|
+
cursor = conn.execute(
|
|
435
|
+
"SELECT COUNT(*) as cnt FROM memories WHERE repository = ?",
|
|
436
|
+
(repository,),
|
|
437
|
+
)
|
|
438
|
+
else:
|
|
439
|
+
cursor = conn.execute(
|
|
440
|
+
"SELECT COUNT(*) as cnt FROM memories WHERE repository = ? AND branch = ?",
|
|
441
|
+
(repository, branch),
|
|
442
|
+
)
|
|
443
|
+
count: int = int(cursor.fetchone()["cnt"])
|
|
444
|
+
return count
|
|
445
|
+
|
|
446
|
+
def list_repositories(self) -> list[str]:
|
|
447
|
+
"""List all repositories with stored memories.
|
|
448
|
+
|
|
449
|
+
Returns:
|
|
450
|
+
List of repository names
|
|
451
|
+
"""
|
|
452
|
+
with self._get_connection() as conn:
|
|
453
|
+
cursor = conn.execute("SELECT DISTINCT repository FROM memories ORDER BY repository")
|
|
454
|
+
return [row["repository"] for row in cursor.fetchall()]
|
|
455
|
+
|
|
456
|
+
def list_branches(self, repository: str) -> list[str]:
|
|
457
|
+
"""List all branches for a repository.
|
|
458
|
+
|
|
459
|
+
Args:
|
|
460
|
+
repository: Repository name
|
|
461
|
+
|
|
462
|
+
Returns:
|
|
463
|
+
List of branch names
|
|
464
|
+
"""
|
|
465
|
+
with self._get_connection() as conn:
|
|
466
|
+
cursor = conn.execute(
|
|
467
|
+
"SELECT DISTINCT branch FROM memories WHERE repository = ? ORDER BY branch",
|
|
468
|
+
(repository,),
|
|
469
|
+
)
|
|
470
|
+
return [row["branch"] for row in cursor.fetchall()]
|
|
471
|
+
|
|
472
|
+
def evict_lru(
|
|
473
|
+
self,
|
|
474
|
+
repository: str = "default",
|
|
475
|
+
branch: str = "main",
|
|
476
|
+
keep_count: int = 100,
|
|
477
|
+
) -> int:
|
|
478
|
+
"""Evict least recently used entries.
|
|
479
|
+
|
|
480
|
+
Keeps the most recently accessed entries up to keep_count.
|
|
481
|
+
|
|
482
|
+
Args:
|
|
483
|
+
repository: Repository scope
|
|
484
|
+
branch: Branch scope
|
|
485
|
+
keep_count: Number of entries to keep
|
|
486
|
+
|
|
487
|
+
Returns:
|
|
488
|
+
Number of entries evicted
|
|
489
|
+
"""
|
|
490
|
+
with self._get_connection() as conn:
|
|
491
|
+
# Get IDs to keep
|
|
492
|
+
cursor = conn.execute(
|
|
493
|
+
"""
|
|
494
|
+
SELECT id FROM memories
|
|
495
|
+
WHERE repository = ? AND branch = ?
|
|
496
|
+
ORDER BY accessed_at DESC
|
|
497
|
+
LIMIT ?
|
|
498
|
+
""",
|
|
499
|
+
(repository, branch, keep_count),
|
|
500
|
+
)
|
|
501
|
+
keep_ids = [row["id"] for row in cursor.fetchall()]
|
|
502
|
+
|
|
503
|
+
if not keep_ids:
|
|
504
|
+
return 0
|
|
505
|
+
|
|
506
|
+
# Delete everything else (placeholders is a generated "?," list;
|
|
507
|
+
# all values are parameterized — not user-interpolated SQL)
|
|
508
|
+
placeholders = ",".join("?" * len(keep_ids))
|
|
509
|
+
cursor = conn.execute(
|
|
510
|
+
f"""
|
|
511
|
+
DELETE FROM memories
|
|
512
|
+
WHERE repository = ? AND branch = ? AND id NOT IN ({placeholders})
|
|
513
|
+
""", # nosec B608
|
|
514
|
+
(repository, branch, *keep_ids),
|
|
515
|
+
)
|
|
516
|
+
conn.commit()
|
|
517
|
+
return cursor.rowcount
|
|
518
|
+
|
|
519
|
+
|
|
520
|
+
def get_memory_store(db_path: Path | str | None = None) -> MemoryStore:
|
|
521
|
+
"""Factory function to get a MemoryStore instance.
|
|
522
|
+
|
|
523
|
+
Args:
|
|
524
|
+
db_path: Optional database path override
|
|
525
|
+
|
|
526
|
+
Returns:
|
|
527
|
+
MemoryStore instance
|
|
528
|
+
"""
|
|
529
|
+
return MemoryStore(db_path=db_path)
|