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,327 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
from synap_git.config import SynapSettings
|
|
7
|
+
from synap_git.diagnostics.logger import get_logger
|
|
8
|
+
from synap_git.git import GitRepository, GitState
|
|
9
|
+
from synap_git.indexer.scanner import RepositoryScanner
|
|
10
|
+
from synap_git.parser.registry import CodeParserRegistry
|
|
11
|
+
from synap_git.provider.factory import get_llm_provider
|
|
12
|
+
from synap_git.retrieval.engine import HybridRetrievalEngine
|
|
13
|
+
from synap_git.storage.sqlite import SynapStore
|
|
14
|
+
from synap_git.utils.serialization import stable_hash
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@dataclass(frozen=True)
|
|
18
|
+
class RuntimeStatus:
|
|
19
|
+
repository_path: str
|
|
20
|
+
branch: str
|
|
21
|
+
git_commit: str | None
|
|
22
|
+
active_commit: str | None
|
|
23
|
+
symbols: int
|
|
24
|
+
files: int
|
|
25
|
+
mode: str
|
|
26
|
+
is_dirty: bool = False
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class SynapRuntime:
|
|
30
|
+
"""Deterministic runtime service for indexing and retrieval."""
|
|
31
|
+
|
|
32
|
+
def __init__(self, settings: SynapSettings) -> None:
|
|
33
|
+
self.settings = settings
|
|
34
|
+
if settings.sqlite_path is None:
|
|
35
|
+
raise ValueError("Storage paths must be configured.")
|
|
36
|
+
|
|
37
|
+
from synap_git.diagnostics.tracing import TraceStore
|
|
38
|
+
|
|
39
|
+
self.trace_store = TraceStore(settings.repository_path)
|
|
40
|
+
self.store = SynapStore(settings.sqlite_path)
|
|
41
|
+
self.git = GitRepository(settings.repository_path)
|
|
42
|
+
self.parser_registry = CodeParserRegistry()
|
|
43
|
+
self.llm_provider = get_llm_provider(settings)
|
|
44
|
+
self.retrieval_engine = HybridRetrievalEngine(
|
|
45
|
+
store=self.store,
|
|
46
|
+
llm_provider=self.llm_provider,
|
|
47
|
+
trace_store=self.trace_store,
|
|
48
|
+
)
|
|
49
|
+
self.logger = get_logger("runtime")
|
|
50
|
+
|
|
51
|
+
from synap_git.indexer.wiki import WikiEngine
|
|
52
|
+
|
|
53
|
+
self.wiki = WikiEngine(settings, self.store)
|
|
54
|
+
self.commit_count = 0
|
|
55
|
+
|
|
56
|
+
def initialize_storage(self) -> None:
|
|
57
|
+
self.settings.ensure_directories()
|
|
58
|
+
if self.store.path.exists() and self.store.recover_if_corrupted():
|
|
59
|
+
self.logger.warning("database_corrupted_wiped")
|
|
60
|
+
self.store.initialize()
|
|
61
|
+
|
|
62
|
+
def _auto_protect_synapse(self) -> None:
|
|
63
|
+
gitignore_path = self.settings.repository_path / ".gitignore"
|
|
64
|
+
try:
|
|
65
|
+
if not gitignore_path.exists():
|
|
66
|
+
gitignore_path.write_text(".synap/\n", encoding="utf-8")
|
|
67
|
+
return
|
|
68
|
+
|
|
69
|
+
content = gitignore_path.read_text(encoding="utf-8")
|
|
70
|
+
lines = [line.strip() for line in content.splitlines()]
|
|
71
|
+
if ".synap/" not in lines and ".synap" not in lines:
|
|
72
|
+
if content and not content.endswith("\n"):
|
|
73
|
+
content += "\n"
|
|
74
|
+
content += ".synap/\n"
|
|
75
|
+
gitignore_path.write_text(content, encoding="utf-8")
|
|
76
|
+
except Exception as e:
|
|
77
|
+
self.logger.warning("failed_to_auto_protect_synapse", error=str(e))
|
|
78
|
+
|
|
79
|
+
def bootstrap(self, *, force: bool = False) -> str | None:
|
|
80
|
+
self.initialize_storage()
|
|
81
|
+
self._auto_protect_synapse()
|
|
82
|
+
git_state = self.git.state()
|
|
83
|
+
branch = git_state.effective_branch
|
|
84
|
+
existing_commit = self.store.get_active_commit(branch)
|
|
85
|
+
|
|
86
|
+
if existing_commit == git_state.head_commit and not force:
|
|
87
|
+
self.logger.info("bootstrap_skipped", branch=branch, commit=existing_commit)
|
|
88
|
+
return existing_commit
|
|
89
|
+
|
|
90
|
+
if (
|
|
91
|
+
existing_commit
|
|
92
|
+
and existing_commit != git_state.head_commit
|
|
93
|
+
and existing_commit != "unknown"
|
|
94
|
+
):
|
|
95
|
+
try:
|
|
96
|
+
from synap_git.git.state import GitState
|
|
97
|
+
|
|
98
|
+
prev = GitState(
|
|
99
|
+
repository_path=self.settings.repository_path,
|
|
100
|
+
is_repository=True,
|
|
101
|
+
head_commit=existing_commit,
|
|
102
|
+
branch=branch,
|
|
103
|
+
)
|
|
104
|
+
change = self.git.classify(prev, git_state)
|
|
105
|
+
if change.kind == "revert":
|
|
106
|
+
self.handle_revert(prev, git_state)
|
|
107
|
+
return git_state.head_commit
|
|
108
|
+
except Exception:
|
|
109
|
+
pass
|
|
110
|
+
|
|
111
|
+
return self.index_repository(git_state=git_state)
|
|
112
|
+
|
|
113
|
+
def wipe_index(self) -> None:
|
|
114
|
+
"""Completely purge the index to allow a fresh deterministic rebuild."""
|
|
115
|
+
self.initialize_storage()
|
|
116
|
+
self.store.clear_all()
|
|
117
|
+
self.logger.info("index_wiped")
|
|
118
|
+
|
|
119
|
+
def handle_commit(self, git_state: GitState) -> None:
|
|
120
|
+
self.index_repository(git_state=git_state)
|
|
121
|
+
# Pass 2 happens inside index_repository eventually
|
|
122
|
+
|
|
123
|
+
def handle_branch_switch(self, git_state: GitState) -> None:
|
|
124
|
+
self.initialize_storage()
|
|
125
|
+
self.bootstrap()
|
|
126
|
+
|
|
127
|
+
def handle_merge(self, git_state: GitState) -> None:
|
|
128
|
+
self.index_repository(git_state=git_state)
|
|
129
|
+
|
|
130
|
+
def handle_revert(self, previous_state: GitState | None, current_state: GitState) -> None:
|
|
131
|
+
self.initialize_storage()
|
|
132
|
+
revert_commit = current_state.head_commit or "unknown"
|
|
133
|
+
reverted_from = previous_state.head_commit if previous_state else "unknown"
|
|
134
|
+
|
|
135
|
+
import json
|
|
136
|
+
import subprocess
|
|
137
|
+
import uuid
|
|
138
|
+
from datetime import UTC, datetime
|
|
139
|
+
|
|
140
|
+
try:
|
|
141
|
+
diff_out = subprocess.check_output( # noqa: S603
|
|
142
|
+
["git", "show", "--name-only", "--format=", revert_commit], # noqa: S607
|
|
143
|
+
cwd=self.settings.repository_path,
|
|
144
|
+
text=True,
|
|
145
|
+
)
|
|
146
|
+
affected = [f.strip() for f in diff_out.splitlines() if f.strip()]
|
|
147
|
+
except Exception:
|
|
148
|
+
affected = []
|
|
149
|
+
|
|
150
|
+
now = int(datetime.now(UTC).timestamp())
|
|
151
|
+
|
|
152
|
+
with self.store.connect() as conn:
|
|
153
|
+
conn.execute(
|
|
154
|
+
"""
|
|
155
|
+
INSERT INTO lessons (lesson_id, branch, revert_commit, reverted_from, what_failed, why_failed, files_affected, status, created_at, expires_at)
|
|
156
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, 'pending', ?, ?)
|
|
157
|
+
""",
|
|
158
|
+
(
|
|
159
|
+
str(uuid.uuid4()),
|
|
160
|
+
current_state.effective_branch,
|
|
161
|
+
revert_commit,
|
|
162
|
+
reverted_from,
|
|
163
|
+
"Revert detected automatically",
|
|
164
|
+
"Awaiting analysis from LLM or User",
|
|
165
|
+
json.dumps(affected),
|
|
166
|
+
now,
|
|
167
|
+
now + (86400 * 7), # 7 days
|
|
168
|
+
),
|
|
169
|
+
)
|
|
170
|
+
|
|
171
|
+
self.logger.info("lesson_pending", revert_commit=revert_commit, reverted_from=reverted_from)
|
|
172
|
+
self.index_repository(git_state=current_state)
|
|
173
|
+
|
|
174
|
+
def index_repository(self, *, git_state: GitState | None = None) -> str | None:
|
|
175
|
+
self.initialize_storage()
|
|
176
|
+
git_state = git_state or self.git.state()
|
|
177
|
+
scanner = RepositoryScanner(
|
|
178
|
+
repository_path=self.settings.repository_path,
|
|
179
|
+
max_file_bytes=self.settings.max_file_bytes,
|
|
180
|
+
)
|
|
181
|
+
scan = scanner.scan()
|
|
182
|
+
|
|
183
|
+
all_parse_results = []
|
|
184
|
+
|
|
185
|
+
# Pass 1: Index all symbols
|
|
186
|
+
for file_info in scan.files:
|
|
187
|
+
rel_path = file_info.relative_path
|
|
188
|
+
existing = self.store.get_file_by_path(rel_path)
|
|
189
|
+
|
|
190
|
+
if existing and existing["content_hash"] == file_info.content_hash:
|
|
191
|
+
# Still need the parse result for Pass 2 if we want to update edges
|
|
192
|
+
# For now, let's just reparse changed files
|
|
193
|
+
continue
|
|
194
|
+
|
|
195
|
+
self.logger.info("indexing_file", path=rel_path)
|
|
196
|
+
|
|
197
|
+
import hashlib
|
|
198
|
+
|
|
199
|
+
file_id_input = rel_path
|
|
200
|
+
file_id_hash = hashlib.sha256(file_id_input.encode()).hexdigest()
|
|
201
|
+
|
|
202
|
+
parse_result = self.parser_registry.parse(file_info.path, relative_path=rel_path)
|
|
203
|
+
all_parse_results.append((file_id_hash, parse_result))
|
|
204
|
+
|
|
205
|
+
symbols_list = []
|
|
206
|
+
for sym in parse_result.symbols:
|
|
207
|
+
symbols_list.append(
|
|
208
|
+
{
|
|
209
|
+
"symbol_id": sym.stable_id,
|
|
210
|
+
"name": sym.name,
|
|
211
|
+
"kind": sym.kind,
|
|
212
|
+
"start_line": sym.start_line,
|
|
213
|
+
"end_line": sym.end_line,
|
|
214
|
+
"ast_hash": sym.ast_hash,
|
|
215
|
+
"metadata": sym.metadata,
|
|
216
|
+
}
|
|
217
|
+
)
|
|
218
|
+
|
|
219
|
+
file_id = self.store.upsert_file_and_symbols(
|
|
220
|
+
file_id=file_id_hash,
|
|
221
|
+
path=rel_path,
|
|
222
|
+
git_oid=file_info.git_oid or "",
|
|
223
|
+
content_hash=file_info.content_hash,
|
|
224
|
+
language=file_info.language or "unknown",
|
|
225
|
+
symbols=symbols_list,
|
|
226
|
+
)
|
|
227
|
+
|
|
228
|
+
# Pass 2: Create structural edges
|
|
229
|
+
# In a real system, we'd only do this for changed files or their dependents.
|
|
230
|
+
# For recovery, we'll re-process all parse results from this run.
|
|
231
|
+
for file_id, parse_result in all_parse_results:
|
|
232
|
+
file_symbols = self.store.get_symbols_by_file(file_id)
|
|
233
|
+
for imp in parse_result.imports:
|
|
234
|
+
target_name = imp.split(".")[-1]
|
|
235
|
+
target_symbols = self.store.get_symbols_by_name(target_name)
|
|
236
|
+
for ts in target_symbols:
|
|
237
|
+
for fs in file_symbols:
|
|
238
|
+
edge_id = stable_hash(
|
|
239
|
+
{
|
|
240
|
+
"source": fs["symbol_id"],
|
|
241
|
+
"target": ts["symbol_id"],
|
|
242
|
+
"type": "depends_on",
|
|
243
|
+
}
|
|
244
|
+
)
|
|
245
|
+
with self.store.connect() as conn:
|
|
246
|
+
conn.execute(
|
|
247
|
+
"""
|
|
248
|
+
INSERT OR IGNORE INTO edges (edge_id, source_symbol, target_symbol, edge_type)
|
|
249
|
+
VALUES (?, ?, ?, 'depends_on')
|
|
250
|
+
""",
|
|
251
|
+
(edge_id, fs["symbol_id"], ts["symbol_id"]),
|
|
252
|
+
)
|
|
253
|
+
|
|
254
|
+
# Pass 3: Wiki Generation
|
|
255
|
+
for file_info in scan.files:
|
|
256
|
+
rel_path = file_info.relative_path
|
|
257
|
+
content = (self.settings.repository_path / rel_path).read_text(errors="ignore")
|
|
258
|
+
|
|
259
|
+
import hashlib
|
|
260
|
+
|
|
261
|
+
file_id_input = rel_path
|
|
262
|
+
file_id_hash = hashlib.sha256(file_id_input.encode()).hexdigest()
|
|
263
|
+
self.wiki.generate_file_wiki(file_id_hash, rel_path, content)
|
|
264
|
+
|
|
265
|
+
self.wiki.generate_project_wiki()
|
|
266
|
+
|
|
267
|
+
self.store.set_active_commit(git_state.effective_branch, git_state.head_commit or "unknown")
|
|
268
|
+
|
|
269
|
+
self.commit_count += 1
|
|
270
|
+
if self.commit_count % 10 == 0:
|
|
271
|
+
import uuid
|
|
272
|
+
|
|
273
|
+
self.store.put_checkpoint(
|
|
274
|
+
checkpoint_id=str(uuid.uuid4()),
|
|
275
|
+
branch=git_state.effective_branch,
|
|
276
|
+
commit_hash=git_state.head_commit or "unknown",
|
|
277
|
+
doing="Automatic periodic checkpoint",
|
|
278
|
+
changed_files="[]",
|
|
279
|
+
next_step="",
|
|
280
|
+
blockers="",
|
|
281
|
+
)
|
|
282
|
+
self.logger.info("auto_checkpoint_created", commit=git_state.head_commit)
|
|
283
|
+
|
|
284
|
+
return git_state.head_commit
|
|
285
|
+
|
|
286
|
+
def status(self) -> RuntimeStatus:
|
|
287
|
+
self.initialize_storage()
|
|
288
|
+
git_state = self.git.state()
|
|
289
|
+
active_commit = self.store.get_active_commit(git_state.effective_branch)
|
|
290
|
+
|
|
291
|
+
with self.store.connect() as conn:
|
|
292
|
+
symbol_count = conn.execute("SELECT COUNT(*) FROM symbols").fetchone()[0]
|
|
293
|
+
file_count = conn.execute("SELECT COUNT(*) FROM files").fetchone()[0]
|
|
294
|
+
|
|
295
|
+
return RuntimeStatus(
|
|
296
|
+
repository_path=self.settings.repository_path.as_posix(),
|
|
297
|
+
branch=git_state.effective_branch,
|
|
298
|
+
git_commit=git_state.head_commit,
|
|
299
|
+
active_commit=active_commit,
|
|
300
|
+
symbols=symbol_count,
|
|
301
|
+
files=file_count,
|
|
302
|
+
mode=self.settings.mode.value,
|
|
303
|
+
is_dirty=git_state.is_dirty,
|
|
304
|
+
)
|
|
305
|
+
|
|
306
|
+
def query_hybrid(
|
|
307
|
+
self,
|
|
308
|
+
query: str,
|
|
309
|
+
*,
|
|
310
|
+
max_tokens: int = 4000,
|
|
311
|
+
) -> tuple[str, list[dict[str, Any]], dict[str, Any]]:
|
|
312
|
+
self.initialize_storage()
|
|
313
|
+
is_dirty = False
|
|
314
|
+
try:
|
|
315
|
+
is_dirty = self.git.state().is_dirty
|
|
316
|
+
except Exception:
|
|
317
|
+
pass
|
|
318
|
+
return self.retrieval_engine.retrieve(query, max_tokens=max_tokens, is_dirty=is_dirty)
|
|
319
|
+
|
|
320
|
+
def doctor(self) -> dict[str, Any]:
|
|
321
|
+
self.initialize_storage()
|
|
322
|
+
with self.store.connect() as conn:
|
|
323
|
+
integrity = conn.execute("PRAGMA quick_check").fetchone()[0]
|
|
324
|
+
return {
|
|
325
|
+
"database_integrity": integrity,
|
|
326
|
+
"status": self.status().__dict__,
|
|
327
|
+
}
|