synap-git 2.1.7__tar.gz → 2.2.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.1.7 → synap_git-2.2.0}/.gitignore +3 -0
  2. {synap_git-2.1.7 → synap_git-2.2.0}/CHANGELOG.md +16 -0
  3. {synap_git-2.1.7 → synap_git-2.2.0}/PKG-INFO +3 -2
  4. {synap_git-2.1.7 → synap_git-2.2.0}/pyproject.toml +1 -0
  5. {synap_git-2.1.7 → synap_git-2.2.0}/src/synap_git/__init__.py +1 -1
  6. {synap_git-2.1.7 → synap_git-2.2.0}/src/synap_git/cli/main.py +61 -25
  7. {synap_git-2.1.7 → synap_git-2.2.0}/src/synap_git/indexer/daemon.py +101 -44
  8. {synap_git-2.1.7 → synap_git-2.2.0}/src/synap_git/indexer/engine.py +1 -0
  9. {synap_git-2.1.7 → synap_git-2.2.0}/src/synap_git/parser/registry.py +46 -0
  10. {synap_git-2.1.7 → synap_git-2.2.0}/src/synap_git/provider/anthropic.py +6 -5
  11. {synap_git-2.1.7 → synap_git-2.2.0}/src/synap_git/provider/openrouter.py +6 -2
  12. {synap_git-2.1.7 → synap_git-2.2.0}/src/synap_git/retrieval/engine.py +9 -4
  13. {synap_git-2.1.7 → synap_git-2.2.0}/src/synap_git/storage/sqlite.py +14 -0
  14. {synap_git-2.1.7 → synap_git-2.2.0}/.synap.example/README.md +0 -0
  15. {synap_git-2.1.7 → synap_git-2.2.0}/LICENSE.md +0 -0
  16. {synap_git-2.1.7 → synap_git-2.2.0}/README.md +0 -0
  17. {synap_git-2.1.7 → synap_git-2.2.0}/src/synap_git/api/__init__.py +0 -0
  18. {synap_git-2.1.7 → synap_git-2.2.0}/src/synap_git/api/app.py +0 -0
  19. {synap_git-2.1.7 → synap_git-2.2.0}/src/synap_git/api/static/index.html +0 -0
  20. {synap_git-2.1.7 → synap_git-2.2.0}/src/synap_git/cli/__init__.py +0 -0
  21. {synap_git-2.1.7 → synap_git-2.2.0}/src/synap_git/cli/__main__.py +0 -0
  22. {synap_git-2.1.7 → synap_git-2.2.0}/src/synap_git/cli/checkpoint.py +0 -0
  23. {synap_git-2.1.7 → synap_git-2.2.0}/src/synap_git/cli/lessons.py +0 -0
  24. {synap_git-2.1.7 → synap_git-2.2.0}/src/synap_git/cli/memory.py +0 -0
  25. {synap_git-2.1.7 → synap_git-2.2.0}/src/synap_git/cli/usage.py +0 -0
  26. {synap_git-2.1.7 → synap_git-2.2.0}/src/synap_git/cli/wiki.py +0 -0
  27. {synap_git-2.1.7 → synap_git-2.2.0}/src/synap_git/config.py +0 -0
  28. {synap_git-2.1.7 → synap_git-2.2.0}/src/synap_git/diagnostics/__init__.py +0 -0
  29. {synap_git-2.1.7 → synap_git-2.2.0}/src/synap_git/diagnostics/logger.py +0 -0
  30. {synap_git-2.1.7 → synap_git-2.2.0}/src/synap_git/diagnostics/tracing.py +0 -0
  31. {synap_git-2.1.7 → synap_git-2.2.0}/src/synap_git/embeddings/__init__.py +0 -0
  32. {synap_git-2.1.7 → synap_git-2.2.0}/src/synap_git/git/__init__.py +0 -0
  33. {synap_git-2.1.7 → synap_git-2.2.0}/src/synap_git/git/state.py +0 -0
  34. {synap_git-2.1.7 → synap_git-2.2.0}/src/synap_git/indexer/__init__.py +0 -0
  35. {synap_git-2.1.7 → synap_git-2.2.0}/src/synap_git/indexer/scanner.py +0 -0
  36. {synap_git-2.1.7 → synap_git-2.2.0}/src/synap_git/indexer/wiki.py +0 -0
  37. {synap_git-2.1.7 → synap_git-2.2.0}/src/synap_git/mcp/__init__.py +0 -0
  38. {synap_git-2.1.7 → synap_git-2.2.0}/src/synap_git/mcp/server.py +0 -0
  39. {synap_git-2.1.7 → synap_git-2.2.0}/src/synap_git/parser/__init__.py +0 -0
  40. {synap_git-2.1.7 → synap_git-2.2.0}/src/synap_git/provider/__init__.py +0 -0
  41. {synap_git-2.1.7 → synap_git-2.2.0}/src/synap_git/provider/base.py +0 -0
  42. {synap_git-2.1.7 → synap_git-2.2.0}/src/synap_git/provider/factory.py +0 -0
  43. {synap_git-2.1.7 → synap_git-2.2.0}/src/synap_git/provider/gemini.py +0 -0
  44. {synap_git-2.1.7 → synap_git-2.2.0}/src/synap_git/provider/ollama.py +0 -0
  45. {synap_git-2.1.7 → synap_git-2.2.0}/src/synap_git/provider/openai.py +0 -0
  46. {synap_git-2.1.7 → synap_git-2.2.0}/src/synap_git/py.typed +0 -0
  47. {synap_git-2.1.7 → synap_git-2.2.0}/src/synap_git/retrieval/__init__.py +0 -0
  48. {synap_git-2.1.7 → synap_git-2.2.0}/src/synap_git/retrieval/memory.py +0 -0
  49. {synap_git-2.1.7 → synap_git-2.2.0}/src/synap_git/security/__init__.py +0 -0
  50. {synap_git-2.1.7 → synap_git-2.2.0}/src/synap_git/security/redactor.py +0 -0
  51. {synap_git-2.1.7 → synap_git-2.2.0}/src/synap_git/security/sanitizer.py +0 -0
  52. {synap_git-2.1.7 → synap_git-2.2.0}/src/synap_git/security/validator.py +0 -0
  53. {synap_git-2.1.7 → synap_git-2.2.0}/src/synap_git/storage/__init__.py +0 -0
  54. {synap_git-2.1.7 → synap_git-2.2.0}/src/synap_git/utils/__init__.py +0 -0
  55. {synap_git-2.1.7 → synap_git-2.2.0}/src/synap_git/utils/serialization.py +0 -0
  56. {synap_git-2.1.7 → synap_git-2.2.0}/website/README.md +0 -0
@@ -29,3 +29,6 @@ config.json
29
29
  status.json
30
30
  fix_exc.py
31
31
  test_embed.py
32
+ .vscode/
33
+ .idea/
34
+ *.log
@@ -5,6 +5,22 @@ 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
9
+
10
+ ### Added
11
+ - **AST Symbol Parser Expansion (AUD-02)**: Implemented Tree-sitter AST symbol extraction handlers for C#, PHP, Kotlin, Scala, and C.
12
+ - **Event-Driven File Watcher (AUD-03)**: Replaced continuous 2s subprocess polling with `watchdog` filesystem event tracking, reducing idle CPU usage to near-zero.
13
+ - **Daemon REST API Diagnostic Checks (AUD-06)**: Added daemon port availability and REST API endpoint status checks to `synap doctor`.
14
+ - **Wiki Task Requeue Subcommand (AUD-08)**: Added `synap wiki retry` CLI subcommand and `retry_failed_wiki_queue` storage method to requeue permanently failed wiki tasks back to pending state.
15
+
16
+ ### Changed
17
+ - **CLI `synap start` Control Flow (AUD-01)**: Fixed `synap start` so it exits cleanly when a daemon is already running unless `--foreground` is explicitly passed.
18
+ - **SQLite WAL Performance Rationale (AUD-07)**: Documented `PRAGMA synchronous=NORMAL` in WAL mode as an intentional architectural tradeoff balancing write throughput and self-healing index recovery.
19
+
20
+ ### Fixed
21
+ - **Graceful Embedding Provider Fallback (AUD-04)**: Configured Anthropic and OpenRouter providers to log a warning and return empty embedding vectors instead of raising unhandled `NotImplementedError`.
22
+ - **Symbol Preservation on File Renames (AUD-05)**: Added `-M` rename detection to `git diff-tree` in incremental indexing to preserve file symbol history and edges across renames.
23
+
8
24
  ## [2.1.7] - 2026-07-29
9
25
 
10
26
  ### Added
@@ -1,6 +1,6 @@
1
- Metadata-Version: 2.4
1
+ Metadata-Version: 2.5
2
2
  Name: synap-git
3
- Version: 2.1.7
3
+ Version: 2.2.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
@@ -37,6 +37,7 @@ Requires-Dist: tree-sitter-languages==1.10.2
37
37
  Requires-Dist: tree-sitter==0.20.4
38
38
  Requires-Dist: typer==0.15.1
39
39
  Requires-Dist: uvicorn==0.34.0
40
+ Requires-Dist: watchdog>=6.0.0
40
41
  Provides-Extra: dev
41
42
  Requires-Dist: bandit==1.8.3; extra == 'dev'
42
43
  Requires-Dist: mypy==1.15.0; extra == 'dev'
@@ -37,6 +37,7 @@ dependencies = [
37
37
  "tree-sitter-languages==1.10.2",
38
38
  "typer==0.15.1",
39
39
  "uvicorn==0.34.0",
40
+ "watchdog>=6.0.0",
40
41
  ]
41
42
 
42
43
  [project.urls]
@@ -2,4 +2,4 @@
2
2
 
3
3
  __all__ = ["__version__"]
4
4
 
5
- __version__ = "2.1.7"
5
+ __version__ = "2.2.0"
@@ -652,6 +652,12 @@ def _detect_install_method() -> str:
652
652
  @app.command()
653
653
  def start(
654
654
  path: Annotated[str, typer.Argument(help="Repository path to watch.")] = ".",
655
+ foreground: Annotated[
656
+ bool,
657
+ typer.Option(
658
+ "--foreground", help="Run FastMCP stdio server in foreground after starting daemon."
659
+ ),
660
+ ] = False,
655
661
  ) -> None:
656
662
  """Start the Synap daemon in the background."""
657
663
  _verify_is_git(path)
@@ -676,6 +682,8 @@ def start(
676
682
  if hb and "port" in hb:
677
683
  console.print(f"[green]✓ UI available at http://127.0.0.1:{hb['port']}[/green]")
678
684
  is_running = True
685
+ if not foreground:
686
+ return
679
687
  else:
680
688
  pid_file.unlink()
681
689
  except Exception as e:
@@ -739,7 +747,10 @@ def start(
739
747
  )
740
748
  raise typer.Exit(1)
741
749
 
742
- # 3. Start MCP Server in the foreground
750
+ if not foreground:
751
+ return
752
+
753
+ # 3. Start MCP Server in the foreground if --foreground flag passed
743
754
  settings = _settings(path, json_output=True)
744
755
  runtime = SynapRuntime(settings)
745
756
  server = SynapMCPServer(runtime)
@@ -1496,40 +1507,52 @@ def doctor(
1496
1507
  task_prov, completed=1, description=f"[red]✗ Unexpected error: {e}[/red]"
1497
1508
  )
1498
1509
 
1499
- task_daemon = progress.add_task("Checking Daemon Status...", total=1)
1510
+ task_daemon = progress.add_task("Checking Daemon & REST API Status...", total=1)
1500
1511
  try:
1512
+ import socket
1513
+
1514
+ import httpx
1515
+
1501
1516
  daemon_info = _read_daemon_heartbeat(settings.repository_path)
1502
- if daemon_info:
1503
- status_str = daemon_info["status"].upper()
1504
- if status_str == "HEALTHY":
1505
- progress.update(
1506
- task_daemon,
1507
- completed=1,
1508
- description=f"[green]✓ Daemon active and healthy (PID {daemon_info['pid']}, uptime {daemon_info['uptime_seconds']}s)[/green]",
1509
- )
1510
- elif status_str == "STARTING":
1511
- progress.update(
1512
- task_daemon,
1513
- completed=1,
1514
- description=f"[green]✓ Daemon is active and currently starting/indexing (PID {daemon_info['pid']})[/green]",
1515
- )
1516
- elif status_str == "STALE":
1517
- progress.update(
1518
- task_daemon,
1519
- completed=1,
1520
- description="[yellow]⚠ Daemon heartbeat file exists but is stale (not running?)[/yellow]",
1521
- )
1522
- else:
1517
+ port = daemon_info.get("port", 9876) if daemon_info else 9876
1518
+
1519
+ # Test port availability
1520
+ with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
1521
+ s.settimeout(0.5)
1522
+ port_in_use = s.connect_ex(("127.0.0.1", port)) == 0
1523
+
1524
+ if daemon_info and port_in_use:
1525
+ try:
1526
+ r = httpx.get(f"http://127.0.0.1:{port}/api/status", timeout=2.0)
1527
+ if r.status_code == 200:
1528
+ progress.update(
1529
+ task_daemon,
1530
+ completed=1,
1531
+ description=f"[green]✓ Daemon active & REST API healthy (PID {daemon_info['pid']}, port {port})[/green]",
1532
+ )
1533
+ else:
1534
+ progress.update(
1535
+ task_daemon,
1536
+ completed=1,
1537
+ description=f"[yellow]⚠ Daemon running on port {port} but REST API returned HTTP {r.status_code}[/yellow]",
1538
+ )
1539
+ except Exception as ex:
1523
1540
  progress.update(
1524
1541
  task_daemon,
1525
1542
  completed=1,
1526
- description=f"[red]✗ Daemon degraded: {daemon_info.get('last_error')}[/red]",
1543
+ description=f"[yellow]⚠ Daemon running on port {port} but API check failed: {ex}[/yellow]",
1527
1544
  )
1545
+ elif daemon_info:
1546
+ progress.update(
1547
+ task_daemon,
1548
+ completed=1,
1549
+ description=f"[yellow]⚠ Heartbeat found for PID {daemon_info['pid']} but port {port} is not listening[/yellow]",
1550
+ )
1528
1551
  else:
1529
1552
  progress.update(
1530
1553
  task_daemon,
1531
1554
  completed=1,
1532
- description="[yellow]⚠ Daemon inactive (not running). Start it using `synap start`[/yellow]",
1555
+ description=f"[yellow]⚠ Daemon inactive (port {port} available). Start using `synap start`[/yellow]",
1533
1556
  )
1534
1557
  except Exception as e:
1535
1558
  progress.update(
@@ -2173,3 +2196,16 @@ def wiki_show(
2173
2196
 
2174
2197
  content = wiki_path.read_text(encoding="utf-8")
2175
2198
  console.print(Markdown(content))
2199
+
2200
+
2201
+ @wiki_app.command("retry")
2202
+ def wiki_retry(
2203
+ path: Annotated[str, typer.Argument(help="Repository path.")] = ".",
2204
+ ) -> None:
2205
+ """Requeue all permanently failed wiki tasks back to pending state."""
2206
+ runtime = SynapRuntime(_settings(path))
2207
+ count = runtime.store.retry_failed_wiki_queue()
2208
+ if count > 0:
2209
+ console.print(f"[green]✓ Requeued {count} failed wiki tasks to pending state.[/green]")
2210
+ else:
2211
+ console.print("[yellow]No failed wiki tasks found in queue.[/yellow]")
@@ -35,6 +35,8 @@ class RuntimeDaemon:
35
35
  self._last_metrics_time: float = 0.0
36
36
  self._last_cpu_time: float = 0.0
37
37
  self._last_prune_time: float = 0.0
38
+ self._fs_changed = False
39
+ self._observer: Any = None
38
40
 
39
41
  async def start(self) -> None:
40
42
  self.runtime.initialize_storage()
@@ -91,6 +93,44 @@ class RuntimeDaemon:
91
93
  self._write_heartbeat(status="healthy")
92
94
  self._install_signal_handlers()
93
95
 
96
+ # Start watchdog file observer for event-driven git indexing
97
+ self._fs_changed = False
98
+ self._observer = None
99
+ try:
100
+ from watchdog.events import FileSystemEventHandler
101
+ from watchdog.observers import Observer
102
+
103
+ daemon_ref = self
104
+
105
+ class _WatcherHandler(FileSystemEventHandler):
106
+ def on_any_event(self, event: Any) -> None:
107
+ path_str = str(getattr(event, "src_path", ""))
108
+ if any(
109
+ ignored in path_str
110
+ for ignored in (
111
+ ".synap",
112
+ ".git",
113
+ ".venv",
114
+ "__pycache__",
115
+ "node_modules",
116
+ ".next",
117
+ "dist",
118
+ "build",
119
+ )
120
+ ):
121
+ return
122
+ daemon_ref._fs_changed = True
123
+
124
+ self._observer = Observer()
125
+ self._observer.schedule(
126
+ _WatcherHandler(), str(self.settings.repository_path), recursive=True
127
+ )
128
+ self._observer.start()
129
+ self.logger.info("watchdog_observer_started")
130
+ except Exception as e:
131
+ self.logger.warning("watchdog_observer_init_failed", error=str(e))
132
+ self._observer = None
133
+
94
134
  # Launch Uvicorn and Wiki Worker in background
95
135
  server_task = asyncio.create_task(self._ui_server.serve())
96
136
  wiki_worker_task = asyncio.create_task(self._wiki_worker_loop())
@@ -98,6 +138,13 @@ class RuntimeDaemon:
98
138
  try:
99
139
  await self._poll_git_loop()
100
140
  finally:
141
+ if self._observer:
142
+ try:
143
+ self._observer.stop()
144
+ self._observer.join(timeout=1.0)
145
+ except Exception:
146
+ pass
147
+
101
148
  self._running = False
102
149
  self.logger.info("daemon_shutting_down")
103
150
  self._ui_server.should_exit = True
@@ -258,60 +305,70 @@ class RuntimeDaemon:
258
305
  structlog.get_logger().error("suppressed_error_caught", error=str(e))
259
306
 
260
307
  async def _poll_git_loop(self) -> None:
308
+ backstop_interval = 30.0
309
+ poll_step = 0.5
310
+ elapsed = backstop_interval # Force initial check on loop start
311
+
261
312
  while not self._stop_event.is_set():
262
- try:
263
- # Periodic pruning (once per hour)
264
- now = time.time()
265
- if now - self._last_prune_time > 3600:
266
- self._last_prune_time = now
267
- count = await asyncio.to_thread(self.runtime.store.prune_expired_lessons)
268
- if count > 0:
269
- self.logger.info("lessons_pruned", count=count)
270
- print(f"\n[Synapse] Pruned {count} expired lessons.")
271
-
272
- state = self.git.state()
273
- change = self.git.classify(self._last_git_state, state)
274
- self._last_git_state = state
275
-
276
- if change.kind == "commit":
277
- self.logger.info("git_commit_detected")
278
- await asyncio.to_thread(self.runtime.handle_commit, state)
279
- elif change.kind in ("branch", "checkout"):
280
- self.logger.info("git_branch_switch_detected")
281
- await asyncio.to_thread(self.runtime.handle_branch_switch, state)
282
- elif change.kind == "merge":
283
- self.logger.info("git_merge_detected")
284
- await asyncio.to_thread(self.runtime.handle_merge, state)
285
- elif change.kind == "revert":
286
- self.logger.info("git_revert_detected")
287
- await asyncio.to_thread(self.runtime.handle_revert, self._last_git_state, state)
288
- elif change.kind not in ("initial", "unchanged"):
289
- self.logger.info("git_change_detected", kind=change.kind)
290
- await asyncio.to_thread(self.runtime.index_repository, git_state=state)
291
-
292
- self._write_heartbeat(status="healthy")
293
- except Exception as e:
294
- self.logger.error("daemon_loop_error", error=str(e))
295
- self._recovery_attempts += 1
296
- self._write_heartbeat(status="degraded", last_error=str(e))
313
+ if self._fs_changed or elapsed >= backstop_interval:
314
+ self._fs_changed = False
315
+ elapsed = 0.0
297
316
 
298
- # Database recovery if corrupted
299
317
  try:
300
- is_wiped = await asyncio.to_thread(self.runtime.store.recover_if_corrupted)
301
- if is_wiped:
302
- self.logger.warning("daemon_rebuilding_corrupted_db")
303
- await asyncio.to_thread(self.runtime.bootstrap, force=True)
304
- except Exception as ex:
305
- self.logger.error("daemon_self_healing_failed", error=str(ex))
318
+ # Periodic pruning (once per hour)
319
+ now = time.time()
320
+ if now - self._last_prune_time > 3600:
321
+ self._last_prune_time = now
322
+ count = await asyncio.to_thread(self.runtime.store.prune_expired_lessons)
323
+ if count > 0:
324
+ self.logger.info("lessons_pruned", count=count)
325
+ print(f"\n[Synapse] Pruned {count} expired lessons.")
326
+
327
+ state = self.git.state()
328
+ change = self.git.classify(self._last_git_state, state)
329
+ self._last_git_state = state
330
+
331
+ if change.kind == "commit":
332
+ self.logger.info("git_commit_detected")
333
+ await asyncio.to_thread(self.runtime.handle_commit, state)
334
+ elif change.kind in ("branch", "checkout"):
335
+ self.logger.info("git_branch_switch_detected")
336
+ await asyncio.to_thread(self.runtime.handle_branch_switch, state)
337
+ elif change.kind == "merge":
338
+ self.logger.info("git_merge_detected")
339
+ await asyncio.to_thread(self.runtime.handle_merge, state)
340
+ elif change.kind == "revert":
341
+ self.logger.info("git_revert_detected")
342
+ await asyncio.to_thread(
343
+ self.runtime.handle_revert, self._last_git_state, state
344
+ )
345
+ elif change.kind not in ("initial", "unchanged"):
346
+ self.logger.info("git_change_detected", kind=change.kind)
347
+ await asyncio.to_thread(self.runtime.index_repository, git_state=state)
348
+
349
+ self._write_heartbeat(status="healthy")
350
+ except Exception as e:
351
+ self.logger.error("daemon_loop_error", error=str(e))
352
+ self._recovery_attempts += 1
353
+ self._write_heartbeat(status="degraded", last_error=str(e))
354
+
355
+ # Database recovery if corrupted
356
+ try:
357
+ is_wiped = await asyncio.to_thread(self.runtime.store.recover_if_corrupted)
358
+ if is_wiped:
359
+ self.logger.warning("daemon_rebuilding_corrupted_db")
360
+ await asyncio.to_thread(self.runtime.bootstrap, force=True)
361
+ except Exception as ex:
362
+ self.logger.error("daemon_self_healing_failed", error=str(ex))
306
363
 
307
364
  try:
308
365
  await asyncio.wait_for(
309
366
  self._stop_event.wait(),
310
- timeout=self.settings.daemon_poll_interval_seconds,
367
+ timeout=poll_step,
311
368
  )
312
369
  break # Stop event was set, exit loop
313
370
  except TimeoutError:
314
- pass # Normal poll interval elapsed, continue loop
371
+ elapsed += poll_step
315
372
 
316
373
  def _install_signal_handlers(self) -> None:
317
374
  loop = asyncio.get_running_loop()
@@ -474,6 +474,7 @@ class SynapRuntime:
474
474
  "git",
475
475
  "diff-tree",
476
476
  "-r",
477
+ "-M",
477
478
  "--no-commit-id",
478
479
  "--name-status",
479
480
  previous_commit,
@@ -217,6 +217,52 @@ class CodeParserRegistry:
217
217
  if kind in ("class", "module", "method"):
218
218
  name = extract_text(node.child_by_field_name("name"))
219
219
 
220
+ elif lang_name == "c_sharp":
221
+ if kind in (
222
+ "class_declaration",
223
+ "method_declaration",
224
+ "interface_declaration",
225
+ "struct_declaration",
226
+ "enum_declaration",
227
+ ):
228
+ name = extract_text(node.child_by_field_name("name"))
229
+
230
+ elif lang_name == "php":
231
+ if kind in (
232
+ "class_declaration",
233
+ "function_definition",
234
+ "method_declaration",
235
+ "interface_declaration",
236
+ "trait_declaration",
237
+ ):
238
+ name = extract_text(node.child_by_field_name("name"))
239
+
240
+ elif lang_name == "kotlin":
241
+ if kind in (
242
+ "class_declaration",
243
+ "function_declaration",
244
+ "object_declaration",
245
+ "interface_declaration",
246
+ ):
247
+ name = extract_text(node.child_by_field_name("name"))
248
+
249
+ elif lang_name == "scala":
250
+ if kind in (
251
+ "class_definition",
252
+ "object_definition",
253
+ "function_definition",
254
+ "trait_definition",
255
+ ):
256
+ name = extract_text(node.child_by_field_name("name"))
257
+
258
+ elif lang_name == "c":
259
+ if kind in ("function_definition", "struct_specifier", "enum_specifier"):
260
+ name = extract_text(node.child_by_field_name("name"))
261
+ if not name:
262
+ declarator = node.child_by_field_name("declarator")
263
+ if declarator:
264
+ name = extract_text(declarator.child_by_field_name("declarator"))
265
+
220
266
  if name:
221
267
  # Compute hash of the node's subtree for change detection
222
268
  ast_content = extract_text(node) or ""
@@ -123,12 +123,13 @@ class AnthropicProvider(LLMProvider):
123
123
  *,
124
124
  model: str | None = None,
125
125
  ) -> list[float]:
126
- # Anthropic does not have a public embedding endpoint as of mid 2024.
127
- # Typically developers use Voyage or OpenAI for embeddings when using Anthropic.
128
- # We will throw a clear error.
129
- raise NotImplementedError(
130
- "Anthropic does not provide a native embeddings API. Please configure SYNAP_EMBED_PROVIDER=ollama or openai."
126
+ import structlog
127
+
128
+ structlog.get_logger().warning(
129
+ "anthropic_embedding_not_supported_degrading_to_fts",
130
+ provider="anthropic",
131
131
  )
132
+ return []
132
133
 
133
134
  def count_tokens(self, text: str) -> int:
134
135
  import tiktoken
@@ -120,6 +120,10 @@ class OpenRouterProvider(OpenAIProvider):
120
120
  *,
121
121
  model: str | None = None,
122
122
  ) -> list[float]:
123
- raise NotImplementedError(
124
- "OpenRouter does not support embeddings. Configure OpenAI or Ollama for embeddings."
123
+ import structlog
124
+
125
+ structlog.get_logger().warning(
126
+ "openrouter_embedding_not_supported_degrading_to_fts",
127
+ provider="openrouter",
125
128
  )
129
+ return []
@@ -122,10 +122,15 @@ class HybridRetrievalEngine:
122
122
  if self.embed_provider:
123
123
  try:
124
124
  query_vector = self.embed_provider.embed(query)
125
- symbols = self.store.get_similar_symbols(query_vector, limit=50)
126
- for rank, sym in enumerate(symbols):
127
- sid = sym["symbol_id"]
128
- semantic_candidates[sid] = {**sym, "reason": "semantic", "semantic_rank": rank}
125
+ if query_vector:
126
+ symbols = self.store.get_similar_symbols(query_vector, limit=50)
127
+ for rank, sym in enumerate(symbols):
128
+ sid = sym["symbol_id"]
129
+ semantic_candidates[sid] = {
130
+ **sym,
131
+ "reason": "semantic",
132
+ "semantic_rank": rank,
133
+ }
129
134
  except Exception as e:
130
135
  import structlog
131
136
 
@@ -44,6 +44,11 @@ class SynapStore:
44
44
  def initialize(self) -> None:
45
45
  self.path.parent.mkdir(parents=True, exist_ok=True)
46
46
  with self.connect() as conn:
47
+ # ARCHITECTURAL DECISION (AUD-07):
48
+ # PRAGMA synchronous=NORMAL in WAL mode is an intentional tradeoff:
49
+ # It avoids fsync() on every transaction commit, providing a 5-10x throughput boost for bulk AST indexing.
50
+ # In the rare event of an OS power-loss crash during a write, SQLite WAL recovery or Synapse's automatic
51
+ # self-healing (`recover_if_corrupted()`) safely reconstructs the deterministic local index.
47
52
  conn.execute("PRAGMA journal_mode=WAL")
48
53
  conn.execute("PRAGMA synchronous=NORMAL")
49
54
  conn.execute("PRAGMA foreign_keys=ON")
@@ -854,6 +859,15 @@ class SynapStore:
854
859
  with self.connect() as conn:
855
860
  conn.execute("DELETE FROM wiki_queue")
856
861
 
862
+ def retry_failed_wiki_queue(self) -> int:
863
+ now = int(datetime.now(UTC).timestamp())
864
+ with self.connect() as conn:
865
+ cursor = conn.execute(
866
+ "UPDATE wiki_queue SET status = 'pending', attempts = 0, updated_at = ? WHERE status = 'failed'",
867
+ (now,),
868
+ )
869
+ return cursor.rowcount
870
+
857
871
  def get_llm_calls(self) -> list[dict[str, Any]]:
858
872
  with self.connect() as conn:
859
873
  rows = conn.execute("SELECT * FROM llm_calls ORDER BY created_at DESC").fetchall()
File without changes
File without changes
File without changes