codevira 1.6.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 (58) hide show
  1. codevira-1.6.0.dist-info/LICENSE +21 -0
  2. codevira-1.6.0.dist-info/METADATA +477 -0
  3. codevira-1.6.0.dist-info/RECORD +58 -0
  4. codevira-1.6.0.dist-info/WHEEL +5 -0
  5. codevira-1.6.0.dist-info/entry_points.txt +2 -0
  6. codevira-1.6.0.dist-info/top_level.txt +2 -0
  7. indexer/__init__.py +1 -0
  8. indexer/chunker.py +428 -0
  9. indexer/global_db.py +197 -0
  10. indexer/graph_generator.py +380 -0
  11. indexer/index_codebase.py +588 -0
  12. indexer/outcome_tracker.py +172 -0
  13. indexer/rule_learner.py +186 -0
  14. indexer/sqlite_graph.py +640 -0
  15. indexer/treesitter_parser.py +423 -0
  16. mcp_server/__init__.py +1 -0
  17. mcp_server/__main__.py +20 -0
  18. mcp_server/auto_init.py +257 -0
  19. mcp_server/cli.py +622 -0
  20. mcp_server/crash_logger.py +236 -0
  21. mcp_server/data/__init__.py +1 -0
  22. mcp_server/data/agents/builder.md +84 -0
  23. mcp_server/data/agents/developer.md +111 -0
  24. mcp_server/data/agents/documenter.md +138 -0
  25. mcp_server/data/agents/orchestrator.md +96 -0
  26. mcp_server/data/agents/planner.md +106 -0
  27. mcp_server/data/agents/reviewer.md +82 -0
  28. mcp_server/data/agents/tester.md +83 -0
  29. mcp_server/data/config.example.yaml +33 -0
  30. mcp_server/data/rules/coding-standards.md +48 -0
  31. mcp_server/data/rules/engineering-excellence.md +28 -0
  32. mcp_server/data/rules/git-cicd-governance.md +32 -0
  33. mcp_server/data/rules/git_commits.md +130 -0
  34. mcp_server/data/rules/incremental-updates.md +5 -0
  35. mcp_server/data/rules/master_rule.md +187 -0
  36. mcp_server/data/rules/multi-language.md +19 -0
  37. mcp_server/data/rules/persistence.md +21 -0
  38. mcp_server/data/rules/resilience-observability.md +17 -0
  39. mcp_server/data/rules/smoke-testing.md +48 -0
  40. mcp_server/data/rules/testing-standards.md +23 -0
  41. mcp_server/detect.py +284 -0
  42. mcp_server/gitignore.py +284 -0
  43. mcp_server/global_sync.py +187 -0
  44. mcp_server/http_server.py +341 -0
  45. mcp_server/ide_inject.py +444 -0
  46. mcp_server/launchd.py +156 -0
  47. mcp_server/migrate.py +215 -0
  48. mcp_server/paths.py +256 -0
  49. mcp_server/prompts.py +136 -0
  50. mcp_server/server.py +1049 -0
  51. mcp_server/tools/__init__.py +0 -0
  52. mcp_server/tools/changesets.py +223 -0
  53. mcp_server/tools/code_reader.py +335 -0
  54. mcp_server/tools/graph.py +637 -0
  55. mcp_server/tools/learning.py +238 -0
  56. mcp_server/tools/playbook.py +89 -0
  57. mcp_server/tools/roadmap.py +599 -0
  58. mcp_server/tools/search.py +145 -0
@@ -0,0 +1,640 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import sqlite3
5
+ import os
6
+ import logging
7
+ from pathlib import Path
8
+ from contextlib import contextmanager
9
+
10
+ logger = logging.getLogger(__name__)
11
+
12
+ class SQLiteGraph:
13
+ def __init__(self, db_path: str | Path):
14
+ self.db_path = Path(db_path)
15
+ self.db_path.parent.mkdir(parents=True, exist_ok=True)
16
+ self.conn = sqlite3.connect(self.db_path, check_same_thread=False)
17
+ self.conn.row_factory = sqlite3.Row
18
+ self._init_db()
19
+
20
+ @contextmanager
21
+ def transaction(self):
22
+ with self.conn:
23
+ yield self.conn
24
+
25
+ def _init_db(self):
26
+ with self.transaction() as conn:
27
+ conn.executescript('''
28
+ CREATE TABLE IF NOT EXISTS nodes (
29
+ id TEXT PRIMARY KEY,
30
+ kind TEXT NOT NULL,
31
+ name TEXT NOT NULL,
32
+ file_path TEXT NOT NULL,
33
+ line_start INTEGER,
34
+ line_end INTEGER,
35
+ docstring TEXT,
36
+ is_public BOOLEAN,
37
+
38
+ -- User/Agent Metadata
39
+ role TEXT,
40
+ type TEXT,
41
+ rules TEXT,
42
+ key_functions TEXT,
43
+ dependencies TEXT,
44
+ stability TEXT DEFAULT 'medium',
45
+ do_not_revert BOOLEAN DEFAULT 0,
46
+ layer TEXT
47
+ );
48
+
49
+ CREATE INDEX IF NOT EXISTS idx_nodes_file_path ON nodes(file_path);
50
+ CREATE INDEX IF NOT EXISTS idx_nodes_name ON nodes(name);
51
+
52
+ CREATE TABLE IF NOT EXISTS edges (
53
+ source_id TEXT,
54
+ target_id TEXT,
55
+ kind TEXT NOT NULL,
56
+ line INTEGER,
57
+ PRIMARY KEY (source_id, target_id, kind),
58
+ FOREIGN KEY (source_id) REFERENCES nodes(id) ON DELETE CASCADE,
59
+ FOREIGN KEY (target_id) REFERENCES nodes(id) ON DELETE CASCADE
60
+ );
61
+
62
+ CREATE INDEX IF NOT EXISTS idx_edges_target ON edges(target_id);
63
+ CREATE INDEX IF NOT EXISTS idx_edges_source ON edges(source_id);
64
+
65
+ -- ----------------------------------------------------
66
+ -- Hashing for Incremental Indexing
67
+ -- ----------------------------------------------------
68
+ CREATE TABLE IF NOT EXISTS file_hashes (
69
+ file_path TEXT PRIMARY KEY,
70
+ sha256 TEXT NOT NULL,
71
+ last_indexed_at DATETIME DEFAULT CURRENT_TIMESTAMP
72
+ );
73
+
74
+ -- ----------------------------------------------------
75
+ -- Memory & Session Logs
76
+ -- ----------------------------------------------------
77
+ CREATE TABLE IF NOT EXISTS sessions (
78
+ session_id TEXT PRIMARY KEY,
79
+ created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
80
+ summary TEXT,
81
+ phase TEXT
82
+ );
83
+
84
+ CREATE TABLE IF NOT EXISTS decisions (
85
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
86
+ session_id TEXT NOT NULL,
87
+ file_path TEXT,
88
+ decision TEXT NOT NULL,
89
+ context TEXT,
90
+ created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
91
+ FOREIGN KEY (session_id) REFERENCES sessions(session_id) ON DELETE CASCADE
92
+ );
93
+
94
+ CREATE INDEX IF NOT EXISTS idx_decisions_session ON decisions(session_id);
95
+ CREATE INDEX IF NOT EXISTS idx_decisions_file ON decisions(file_path);
96
+
97
+ -- ----------------------------------------------------
98
+ -- v1.4: Outcome Tracking (feedback loop)
99
+ -- ----------------------------------------------------
100
+ CREATE TABLE IF NOT EXISTS outcomes (
101
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
102
+ session_id TEXT NOT NULL,
103
+ file_path TEXT NOT NULL,
104
+ decision_id INTEGER,
105
+ outcome_type TEXT NOT NULL, -- 'kept' | 'modified' | 'reverted'
106
+ delta_summary TEXT,
107
+ detected_at DATETIME DEFAULT CURRENT_TIMESTAMP,
108
+ FOREIGN KEY (session_id) REFERENCES sessions(session_id) ON DELETE CASCADE,
109
+ FOREIGN KEY (decision_id) REFERENCES decisions(id) ON DELETE SET NULL
110
+ );
111
+
112
+ CREATE INDEX IF NOT EXISTS idx_outcomes_session ON outcomes(session_id);
113
+ CREATE INDEX IF NOT EXISTS idx_outcomes_file ON outcomes(file_path);
114
+
115
+ -- ----------------------------------------------------
116
+ -- v1.4: Developer Preferences (learned from corrections)
117
+ -- ----------------------------------------------------
118
+ CREATE TABLE IF NOT EXISTS preferences (
119
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
120
+ category TEXT NOT NULL, -- 'naming' | 'structure' | 'patterns' | 'formatting'
121
+ signal TEXT NOT NULL,
122
+ example TEXT,
123
+ frequency INTEGER DEFAULT 1,
124
+ last_seen DATETIME DEFAULT CURRENT_TIMESTAMP
125
+ );
126
+
127
+ CREATE INDEX IF NOT EXISTS idx_preferences_category ON preferences(category);
128
+
129
+ -- ----------------------------------------------------
130
+ -- v1.4: Learned Rules (auto-generated from patterns)
131
+ -- ----------------------------------------------------
132
+ CREATE TABLE IF NOT EXISTS learned_rules (
133
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
134
+ rule_text TEXT NOT NULL,
135
+ confidence REAL DEFAULT 0.5,
136
+ source_sessions TEXT, -- JSON array of session IDs
137
+ category TEXT, -- 'testing' | 'imports' | 'structure' | 'naming'
138
+ file_pattern TEXT, -- glob pattern this rule applies to
139
+ created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
140
+ updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
141
+ );
142
+
143
+ CREATE INDEX IF NOT EXISTS idx_learned_rules_category ON learned_rules(category);
144
+
145
+ -- ----------------------------------------------------
146
+ -- v1.5: Function-level symbols and call graph
147
+ -- ----------------------------------------------------
148
+ CREATE TABLE IF NOT EXISTS symbols (
149
+ id TEXT PRIMARY KEY, -- 'file:path.py::func_name'
150
+ file_node_id TEXT, -- FK to nodes.id
151
+ name TEXT NOT NULL,
152
+ kind TEXT, -- 'function' | 'class' | 'method' | 'interface' | 'struct'
153
+ signature TEXT,
154
+ parameters TEXT, -- JSON: [{name, type}]
155
+ return_type TEXT,
156
+ start_line INTEGER,
157
+ end_line INTEGER,
158
+ docstring TEXT,
159
+ is_public BOOLEAN DEFAULT 1,
160
+ calls TEXT, -- JSON: ['func_a', 'func_b']
161
+ FOREIGN KEY (file_node_id) REFERENCES nodes(id) ON DELETE CASCADE
162
+ );
163
+
164
+ CREATE INDEX IF NOT EXISTS idx_symbols_file ON symbols(file_node_id);
165
+ CREATE INDEX IF NOT EXISTS idx_symbols_name ON symbols(name);
166
+
167
+ CREATE TABLE IF NOT EXISTS call_edges (
168
+ caller_id TEXT, -- symbols.id
169
+ callee_id TEXT, -- symbols.id
170
+ line INTEGER,
171
+ PRIMARY KEY (caller_id, callee_id),
172
+ FOREIGN KEY (caller_id) REFERENCES symbols(id) ON DELETE CASCADE,
173
+ FOREIGN KEY (callee_id) REFERENCES symbols(id) ON DELETE CASCADE
174
+ );
175
+
176
+ CREATE INDEX IF NOT EXISTS idx_call_edges_callee ON call_edges(callee_id);
177
+ CREATE INDEX IF NOT EXISTS idx_call_edges_caller ON call_edges(caller_id);
178
+ ''')
179
+ conn.execute("PRAGMA foreign_keys = ON;")
180
+
181
+ # v1.5 migrations: add source column for global sync tracking
182
+ try:
183
+ conn.execute("ALTER TABLE preferences ADD COLUMN source TEXT DEFAULT 'local'")
184
+ except sqlite3.OperationalError:
185
+ pass # Column already exists
186
+ try:
187
+ conn.execute("ALTER TABLE learned_rules ADD COLUMN source TEXT DEFAULT 'local'")
188
+ except sqlite3.OperationalError:
189
+ pass # Column already exists
190
+
191
+ def add_node(self, node_id: str, kind: str, name: str, file_path: str, **kwargs):
192
+ existing = self.get_node(node_id)
193
+ fields = ["id", "kind", "name", "file_path"]
194
+ values = [node_id, kind, name, file_path]
195
+
196
+ metadata_fields = ["line_start", "line_end", "docstring", "is_public",
197
+ "role", "type", "rules", "key_functions", "dependencies",
198
+ "stability", "do_not_revert", "layer"]
199
+
200
+ for k in metadata_fields:
201
+ if k in kwargs:
202
+ fields.append(k)
203
+ values.append(kwargs[k])
204
+ elif existing and existing.get(k) is not None:
205
+ fields.append(k)
206
+ values.append(existing[k])
207
+
208
+ placeholders = ",".join(["?"] * len(fields))
209
+ query = f"INSERT OR REPLACE INTO nodes ({','.join(fields)}) VALUES ({placeholders})"
210
+
211
+ with self.transaction() as conn:
212
+ conn.execute(query, values)
213
+
214
+ def update_node_metadata(self, node_id: str, **kwargs):
215
+ valid_fields = ["role", "type", "rules", "key_functions", "dependencies",
216
+ "stability", "do_not_revert", "layer"]
217
+ updates, values = [], []
218
+ for k, v in kwargs.items():
219
+ if k in valid_fields:
220
+ updates.append(f"{k} = ?")
221
+ values.append(v)
222
+ if not updates: return
223
+
224
+ values.append(node_id)
225
+ query = f"UPDATE nodes SET {', '.join(updates)} WHERE id = ?"
226
+ with self.transaction() as conn:
227
+ conn.execute(query, values)
228
+
229
+ def get_node(self, node_id: str) -> dict | None:
230
+ cur = self.conn.execute('SELECT * FROM nodes WHERE id = ?', (node_id,))
231
+ row = cur.fetchone()
232
+ return dict(row) if row else None
233
+
234
+ def get_node_by_path(self, file_path: str) -> dict | None:
235
+ cur = self.conn.execute('SELECT * FROM nodes WHERE file_path = ? AND kind = "file" LIMIT 1', (file_path,))
236
+ row = cur.fetchone()
237
+ return dict(row) if row else None
238
+
239
+ def list_file_nodes(self, layer: str | None = None, stability: str | None = None, do_not_revert: bool | None = None) -> list[dict]:
240
+ query = 'SELECT * FROM nodes WHERE kind = "file"'
241
+ params = []
242
+ if layer:
243
+ query += ' AND layer = ?'
244
+ params.append(layer)
245
+ if stability:
246
+ query += ' AND stability = ?'
247
+ params.append(stability)
248
+ if do_not_revert is not None:
249
+ query += ' AND do_not_revert = ?'
250
+ params.append(1 if do_not_revert else 0)
251
+
252
+ cur = self.conn.execute(query, params)
253
+ return [dict(r) for r in cur.fetchall()]
254
+
255
+ def get_blast_radius(self, node_id: str, max_depth: int = 3) -> list[dict]:
256
+ query = '''
257
+ WITH RECURSIVE
258
+ dependents(id, path, depth) AS (
259
+ SELECT id, id, 0 FROM nodes WHERE id = ?
260
+ UNION ALL
261
+ SELECT e.source_id, d.path || '->' || e.source_id, d.depth + 1
262
+ FROM edges e
263
+ JOIN dependents d ON e.target_id = d.id
264
+ WHERE d.depth < ? AND instr(d.path, e.source_id) = 0
265
+ )
266
+ SELECT DISTINCT n.*, d.depth
267
+ FROM dependents d
268
+ JOIN nodes n ON d.id = n.id
269
+ WHERE d.id != ?
270
+ ORDER BY d.depth ASC;
271
+ '''
272
+ with self.transaction() as conn:
273
+ cur = conn.execute(query, (node_id, max_depth, node_id))
274
+ return [dict(r) for r in cur.fetchall()]
275
+
276
+ # ------------------------------------------------------------------
277
+ # Edge management
278
+ # ------------------------------------------------------------------
279
+
280
+ def add_edge(self, source_id: str, target_id: str, kind: str = "imports", line: int | None = None):
281
+ with self.transaction() as conn:
282
+ conn.execute(
283
+ 'INSERT OR REPLACE INTO edges (source_id, target_id, kind, line) VALUES (?, ?, ?, ?)',
284
+ (source_id, target_id, kind, line),
285
+ )
286
+
287
+ def remove_edges_for_node(self, node_id: str):
288
+ with self.transaction() as conn:
289
+ conn.execute('DELETE FROM edges WHERE source_id = ?', (node_id,))
290
+
291
+ def get_edges_from(self, node_id: str) -> list[dict]:
292
+ cur = self.conn.execute('SELECT * FROM edges WHERE source_id = ?', (node_id,))
293
+ return [dict(r) for r in cur.fetchall()]
294
+
295
+ def get_edges_to(self, node_id: str) -> list[dict]:
296
+ cur = self.conn.execute('SELECT * FROM edges WHERE target_id = ?', (node_id,))
297
+ return [dict(r) for r in cur.fetchall()]
298
+
299
+ def get_all_edges(self) -> list[dict]:
300
+ cur = self.conn.execute('SELECT * FROM edges')
301
+ return [dict(r) for r in cur.fetchall()]
302
+
303
+ # ------------------------------------------------------------------
304
+ # Outcome tracking (feedback loop)
305
+ # ------------------------------------------------------------------
306
+
307
+ def record_outcome(self, session_id: str, file_path: str, outcome_type: str,
308
+ decision_id: int | None = None, delta_summary: str | None = None):
309
+ with self.transaction() as conn:
310
+ conn.execute('''
311
+ INSERT INTO outcomes (session_id, file_path, decision_id, outcome_type, delta_summary)
312
+ VALUES (?, ?, ?, ?, ?)
313
+ ''', (session_id, file_path, decision_id, outcome_type, delta_summary))
314
+
315
+ def get_outcomes_for_file(self, file_path: str, limit: int = 20) -> list[dict]:
316
+ cur = self.conn.execute('''
317
+ SELECT o.*, d.decision FROM outcomes o
318
+ LEFT JOIN decisions d ON o.decision_id = d.id
319
+ WHERE o.file_path = ?
320
+ ORDER BY o.detected_at DESC LIMIT ?
321
+ ''', (file_path, limit))
322
+ return [dict(r) for r in cur.fetchall()]
323
+
324
+ def get_decision_confidence(self, file_path: str | None = None, pattern: str | None = None) -> dict:
325
+ """Calculate confidence scores based on outcome history."""
326
+ if file_path:
327
+ cur = self.conn.execute('''
328
+ SELECT outcome_type, COUNT(*) as cnt FROM outcomes
329
+ WHERE file_path = ? GROUP BY outcome_type
330
+ ''', (file_path,))
331
+ elif pattern:
332
+ cur = self.conn.execute('''
333
+ SELECT outcome_type, COUNT(*) as cnt FROM outcomes
334
+ WHERE file_path LIKE ? GROUP BY outcome_type
335
+ ''', (f'%{pattern}%',))
336
+ else:
337
+ cur = self.conn.execute(
338
+ 'SELECT outcome_type, COUNT(*) as cnt FROM outcomes GROUP BY outcome_type'
339
+ )
340
+
341
+ counts = {row['outcome_type']: row['cnt'] for row in cur.fetchall()}
342
+ total = sum(counts.values())
343
+ kept = counts.get('kept', 0)
344
+ modified = counts.get('modified', 0)
345
+ reverted = counts.get('reverted', 0)
346
+
347
+ confidence = (kept + modified * 0.5) / total if total > 0 else 0.0
348
+ return {
349
+ "total_decisions": total,
350
+ "kept": kept,
351
+ "modified": modified,
352
+ "reverted": reverted,
353
+ "confidence": round(confidence, 3),
354
+ }
355
+
356
+ # ------------------------------------------------------------------
357
+ # Developer preferences
358
+ # ------------------------------------------------------------------
359
+
360
+ def record_preference(self, category: str, signal: str, example: str | None = None,
361
+ source: str = "local"):
362
+ existing = self.conn.execute(
363
+ 'SELECT id, frequency FROM preferences WHERE category = ? AND signal = ?',
364
+ (category, signal),
365
+ ).fetchone()
366
+
367
+ if existing:
368
+ with self.transaction() as conn:
369
+ conn.execute('''
370
+ UPDATE preferences SET frequency = frequency + 1, last_seen = CURRENT_TIMESTAMP, example = COALESCE(?, example)
371
+ WHERE id = ?
372
+ ''', (example, existing['id']))
373
+ else:
374
+ with self.transaction() as conn:
375
+ conn.execute('''
376
+ INSERT INTO preferences (category, signal, example, source) VALUES (?, ?, ?, ?)
377
+ ''', (category, signal, example, source))
378
+
379
+ def get_preferences(self, category: str | None = None, min_frequency: int = 1) -> list[dict]:
380
+ if category:
381
+ cur = self.conn.execute('''
382
+ SELECT * FROM preferences WHERE category = ? AND frequency >= ?
383
+ ORDER BY frequency DESC
384
+ ''', (category, min_frequency))
385
+ else:
386
+ cur = self.conn.execute('''
387
+ SELECT * FROM preferences WHERE frequency >= ? ORDER BY frequency DESC
388
+ ''', (min_frequency,))
389
+ return [dict(r) for r in cur.fetchall()]
390
+
391
+ # ------------------------------------------------------------------
392
+ # Learned rules
393
+ # ------------------------------------------------------------------
394
+
395
+ def add_learned_rule(self, rule_text: str, confidence: float, source_sessions: list[str],
396
+ category: str | None = None, file_pattern: str | None = None):
397
+ with self.transaction() as conn:
398
+ conn.execute('''
399
+ INSERT INTO learned_rules (rule_text, confidence, source_sessions, category, file_pattern)
400
+ VALUES (?, ?, ?, ?, ?)
401
+ ''', (rule_text, confidence, json.dumps(source_sessions), category, file_pattern))
402
+
403
+ def update_learned_rule(self, rule_id: int, confidence: float | None = None,
404
+ source_sessions: list[str] | None = None):
405
+ updates, values = [], []
406
+ if confidence is not None:
407
+ updates.append("confidence = ?")
408
+ values.append(confidence)
409
+ if source_sessions is not None:
410
+ updates.append("source_sessions = ?")
411
+ values.append(json.dumps(source_sessions))
412
+ if updates:
413
+ updates.append("updated_at = CURRENT_TIMESTAMP")
414
+ values.append(rule_id)
415
+ with self.transaction() as conn:
416
+ conn.execute(f'UPDATE learned_rules SET {", ".join(updates)} WHERE id = ?', values)
417
+
418
+ def get_learned_rules(self, category: str | None = None, file_pattern: str | None = None,
419
+ min_confidence: float = 0.0) -> list[dict]:
420
+ query = 'SELECT * FROM learned_rules WHERE confidence >= ?'
421
+ params: list = [min_confidence]
422
+ if category:
423
+ query += ' AND category = ?'
424
+ params.append(category)
425
+ if file_pattern:
426
+ query += ' AND (file_pattern IS NULL OR ? LIKE file_pattern)'
427
+ params.append(file_pattern)
428
+ query += ' ORDER BY confidence DESC'
429
+ cur = self.conn.execute(query, params)
430
+ return [dict(r) for r in cur.fetchall()]
431
+
432
+ # ------------------------------------------------------------------
433
+ # Project maturity metrics
434
+ # ------------------------------------------------------------------
435
+
436
+ def get_project_maturity(self) -> dict:
437
+ """Compute overall project maturity based on outcomes and coverage."""
438
+ # Overall confidence
439
+ confidence = self.get_decision_confidence()
440
+
441
+ # File coverage: files with at least one session decision
442
+ total_files = self.conn.execute('SELECT COUNT(*) as c FROM nodes WHERE kind = "file"').fetchone()['c']
443
+ covered_files = self.conn.execute(
444
+ 'SELECT COUNT(DISTINCT file_path) as c FROM decisions WHERE file_path IS NOT NULL'
445
+ ).fetchone()['c']
446
+
447
+ # Learned rules count
448
+ rule_count = self.conn.execute('SELECT COUNT(*) as c FROM learned_rules WHERE confidence >= 0.5').fetchone()['c']
449
+
450
+ # Preference signals count
451
+ pref_count = self.conn.execute('SELECT COUNT(*) as c FROM preferences WHERE frequency >= 2').fetchone()['c']
452
+
453
+ # Session count
454
+ session_count = self.conn.execute('SELECT COUNT(*) as c FROM sessions').fetchone()['c']
455
+
456
+ coverage = round(covered_files / total_files, 3) if total_files > 0 else 0.0
457
+
458
+ return {
459
+ "session_count": session_count,
460
+ "total_files": total_files,
461
+ "covered_files": covered_files,
462
+ "coverage": coverage,
463
+ "overall_confidence": confidence["confidence"],
464
+ "outcome_breakdown": confidence,
465
+ "learned_rules": rule_count,
466
+ "preference_signals": pref_count,
467
+ }
468
+
469
+ # ------------------------------------------------------------------
470
+ # Session helpers
471
+ # ------------------------------------------------------------------
472
+
473
+ def get_recent_sessions(self, limit: int = 5) -> list[dict]:
474
+ cur = self.conn.execute('''
475
+ SELECT * FROM sessions ORDER BY created_at DESC LIMIT ?
476
+ ''', (limit,))
477
+ return [dict(r) for r in cur.fetchall()]
478
+
479
+ def get_recent_decisions(self, limit: int = 10) -> list[dict]:
480
+ cur = self.conn.execute('''
481
+ SELECT d.*, s.summary, s.phase FROM decisions d
482
+ JOIN sessions s ON d.session_id = s.session_id
483
+ ORDER BY d.created_at DESC LIMIT ?
484
+ ''', (limit,))
485
+ return [dict(r) for r in cur.fetchall()]
486
+
487
+ def get_file_hash(self, file_path: str) -> str | None:
488
+ cur = self.conn.execute('SELECT sha256 FROM file_hashes WHERE file_path = ?', (file_path,))
489
+ row = cur.fetchone()
490
+ return row['sha256'] if row else None
491
+
492
+ def update_file_hash(self, file_path: str, sha256: str):
493
+ with self.transaction() as conn:
494
+ conn.execute('''
495
+ INSERT OR REPLACE INTO file_hashes (file_path, sha256, last_indexed_at)
496
+ VALUES (?, ?, CURRENT_TIMESTAMP)
497
+ ''', (file_path, sha256))
498
+
499
+ def log_session(self, session_id: str, summary: str, phase: str, decisions: list[dict]):
500
+ with self.transaction() as conn:
501
+ conn.execute('''
502
+ INSERT OR REPLACE INTO sessions (session_id, summary, phase)
503
+ VALUES (?, ?, ?)
504
+ ''', (session_id, summary, phase))
505
+
506
+ for d in decisions:
507
+ conn.execute('''
508
+ INSERT INTO decisions (session_id, file_path, decision, context)
509
+ VALUES (?, ?, ?, ?)
510
+ ''', (session_id, d.get("file_path"), d.get("decision"), d.get("context")))
511
+
512
+ def search_decisions(self, query: str, limit: int = 10, session_id: str | None = None) -> list[dict]:
513
+ sql = '''
514
+ SELECT d.decision, d.context, d.file_path, s.summary, s.phase, d.created_at
515
+ FROM decisions d
516
+ JOIN sessions s ON d.session_id = s.session_id
517
+ WHERE (d.decision LIKE ? OR d.context LIKE ? OR s.summary LIKE ?)
518
+ '''
519
+ params = [f'%{query}%', f'%{query}%', f'%{query}%']
520
+
521
+ if session_id:
522
+ sql += ' AND d.session_id = ?'
523
+ params.append(session_id)
524
+
525
+ sql += ' ORDER BY d.created_at DESC LIMIT ?'
526
+ params.append(limit)
527
+
528
+ cur = self.conn.execute(sql, params)
529
+ return [dict(r) for r in cur.fetchall()]
530
+
531
+ # ------------------------------------------------------------------
532
+ # v1.5: Function-level symbols and call graph
533
+ # ------------------------------------------------------------------
534
+
535
+ def add_symbol(self, symbol_id: str, file_node_id: str, name: str, kind: str,
536
+ signature: str | None = None, parameters: str | None = None,
537
+ return_type: str | None = None, start_line: int | None = None,
538
+ end_line: int | None = None, docstring: str | None = None,
539
+ is_public: bool = True, calls: str | None = None):
540
+ """Insert or replace a function/class symbol."""
541
+ with self.transaction() as conn:
542
+ conn.execute('''
543
+ INSERT OR REPLACE INTO symbols
544
+ (id, file_node_id, name, kind, signature, parameters, return_type,
545
+ start_line, end_line, docstring, is_public, calls)
546
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
547
+ ''', (symbol_id, file_node_id, name, kind, signature, parameters,
548
+ return_type, start_line, end_line, docstring, is_public, calls))
549
+
550
+ def remove_symbols_for_file(self, file_node_id: str):
551
+ """Remove all symbols (and their call_edges) for a file node."""
552
+ with self.transaction() as conn:
553
+ # call_edges cascade from symbols via FK
554
+ conn.execute("DELETE FROM symbols WHERE file_node_id = ?", (file_node_id,))
555
+
556
+ def add_call_edge(self, caller_id: str, callee_id: str, line: int | None = None):
557
+ """Record a function call relationship."""
558
+ with self.transaction() as conn:
559
+ conn.execute('''
560
+ INSERT OR REPLACE INTO call_edges (caller_id, callee_id, line)
561
+ VALUES (?, ?, ?)
562
+ ''', (caller_id, callee_id, line))
563
+
564
+ def get_callers(self, symbol_id: str) -> list[dict]:
565
+ """Get all functions that call this symbol."""
566
+ cur = self.conn.execute('''
567
+ SELECT s.id, s.name, s.kind, s.file_node_id, ce.line
568
+ FROM call_edges ce
569
+ JOIN symbols s ON ce.caller_id = s.id
570
+ WHERE ce.callee_id = ?
571
+ ORDER BY s.name
572
+ ''', (symbol_id,))
573
+ return [dict(r) for r in cur.fetchall()]
574
+
575
+ def get_callees(self, symbol_id: str) -> list[dict]:
576
+ """Get all functions called by this symbol."""
577
+ cur = self.conn.execute('''
578
+ SELECT s.id, s.name, s.kind, s.file_node_id, ce.line
579
+ FROM call_edges ce
580
+ JOIN symbols s ON ce.callee_id = s.id
581
+ WHERE ce.caller_id = ?
582
+ ORDER BY s.name
583
+ ''', (symbol_id,))
584
+ return [dict(r) for r in cur.fetchall()]
585
+
586
+ def get_symbols_for_file(self, file_node_id: str) -> list[dict]:
587
+ """Get all symbols in a file."""
588
+ cur = self.conn.execute('''
589
+ SELECT * FROM symbols WHERE file_node_id = ? ORDER BY start_line
590
+ ''', (file_node_id,))
591
+ return [dict(r) for r in cur.fetchall()]
592
+
593
+ def find_symbol(self, name: str, file_path: str | None = None) -> dict | None:
594
+ """Find a symbol by name, optionally scoped to a file."""
595
+ if file_path:
596
+ node_id = f"file:{file_path}"
597
+ cur = self.conn.execute(
598
+ "SELECT * FROM symbols WHERE name = ? AND file_node_id = ?",
599
+ (name, node_id),
600
+ )
601
+ else:
602
+ cur = self.conn.execute(
603
+ "SELECT * FROM symbols WHERE name = ? LIMIT 1", (name,),
604
+ )
605
+ row = cur.fetchone()
606
+ return dict(row) if row else None
607
+
608
+ def get_symbol_count(self) -> int:
609
+ return self.conn.execute("SELECT COUNT(*) FROM symbols").fetchone()[0]
610
+
611
+ def get_call_edge_count(self) -> int:
612
+ return self.conn.execute("SELECT COUNT(*) FROM call_edges").fetchone()[0]
613
+
614
+ def find_hotspot_functions(self, min_lines: int = 50) -> list[dict]:
615
+ """Find large functions exceeding line threshold."""
616
+ cur = self.conn.execute('''
617
+ SELECT s.*, (s.end_line - s.start_line) as line_count,
618
+ n.file_path as full_path
619
+ FROM symbols s
620
+ JOIN nodes n ON s.file_node_id = n.id
621
+ WHERE (s.end_line - s.start_line) >= ?
622
+ ORDER BY (s.end_line - s.start_line) DESC
623
+ ''', (min_lines,))
624
+ return [dict(r) for r in cur.fetchall()]
625
+
626
+ def find_high_fan_in(self, min_callers: int = 5) -> list[dict]:
627
+ """Find symbols with many callers (high fan-in = high risk)."""
628
+ cur = self.conn.execute('''
629
+ SELECT s.id, s.name, s.kind, s.file_node_id, COUNT(ce.caller_id) as caller_count
630
+ FROM symbols s
631
+ JOIN call_edges ce ON ce.callee_id = s.id
632
+ GROUP BY s.id
633
+ HAVING caller_count >= ?
634
+ ORDER BY caller_count DESC
635
+ ''', (min_callers,))
636
+ return [dict(r) for r in cur.fetchall()]
637
+
638
+ def close(self):
639
+ self.conn.close()
640
+