synap-git 0.2.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (43) hide show
  1. synap_git/__init__.py +5 -0
  2. synap_git/api/__init__.py +5 -0
  3. synap_git/api/app.py +111 -0
  4. synap_git/api/static/index.html +615 -0
  5. synap_git/cli/__init__.py +3 -0
  6. synap_git/cli/__main__.py +4 -0
  7. synap_git/cli/main.py +1174 -0
  8. synap_git/config.py +169 -0
  9. synap_git/diagnostics/__init__.py +0 -0
  10. synap_git/diagnostics/logger.py +82 -0
  11. synap_git/diagnostics/tracing.py +71 -0
  12. synap_git/embeddings/__init__.py +0 -0
  13. synap_git/git/__init__.py +11 -0
  14. synap_git/git/state.py +139 -0
  15. synap_git/indexer/__init__.py +0 -0
  16. synap_git/indexer/daemon.py +143 -0
  17. synap_git/indexer/engine.py +327 -0
  18. synap_git/indexer/scanner.py +463 -0
  19. synap_git/indexer/wiki.py +78 -0
  20. synap_git/mcp/__init__.py +5 -0
  21. synap_git/mcp/server.py +232 -0
  22. synap_git/parser/__init__.py +0 -0
  23. synap_git/parser/registry.py +209 -0
  24. synap_git/provider/anthropic.py +86 -0
  25. synap_git/provider/base.py +44 -0
  26. synap_git/provider/factory.py +101 -0
  27. synap_git/provider/gemini.py +98 -0
  28. synap_git/provider/mock.py +66 -0
  29. synap_git/provider/ollama.py +98 -0
  30. synap_git/provider/openai.py +90 -0
  31. synap_git/py.typed +1 -0
  32. synap_git/retrieval/__init__.py +0 -0
  33. synap_git/retrieval/engine.py +258 -0
  34. synap_git/retrieval/memory.py +123 -0
  35. synap_git/storage/__init__.py +3 -0
  36. synap_git/storage/sqlite.py +575 -0
  37. synap_git/utils/__init__.py +0 -0
  38. synap_git/utils/serialization.py +68 -0
  39. synap_git-0.2.0.dist-info/METADATA +251 -0
  40. synap_git-0.2.0.dist-info/RECORD +43 -0
  41. synap_git-0.2.0.dist-info/WHEEL +4 -0
  42. synap_git-0.2.0.dist-info/entry_points.txt +2 -0
  43. synap_git-0.2.0.dist-info/licenses/LICENSE.md +201 -0
@@ -0,0 +1,123 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ from dataclasses import dataclass
5
+ from typing import Any
6
+
7
+ from synap_git.config import SynapSettings
8
+ from synap_git.git.state import GitState
9
+ from synap_git.storage.sqlite import SynapStore
10
+
11
+
12
+ @dataclass
13
+ class DirtyWarning:
14
+ files: list[str]
15
+ commit: str
16
+ message: str
17
+
18
+
19
+ @dataclass
20
+ class InjectionContext:
21
+ project_overview: str
22
+ current_branch: str
23
+ current_commit: str
24
+ dirty_warning: DirtyWarning | None
25
+ recent_commits: list[dict[str, Any]]
26
+ recent_decisions: list[dict[str, Any]]
27
+ active_checkpoint: dict[str, Any] | None
28
+ approved_lessons: list[dict[str, Any]]
29
+ pending_lessons: list[dict[str, Any]]
30
+ architecture_summary: str
31
+
32
+ def format_header(self) -> str:
33
+ parts = []
34
+ parts.append("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━")
35
+ parts.append(
36
+ f"SYNAPSE CONTEXT — {self.current_branch} ({self.current_commit[:8] if self.current_commit else 'none'})"
37
+ )
38
+ parts.append("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━")
39
+
40
+ if self.project_overview:
41
+ parts.append(self.project_overview)
42
+ else:
43
+ parts.append("No project overview available.")
44
+
45
+ parts.append("")
46
+
47
+ if self.active_checkpoint:
48
+ parts.append("ACTIVE CHECKPOINT:")
49
+ parts.append(f" Working on: {self.active_checkpoint.get('doing', '')}")
50
+
51
+ try:
52
+ changed = json.loads(self.active_checkpoint.get("changed_files", "[]"))
53
+ parts.append(f" Changed files: {', '.join(changed)}")
54
+ except json.JSONDecodeError:
55
+ parts.append(f" Changed files: {self.active_checkpoint.get('changed_files', '')}")
56
+
57
+ parts.append(f" Next step: {self.active_checkpoint.get('next_step', '')}")
58
+ parts.append("")
59
+
60
+ if self.approved_lessons:
61
+ parts.append("APPROVED LESSONS (apply always):")
62
+ for i, lesson in enumerate(self.approved_lessons, 1):
63
+ parts.append(
64
+ f" [{i}] {lesson.get('what_failed', '')} -> {lesson.get('why_failed', '')}"
65
+ )
66
+ parts.append("")
67
+
68
+ if self.pending_lessons:
69
+ parts.append("PENDING LESSONS (unverified — use with caution):")
70
+ for i, lesson in enumerate(self.pending_lessons, 1):
71
+ parts.append(f" [{i}] ⚠ {lesson.get('what_failed', '')} — awaiting user review")
72
+ parts.append("")
73
+
74
+ if self.recent_decisions:
75
+ parts.append("RECENT DECISIONS:")
76
+ for dec in self.recent_decisions:
77
+ parts.append(f" [{dec.get('created_at', '')}] {dec.get('content', '')}")
78
+ parts.append("")
79
+
80
+ if self.dirty_warning:
81
+ parts.append(
82
+ f"⚠ DIRTY TREE: {len(self.dirty_warning.files)} files modified since {self.dirty_warning.commit}"
83
+ )
84
+
85
+ parts.append("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━")
86
+ return "\n".join(parts)
87
+
88
+
89
+ def check_dirty_tree(git_state: GitState) -> DirtyWarning | None:
90
+ if git_state.is_dirty:
91
+ # We'd get actual files from GitState ideally. Let's return empty for now.
92
+ return DirtyWarning(
93
+ files=[],
94
+ commit=git_state.head_commit[:8] if git_state.head_commit else "unknown",
95
+ message="⚠ Modified files not yet committed. Agent context may not match reported commit.",
96
+ )
97
+ return None
98
+
99
+
100
+ def read_wiki(settings: SynapSettings, filename: str) -> str:
101
+ path = settings.state_path / "wiki" / filename
102
+ if path.exists():
103
+ return path.read_text(encoding="utf-8")
104
+ return ""
105
+
106
+
107
+ def build_injection_context(
108
+ settings: SynapSettings, store: SynapStore, git_state: GitState
109
+ ) -> InjectionContext:
110
+ branch = git_state.effective_branch
111
+
112
+ return InjectionContext(
113
+ project_overview=read_wiki(settings, "overview.md"),
114
+ current_branch=branch,
115
+ current_commit=git_state.head_commit or "unknown",
116
+ dirty_warning=check_dirty_tree(git_state),
117
+ recent_commits=[],
118
+ recent_decisions=store.get_decisions(branch, limit=10),
119
+ active_checkpoint=store.get_latest_checkpoint(branch),
120
+ approved_lessons=store.get_lessons(status="approved"),
121
+ pending_lessons=store.get_lessons(status="pending"),
122
+ architecture_summary=read_wiki(settings, "architecture.md"),
123
+ )
@@ -0,0 +1,3 @@
1
+ from synap_git.storage.sqlite import SynapStore
2
+
3
+ __all__ = ["SynapStore"]
@@ -0,0 +1,575 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import sqlite3
5
+ from collections.abc import Iterator
6
+ from contextlib import contextmanager
7
+ from datetime import UTC, datetime
8
+ from enum import Enum
9
+ from pathlib import Path
10
+ from typing import Any
11
+
12
+ from synap_git.utils.serialization import stable_hash
13
+
14
+
15
+ class LessonStatus(str, Enum):
16
+ PENDING = "pending"
17
+ APPROVED = "approved"
18
+ REJECTED = "rejected"
19
+ EXPIRED = "expired"
20
+ SUPERSEDED = "superseded"
21
+ FAILED = "failed"
22
+
23
+
24
+ class SynapStore:
25
+ """Deterministic SQLite store for repository symbols and structural relationships."""
26
+
27
+ def __init__(self, path: Path) -> None:
28
+ self.path = path
29
+
30
+ def initialize(self) -> None:
31
+ self.path.parent.mkdir(parents=True, exist_ok=True)
32
+ with self.connect() as conn:
33
+ conn.execute("PRAGMA journal_mode=WAL")
34
+ conn.execute("PRAGMA synchronous=NORMAL")
35
+ conn.execute("PRAGMA foreign_keys=ON")
36
+
37
+ conn.executescript("""
38
+ CREATE TABLE IF NOT EXISTS files (
39
+ file_id TEXT PRIMARY KEY,
40
+ path TEXT UNIQUE,
41
+ git_oid TEXT,
42
+ content_hash TEXT,
43
+ language TEXT,
44
+ updated_at TEXT NOT NULL
45
+ );
46
+
47
+ CREATE TABLE IF NOT EXISTS symbols (
48
+ symbol_id TEXT PRIMARY KEY,
49
+ file_id TEXT,
50
+ name TEXT,
51
+ kind TEXT,
52
+ start_line INTEGER,
53
+ end_line INTEGER,
54
+ ast_hash TEXT,
55
+ metadata_json TEXT,
56
+ FOREIGN KEY(file_id) REFERENCES files(file_id) ON DELETE CASCADE
57
+ );
58
+
59
+ CREATE TABLE IF NOT EXISTS edges (
60
+ edge_id TEXT PRIMARY KEY,
61
+ source_symbol TEXT,
62
+ target_symbol TEXT,
63
+ edge_type TEXT,
64
+ FOREIGN KEY(source_symbol) REFERENCES symbols(symbol_id) ON DELETE CASCADE,
65
+ FOREIGN KEY(target_symbol) REFERENCES symbols(symbol_id) ON DELETE CASCADE
66
+ );
67
+
68
+ CREATE TABLE IF NOT EXISTS embeddings (
69
+ embedding_id TEXT PRIMARY KEY,
70
+ symbol_id TEXT,
71
+ model_name TEXT,
72
+ model_version TEXT,
73
+ prompt_version TEXT,
74
+ vector BLOB,
75
+ content_hash TEXT,
76
+ FOREIGN KEY(symbol_id) REFERENCES symbols(symbol_id) ON DELETE CASCADE
77
+ );
78
+
79
+ CREATE TABLE IF NOT EXISTS active_state (
80
+ branch TEXT PRIMARY KEY,
81
+ git_commit_hash TEXT,
82
+ updated_at TEXT NOT NULL
83
+ );
84
+
85
+ CREATE TABLE IF NOT EXISTS decisions (
86
+ decision_id TEXT PRIMARY KEY,
87
+ branch TEXT NOT NULL,
88
+ commit_hash TEXT NOT NULL,
89
+ content TEXT NOT NULL,
90
+ context TEXT,
91
+ agent_id TEXT,
92
+ created_at INTEGER NOT NULL
93
+ );
94
+
95
+ CREATE TABLE IF NOT EXISTS checkpoints (
96
+ checkpoint_id TEXT PRIMARY KEY,
97
+ branch TEXT NOT NULL,
98
+ commit_hash TEXT NOT NULL,
99
+ doing TEXT NOT NULL,
100
+ changed_files TEXT NOT NULL,
101
+ next_step TEXT,
102
+ decisions TEXT,
103
+ blockers TEXT,
104
+ token_count INTEGER,
105
+ created_at INTEGER NOT NULL
106
+ );
107
+
108
+ CREATE TABLE IF NOT EXISTS lessons (
109
+ lesson_id TEXT PRIMARY KEY,
110
+ branch TEXT NOT NULL,
111
+ revert_commit TEXT NOT NULL,
112
+ reverted_from TEXT NOT NULL,
113
+ what_failed TEXT NOT NULL,
114
+ why_failed TEXT NOT NULL,
115
+ files_affected TEXT NOT NULL,
116
+ status TEXT NOT NULL DEFAULT 'pending',
117
+ created_at INTEGER NOT NULL,
118
+ approved_at INTEGER,
119
+ expires_at INTEGER NOT NULL,
120
+ approval_actor TEXT
121
+ );
122
+
123
+ CREATE TABLE IF NOT EXISTS activity (
124
+ activity_id TEXT PRIMARY KEY,
125
+ branch TEXT NOT NULL,
126
+ commit_hash TEXT NOT NULL,
127
+ action TEXT NOT NULL,
128
+ files TEXT,
129
+ created_at INTEGER NOT NULL
130
+ );
131
+
132
+ CREATE TABLE IF NOT EXISTS llm_calls (
133
+ call_id TEXT PRIMARY KEY,
134
+ provider TEXT NOT NULL,
135
+ model TEXT NOT NULL,
136
+ input_tokens INTEGER NOT NULL,
137
+ output_tokens INTEGER NOT NULL,
138
+ cost_usd REAL NOT NULL,
139
+ purpose TEXT NOT NULL,
140
+ file_path TEXT,
141
+ created_at INTEGER NOT NULL
142
+ );
143
+
144
+ CREATE INDEX IF NOT EXISTS idx_symbols_file ON symbols(file_id);
145
+ CREATE INDEX IF NOT EXISTS idx_symbols_name ON symbols(name);
146
+ CREATE INDEX IF NOT EXISTS idx_edges_source ON edges(source_symbol);
147
+ CREATE INDEX IF NOT EXISTS idx_edges_target ON edges(target_symbol);
148
+ CREATE INDEX IF NOT EXISTS idx_embeddings_symbol ON embeddings(symbol_id);
149
+ CREATE INDEX IF NOT EXISTS idx_lessons_status ON lessons(status);
150
+ CREATE INDEX IF NOT EXISTS idx_checkpoints_branch ON checkpoints(branch);
151
+ CREATE INDEX IF NOT EXISTS idx_activity_branch ON activity(branch);
152
+ """)
153
+
154
+ @contextmanager
155
+ def connect(self) -> Iterator[sqlite3.Connection]:
156
+ conn = sqlite3.connect(self.path, timeout=30.0)
157
+ conn.row_factory = sqlite3.Row
158
+ try:
159
+ conn.execute("PRAGMA foreign_keys=ON")
160
+ yield conn
161
+ conn.commit()
162
+ except Exception:
163
+ conn.rollback()
164
+ raise
165
+ finally:
166
+ conn.close()
167
+
168
+ def upsert_file_and_symbols(
169
+ self,
170
+ file_id: str,
171
+ path: str,
172
+ git_oid: str,
173
+ content_hash: str,
174
+ language: str,
175
+ symbols: list[dict[str, Any]],
176
+ ) -> str:
177
+ now = datetime.now(UTC).isoformat()
178
+ with self.connect() as conn:
179
+ conn.execute(
180
+ """
181
+ INSERT INTO files (file_id, path, git_oid, content_hash, language, updated_at)
182
+ VALUES (?, ?, ?, ?, ?, ?)
183
+ ON CONFLICT(path) DO UPDATE SET
184
+ file_id = excluded.file_id,
185
+ git_oid = excluded.git_oid,
186
+ content_hash = excluded.content_hash,
187
+ language = excluded.language,
188
+ updated_at = excluded.updated_at
189
+ """,
190
+ (file_id, path, git_oid, content_hash, language, now),
191
+ )
192
+ conn.execute("DELETE FROM symbols WHERE file_id = ?", (file_id,))
193
+ for sym in symbols:
194
+ conn.execute(
195
+ """
196
+ INSERT INTO symbols (symbol_id, file_id, name, kind, start_line, end_line, ast_hash, metadata_json)
197
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)
198
+ """,
199
+ (
200
+ sym["symbol_id"],
201
+ file_id,
202
+ sym["name"],
203
+ sym["kind"],
204
+ sym["start_line"],
205
+ sym["end_line"],
206
+ sym["ast_hash"],
207
+ json.dumps(sym.get("metadata") or {}),
208
+ ),
209
+ )
210
+ return file_id
211
+
212
+ def get_file_by_path(self, path: str) -> dict[str, Any] | None:
213
+ with self.connect() as conn:
214
+ row = conn.execute("SELECT * FROM files WHERE path = ?", (path,)).fetchone()
215
+ return dict(row) if row else None
216
+
217
+ def set_active_commit(self, branch: str, commit_hash: str) -> None:
218
+ now = datetime.now(UTC).isoformat()
219
+ with self.connect() as conn:
220
+ conn.execute(
221
+ """
222
+ INSERT INTO active_state (branch, git_commit_hash, updated_at)
223
+ VALUES (?, ?, ?)
224
+ ON CONFLICT(branch) DO UPDATE SET
225
+ git_commit_hash = excluded.git_commit_hash,
226
+ updated_at = excluded.updated_at
227
+ """,
228
+ (branch, commit_hash, now),
229
+ )
230
+
231
+ def get_active_commit(self, branch: str) -> str | None:
232
+ with self.connect() as conn:
233
+ row = conn.execute(
234
+ "SELECT git_commit_hash FROM active_state WHERE branch = ?", (branch,)
235
+ ).fetchone()
236
+ return row["git_commit_hash"] if row else None
237
+
238
+ def get_symbols_by_file(self, file_id: str) -> list[dict[str, Any]]:
239
+ with self.connect() as conn:
240
+ rows = conn.execute(
241
+ """
242
+ SELECT s.*, f.path as source_path
243
+ FROM symbols s
244
+ JOIN files f ON s.file_id = f.file_id
245
+ WHERE s.file_id = ?
246
+ """,
247
+ (file_id,),
248
+ ).fetchall()
249
+ return [dict(row) for row in rows]
250
+
251
+ def get_symbols_by_name(self, name: str) -> list[dict[str, Any]]:
252
+ with self.connect() as conn:
253
+ rows = conn.execute(
254
+ """
255
+ SELECT s.*, f.path as source_path
256
+ FROM symbols s
257
+ JOIN files f ON s.file_id = f.file_id
258
+ WHERE s.name LIKE ?
259
+ """,
260
+ (f"%{name}%",),
261
+ ).fetchall()
262
+ return [dict(row) for row in rows]
263
+
264
+ def get_neighborhood(self, symbol_id: str, depth: int = 2) -> list[dict[str, Any]]:
265
+ """Use Recursive CTE for SQL-side graph traversal."""
266
+ with self.connect() as conn:
267
+ rows = conn.execute(
268
+ """
269
+ WITH RECURSIVE neighborhood(id, d) AS (
270
+ SELECT ? as id, 0 as d
271
+ UNION
272
+ SELECT target_symbol, d + 1
273
+ FROM edges JOIN neighborhood ON source_symbol = id
274
+ WHERE d < ?
275
+ UNION
276
+ SELECT source_symbol, d + 1
277
+ FROM edges JOIN neighborhood ON target_symbol = id
278
+ WHERE d < ?
279
+ )
280
+ SELECT s.*, f.path as source_path, n.d as distance
281
+ FROM symbols s
282
+ JOIN files f ON s.file_id = f.file_id
283
+ JOIN neighborhood n ON s.symbol_id = n.id
284
+ ORDER BY n.d ASC
285
+ """,
286
+ (symbol_id, depth, depth),
287
+ ).fetchall()
288
+ return [dict(row) for row in rows]
289
+
290
+ def put_embedding(
291
+ self,
292
+ symbol_id: str,
293
+ model_name: str,
294
+ model_version: str,
295
+ prompt_version: str,
296
+ vector: list[float],
297
+ content_hash: str,
298
+ ) -> None:
299
+ embedding_id = stable_hash(
300
+ {
301
+ "symbol_id": symbol_id,
302
+ "model": model_name,
303
+ "version": model_version,
304
+ "prompt": prompt_version,
305
+ }
306
+ )
307
+ with self.connect() as conn:
308
+ conn.execute(
309
+ """
310
+ INSERT INTO embeddings (embedding_id, symbol_id, model_name, model_version, prompt_version, vector, content_hash)
311
+ VALUES (?, ?, ?, ?, ?, ?, ?)
312
+ ON CONFLICT(embedding_id) DO UPDATE SET
313
+ vector = excluded.vector,
314
+ content_hash = excluded.content_hash
315
+ """,
316
+ (
317
+ embedding_id,
318
+ symbol_id,
319
+ model_name,
320
+ model_version,
321
+ prompt_version,
322
+ json.dumps(vector),
323
+ content_hash,
324
+ ),
325
+ )
326
+
327
+ def get_embedding(self, symbol_id: str, model_name: str) -> dict[str, Any] | None:
328
+ with self.connect() as conn:
329
+ row = conn.execute(
330
+ """
331
+ SELECT * FROM embeddings
332
+ WHERE symbol_id = ? AND model_name = ?
333
+ """,
334
+ (symbol_id, model_name),
335
+ ).fetchone()
336
+ return dict(row) if row else None
337
+
338
+ def put_decision(
339
+ self, decision_id: str, branch: str, commit_hash: str, content: str, context_info: str
340
+ ) -> None:
341
+ now = int(datetime.now(UTC).timestamp())
342
+ with self.connect() as conn:
343
+ conn.execute(
344
+ """
345
+ INSERT INTO decisions (decision_id, branch, commit_hash, content, context, agent_id, created_at)
346
+ VALUES (?, ?, ?, ?, ?, 'agent', ?)
347
+ """,
348
+ (decision_id, branch, commit_hash, content, context_info, now),
349
+ )
350
+
351
+ def put_checkpoint(
352
+ self,
353
+ checkpoint_id: str,
354
+ branch: str,
355
+ commit_hash: str,
356
+ doing: str,
357
+ changed_files: str,
358
+ next_step: str,
359
+ blockers: str,
360
+ ) -> None:
361
+ now = int(datetime.now(UTC).timestamp())
362
+ with self.connect() as conn:
363
+ conn.execute(
364
+ """
365
+ INSERT INTO checkpoints (checkpoint_id, branch, commit_hash, doing, changed_files, next_step, blockers, created_at)
366
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)
367
+ """,
368
+ (
369
+ checkpoint_id,
370
+ branch,
371
+ commit_hash,
372
+ doing,
373
+ changed_files,
374
+ next_step,
375
+ blockers,
376
+ now,
377
+ ),
378
+ )
379
+
380
+ def update_lesson(
381
+ self, lesson_id: str, why_failed: str, status: str, actor: str = "system"
382
+ ) -> None:
383
+ try:
384
+ LessonStatus(status)
385
+ except ValueError:
386
+ raise ValueError(f"Invalid lesson status: {status}")
387
+
388
+ now = int(datetime.now(UTC).timestamp())
389
+ with self.connect() as conn:
390
+ conn.execute(
391
+ """
392
+ UPDATE lessons SET why_failed = ?, status = ?, approved_at = ?, approval_actor = ?
393
+ WHERE lesson_id = ?
394
+ """,
395
+ (why_failed, status, now, actor, lesson_id),
396
+ )
397
+
398
+ def prune_expired_lessons(self) -> int:
399
+ now = int(datetime.now(UTC).timestamp())
400
+ with self.connect() as conn:
401
+ cursor = conn.execute(
402
+ """
403
+ UPDATE lessons
404
+ SET status = ?
405
+ WHERE status IN (?, ?) AND expires_at < ?
406
+ """,
407
+ (
408
+ LessonStatus.EXPIRED.value,
409
+ LessonStatus.PENDING.value,
410
+ LessonStatus.APPROVED.value,
411
+ now,
412
+ ),
413
+ )
414
+ return cursor.rowcount
415
+
416
+ def get_decisions(self, branch: str, limit: int = 10) -> list[dict[str, Any]]:
417
+ with self.connect() as conn:
418
+ rows = conn.execute(
419
+ "SELECT * FROM decisions WHERE branch = ? ORDER BY created_at DESC LIMIT ?",
420
+ (branch, limit),
421
+ ).fetchall()
422
+ return [dict(row) for row in rows]
423
+
424
+ def get_latest_checkpoint(self, branch: str) -> dict[str, Any] | None:
425
+ with self.connect() as conn:
426
+ row = conn.execute(
427
+ "SELECT * FROM checkpoints WHERE branch = ? ORDER BY created_at DESC LIMIT 1",
428
+ (branch,),
429
+ ).fetchone()
430
+ return dict(row) if row else None
431
+
432
+ def get_checkpoints(self, branch: str) -> list[dict[str, Any]]:
433
+ with self.connect() as conn:
434
+ rows = conn.execute(
435
+ "SELECT * FROM checkpoints WHERE branch = ? ORDER BY created_at DESC",
436
+ (branch,),
437
+ ).fetchall()
438
+ return [dict(row) for row in rows]
439
+
440
+ def get_checkpoint(self, checkpoint_id: str) -> dict[str, Any] | None:
441
+ with self.connect() as conn:
442
+ row = conn.execute(
443
+ "SELECT * FROM checkpoints WHERE checkpoint_id = ?",
444
+ (checkpoint_id,),
445
+ ).fetchone()
446
+ return dict(row) if row else None
447
+
448
+ def delete_checkpoint(self, checkpoint_id: str) -> None:
449
+ with self.connect() as conn:
450
+ conn.execute(
451
+ "DELETE FROM checkpoints WHERE checkpoint_id = ?",
452
+ (checkpoint_id,),
453
+ )
454
+
455
+ def get_lessons(self, status: str) -> list[dict[str, Any]]:
456
+ now = int(datetime.now(UTC).timestamp())
457
+ with self.connect() as conn:
458
+ rows = conn.execute(
459
+ "SELECT * FROM lessons WHERE status = ? AND expires_at >= ? ORDER BY created_at DESC",
460
+ (status, now),
461
+ ).fetchall()
462
+ return [dict(row) for row in rows]
463
+
464
+ def get_activity(self, branch: str, limit: int = 50) -> list[dict[str, Any]]:
465
+ with self.connect() as conn:
466
+ rows = conn.execute(
467
+ "SELECT * FROM activity WHERE branch = ? ORDER BY created_at DESC LIMIT ?",
468
+ (branch, limit),
469
+ ).fetchall()
470
+ return [dict(row) for row in rows]
471
+
472
+ def integrity_check(self) -> str:
473
+ with self.connect() as conn:
474
+ return str(conn.execute("PRAGMA quick_check").fetchone()[0])
475
+
476
+ def recover_if_corrupted(self) -> bool:
477
+ """Checks DB integrity. If corrupted, deletes SQLite files to force a clean rebuild from Git."""
478
+ try:
479
+ if not self.path.exists():
480
+ return False
481
+ with self.connect() as conn:
482
+ res = conn.execute("PRAGMA quick_check").fetchone()[0]
483
+ if res == "ok":
484
+ return False
485
+ except Exception:
486
+ pass
487
+
488
+ # If we got here, it's corrupted or unreachable. Wipe it securely.
489
+ for ext in ["", "-wal", "-shm"]:
490
+ f = Path(f"{self.path}{ext}")
491
+ if f.exists():
492
+ try:
493
+ f.unlink()
494
+ except OSError:
495
+ pass
496
+ return True
497
+
498
+ def put_llm_call(
499
+ self,
500
+ provider: str,
501
+ model: str,
502
+ input_tokens: int,
503
+ output_tokens: int,
504
+ purpose: str,
505
+ file_path: str | None = None,
506
+ ) -> None:
507
+ import uuid
508
+ from datetime import UTC
509
+
510
+ cost = 0.0
511
+ p_lower = provider.lower() if provider else ""
512
+ m_lower = model.lower() if model else ""
513
+
514
+ if "openai" in p_lower:
515
+ if "mini" in m_lower:
516
+ cost = (input_tokens * 0.150 + output_tokens * 0.600) / 1_000_000
517
+ else:
518
+ cost = (input_tokens * 5.00 + output_tokens * 15.00) / 1_000_000
519
+ elif "gemini" in p_lower:
520
+ if "pro" in m_lower:
521
+ cost = (input_tokens * 3.50 + output_tokens * 10.50) / 1_000_000
522
+ else:
523
+ cost = (input_tokens * 0.075 + output_tokens * 0.300) / 1_000_000
524
+ elif "anthropic" in p_lower:
525
+ if "sonnet" in m_lower:
526
+ cost = (input_tokens * 3.00 + output_tokens * 15.00) / 1_000_000
527
+ elif "haiku" in m_lower:
528
+ cost = (input_tokens * 0.25 + output_tokens * 1.25) / 1_000_000
529
+ else:
530
+ cost = (input_tokens * 3.00 + output_tokens * 15.00) / 1_000_000
531
+
532
+ call_id = str(uuid.uuid4())
533
+ now = int(datetime.now(UTC).timestamp())
534
+ with self.connect() as conn:
535
+ conn.execute(
536
+ """
537
+ INSERT INTO llm_calls (call_id, provider, model, input_tokens, output_tokens, cost_usd, purpose, file_path, created_at)
538
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
539
+ """,
540
+ (
541
+ call_id,
542
+ provider or "unknown",
543
+ model or "unknown",
544
+ input_tokens,
545
+ output_tokens,
546
+ cost,
547
+ purpose,
548
+ file_path,
549
+ now,
550
+ ),
551
+ )
552
+
553
+ def get_llm_calls(self) -> list[dict[str, Any]]:
554
+ with self.connect() as conn:
555
+ rows = conn.execute("SELECT * FROM llm_calls ORDER BY created_at DESC").fetchall()
556
+ return [dict(row) for row in rows]
557
+
558
+ def clear_llm_calls(self) -> None:
559
+ with self.connect() as conn:
560
+ conn.execute("DELETE FROM llm_calls")
561
+
562
+ def clear_all(self) -> None:
563
+ with self.connect() as conn:
564
+ conn.execute("DELETE FROM edges")
565
+ conn.execute("DELETE FROM symbols")
566
+ conn.execute("DELETE FROM files")
567
+ conn.execute("DELETE FROM embeddings")
568
+ conn.execute("DELETE FROM active_state")
569
+ conn.execute("DELETE FROM decisions")
570
+ conn.execute("DELETE FROM checkpoints")
571
+ conn.execute("DELETE FROM lessons")
572
+ conn.execute("DELETE FROM activity")
573
+ conn.execute("DELETE FROM llm_calls")
574
+
575
+ conn.execute("VACUUM")
File without changes