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.
- synap_git/__init__.py +5 -0
- synap_git/api/__init__.py +5 -0
- synap_git/api/app.py +111 -0
- synap_git/api/static/index.html +615 -0
- synap_git/cli/__init__.py +3 -0
- synap_git/cli/__main__.py +4 -0
- synap_git/cli/main.py +1174 -0
- synap_git/config.py +169 -0
- synap_git/diagnostics/__init__.py +0 -0
- synap_git/diagnostics/logger.py +82 -0
- synap_git/diagnostics/tracing.py +71 -0
- synap_git/embeddings/__init__.py +0 -0
- synap_git/git/__init__.py +11 -0
- synap_git/git/state.py +139 -0
- synap_git/indexer/__init__.py +0 -0
- synap_git/indexer/daemon.py +143 -0
- synap_git/indexer/engine.py +327 -0
- synap_git/indexer/scanner.py +463 -0
- synap_git/indexer/wiki.py +78 -0
- synap_git/mcp/__init__.py +5 -0
- synap_git/mcp/server.py +232 -0
- synap_git/parser/__init__.py +0 -0
- synap_git/parser/registry.py +209 -0
- synap_git/provider/anthropic.py +86 -0
- synap_git/provider/base.py +44 -0
- synap_git/provider/factory.py +101 -0
- synap_git/provider/gemini.py +98 -0
- synap_git/provider/mock.py +66 -0
- synap_git/provider/ollama.py +98 -0
- synap_git/provider/openai.py +90 -0
- synap_git/py.typed +1 -0
- synap_git/retrieval/__init__.py +0 -0
- synap_git/retrieval/engine.py +258 -0
- synap_git/retrieval/memory.py +123 -0
- synap_git/storage/__init__.py +3 -0
- synap_git/storage/sqlite.py +575 -0
- synap_git/utils/__init__.py +0 -0
- synap_git/utils/serialization.py +68 -0
- synap_git-0.2.0.dist-info/METADATA +251 -0
- synap_git-0.2.0.dist-info/RECORD +43 -0
- synap_git-0.2.0.dist-info/WHEEL +4 -0
- synap_git-0.2.0.dist-info/entry_points.txt +2 -0
- 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,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
|