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,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
+ }