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.
Files changed (56) hide show
  1. {synap_git-2.3.1 → synap_git-2.4.0}/CHANGELOG.md +20 -1
  2. {synap_git-2.3.1 → synap_git-2.4.0}/PKG-INFO +12 -8
  3. {synap_git-2.3.1 → synap_git-2.4.0}/README.md +10 -6
  4. {synap_git-2.3.1 → synap_git-2.4.0}/src/synap_git/__init__.py +1 -1
  5. {synap_git-2.3.1 → synap_git-2.4.0}/src/synap_git/api/app.py +31 -13
  6. {synap_git-2.3.1 → synap_git-2.4.0}/src/synap_git/cli/main.py +217 -64
  7. {synap_git-2.3.1 → synap_git-2.4.0}/src/synap_git/git/state.py +31 -2
  8. {synap_git-2.3.1 → synap_git-2.4.0}/src/synap_git/indexer/engine.py +27 -33
  9. {synap_git-2.3.1 → synap_git-2.4.0}/src/synap_git/mcp/server.py +10 -2
  10. {synap_git-2.3.1 → synap_git-2.4.0}/src/synap_git/provider/factory.py +31 -5
  11. {synap_git-2.3.1 → synap_git-2.4.0}/src/synap_git/provider/ollama.py +15 -6
  12. {synap_git-2.3.1 → synap_git-2.4.0}/src/synap_git/retrieval/engine.py +48 -18
  13. {synap_git-2.3.1 → synap_git-2.4.0}/src/synap_git/storage/sqlite.py +15 -9
  14. {synap_git-2.3.1 → synap_git-2.4.0}/.gitignore +0 -0
  15. {synap_git-2.3.1 → synap_git-2.4.0}/.synap.example/README.md +0 -0
  16. {synap_git-2.3.1 → synap_git-2.4.0}/LICENSE.md +0 -0
  17. {synap_git-2.3.1 → synap_git-2.4.0}/pyproject.toml +0 -0
  18. {synap_git-2.3.1 → synap_git-2.4.0}/src/synap_git/api/__init__.py +0 -0
  19. {synap_git-2.3.1 → synap_git-2.4.0}/src/synap_git/api/static/index.html +0 -0
  20. {synap_git-2.3.1 → synap_git-2.4.0}/src/synap_git/cli/__init__.py +0 -0
  21. {synap_git-2.3.1 → synap_git-2.4.0}/src/synap_git/cli/__main__.py +0 -0
  22. {synap_git-2.3.1 → synap_git-2.4.0}/src/synap_git/cli/checkpoint.py +0 -0
  23. {synap_git-2.3.1 → synap_git-2.4.0}/src/synap_git/cli/lessons.py +0 -0
  24. {synap_git-2.3.1 → synap_git-2.4.0}/src/synap_git/cli/memory.py +0 -0
  25. {synap_git-2.3.1 → synap_git-2.4.0}/src/synap_git/cli/usage.py +0 -0
  26. {synap_git-2.3.1 → synap_git-2.4.0}/src/synap_git/cli/wiki.py +0 -0
  27. {synap_git-2.3.1 → synap_git-2.4.0}/src/synap_git/config.py +0 -0
  28. {synap_git-2.3.1 → synap_git-2.4.0}/src/synap_git/diagnostics/__init__.py +0 -0
  29. {synap_git-2.3.1 → synap_git-2.4.0}/src/synap_git/diagnostics/logger.py +0 -0
  30. {synap_git-2.3.1 → synap_git-2.4.0}/src/synap_git/diagnostics/tracing.py +0 -0
  31. {synap_git-2.3.1 → synap_git-2.4.0}/src/synap_git/embeddings/__init__.py +0 -0
  32. {synap_git-2.3.1 → synap_git-2.4.0}/src/synap_git/git/__init__.py +0 -0
  33. {synap_git-2.3.1 → synap_git-2.4.0}/src/synap_git/indexer/__init__.py +0 -0
  34. {synap_git-2.3.1 → synap_git-2.4.0}/src/synap_git/indexer/daemon.py +0 -0
  35. {synap_git-2.3.1 → synap_git-2.4.0}/src/synap_git/indexer/scanner.py +0 -0
  36. {synap_git-2.3.1 → synap_git-2.4.0}/src/synap_git/indexer/wiki.py +0 -0
  37. {synap_git-2.3.1 → synap_git-2.4.0}/src/synap_git/mcp/__init__.py +0 -0
  38. {synap_git-2.3.1 → synap_git-2.4.0}/src/synap_git/parser/__init__.py +0 -0
  39. {synap_git-2.3.1 → synap_git-2.4.0}/src/synap_git/parser/registry.py +0 -0
  40. {synap_git-2.3.1 → synap_git-2.4.0}/src/synap_git/provider/__init__.py +0 -0
  41. {synap_git-2.3.1 → synap_git-2.4.0}/src/synap_git/provider/anthropic.py +0 -0
  42. {synap_git-2.3.1 → synap_git-2.4.0}/src/synap_git/provider/base.py +0 -0
  43. {synap_git-2.3.1 → synap_git-2.4.0}/src/synap_git/provider/gemini.py +0 -0
  44. {synap_git-2.3.1 → synap_git-2.4.0}/src/synap_git/provider/openai.py +0 -0
  45. {synap_git-2.3.1 → synap_git-2.4.0}/src/synap_git/provider/openrouter.py +0 -0
  46. {synap_git-2.3.1 → synap_git-2.4.0}/src/synap_git/py.typed +0 -0
  47. {synap_git-2.3.1 → synap_git-2.4.0}/src/synap_git/retrieval/__init__.py +0 -0
  48. {synap_git-2.3.1 → synap_git-2.4.0}/src/synap_git/retrieval/memory.py +0 -0
  49. {synap_git-2.3.1 → synap_git-2.4.0}/src/synap_git/security/__init__.py +0 -0
  50. {synap_git-2.3.1 → synap_git-2.4.0}/src/synap_git/security/redactor.py +0 -0
  51. {synap_git-2.3.1 → synap_git-2.4.0}/src/synap_git/security/sanitizer.py +0 -0
  52. {synap_git-2.3.1 → synap_git-2.4.0}/src/synap_git/security/validator.py +0 -0
  53. {synap_git-2.3.1 → synap_git-2.4.0}/src/synap_git/storage/__init__.py +0 -0
  54. {synap_git-2.3.1 → synap_git-2.4.0}/src/synap_git/utils/__init__.py +0 -0
  55. {synap_git-2.3.1 → synap_git-2.4.0}/src/synap_git/utils/serialization.py +0 -0
  56. {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.2.0] - 2026-08-03
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.4
1
+ Metadata-Version: 2.5
2
2
  Name: synap-git
3
- Version: 2.3.1
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]` — Terminate background daemon.
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 parsers.
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]` — Terminate background daemon.
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 parsers.
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
 
@@ -2,4 +2,4 @@
2
2
 
3
3
  __all__ = ["__version__"]
4
4
 
5
- __version__ = "2.3.1"
5
+ __version__ = "2.4.0"
@@ -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
- status = await asyncio.to_thread(runtime.status)
83
- yield f"data: {json.dumps(status.__dict__)}\n\n"
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
- approved = await asyncio.to_thread(runtime.store.get_lessons, "approved")
119
- pending = await asyncio.to_thread(runtime.store.get_lessons, "pending")
120
- return {"status": "ok", "approved": approved, "pending": pending}
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
- with runtime.store.connect() as conn:
124
- rows = conn.execute("SELECT * FROM llm_calls ORDER BY created_at DESC").fetchall()
125
- return [dict(r) for r in rows]
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
- with runtime.store.connect() as conn:
134
- rows = conn.execute(
135
- "SELECT * FROM checkpoints ORDER BY created_at DESC LIMIT 20"
136
- ).fetchall()
137
- return [dict(r) for r in rows]
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
- " 2. [bold]synap mcp config[/bold] - Get JSON config for your IDE (Cursor/Windsurf)."
575
+ f" 1. [bold]synap index {path}[/bold] - Index/update all symbols into the database."
563
576
  )
564
- console.print(" 3. Connect your AI agent using the MCP config.")
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
- status = hb.get("status")
728
- if status in ("healthy", "degraded", "starting"):
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 not success:
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
- @app.command()
899
- def status(
900
- path: Annotated[str, typer.Argument(help="Repository path to inspect.")] = ".",
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
- console.print(table)
968
- console.print()
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
- console.print(
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() -> None:
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()["info"]["version"]
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 via git pull...[/yellow]")
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
- subprocess.run(["git", "pull", "origin", "main"], check=True)
1113
- # Try pip, then uv
1114
- try:
1115
- subprocess.run([sys.executable, "-m", "pip", "install", "-e", "."], check=True)
1116
- except Exception:
1117
- subprocess.run(["uv", "pip", "install", "-e", "."], check=True)
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"[bold red]✗ Git update failed:[/bold red] {e}")
1123
- raise typer.Exit(1)
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 == "venv" or install_method == "pip":
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 state(self) -> GitState:
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
- return GitState(
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
- from rich.progress import BarColumn, Progress, SpinnerColumn, TaskProgressColumn, TextColumn
294
-
295
- with Progress(
296
- SpinnerColumn(),
297
- TextColumn("[progress.description]{task.description}"),
298
- BarColumn(),
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
- except KeyboardInterrupt:
313
- self.logger.warning("embedding_interrupted_by_user")
314
- raise
315
- except NotImplementedError:
316
- self.logger.warning(
317
- "embedding_not_supported", provider=provider.__class__.__name__
318
- )
319
- return
320
- except Exception as e:
321
- self.logger.debug(
322
- "embedding_failed_for_symbol", symbol=sym["name"], error=str(e)
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(query, max_tokens=max_tokens, is_dirty=is_dirty)
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(self, query: str, max_tokens: int = 4000) -> dict[str, Any]:
26
- result, ctx, debug = self.runtime.query_hybrid(query, max_tokens=max_tokens)
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
- return RateLimitedProvider(provider, calls_per_second=3.0)
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=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=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
- 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]
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 = "logic"
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
- else:
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
- def _cosine_similarity(vec1_json: str, vec2_json: str) -> float:
16
+ @functools.lru_cache(maxsize=4096)
17
+ def _parse_cached_vec(vec_json: str) -> tuple[tuple[float, ...], float]:
16
18
  try:
17
- v1 = json.loads(vec1_json)
18
- v2 = json.loads(vec2_json)
19
- dot = sum(a * b for a, b in zip(v1, v2, strict=False))
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