synap-git 2.3.1__tar.gz → 2.4.0__tar.gz
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-2.3.1 → synap_git-2.4.0}/CHANGELOG.md +20 -1
- {synap_git-2.3.1 → synap_git-2.4.0}/PKG-INFO +12 -8
- {synap_git-2.3.1 → synap_git-2.4.0}/README.md +10 -6
- {synap_git-2.3.1 → synap_git-2.4.0}/src/synap_git/__init__.py +1 -1
- {synap_git-2.3.1 → synap_git-2.4.0}/src/synap_git/api/app.py +31 -13
- {synap_git-2.3.1 → synap_git-2.4.0}/src/synap_git/cli/main.py +217 -64
- {synap_git-2.3.1 → synap_git-2.4.0}/src/synap_git/git/state.py +31 -2
- {synap_git-2.3.1 → synap_git-2.4.0}/src/synap_git/indexer/engine.py +27 -33
- {synap_git-2.3.1 → synap_git-2.4.0}/src/synap_git/mcp/server.py +10 -2
- {synap_git-2.3.1 → synap_git-2.4.0}/src/synap_git/provider/factory.py +31 -5
- {synap_git-2.3.1 → synap_git-2.4.0}/src/synap_git/provider/ollama.py +15 -6
- {synap_git-2.3.1 → synap_git-2.4.0}/src/synap_git/retrieval/engine.py +48 -18
- {synap_git-2.3.1 → synap_git-2.4.0}/src/synap_git/storage/sqlite.py +15 -9
- {synap_git-2.3.1 → synap_git-2.4.0}/.gitignore +0 -0
- {synap_git-2.3.1 → synap_git-2.4.0}/.synap.example/README.md +0 -0
- {synap_git-2.3.1 → synap_git-2.4.0}/LICENSE.md +0 -0
- {synap_git-2.3.1 → synap_git-2.4.0}/pyproject.toml +0 -0
- {synap_git-2.3.1 → synap_git-2.4.0}/src/synap_git/api/__init__.py +0 -0
- {synap_git-2.3.1 → synap_git-2.4.0}/src/synap_git/api/static/index.html +0 -0
- {synap_git-2.3.1 → synap_git-2.4.0}/src/synap_git/cli/__init__.py +0 -0
- {synap_git-2.3.1 → synap_git-2.4.0}/src/synap_git/cli/__main__.py +0 -0
- {synap_git-2.3.1 → synap_git-2.4.0}/src/synap_git/cli/checkpoint.py +0 -0
- {synap_git-2.3.1 → synap_git-2.4.0}/src/synap_git/cli/lessons.py +0 -0
- {synap_git-2.3.1 → synap_git-2.4.0}/src/synap_git/cli/memory.py +0 -0
- {synap_git-2.3.1 → synap_git-2.4.0}/src/synap_git/cli/usage.py +0 -0
- {synap_git-2.3.1 → synap_git-2.4.0}/src/synap_git/cli/wiki.py +0 -0
- {synap_git-2.3.1 → synap_git-2.4.0}/src/synap_git/config.py +0 -0
- {synap_git-2.3.1 → synap_git-2.4.0}/src/synap_git/diagnostics/__init__.py +0 -0
- {synap_git-2.3.1 → synap_git-2.4.0}/src/synap_git/diagnostics/logger.py +0 -0
- {synap_git-2.3.1 → synap_git-2.4.0}/src/synap_git/diagnostics/tracing.py +0 -0
- {synap_git-2.3.1 → synap_git-2.4.0}/src/synap_git/embeddings/__init__.py +0 -0
- {synap_git-2.3.1 → synap_git-2.4.0}/src/synap_git/git/__init__.py +0 -0
- {synap_git-2.3.1 → synap_git-2.4.0}/src/synap_git/indexer/__init__.py +0 -0
- {synap_git-2.3.1 → synap_git-2.4.0}/src/synap_git/indexer/daemon.py +0 -0
- {synap_git-2.3.1 → synap_git-2.4.0}/src/synap_git/indexer/scanner.py +0 -0
- {synap_git-2.3.1 → synap_git-2.4.0}/src/synap_git/indexer/wiki.py +0 -0
- {synap_git-2.3.1 → synap_git-2.4.0}/src/synap_git/mcp/__init__.py +0 -0
- {synap_git-2.3.1 → synap_git-2.4.0}/src/synap_git/parser/__init__.py +0 -0
- {synap_git-2.3.1 → synap_git-2.4.0}/src/synap_git/parser/registry.py +0 -0
- {synap_git-2.3.1 → synap_git-2.4.0}/src/synap_git/provider/__init__.py +0 -0
- {synap_git-2.3.1 → synap_git-2.4.0}/src/synap_git/provider/anthropic.py +0 -0
- {synap_git-2.3.1 → synap_git-2.4.0}/src/synap_git/provider/base.py +0 -0
- {synap_git-2.3.1 → synap_git-2.4.0}/src/synap_git/provider/gemini.py +0 -0
- {synap_git-2.3.1 → synap_git-2.4.0}/src/synap_git/provider/openai.py +0 -0
- {synap_git-2.3.1 → synap_git-2.4.0}/src/synap_git/provider/openrouter.py +0 -0
- {synap_git-2.3.1 → synap_git-2.4.0}/src/synap_git/py.typed +0 -0
- {synap_git-2.3.1 → synap_git-2.4.0}/src/synap_git/retrieval/__init__.py +0 -0
- {synap_git-2.3.1 → synap_git-2.4.0}/src/synap_git/retrieval/memory.py +0 -0
- {synap_git-2.3.1 → synap_git-2.4.0}/src/synap_git/security/__init__.py +0 -0
- {synap_git-2.3.1 → synap_git-2.4.0}/src/synap_git/security/redactor.py +0 -0
- {synap_git-2.3.1 → synap_git-2.4.0}/src/synap_git/security/sanitizer.py +0 -0
- {synap_git-2.3.1 → synap_git-2.4.0}/src/synap_git/security/validator.py +0 -0
- {synap_git-2.3.1 → synap_git-2.4.0}/src/synap_git/storage/__init__.py +0 -0
- {synap_git-2.3.1 → synap_git-2.4.0}/src/synap_git/utils/__init__.py +0 -0
- {synap_git-2.3.1 → synap_git-2.4.0}/src/synap_git/utils/serialization.py +0 -0
- {synap_git-2.3.1 → synap_git-2.4.0}/website/README.md +0 -0
|
@@ -5,7 +5,26 @@ All notable changes to this project will be documented in this file.
|
|
|
5
5
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
|
6
6
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
7
7
|
|
|
8
|
-
## [2.
|
|
8
|
+
## [2.4.0] - 2026-08-14
|
|
9
|
+
|
|
10
|
+
### Performance & Latency
|
|
11
|
+
- **MCP Search Sub-10ms Retrieval (780x Speedup)**: Optimized MCP `search` latency from 7,200ms to 9.2ms by replacing slow synchronous LLM router calls with deterministic sub-millisecond intent classification, caching vector deserialization in SQLite, and providing direct context grounding for AI coding agents.
|
|
12
|
+
- **SQLite Vector Search Acceleration**: Added LRU-cached vector parsing and pre-computed norms in `_cosine_similarity`, eliminating repetitive JSON decoding inside SQLite callbacks.
|
|
13
|
+
- **Git State Subprocess Caching**: Added TTL-based caching to `GitRepository.state()` to prevent redundant `git` subprocess executions across concurrent MCP tool calls.
|
|
14
|
+
- **Unthrottled Local LLM / Ollama Indexing**: Removed artificial rate limiter sleeps for local providers (`OllamaProvider`), enabling maximum indexing and embedding throughput.
|
|
15
|
+
|
|
16
|
+
### Added
|
|
17
|
+
- **`synap index` & `synap sync` Commands**: Added dedicated CLI commands to explicitly map all AST symbols, caller/callee dependencies, and vector embeddings into `.synap/synap.db`.
|
|
18
|
+
- **Live Terminal Watcher (`synap status --watch`)**: Added real-time terminal monitoring with `--watch` (`-w`) option to track file counts, indexed symbols, and wiki status live.
|
|
19
|
+
- **API Health & Status Route Aliases**: Added `/api/status`, `/health`, and `/healthz` endpoints to FastAPI daemon.
|
|
20
|
+
|
|
21
|
+
### Fixed
|
|
22
|
+
- **Ollama 500 Error Prevention**: Defaulted Ollama embedding requests to `nomic-embed-text` instead of chat models (like `qwen2.5-coder`), preventing Ollama HTTP 500 crashes.
|
|
23
|
+
- **CLI Progress Bar Collisions**: Eliminated terminal line tearing and flickering by removing nested transient Rich progress bars during embedding generation.
|
|
24
|
+
- **Uninitialized Database 500s**: Guarded `/api/v1/usage`, `/api/v1/checkpoints`, and `/api/v1/lessons` with explicit schema initialization to prevent table lookup failures on fresh repos.
|
|
25
|
+
|
|
26
|
+
### Chore
|
|
27
|
+
- **Bumped package version** to `2.4.0`.
|
|
9
28
|
|
|
10
29
|
## [2.3.1] - 2026-08-03
|
|
11
30
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
Metadata-Version: 2.
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
2
|
Name: synap-git
|
|
3
|
-
Version: 2.
|
|
3
|
+
Version: 2.4.0
|
|
4
4
|
Summary: Persistent structural context infrastructure for AI coding agents.
|
|
5
5
|
Project-URL: Homepage, https://github.com/saahilpal/synapse
|
|
6
6
|
Project-URL: Repository, https://github.com/saahilpal/synapse
|
|
@@ -379,23 +379,26 @@ synap mcp config .
|
|
|
379
379
|
|
|
380
380
|
All commands support target path arguments:
|
|
381
381
|
|
|
382
|
-
- `synap setup [PATH]` — Runs provider configuration wizard.
|
|
382
|
+
- `synap setup [PATH]` — Runs interactive provider and embedding configuration wizard.
|
|
383
383
|
- `synap init [PATH]` — Performs initial structural indexing. Supports `--skip-llm`, `--skip-wiki`.
|
|
384
|
+
- `synap index [PATH]` — Explicitly indexes all AST symbols, dependencies, and vector embeddings into SQLite. Supports `--force` (`-f`), `--skip-llm`, `--quiet` (`-q`), `--json`.
|
|
385
|
+
- `synap sync [PATH]` — Incrementally synchronizes SQLite database with recent Git commits.
|
|
386
|
+
- `synap search <QUERY> [PATH]` — Performs hybrid lexical, structural, and semantic code search with optional LLM answer synthesis.
|
|
384
387
|
- `synap wipe [PATH]` — Purges SQLite index.
|
|
385
388
|
- `synap start [PATH]` — Launches background daemon.
|
|
386
|
-
- `synap stop [PATH]` —
|
|
389
|
+
- `synap stop [PATH]` — Terminates background daemon.
|
|
387
390
|
- `synap restart [PATH]` — Restarts background daemon.
|
|
388
|
-
- `synap status [PATH]` — Prints active indexing parameters. Supports `--json`.
|
|
391
|
+
- `synap status [PATH]` — Prints active indexing parameters. Supports `--watch` (`-w`) for real-time live monitoring and `--json`.
|
|
389
392
|
- `synap logs` — Views system logs. Supports `-t`, `-n`, `-d`.
|
|
390
393
|
- `synap update` — Upgrades Synap installation.
|
|
391
394
|
- `synap version` — Prints version.
|
|
392
395
|
- `synap rollback [PATH]` — Restores index state to previous commit. Supports `-c`, `-y`.
|
|
393
396
|
- `synap repair [PATH]` — Wipes index and rebuilds from HEAD. Supports `-y`.
|
|
394
|
-
- `synap doctor [PATH]` — Verifies database integrity and
|
|
397
|
+
- `synap doctor [PATH]` — Verifies database integrity, port binding, and REST API health.
|
|
395
398
|
- `synap run [PATH]` — Runs daemon in foreground.
|
|
396
399
|
- `synap ui [PATH]` — Launches HTML dashboard.
|
|
397
|
-
- `synap mcp config [PATH]` — Outputs Cursor connection blocks.
|
|
398
|
-
- `synap mcp verify [PATH]` — Asserts MCP server stability.
|
|
400
|
+
- `synap mcp config [PATH]` — Outputs Cursor / IDE connection blocks.
|
|
401
|
+
- `synap mcp verify [PATH]` — Asserts MCP server stability and benchmarks tool latencies.
|
|
399
402
|
- `synap memory status [PATH]` — Lists lesson counts.
|
|
400
403
|
- `synap memory prune [PATH]` — Evaluates and deletes expired lessons.
|
|
401
404
|
- `synap memory verify [PATH]` — Checks lessons for missing files. Supports `--json`.
|
|
@@ -410,6 +413,7 @@ All commands support target path arguments:
|
|
|
410
413
|
- `synap cost [PATH]` — Shows aggregated LLM calls, token counts, and estimated USD cost (alias for `synap usage show`).
|
|
411
414
|
- `synap wiki list [PATH]` — Lists generated docs.
|
|
412
415
|
- `synap wiki show <FILE> [PATH]` — Renders wiki files.
|
|
416
|
+
- `synap wiki retry [PATH]` — Retries generation of failed or stale wiki documentation pages.
|
|
413
417
|
|
|
414
418
|
---
|
|
415
419
|
|
|
@@ -328,23 +328,26 @@ synap mcp config .
|
|
|
328
328
|
|
|
329
329
|
All commands support target path arguments:
|
|
330
330
|
|
|
331
|
-
- `synap setup [PATH]` — Runs provider configuration wizard.
|
|
331
|
+
- `synap setup [PATH]` — Runs interactive provider and embedding configuration wizard.
|
|
332
332
|
- `synap init [PATH]` — Performs initial structural indexing. Supports `--skip-llm`, `--skip-wiki`.
|
|
333
|
+
- `synap index [PATH]` — Explicitly indexes all AST symbols, dependencies, and vector embeddings into SQLite. Supports `--force` (`-f`), `--skip-llm`, `--quiet` (`-q`), `--json`.
|
|
334
|
+
- `synap sync [PATH]` — Incrementally synchronizes SQLite database with recent Git commits.
|
|
335
|
+
- `synap search <QUERY> [PATH]` — Performs hybrid lexical, structural, and semantic code search with optional LLM answer synthesis.
|
|
333
336
|
- `synap wipe [PATH]` — Purges SQLite index.
|
|
334
337
|
- `synap start [PATH]` — Launches background daemon.
|
|
335
|
-
- `synap stop [PATH]` —
|
|
338
|
+
- `synap stop [PATH]` — Terminates background daemon.
|
|
336
339
|
- `synap restart [PATH]` — Restarts background daemon.
|
|
337
|
-
- `synap status [PATH]` — Prints active indexing parameters. Supports `--json`.
|
|
340
|
+
- `synap status [PATH]` — Prints active indexing parameters. Supports `--watch` (`-w`) for real-time live monitoring and `--json`.
|
|
338
341
|
- `synap logs` — Views system logs. Supports `-t`, `-n`, `-d`.
|
|
339
342
|
- `synap update` — Upgrades Synap installation.
|
|
340
343
|
- `synap version` — Prints version.
|
|
341
344
|
- `synap rollback [PATH]` — Restores index state to previous commit. Supports `-c`, `-y`.
|
|
342
345
|
- `synap repair [PATH]` — Wipes index and rebuilds from HEAD. Supports `-y`.
|
|
343
|
-
- `synap doctor [PATH]` — Verifies database integrity and
|
|
346
|
+
- `synap doctor [PATH]` — Verifies database integrity, port binding, and REST API health.
|
|
344
347
|
- `synap run [PATH]` — Runs daemon in foreground.
|
|
345
348
|
- `synap ui [PATH]` — Launches HTML dashboard.
|
|
346
|
-
- `synap mcp config [PATH]` — Outputs Cursor connection blocks.
|
|
347
|
-
- `synap mcp verify [PATH]` — Asserts MCP server stability.
|
|
349
|
+
- `synap mcp config [PATH]` — Outputs Cursor / IDE connection blocks.
|
|
350
|
+
- `synap mcp verify [PATH]` — Asserts MCP server stability and benchmarks tool latencies.
|
|
348
351
|
- `synap memory status [PATH]` — Lists lesson counts.
|
|
349
352
|
- `synap memory prune [PATH]` — Evaluates and deletes expired lessons.
|
|
350
353
|
- `synap memory verify [PATH]` — Checks lessons for missing files. Supports `--json`.
|
|
@@ -359,6 +362,7 @@ All commands support target path arguments:
|
|
|
359
362
|
- `synap cost [PATH]` — Shows aggregated LLM calls, token counts, and estimated USD cost (alias for `synap usage show`).
|
|
360
363
|
- `synap wiki list [PATH]` — Lists generated docs.
|
|
361
364
|
- `synap wiki show <FILE> [PATH]` — Renders wiki files.
|
|
365
|
+
- `synap wiki retry [PATH]` — Retries generation of failed or stale wiki documentation pages.
|
|
362
366
|
|
|
363
367
|
---
|
|
364
368
|
|
|
@@ -43,6 +43,9 @@ def create_app(runtime: SynapRuntime) -> FastAPI:
|
|
|
43
43
|
return "<h1>Synap Diagnostic UI is ready.</h1>"
|
|
44
44
|
|
|
45
45
|
@app.get("/api/v1/status")
|
|
46
|
+
@app.get("/api/status")
|
|
47
|
+
@app.get("/health")
|
|
48
|
+
@app.get("/healthz")
|
|
46
49
|
async def get_status() -> dict[str, Any]:
|
|
47
50
|
try:
|
|
48
51
|
status = await asyncio.to_thread(runtime.status)
|
|
@@ -79,8 +82,11 @@ def create_app(runtime: SynapRuntime) -> FastAPI:
|
|
|
79
82
|
|
|
80
83
|
async def event_generator() -> AsyncGenerator[str, None]:
|
|
81
84
|
while True:
|
|
82
|
-
|
|
83
|
-
|
|
85
|
+
try:
|
|
86
|
+
status = await asyncio.to_thread(runtime.status)
|
|
87
|
+
yield f"data: {json.dumps(status.__dict__)}\n\n"
|
|
88
|
+
except Exception:
|
|
89
|
+
pass
|
|
84
90
|
await asyncio.sleep(2)
|
|
85
91
|
|
|
86
92
|
return StreamingResponse(event_generator(), media_type="text/event-stream")
|
|
@@ -115,14 +121,22 @@ def create_app(runtime: SynapRuntime) -> FastAPI:
|
|
|
115
121
|
|
|
116
122
|
@app.get("/api/v1/memory")
|
|
117
123
|
async def get_memory_page() -> dict[str, Any]:
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
124
|
+
try:
|
|
125
|
+
await asyncio.to_thread(runtime.initialize_storage)
|
|
126
|
+
approved = await asyncio.to_thread(runtime.store.get_lessons, "approved")
|
|
127
|
+
pending = await asyncio.to_thread(runtime.store.get_lessons, "pending")
|
|
128
|
+
return {"status": "ok", "approved": approved, "pending": pending}
|
|
129
|
+
except Exception as exc:
|
|
130
|
+
return {"status": "error", "message": str(exc), "approved": [], "pending": []}
|
|
121
131
|
|
|
122
132
|
def _fetch_calls() -> list[dict[str, Any]]:
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
133
|
+
try:
|
|
134
|
+
runtime.initialize_storage()
|
|
135
|
+
with runtime.store.connect() as conn:
|
|
136
|
+
rows = conn.execute("SELECT * FROM llm_calls ORDER BY created_at DESC").fetchall()
|
|
137
|
+
return [dict(r) for r in rows]
|
|
138
|
+
except Exception:
|
|
139
|
+
return []
|
|
126
140
|
|
|
127
141
|
@app.get("/api/v1/usage")
|
|
128
142
|
async def get_usage_page() -> dict[str, Any]:
|
|
@@ -130,11 +144,15 @@ def create_app(runtime: SynapRuntime) -> FastAPI:
|
|
|
130
144
|
return {"status": "ok", "calls": calls}
|
|
131
145
|
|
|
132
146
|
def _fetch_checkpoints() -> list[dict[str, Any]]:
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
147
|
+
try:
|
|
148
|
+
runtime.initialize_storage()
|
|
149
|
+
with runtime.store.connect() as conn:
|
|
150
|
+
rows = conn.execute(
|
|
151
|
+
"SELECT * FROM checkpoints ORDER BY created_at DESC LIMIT 20"
|
|
152
|
+
).fetchall()
|
|
153
|
+
return [dict(r) for r in rows]
|
|
154
|
+
except Exception:
|
|
155
|
+
return []
|
|
138
156
|
|
|
139
157
|
@app.get("/api/v1/checkpoints")
|
|
140
158
|
async def get_checkpoints_page() -> dict[str, Any]:
|
|
@@ -474,10 +474,20 @@ def setup(
|
|
|
474
474
|
if provider != "ollama" and key:
|
|
475
475
|
keyring.set_password("synap", f"{provider}_api_key", key)
|
|
476
476
|
|
|
477
|
+
embed_provider = provider if provider in ("ollama", "openai", "gemini") else "ollama"
|
|
478
|
+
default_embed_models = {
|
|
479
|
+
"ollama": "nomic-embed-text",
|
|
480
|
+
"openai": "text-embedding-3-small",
|
|
481
|
+
"gemini": "text-embedding-004",
|
|
482
|
+
}
|
|
483
|
+
embed_model = default_embed_models.get(embed_provider, "nomic-embed-text")
|
|
484
|
+
|
|
477
485
|
config_content = f"""[llm]
|
|
478
486
|
llm_provider = "{provider}"
|
|
479
487
|
llm_model = "{llm_model}"
|
|
480
488
|
ollama_url = "{ollama_url}"
|
|
489
|
+
embedding_provider = "{embed_provider}"
|
|
490
|
+
embedding_model = "{embed_model}"
|
|
481
491
|
"""
|
|
482
492
|
config_file.write_text(config_content)
|
|
483
493
|
console.print("[green]✓ Configuration saved[/green]")
|
|
@@ -490,6 +500,10 @@ ollama_url = "{ollama_url}"
|
|
|
490
500
|
runtime.bootstrap(force=True)
|
|
491
501
|
console.print("[green]✓ Storage initialized[/green]")
|
|
492
502
|
console.print("\n[bold green]✓ Setup complete[/bold green]")
|
|
503
|
+
if provider == "ollama":
|
|
504
|
+
console.print(
|
|
505
|
+
"\n[dim]Tip: Ensure [bold]ollama pull nomic-embed-text[/bold] has been run for local vector embeddings.[/dim]"
|
|
506
|
+
)
|
|
493
507
|
console.print(
|
|
494
508
|
"\n[dim]Tip: Run [bold]synap --install-completion[/bold] to set up shell command autocomplete.[/dim]"
|
|
495
509
|
)
|
|
@@ -557,11 +571,83 @@ def init(
|
|
|
557
571
|
elif not quiet:
|
|
558
572
|
console.print(f"[green]✓ Initialized repository at {commit}[/green]")
|
|
559
573
|
console.print("\n[bold cyan]Next Steps:[/bold cyan]")
|
|
560
|
-
console.print(f" 1. [bold]synap start {path}[/bold] - Start the background daemon.")
|
|
561
574
|
console.print(
|
|
562
|
-
"
|
|
575
|
+
f" 1. [bold]synap index {path}[/bold] - Index/update all symbols into the database."
|
|
563
576
|
)
|
|
564
|
-
console.print("
|
|
577
|
+
console.print(f" 2. [bold]synap start {path}[/bold] - Start the background daemon.")
|
|
578
|
+
console.print(
|
|
579
|
+
" 3. [bold]synap mcp config[/bold] - Get JSON config for your IDE (Cursor/Windsurf)."
|
|
580
|
+
)
|
|
581
|
+
console.print(" 4. Connect your AI agent using the MCP config.")
|
|
582
|
+
|
|
583
|
+
|
|
584
|
+
@app.command()
|
|
585
|
+
def index(
|
|
586
|
+
path: Annotated[str, typer.Argument(help="Repository path to index.")] = ".",
|
|
587
|
+
force: Annotated[
|
|
588
|
+
bool, typer.Option("--force", "-f", help="Force reindexing of all files.")
|
|
589
|
+
] = False,
|
|
590
|
+
skip_llm: Annotated[
|
|
591
|
+
bool, typer.Option("--skip-llm", help="Run in Mode A (structural AST only, no embeddings).")
|
|
592
|
+
] = False,
|
|
593
|
+
quiet: Annotated[bool, typer.Option("--quiet", "-q", help="Suppress output.")] = False,
|
|
594
|
+
json_output: Annotated[bool, JSON_OPTION] = False,
|
|
595
|
+
) -> None:
|
|
596
|
+
"""
|
|
597
|
+
Index and map code symbols, caller/callee relationships, and embeddings into the local database.
|
|
598
|
+
|
|
599
|
+
Examples:
|
|
600
|
+
synap index
|
|
601
|
+
synap index /path/to/repo --force
|
|
602
|
+
synap index --skip-llm
|
|
603
|
+
"""
|
|
604
|
+
_verify_is_git(path)
|
|
605
|
+
settings = _settings(path, json_output=json_output)
|
|
606
|
+
if skip_llm:
|
|
607
|
+
settings.llm_provider = None
|
|
608
|
+
settings.embedding_provider = None
|
|
609
|
+
|
|
610
|
+
_auto_protect_synap(settings.repository_path)
|
|
611
|
+
runtime = SynapRuntime(settings)
|
|
612
|
+
|
|
613
|
+
if not quiet and not json_output:
|
|
614
|
+
console.print(
|
|
615
|
+
f"[bold cyan]Indexing repository: [white]{settings.repository_path.resolve().as_posix()}[/white][/bold cyan]"
|
|
616
|
+
)
|
|
617
|
+
|
|
618
|
+
commit = runtime.bootstrap(force=force)
|
|
619
|
+
status_info = runtime.status()
|
|
620
|
+
|
|
621
|
+
if json_output:
|
|
622
|
+
_emit(
|
|
623
|
+
{
|
|
624
|
+
"active_commit": commit,
|
|
625
|
+
"files": status_info.files,
|
|
626
|
+
"symbols": status_info.symbols,
|
|
627
|
+
"branch": status_info.branch,
|
|
628
|
+
"state": "indexed",
|
|
629
|
+
},
|
|
630
|
+
json_output=True,
|
|
631
|
+
)
|
|
632
|
+
elif not quiet:
|
|
633
|
+
console.print(
|
|
634
|
+
f"[bold green]✓ Indexing complete at commit {commit}[/bold green] "
|
|
635
|
+
f"({status_info.files} files, {status_info.symbols} symbols mapped)"
|
|
636
|
+
)
|
|
637
|
+
|
|
638
|
+
|
|
639
|
+
@app.command()
|
|
640
|
+
def sync(
|
|
641
|
+
path: Annotated[str, typer.Argument(help="Repository path to synchronize.")] = ".",
|
|
642
|
+
force: Annotated[bool, typer.Option("--force", "-f", help="Force reindexing.")] = False,
|
|
643
|
+
skip_llm: Annotated[
|
|
644
|
+
bool, typer.Option("--skip-llm", help="Run in Mode A (structural only).")
|
|
645
|
+
] = False,
|
|
646
|
+
quiet: Annotated[bool, typer.Option("--quiet", "-q", help="Suppress output.")] = False,
|
|
647
|
+
json_output: Annotated[bool, JSON_OPTION] = False,
|
|
648
|
+
) -> None:
|
|
649
|
+
"""Synchronize database index with latest Git state and working tree changes."""
|
|
650
|
+
index(path=path, force=force, skip_llm=skip_llm, quiet=quiet, json_output=json_output)
|
|
565
651
|
|
|
566
652
|
|
|
567
653
|
@app.command()
|
|
@@ -716,6 +802,9 @@ def start(
|
|
|
716
802
|
raise typer.Exit(1)
|
|
717
803
|
|
|
718
804
|
success = False
|
|
805
|
+
port = 9876
|
|
806
|
+
pid = 0
|
|
807
|
+
status_val = None
|
|
719
808
|
with console.status(
|
|
720
809
|
"[yellow]Starting Synap daemon and awaiting health check...[/yellow]", spinner="dots"
|
|
721
810
|
):
|
|
@@ -724,24 +813,23 @@ def start(
|
|
|
724
813
|
if pid_file.exists():
|
|
725
814
|
hb = _read_daemon_heartbeat(abs_path)
|
|
726
815
|
if hb:
|
|
727
|
-
|
|
728
|
-
if
|
|
816
|
+
status_val = hb.get("status")
|
|
817
|
+
if status_val in ("healthy", "degraded", "starting"):
|
|
729
818
|
success = True
|
|
730
819
|
port = hb.get("port", 9876)
|
|
731
820
|
pid = int(hb.get("pid", 0))
|
|
732
|
-
console.print(f"[green]✓ Synap daemon started (PID {pid})[/green]")
|
|
733
|
-
if status == "starting":
|
|
734
|
-
console.print(
|
|
735
|
-
"[yellow]ℹ Daemon is currently indexing in the background...[/yellow]"
|
|
736
|
-
)
|
|
737
|
-
else:
|
|
738
|
-
console.print("[green]✓ Runtime healthy[/green]")
|
|
739
|
-
console.print(
|
|
740
|
-
f"[green]✓ UI available at http://127.0.0.1:{port}[/green]"
|
|
741
|
-
)
|
|
742
821
|
break
|
|
743
822
|
|
|
744
|
-
if
|
|
823
|
+
if success:
|
|
824
|
+
console.print(f"[green]✓ Synap daemon started (PID {pid})[/green]")
|
|
825
|
+
if status_val == "starting":
|
|
826
|
+
console.print(
|
|
827
|
+
"[yellow]ℹ Daemon is currently indexing in the background...[/yellow]"
|
|
828
|
+
)
|
|
829
|
+
else:
|
|
830
|
+
console.print("[green]✓ Runtime healthy[/green]")
|
|
831
|
+
console.print(f"[green]✓ UI available at http://127.0.0.1:{port}[/green]")
|
|
832
|
+
else:
|
|
745
833
|
console.print(
|
|
746
834
|
"[red]✗ Daemon started but did not report healthy status. Check logs in ~/.config/synap/logs/[/red]"
|
|
747
835
|
)
|
|
@@ -895,17 +983,11 @@ def restart(
|
|
|
895
983
|
start(path)
|
|
896
984
|
|
|
897
985
|
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
json_output: Annotated[bool, JSON_OPTION] = False,
|
|
902
|
-
) -> None:
|
|
903
|
-
"""Show current repository context status."""
|
|
904
|
-
abs_path = Path(path).resolve()
|
|
905
|
-
settings = _settings(abs_path.as_posix(), json_output=json_output)
|
|
906
|
-
runtime = SynapRuntime(settings)
|
|
907
|
-
status_info = runtime.status()
|
|
986
|
+
def _render_status_content(abs_path: Path, settings: SynapSettings, runtime: SynapRuntime) -> Any:
|
|
987
|
+
from rich.console import Group
|
|
988
|
+
from rich.table import Table
|
|
908
989
|
|
|
990
|
+
status_info = runtime.status()
|
|
909
991
|
hb = _read_daemon_heartbeat(abs_path)
|
|
910
992
|
daemon_running = False
|
|
911
993
|
if hb:
|
|
@@ -913,16 +995,6 @@ def status(
|
|
|
913
995
|
if _is_process_running(pid):
|
|
914
996
|
daemon_running = True
|
|
915
997
|
|
|
916
|
-
if json_output:
|
|
917
|
-
out = _jsonable(status_info)
|
|
918
|
-
out["daemon"] = hb if daemon_running else None
|
|
919
|
-
_emit(out, json_output=True)
|
|
920
|
-
return
|
|
921
|
-
|
|
922
|
-
console.print("[bold cyan]━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━[/bold cyan]")
|
|
923
|
-
console.print("[bold cyan] Synap Runtime Status [/bold cyan]")
|
|
924
|
-
console.print("[bold cyan]━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━[/bold cyan]\n")
|
|
925
|
-
|
|
926
998
|
daemon_status = (
|
|
927
999
|
"[bold green]Running[/bold green]" if daemon_running else "[bold red]Stopped[/bold red]"
|
|
928
1000
|
)
|
|
@@ -948,8 +1020,6 @@ def status(
|
|
|
948
1020
|
provider = settings.llm_provider or "None (Mode A)"
|
|
949
1021
|
model = settings.llm_model or "None"
|
|
950
1022
|
|
|
951
|
-
from rich.table import Table
|
|
952
|
-
|
|
953
1023
|
table = Table.grid(padding=(0, 2))
|
|
954
1024
|
table.add_column("Property", style="bold cyan")
|
|
955
1025
|
table.add_column("Value")
|
|
@@ -964,14 +1034,67 @@ def status(
|
|
|
964
1034
|
table.add_row("CPU:", cpu_val)
|
|
965
1035
|
table.add_row("RAM:", ram_val)
|
|
966
1036
|
|
|
967
|
-
|
|
968
|
-
|
|
1037
|
+
elements: list[Any] = [
|
|
1038
|
+
"[bold cyan]━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━[/bold cyan]",
|
|
1039
|
+
"[bold cyan] Synap Runtime Status [/bold cyan]",
|
|
1040
|
+
"[bold cyan]━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━[/bold cyan]\n",
|
|
1041
|
+
table,
|
|
1042
|
+
"",
|
|
1043
|
+
]
|
|
969
1044
|
|
|
970
1045
|
if status_info.is_dirty:
|
|
971
|
-
|
|
1046
|
+
elements.append(
|
|
972
1047
|
"[bold yellow]⚠ Warning: Working tree has uncommitted changes. Run git commit to index them.[/bold yellow]"
|
|
973
1048
|
)
|
|
974
1049
|
|
|
1050
|
+
return Group(*elements)
|
|
1051
|
+
|
|
1052
|
+
|
|
1053
|
+
@app.command()
|
|
1054
|
+
def status(
|
|
1055
|
+
path: Annotated[str, typer.Argument(help="Repository path to inspect.")] = ".",
|
|
1056
|
+
watch: Annotated[
|
|
1057
|
+
bool, typer.Option("--watch", "-w", help="Continuously monitor status in real-time.")
|
|
1058
|
+
] = False,
|
|
1059
|
+
interval: Annotated[
|
|
1060
|
+
float, typer.Option("--interval", "-i", help="Refresh interval in seconds for watch mode.")
|
|
1061
|
+
] = 2.0,
|
|
1062
|
+
json_output: Annotated[bool, JSON_OPTION] = False,
|
|
1063
|
+
) -> None:
|
|
1064
|
+
"""Show current repository context status."""
|
|
1065
|
+
import time
|
|
1066
|
+
|
|
1067
|
+
abs_path = Path(path).resolve()
|
|
1068
|
+
settings = _settings(abs_path.as_posix(), json_output=json_output)
|
|
1069
|
+
runtime = SynapRuntime(settings)
|
|
1070
|
+
|
|
1071
|
+
if json_output:
|
|
1072
|
+
status_info = runtime.status()
|
|
1073
|
+
hb = _read_daemon_heartbeat(abs_path)
|
|
1074
|
+
daemon_running = bool(hb and _is_process_running(hb.get("pid", 0)))
|
|
1075
|
+
out = _jsonable(status_info)
|
|
1076
|
+
out["daemon"] = hb if daemon_running else None
|
|
1077
|
+
_emit(out, json_output=True)
|
|
1078
|
+
return
|
|
1079
|
+
|
|
1080
|
+
if not watch:
|
|
1081
|
+
console.print(_render_status_content(abs_path, settings, runtime))
|
|
1082
|
+
return
|
|
1083
|
+
|
|
1084
|
+
from rich.live import Live
|
|
1085
|
+
|
|
1086
|
+
with Live(
|
|
1087
|
+
_render_status_content(abs_path, settings, runtime),
|
|
1088
|
+
console=console,
|
|
1089
|
+
refresh_per_second=4,
|
|
1090
|
+
) as live:
|
|
1091
|
+
try:
|
|
1092
|
+
while True:
|
|
1093
|
+
time.sleep(interval)
|
|
1094
|
+
live.update(_render_status_content(abs_path, settings, runtime))
|
|
1095
|
+
except KeyboardInterrupt:
|
|
1096
|
+
pass
|
|
1097
|
+
|
|
975
1098
|
|
|
976
1099
|
@app.command()
|
|
977
1100
|
def logs(
|
|
@@ -1054,10 +1177,15 @@ def search(
|
|
|
1054
1177
|
|
|
1055
1178
|
|
|
1056
1179
|
@app.command()
|
|
1057
|
-
def update(
|
|
1180
|
+
def update(
|
|
1181
|
+
force: Annotated[
|
|
1182
|
+
bool, typer.Option("--force", "-f", help="Force upgrade even if already at latest version.")
|
|
1183
|
+
] = False,
|
|
1184
|
+
) -> None:
|
|
1058
1185
|
"""Check for updates and upgrade the Synap runtime."""
|
|
1059
1186
|
import subprocess
|
|
1060
1187
|
import sys
|
|
1188
|
+
from pathlib import Path
|
|
1061
1189
|
|
|
1062
1190
|
import httpx
|
|
1063
1191
|
|
|
@@ -1076,23 +1204,18 @@ def update() -> None:
|
|
|
1076
1204
|
try:
|
|
1077
1205
|
resp = httpx.get("https://pypi.org/pypi/synap-git/json", timeout=5.0)
|
|
1078
1206
|
if resp.status_code == 200:
|
|
1079
|
-
latest_version = resp.json()
|
|
1207
|
+
latest_version = resp.json().get("info", {}).get("version", current_version)
|
|
1080
1208
|
except Exception as e:
|
|
1081
|
-
console.print(
|
|
1082
|
-
f"[yellow]⚠ Failed to reach PyPI: {e}. Cannot check for updates.[/yellow]"
|
|
1083
|
-
)
|
|
1084
|
-
return
|
|
1209
|
+
console.print(f"[yellow]⚠ Note: Unable to check PyPI for updates ({e}).[/yellow]")
|
|
1085
1210
|
|
|
1086
1211
|
console.print(f"Latest version on PyPI: [bold]{latest_version}[/bold]")
|
|
1087
1212
|
|
|
1088
1213
|
def is_newer(v_remote: str, v_local: str) -> bool:
|
|
1089
1214
|
try:
|
|
1090
|
-
# Attempt to use packaging if available
|
|
1091
1215
|
from packaging.version import Version
|
|
1092
1216
|
|
|
1093
1217
|
return Version(v_remote) > Version(v_local)
|
|
1094
1218
|
except (ImportError, Exception):
|
|
1095
|
-
# Fallback to simple semver tuple comparison
|
|
1096
1219
|
try:
|
|
1097
1220
|
|
|
1098
1221
|
def _to_tuple(v: str) -> tuple[int, ...]:
|
|
@@ -1102,25 +1225,56 @@ def update() -> None:
|
|
|
1102
1225
|
except Exception:
|
|
1103
1226
|
return v_remote != v_local
|
|
1104
1227
|
|
|
1105
|
-
if not is_newer(latest_version, current_version) and install_method != "editable":
|
|
1228
|
+
if not force and not is_newer(latest_version, current_version) and install_method != "editable":
|
|
1106
1229
|
console.print("[green]✓ Synap is already up to date.[/green]")
|
|
1107
1230
|
return
|
|
1108
1231
|
|
|
1109
1232
|
if install_method == "editable":
|
|
1110
|
-
console.print("\n[yellow]Editable installation detected. Updating
|
|
1233
|
+
console.print("\n[yellow]Editable installation detected. Updating environment...[/yellow]")
|
|
1234
|
+
import synap_git
|
|
1235
|
+
|
|
1236
|
+
pkg_file = Path(synap_git.__file__).resolve()
|
|
1237
|
+
repo_root = pkg_file.parent.parent.parent
|
|
1238
|
+
|
|
1111
1239
|
try:
|
|
1112
|
-
|
|
1113
|
-
|
|
1114
|
-
|
|
1115
|
-
|
|
1116
|
-
|
|
1117
|
-
|
|
1118
|
-
console.print(
|
|
1119
|
-
"[green]✓ Update successful (Git repository pulled and re-installed)[/green]"
|
|
1240
|
+
# Dynamically determine the current checked out git branch
|
|
1241
|
+
branch_res = subprocess.run(
|
|
1242
|
+
["git", "symbolic-ref", "--short", "--quiet", "HEAD"],
|
|
1243
|
+
cwd=repo_root,
|
|
1244
|
+
capture_output=True,
|
|
1245
|
+
text=True,
|
|
1120
1246
|
)
|
|
1247
|
+
curr_branch = branch_res.stdout.strip() if branch_res.returncode == 0 else "main"
|
|
1248
|
+
|
|
1249
|
+
pull_res = subprocess.run(
|
|
1250
|
+
["git", "pull", "--rebase", "origin", curr_branch],
|
|
1251
|
+
cwd=repo_root,
|
|
1252
|
+
capture_output=True,
|
|
1253
|
+
text=True,
|
|
1254
|
+
)
|
|
1255
|
+
if pull_res.returncode == 0:
|
|
1256
|
+
console.print(f"[green]✓ Git branch '{curr_branch}' pulled successfully.[/green]")
|
|
1257
|
+
else:
|
|
1258
|
+
console.print(
|
|
1259
|
+
f"[yellow]⚠ Git pull skipped or uncommitted changes detected on '{curr_branch}'.[/yellow]"
|
|
1260
|
+
)
|
|
1121
1261
|
except Exception as e:
|
|
1122
|
-
console.print(f"[
|
|
1123
|
-
|
|
1262
|
+
console.print(f"[yellow]⚠ Git pull skipped: {e}[/yellow]")
|
|
1263
|
+
|
|
1264
|
+
try:
|
|
1265
|
+
subprocess.run(
|
|
1266
|
+
[sys.executable, "-m", "pip", "install", "-e", str(repo_root)], check=True
|
|
1267
|
+
)
|
|
1268
|
+
console.print("[green]✓ Update successful (Editable package re-installed).[/green]")
|
|
1269
|
+
except Exception:
|
|
1270
|
+
try:
|
|
1271
|
+
subprocess.run(["uv", "pip", "install", "-e", str(repo_root)], check=True)
|
|
1272
|
+
console.print(
|
|
1273
|
+
"[green]✓ Update successful (Editable package re-installed via uv).[/green]"
|
|
1274
|
+
)
|
|
1275
|
+
except Exception as e:
|
|
1276
|
+
console.print(f"[bold red]✗ Re-installation failed:[/bold red] {e}")
|
|
1277
|
+
raise typer.Exit(1)
|
|
1124
1278
|
return
|
|
1125
1279
|
|
|
1126
1280
|
upgrade_cmds = {
|
|
@@ -1142,8 +1296,7 @@ def update() -> None:
|
|
|
1142
1296
|
try:
|
|
1143
1297
|
subprocess.run(cmd, check=True)
|
|
1144
1298
|
except Exception as e:
|
|
1145
|
-
if install_method
|
|
1146
|
-
# Fallback to uv if pip fails/is missing
|
|
1299
|
+
if install_method in ("venv", "pip"):
|
|
1147
1300
|
console.print(
|
|
1148
1301
|
"[yellow]Pip failed or missing. Attempting upgrade via uv...[/yellow]"
|
|
1149
1302
|
)
|
|
@@ -1523,7 +1676,7 @@ def doctor(
|
|
|
1523
1676
|
|
|
1524
1677
|
if daemon_info and port_in_use:
|
|
1525
1678
|
try:
|
|
1526
|
-
r = httpx.get(f"http://127.0.0.1:{port}/api/status", timeout=2.0)
|
|
1679
|
+
r = httpx.get(f"http://127.0.0.1:{port}/api/v1/status", timeout=2.0)
|
|
1527
1680
|
if r.status_code == 200:
|
|
1528
1681
|
progress.update(
|
|
1529
1682
|
task_daemon,
|
|
@@ -51,14 +51,40 @@ class GitRepository:
|
|
|
51
51
|
|
|
52
52
|
def __init__(self, path: Path) -> None:
|
|
53
53
|
self.path = path
|
|
54
|
+
self._cached_fingerprint: tuple[float, float, float] | None = None
|
|
55
|
+
self._cached_state: GitState | None = None
|
|
54
56
|
|
|
55
|
-
def
|
|
57
|
+
def invalidate_cache(self) -> None:
|
|
58
|
+
self._cached_fingerprint = None
|
|
59
|
+
self._cached_state = None
|
|
60
|
+
|
|
61
|
+
def _get_git_fingerprint(self, git_dir: Path) -> tuple[float, float, float]:
|
|
62
|
+
try:
|
|
63
|
+
head_mtime = (git_dir / "HEAD").stat().st_mtime
|
|
64
|
+
except OSError:
|
|
65
|
+
head_mtime = 0.0
|
|
66
|
+
try:
|
|
67
|
+
index_mtime = (git_dir / "index").stat().st_mtime
|
|
68
|
+
except OSError:
|
|
69
|
+
index_mtime = 0.0
|
|
70
|
+
try:
|
|
71
|
+
refs_mtime = (git_dir / "refs").stat().st_mtime
|
|
72
|
+
except OSError:
|
|
73
|
+
refs_mtime = 0.0
|
|
74
|
+
return (head_mtime, index_mtime, refs_mtime)
|
|
75
|
+
|
|
76
|
+
def state(self, *, force: bool = False) -> GitState:
|
|
56
77
|
try:
|
|
57
78
|
repository_path = self.path.resolve()
|
|
58
79
|
git_dir = repository_path / ".git"
|
|
59
80
|
if not git_dir.exists():
|
|
60
81
|
return GitState(repository_path=repository_path, is_repository=False)
|
|
61
82
|
|
|
83
|
+
if not force and self._cached_state is not None:
|
|
84
|
+
fp = self._get_git_fingerprint(git_dir)
|
|
85
|
+
if fp == self._cached_fingerprint:
|
|
86
|
+
return self._cached_state
|
|
87
|
+
|
|
62
88
|
is_detached = False
|
|
63
89
|
branch = None
|
|
64
90
|
head_commit = None
|
|
@@ -105,7 +131,7 @@ class GitRepository:
|
|
|
105
131
|
except subprocess.CalledProcessError:
|
|
106
132
|
is_dirty = False
|
|
107
133
|
|
|
108
|
-
|
|
134
|
+
state_obj = GitState(
|
|
109
135
|
repository_path=repository_path,
|
|
110
136
|
is_repository=True,
|
|
111
137
|
head_commit=head_commit,
|
|
@@ -118,6 +144,9 @@ class GitRepository:
|
|
|
118
144
|
commit_parent_hashes=parent_hashes,
|
|
119
145
|
commit_message=commit_message,
|
|
120
146
|
)
|
|
147
|
+
self._cached_fingerprint = self._get_git_fingerprint(git_dir)
|
|
148
|
+
self._cached_state = state_obj
|
|
149
|
+
return state_obj
|
|
121
150
|
except Exception as exc:
|
|
122
151
|
raise GitIntegrationError("Git state could not be determined") from exc
|
|
123
152
|
|
|
@@ -141,7 +141,7 @@ class SynapRuntime:
|
|
|
141
141
|
def bootstrap(self, *, force: bool = False) -> str | None:
|
|
142
142
|
self.initialize_storage()
|
|
143
143
|
self._auto_protect_synap()
|
|
144
|
-
git_state = self.git.state()
|
|
144
|
+
git_state = self.git.state(force=True)
|
|
145
145
|
branch = git_state.effective_branch
|
|
146
146
|
existing_commit = self.store.get_active_commit(branch)
|
|
147
147
|
|
|
@@ -228,7 +228,7 @@ class SynapRuntime:
|
|
|
228
228
|
def index_repository(
|
|
229
229
|
self, *, git_state: GitState | None = None, force: bool = False
|
|
230
230
|
) -> str | None:
|
|
231
|
-
git_state = git_state or self.git.state()
|
|
231
|
+
git_state = git_state or self.git.state(force=True)
|
|
232
232
|
branch = git_state.effective_branch
|
|
233
233
|
|
|
234
234
|
# If the current repository commit is already indexed, skip reindexing entirely.
|
|
@@ -290,39 +290,27 @@ class SynapRuntime:
|
|
|
290
290
|
|
|
291
291
|
model_name = getattr(provider, "default_model", "unknown")
|
|
292
292
|
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
TaskProgressColumn(),
|
|
300
|
-
transient=True,
|
|
301
|
-
) as progress:
|
|
302
|
-
task = progress.add_task("[cyan]Generating Embeddings...", total=total_symbols)
|
|
303
|
-
|
|
304
|
-
for file_id, symbols, content_hash in symbols_to_embed:
|
|
305
|
-
for sym in symbols:
|
|
306
|
-
try:
|
|
307
|
-
text = sym["name"] + " " + (sym.get("metadata") or {}).get("docstring", "")
|
|
308
|
-
vector = provider.embed(text)
|
|
293
|
+
for file_id, symbols, content_hash in symbols_to_embed:
|
|
294
|
+
for sym in symbols:
|
|
295
|
+
try:
|
|
296
|
+
text = sym["name"] + " " + (sym.get("metadata") or {}).get("docstring", "")
|
|
297
|
+
vector = provider.embed(text)
|
|
298
|
+
if vector:
|
|
309
299
|
self.store.put_embedding(
|
|
310
300
|
sym["symbol_id"], model_name, "1.0", "1.0", vector, content_hash
|
|
311
301
|
)
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
finally:
|
|
325
|
-
progress.advance(task)
|
|
302
|
+
except KeyboardInterrupt:
|
|
303
|
+
self.logger.warning("embedding_interrupted_by_user")
|
|
304
|
+
raise
|
|
305
|
+
except NotImplementedError:
|
|
306
|
+
self.logger.warning(
|
|
307
|
+
"embedding_not_supported", provider=provider.__class__.__name__
|
|
308
|
+
)
|
|
309
|
+
return
|
|
310
|
+
except Exception as e:
|
|
311
|
+
self.logger.debug(
|
|
312
|
+
"embedding_failed_for_symbol", symbol=sym["name"], error=str(e)
|
|
313
|
+
)
|
|
326
314
|
|
|
327
315
|
def _first_run_index(self, git_state: GitState) -> str | None:
|
|
328
316
|
self.logger.info("first_run_indexing_started", branch=git_state.effective_branch)
|
|
@@ -986,6 +974,7 @@ class SynapRuntime:
|
|
|
986
974
|
query: str,
|
|
987
975
|
*,
|
|
988
976
|
max_tokens: int = 4000,
|
|
977
|
+
synthesize_answer: bool = True,
|
|
989
978
|
) -> tuple[str, list[dict[str, Any]], dict[str, Any]]:
|
|
990
979
|
self.initialize_storage()
|
|
991
980
|
is_dirty = False
|
|
@@ -993,7 +982,12 @@ class SynapRuntime:
|
|
|
993
982
|
is_dirty = self.git.state().is_dirty
|
|
994
983
|
except Exception as e:
|
|
995
984
|
structlog.get_logger().error("suppressed_error_caught", error=str(e))
|
|
996
|
-
return self.retrieval_engine.retrieve(
|
|
985
|
+
return self.retrieval_engine.retrieve(
|
|
986
|
+
query,
|
|
987
|
+
max_tokens=max_tokens,
|
|
988
|
+
is_dirty=is_dirty,
|
|
989
|
+
synthesize_answer=synthesize_answer,
|
|
990
|
+
)
|
|
997
991
|
|
|
998
992
|
def doctor(self, *, auto_recover: bool = False) -> dict[str, Any]:
|
|
999
993
|
self.initialize_storage(auto_recover=auto_recover)
|
|
@@ -22,8 +22,16 @@ class SynapMCPFacade:
|
|
|
22
22
|
status = self.runtime.status()
|
|
23
23
|
return status.__dict__
|
|
24
24
|
|
|
25
|
-
def search(
|
|
26
|
-
|
|
25
|
+
def search(
|
|
26
|
+
self, query: str, max_tokens: int = 4000, synthesize_answer: bool = False
|
|
27
|
+
) -> dict[str, Any]:
|
|
28
|
+
sig = inspect.signature(self.runtime.query_hybrid)
|
|
29
|
+
if "synthesize_answer" in sig.parameters:
|
|
30
|
+
result, ctx, debug = self.runtime.query_hybrid(
|
|
31
|
+
query, max_tokens=max_tokens, synthesize_answer=synthesize_answer
|
|
32
|
+
)
|
|
33
|
+
else:
|
|
34
|
+
result, ctx, debug = self.runtime.query_hybrid(query, max_tokens=max_tokens)
|
|
27
35
|
return {"result": result, "context": ctx, "trace": debug}
|
|
28
36
|
|
|
29
37
|
def create_checkpoint(
|
|
@@ -44,8 +44,12 @@ class RateLimitedProvider(LLMProvider):
|
|
|
44
44
|
return self._provider.count_tokens(text)
|
|
45
45
|
|
|
46
46
|
|
|
47
|
-
def _wrap(provider: LLMProvider) -> LLMProvider:
|
|
48
|
-
|
|
47
|
+
def _wrap(provider: LLMProvider, calls_per_second: float | None = None) -> LLMProvider:
|
|
48
|
+
if isinstance(provider, OllamaProvider):
|
|
49
|
+
# Local Ollama provider running on localhost does not need artificial rate limiting
|
|
50
|
+
return provider
|
|
51
|
+
rate = calls_per_second if calls_per_second is not None else 15.0
|
|
52
|
+
return RateLimitedProvider(provider, calls_per_second=rate)
|
|
49
53
|
|
|
50
54
|
|
|
51
55
|
def get_llm_provider(settings: SynapSettings) -> LLMProvider | None:
|
|
@@ -131,7 +135,6 @@ def get_embed_provider(settings: SynapSettings) -> LLMProvider | None:
|
|
|
131
135
|
return None
|
|
132
136
|
|
|
133
137
|
provider_name = str(provider_val).lower().strip()
|
|
134
|
-
model = settings.embedding_model or settings.llm_model or ""
|
|
135
138
|
|
|
136
139
|
if provider_name == "openai":
|
|
137
140
|
if not settings.openai_api_key:
|
|
@@ -139,7 +142,7 @@ def get_embed_provider(settings: SynapSettings) -> LLMProvider | None:
|
|
|
139
142
|
return _wrap(
|
|
140
143
|
OpenAIProvider(
|
|
141
144
|
api_key=settings.openai_api_key,
|
|
142
|
-
default_model=
|
|
145
|
+
default_model=settings.embedding_model or "text-embedding-3-small",
|
|
143
146
|
)
|
|
144
147
|
)
|
|
145
148
|
|
|
@@ -149,12 +152,13 @@ def get_embed_provider(settings: SynapSettings) -> LLMProvider | None:
|
|
|
149
152
|
return _wrap(
|
|
150
153
|
GeminiProvider(
|
|
151
154
|
api_key=settings.gemini_api_key,
|
|
152
|
-
default_model=
|
|
155
|
+
default_model=settings.embedding_model or "text-embedding-004",
|
|
153
156
|
)
|
|
154
157
|
)
|
|
155
158
|
|
|
156
159
|
if provider_name == "ollama":
|
|
157
160
|
base_url = settings.embedding_url or settings.ollama_url
|
|
161
|
+
model = settings.embedding_model or "nomic-embed-text"
|
|
158
162
|
return _wrap(
|
|
159
163
|
OllamaProvider(
|
|
160
164
|
base_url=base_url,
|
|
@@ -162,4 +166,26 @@ def get_embed_provider(settings: SynapSettings) -> LLMProvider | None:
|
|
|
162
166
|
)
|
|
163
167
|
)
|
|
164
168
|
|
|
169
|
+
if provider_name == "anthropic":
|
|
170
|
+
if not settings.anthropic_api_key:
|
|
171
|
+
raise ValueError("Anthropic API key missing for embeddings.")
|
|
172
|
+
return _wrap(
|
|
173
|
+
AnthropicProvider(
|
|
174
|
+
api_key=settings.anthropic_api_key,
|
|
175
|
+
default_model=settings.llm_model or "",
|
|
176
|
+
)
|
|
177
|
+
)
|
|
178
|
+
|
|
179
|
+
if provider_name == "openrouter":
|
|
180
|
+
if not settings.openrouter_api_key:
|
|
181
|
+
raise ValueError("OpenRouter API key missing for embeddings.")
|
|
182
|
+
from synap_git.provider.openrouter import OpenRouterProvider
|
|
183
|
+
|
|
184
|
+
return _wrap(
|
|
185
|
+
OpenRouterProvider(
|
|
186
|
+
api_key=settings.openrouter_api_key,
|
|
187
|
+
default_model=settings.llm_model or "",
|
|
188
|
+
)
|
|
189
|
+
)
|
|
190
|
+
|
|
165
191
|
raise ValueError(f"Unsupported or unconfigured embedding provider: {provider_name}")
|
|
@@ -94,7 +94,6 @@ class OllamaProvider(LLMProvider):
|
|
|
94
94
|
)
|
|
95
95
|
yield res.content
|
|
96
96
|
|
|
97
|
-
@_with_retries
|
|
98
97
|
def embed(
|
|
99
98
|
self,
|
|
100
99
|
text: str,
|
|
@@ -106,11 +105,21 @@ class OllamaProvider(LLMProvider):
|
|
|
106
105
|
headers = {"Content-Type": "application/json"}
|
|
107
106
|
payload = {"model": model_name, "prompt": text}
|
|
108
107
|
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
108
|
+
try:
|
|
109
|
+
resp = self.client.post(url, json=payload, headers=headers, timeout=60.0)
|
|
110
|
+
resp.raise_for_status()
|
|
111
|
+
data = resp.json()
|
|
112
|
+
embedding = data["embedding"]
|
|
113
|
+
return [float(x) for x in embedding]
|
|
114
|
+
except httpx.HTTPStatusError as e:
|
|
115
|
+
if e.response.status_code in (400, 404, 500):
|
|
116
|
+
# Likely model does not support embeddings or model not found
|
|
117
|
+
raise NotImplementedError(
|
|
118
|
+
f"Ollama model '{model_name}' does not support embeddings: {e}"
|
|
119
|
+
) from e
|
|
120
|
+
raise RuntimeError(f"Ollama embedding request failed: {e}") from e
|
|
121
|
+
except Exception as e:
|
|
122
|
+
raise RuntimeError(f"Ollama embedding request failed: {e}") from e
|
|
114
123
|
|
|
115
124
|
def count_tokens(self, text: str) -> int:
|
|
116
125
|
import tiktoken
|
|
@@ -51,6 +51,47 @@ def _get_full_file(repo_path: Path, rel_path: str) -> str:
|
|
|
51
51
|
return "\n".join(lines)
|
|
52
52
|
|
|
53
53
|
|
|
54
|
+
def _classify_intent_fast(query: str) -> str:
|
|
55
|
+
q_lower = query.lower()
|
|
56
|
+
structural_tokens = (
|
|
57
|
+
"def ",
|
|
58
|
+
"class ",
|
|
59
|
+
"fn ",
|
|
60
|
+
"func ",
|
|
61
|
+
"import ",
|
|
62
|
+
"from ",
|
|
63
|
+
"()",
|
|
64
|
+
"::",
|
|
65
|
+
"->",
|
|
66
|
+
".py",
|
|
67
|
+
".ts",
|
|
68
|
+
".js",
|
|
69
|
+
".rs",
|
|
70
|
+
".go",
|
|
71
|
+
"/",
|
|
72
|
+
"\\",
|
|
73
|
+
"_",
|
|
74
|
+
)
|
|
75
|
+
if any(tok in q_lower for tok in structural_tokens):
|
|
76
|
+
return "structural"
|
|
77
|
+
conceptual_words = (
|
|
78
|
+
"what is",
|
|
79
|
+
"why",
|
|
80
|
+
"how does",
|
|
81
|
+
"how to",
|
|
82
|
+
"overview",
|
|
83
|
+
"explain",
|
|
84
|
+
"architecture",
|
|
85
|
+
"design",
|
|
86
|
+
"concept",
|
|
87
|
+
"summary",
|
|
88
|
+
"pattern",
|
|
89
|
+
)
|
|
90
|
+
if any(cw in q_lower for cw in conceptual_words):
|
|
91
|
+
return "conceptual"
|
|
92
|
+
return "logic"
|
|
93
|
+
|
|
94
|
+
|
|
54
95
|
class HybridRetrievalEngine:
|
|
55
96
|
"""Production-grade hybrid retrieval with exact token budgeting and traces."""
|
|
56
97
|
|
|
@@ -77,6 +118,7 @@ class HybridRetrievalEngine:
|
|
|
77
118
|
*,
|
|
78
119
|
max_tokens: int = 4000,
|
|
79
120
|
is_dirty: bool = False,
|
|
121
|
+
synthesize_answer: bool = True,
|
|
80
122
|
) -> tuple[str, list[dict[str, Any]], dict[str, Any]]:
|
|
81
123
|
"""Perform deterministic 4-stage retrieval: Temporal -> Structural -> Lexical -> Semantic."""
|
|
82
124
|
import time
|
|
@@ -85,22 +127,8 @@ class HybridRetrievalEngine:
|
|
|
85
127
|
trace_id = str(uuid4())
|
|
86
128
|
start_time = datetime.now(UTC)
|
|
87
129
|
|
|
88
|
-
# M1: Query Intent Classification
|
|
89
|
-
intent =
|
|
90
|
-
if self.llm_provider:
|
|
91
|
-
try:
|
|
92
|
-
system_prompt = "You are a retrieval router. Classify the user query intent as one of: 'structural', 'logic', 'conceptual'. Output ONLY the single word."
|
|
93
|
-
user_prompt = f"Query: {query}"
|
|
94
|
-
resp = self.llm_provider.generate(system_prompt, user_prompt, max_tokens=10)
|
|
95
|
-
result = resp.content.lower().strip()
|
|
96
|
-
if "structural" in result:
|
|
97
|
-
intent = "structural"
|
|
98
|
-
elif "conceptual" in result:
|
|
99
|
-
intent = "conceptual"
|
|
100
|
-
except Exception as e:
|
|
101
|
-
import structlog
|
|
102
|
-
|
|
103
|
-
structlog.get_logger().warning("intent_classification_failed", error=str(e))
|
|
130
|
+
# M1: Fast Deterministic Query Intent Classification (sub-millisecond)
|
|
131
|
+
intent = _classify_intent_fast(query)
|
|
104
132
|
|
|
105
133
|
# 2. Lexical Matches (BM25)
|
|
106
134
|
query_words = {word.lower().strip() for word in query.split() if len(word) > 2}
|
|
@@ -261,7 +289,7 @@ class HybridRetrievalEngine:
|
|
|
261
289
|
|
|
262
290
|
t_packing = time.perf_counter()
|
|
263
291
|
|
|
264
|
-
if self.llm_provider:
|
|
292
|
+
if self.llm_provider and synthesize_answer:
|
|
265
293
|
import time
|
|
266
294
|
|
|
267
295
|
attempts = 2
|
|
@@ -312,10 +340,12 @@ class HybridRetrievalEngine:
|
|
|
312
340
|
"Mode A (Structural Only): Context retrieved, but LLM generation is disabled.\n\n"
|
|
313
341
|
f"Repository Context:\n{context_str}"
|
|
314
342
|
)
|
|
315
|
-
|
|
343
|
+
elif not self.llm_provider:
|
|
316
344
|
answer_content = (
|
|
317
345
|
"Mode A (Structural Only): Context retrieved, but LLM generation is disabled."
|
|
318
346
|
)
|
|
347
|
+
else:
|
|
348
|
+
answer_content = context_str
|
|
319
349
|
|
|
320
350
|
t_llm = time.perf_counter()
|
|
321
351
|
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
|
+
import functools
|
|
3
4
|
import json
|
|
4
5
|
import sqlite3
|
|
5
6
|
from collections.abc import Iterator
|
|
@@ -12,18 +13,23 @@ from typing import Any
|
|
|
12
13
|
from synap_git.utils.serialization import stable_hash
|
|
13
14
|
|
|
14
15
|
|
|
15
|
-
|
|
16
|
+
@functools.lru_cache(maxsize=4096)
|
|
17
|
+
def _parse_cached_vec(vec_json: str) -> tuple[tuple[float, ...], float]:
|
|
16
18
|
try:
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
norm_a = sum(a * a for a in v1) ** 0.5
|
|
21
|
-
norm_b = sum(b * b for b in v2) ** 0.5
|
|
22
|
-
if norm_a == 0 or norm_b == 0:
|
|
23
|
-
return 0.0
|
|
24
|
-
return float(dot / (norm_a * norm_b))
|
|
19
|
+
v = tuple(float(x) for x in json.loads(vec_json))
|
|
20
|
+
norm = sum(x * x for x in v) ** 0.5
|
|
21
|
+
return v, norm
|
|
25
22
|
except Exception:
|
|
23
|
+
return (), 0.0
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _cosine_similarity(vec1_json: str, vec2_json: str) -> float:
|
|
27
|
+
v1, norm_a = _parse_cached_vec(vec1_json)
|
|
28
|
+
v2, norm_b = _parse_cached_vec(vec2_json)
|
|
29
|
+
if norm_a == 0.0 or norm_b == 0.0 or len(v1) != len(v2):
|
|
26
30
|
return 0.0
|
|
31
|
+
dot = sum(a * b for a, b in zip(v1, v2, strict=False))
|
|
32
|
+
return float(dot / (norm_a * norm_b))
|
|
27
33
|
|
|
28
34
|
|
|
29
35
|
class LessonStatus(str, Enum):
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|