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.
Files changed (37) hide show
  1. docugraph/__init__.py +24 -0
  2. docugraph/_version.py +28 -0
  3. docugraph/agents/__init__.py +27 -0
  4. docugraph/agents/coding_agent.py +532 -0
  5. docugraph/agents/tools/__init__.py +62 -0
  6. docugraph/agents/tools/graph_query.py +203 -0
  7. docugraph/agents/tools/memory_ops.py +305 -0
  8. docugraph/agents/tools/search_code.py +305 -0
  9. docugraph/agents/tools/search_docs.py +148 -0
  10. docugraph/core/__init__.py +34 -0
  11. docugraph/core/config.py +183 -0
  12. docugraph/core/embeddings.py +394 -0
  13. docugraph/core/llm.py +892 -0
  14. docugraph/core/models.py +145 -0
  15. docugraph/ingestion/__init__.py +64 -0
  16. docugraph/ingestion/api_docs.py +646 -0
  17. docugraph/ingestion/chunker.py +382 -0
  18. docugraph/ingestion/crawler.py +305 -0
  19. docugraph/ingestion/git_indexer.py +556 -0
  20. docugraph/ingestion/local_files.py +516 -0
  21. docugraph/interfaces/__init__.py +1 -0
  22. docugraph/interfaces/cli.py +983 -0
  23. docugraph/interfaces/mcp_server.py +918 -0
  24. docugraph/retrieval/__init__.py +54 -0
  25. docugraph/retrieval/context_builder.py +522 -0
  26. docugraph/retrieval/hybrid_search.py +534 -0
  27. docugraph/retrieval/reranker.py +532 -0
  28. docugraph/storage/__init__.py +14 -0
  29. docugraph/storage/graph_store.py +602 -0
  30. docugraph/storage/memory_store.py +529 -0
  31. docugraph/storage/vector_store.py +367 -0
  32. docugraph-0.1.0.dist-info/METADATA +354 -0
  33. docugraph-0.1.0.dist-info/RECORD +37 -0
  34. docugraph-0.1.0.dist-info/WHEEL +4 -0
  35. docugraph-0.1.0.dist-info/entry_points.txt +3 -0
  36. docugraph-0.1.0.dist-info/licenses/LICENSE +201 -0
  37. 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)