synap-git 2.1.1__tar.gz → 2.1.3__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 (45) hide show
  1. {synap_git-2.1.1 → synap_git-2.1.3}/CHANGELOG.md +12 -0
  2. {synap_git-2.1.1 → synap_git-2.1.3}/PKG-INFO +1 -1
  3. {synap_git-2.1.1 → synap_git-2.1.3}/src/synap_git/__init__.py +1 -1
  4. {synap_git-2.1.1 → synap_git-2.1.3}/src/synap_git/cli/main.py +43 -26
  5. {synap_git-2.1.1 → synap_git-2.1.3}/src/synap_git/indexer/daemon.py +1 -0
  6. {synap_git-2.1.1 → synap_git-2.1.3}/src/synap_git/indexer/engine.py +77 -52
  7. {synap_git-2.1.1 → synap_git-2.1.3}/.gitignore +0 -0
  8. {synap_git-2.1.1 → synap_git-2.1.3}/.synap.example/README.md +0 -0
  9. {synap_git-2.1.1 → synap_git-2.1.3}/LICENSE.md +0 -0
  10. {synap_git-2.1.1 → synap_git-2.1.3}/README.md +0 -0
  11. {synap_git-2.1.1 → synap_git-2.1.3}/pyproject.toml +0 -0
  12. {synap_git-2.1.1 → synap_git-2.1.3}/src/synap_git/api/__init__.py +0 -0
  13. {synap_git-2.1.1 → synap_git-2.1.3}/src/synap_git/api/app.py +0 -0
  14. {synap_git-2.1.1 → synap_git-2.1.3}/src/synap_git/api/static/index.html +0 -0
  15. {synap_git-2.1.1 → synap_git-2.1.3}/src/synap_git/cli/__init__.py +0 -0
  16. {synap_git-2.1.1 → synap_git-2.1.3}/src/synap_git/cli/__main__.py +0 -0
  17. {synap_git-2.1.1 → synap_git-2.1.3}/src/synap_git/config.py +0 -0
  18. {synap_git-2.1.1 → synap_git-2.1.3}/src/synap_git/diagnostics/__init__.py +0 -0
  19. {synap_git-2.1.1 → synap_git-2.1.3}/src/synap_git/diagnostics/logger.py +0 -0
  20. {synap_git-2.1.1 → synap_git-2.1.3}/src/synap_git/diagnostics/tracing.py +0 -0
  21. {synap_git-2.1.1 → synap_git-2.1.3}/src/synap_git/embeddings/__init__.py +0 -0
  22. {synap_git-2.1.1 → synap_git-2.1.3}/src/synap_git/git/__init__.py +0 -0
  23. {synap_git-2.1.1 → synap_git-2.1.3}/src/synap_git/git/state.py +0 -0
  24. {synap_git-2.1.1 → synap_git-2.1.3}/src/synap_git/indexer/__init__.py +0 -0
  25. {synap_git-2.1.1 → synap_git-2.1.3}/src/synap_git/indexer/scanner.py +0 -0
  26. {synap_git-2.1.1 → synap_git-2.1.3}/src/synap_git/indexer/wiki.py +0 -0
  27. {synap_git-2.1.1 → synap_git-2.1.3}/src/synap_git/mcp/__init__.py +0 -0
  28. {synap_git-2.1.1 → synap_git-2.1.3}/src/synap_git/mcp/server.py +0 -0
  29. {synap_git-2.1.1 → synap_git-2.1.3}/src/synap_git/parser/__init__.py +0 -0
  30. {synap_git-2.1.1 → synap_git-2.1.3}/src/synap_git/parser/registry.py +0 -0
  31. {synap_git-2.1.1 → synap_git-2.1.3}/src/synap_git/provider/anthropic.py +0 -0
  32. {synap_git-2.1.1 → synap_git-2.1.3}/src/synap_git/provider/base.py +0 -0
  33. {synap_git-2.1.1 → synap_git-2.1.3}/src/synap_git/provider/factory.py +0 -0
  34. {synap_git-2.1.1 → synap_git-2.1.3}/src/synap_git/provider/gemini.py +0 -0
  35. {synap_git-2.1.1 → synap_git-2.1.3}/src/synap_git/provider/ollama.py +0 -0
  36. {synap_git-2.1.1 → synap_git-2.1.3}/src/synap_git/provider/openai.py +0 -0
  37. {synap_git-2.1.1 → synap_git-2.1.3}/src/synap_git/provider/openrouter.py +0 -0
  38. {synap_git-2.1.1 → synap_git-2.1.3}/src/synap_git/py.typed +0 -0
  39. {synap_git-2.1.1 → synap_git-2.1.3}/src/synap_git/retrieval/__init__.py +0 -0
  40. {synap_git-2.1.1 → synap_git-2.1.3}/src/synap_git/retrieval/engine.py +0 -0
  41. {synap_git-2.1.1 → synap_git-2.1.3}/src/synap_git/retrieval/memory.py +0 -0
  42. {synap_git-2.1.1 → synap_git-2.1.3}/src/synap_git/storage/__init__.py +0 -0
  43. {synap_git-2.1.1 → synap_git-2.1.3}/src/synap_git/storage/sqlite.py +0 -0
  44. {synap_git-2.1.1 → synap_git-2.1.3}/src/synap_git/utils/__init__.py +0 -0
  45. {synap_git-2.1.1 → synap_git-2.1.3}/src/synap_git/utils/serialization.py +0 -0
@@ -5,6 +5,18 @@ 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.1.3] - 2026-06-11
9
+
10
+ ### Changed
11
+ - **CLI Interactivity Enhancement**: Wrapped all long-running `synap` CLI commands (`init`, `start`, `stop`, `rollback`, `repair`) with interactive rich status spinners to ensure users never stare at an empty screen.
12
+ - **Incremental Indexing Progress**: Added a rich progress status to the `_incremental_index` background loop to provide visibility into parsing tasks during cache hits.
13
+
14
+ ## [2.1.2] - 2026-06-11
15
+
16
+ ### Fixed
17
+ - **CRITICAL: Daemon Thread Explosion**: Patched a massive concurrency edge case in `SynapRuntime` where large repository indexing would trigger unbounded `threading.Thread` loops for LLM embeddings. A bounded `ThreadPoolExecutor` has been introduced to prevent OOMs, OS thread starvation, and rate-limit hits during first-run indexing.
18
+ - **Daemon Lifecycle**: Ensure `embedding_executor` shuts down cleanly without leaking background threads when the daemon loop receives SIGTERM.
19
+
8
20
  ## [2.1.1] - 2026-06-11
9
21
 
10
22
  ### Fixed
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: synap-git
3
- Version: 2.1.1
3
+ Version: 2.1.3
4
4
  Summary: Persistent structural context infrastructure for AI coding agents.
5
5
  Project-URL: Homepage, https://github.com/saahilpal/synap-git
6
6
  Project-URL: Repository, https://github.com/saahilpal/synap-git
@@ -2,4 +2,4 @@
2
2
 
3
3
  __all__ = ["__version__"]
4
4
 
5
- __version__ = "2.1.1"
5
+ __version__ = "2.1.3"
@@ -524,7 +524,13 @@ def init(
524
524
  _auto_protect_synap(settings.repository_path)
525
525
 
526
526
  runtime = SynapRuntime(settings)
527
- commit = runtime.bootstrap(force=force)
527
+ if not quiet and not json_output:
528
+ with console.status(
529
+ "[yellow]Initializing Synap runtime and scanning repository...[/yellow]", spinner="dots"
530
+ ):
531
+ commit = runtime.bootstrap(force=force)
532
+ else:
533
+ commit = runtime.bootstrap(force=force)
528
534
 
529
535
  if json_output:
530
536
  _emit({"active_commit": commit, "state": "initialized"}, json_output=True)
@@ -670,20 +676,22 @@ def start(
670
676
  console.print(f"[bold red]✗ Failed to start daemon:[/bold red] {e}")
671
677
  raise typer.Exit(1)
672
678
 
673
- console.print("[yellow]Starting Synap daemon...[/yellow]")
674
679
  success = False
675
- for _ in range(15): # wait up to 3 seconds
676
- time.sleep(0.2)
677
- if pid_file.exists():
678
- hb = _read_daemon_heartbeat(abs_path)
679
- if hb and hb.get("status") in ("healthy", "degraded"):
680
- success = True
681
- port = hb.get("port", 9876)
682
- pid = int(hb.get("pid", 0))
683
- console.print(f"[green]✓ Synap daemon started (PID {pid})[/green]")
684
- console.print("[green]✓ Runtime healthy[/green]")
685
- console.print(f"[green]✓ UI available at http://127.0.0.1:{port}[/green]")
686
- break
680
+ with console.status(
681
+ "[yellow]Starting Synap daemon and awaiting health check...[/yellow]", spinner="dots"
682
+ ):
683
+ for _ in range(15): # wait up to 3 seconds
684
+ time.sleep(0.2)
685
+ if pid_file.exists():
686
+ hb = _read_daemon_heartbeat(abs_path)
687
+ if hb and hb.get("status") in ("healthy", "degraded"):
688
+ success = True
689
+ port = hb.get("port", 9876)
690
+ pid = int(hb.get("pid", 0))
691
+ console.print(f"[green]✓ Synap daemon started (PID {pid})[/green]")
692
+ console.print("[green]✓ Runtime healthy[/green]")
693
+ console.print(f"[green]✓ UI available at http://127.0.0.1:{port}[/green]")
694
+ break
687
695
 
688
696
  if not success:
689
697
  console.print(
@@ -766,11 +774,15 @@ def stop(
766
774
 
767
775
  # Wait for graceful shutdown (up to 10 seconds)
768
776
  success = False
769
- for _ in range(50):
770
- time.sleep(0.2)
771
- if not _is_process_running(pid):
772
- success = True
773
- break
777
+ with console.status(
778
+ f"[yellow]Waiting for daemon (PID {pid}) to shut down gracefully...[/yellow]",
779
+ spinner="dots",
780
+ ):
781
+ for _ in range(50):
782
+ time.sleep(0.2)
783
+ if not _is_process_running(pid):
784
+ success = True
785
+ break
774
786
 
775
787
  # If graceful failed, force kill
776
788
  if not success:
@@ -1234,7 +1246,10 @@ def rollback(
1234
1246
 
1235
1247
  structlog.get_logger().error("suppressed_error_caught", error=str(e), exc_info=True)
1236
1248
 
1237
- runtime.bootstrap(force=True)
1249
+ with console.status(
1250
+ f"[yellow]Reindexing repository to match {selected_commit}...[/yellow]", spinner="dots"
1251
+ ):
1252
+ runtime.bootstrap(force=True)
1238
1253
  console.print(f"[green]✓ Rolled back successfully to {selected_commit}[/green]")
1239
1254
 
1240
1255
 
@@ -1294,15 +1309,17 @@ def repair(
1294
1309
 
1295
1310
  runtime.initialize_storage()
1296
1311
  console.print("[2/3] Reindexing all symbols")
1297
- runtime.bootstrap(force=True)
1312
+ with console.status("[yellow]Reindexing repository...[/yellow]", spinner="dots"):
1313
+ runtime.bootstrap(force=True)
1298
1314
 
1299
1315
  console.print("[3/3] Regenerating wiki from last known state")
1300
- try:
1301
- runtime.wiki.generate_project_wiki()
1302
- except Exception as e:
1303
- import structlog
1316
+ with console.status("[yellow]Regenerating wiki documentation...[/yellow]", spinner="dots"):
1317
+ try:
1318
+ runtime.wiki.generate_project_wiki()
1319
+ except Exception as e:
1320
+ import structlog
1304
1321
 
1305
- structlog.get_logger().error("suppressed_error_caught", error=str(e), exc_info=True)
1322
+ structlog.get_logger().error("suppressed_error_caught", error=str(e), exc_info=True)
1306
1323
 
1307
1324
  status_info = runtime.status()
1308
1325
  console.print(f"\n[green]✓ Recovery complete. {status_info.files} files restored.[/green]")
@@ -123,6 +123,7 @@ class RuntimeDaemon:
123
123
  except (asyncio.CancelledError, Exception):
124
124
  pass
125
125
 
126
+ self.runtime.shutdown()
126
127
  self._delete_heartbeat()
127
128
  self.logger.info("daemon_stopped")
128
129
 
@@ -81,6 +81,17 @@ class SynapRuntime:
81
81
  self.wiki = WikiEngine(settings, self.store)
82
82
  self.commit_count = 0
83
83
 
84
+ import concurrent.futures
85
+
86
+ self.embedding_executor = concurrent.futures.ThreadPoolExecutor(
87
+ max_workers=5, thread_name_prefix="embed_worker"
88
+ )
89
+
90
+ def shutdown(self) -> None:
91
+ """Gracefully shutdown background tasks and executors."""
92
+ self.logger.info("shutting_down_embedding_executor")
93
+ self.embedding_executor.shutdown(wait=False)
94
+
84
95
  def initialize_storage(self, *, auto_recover: bool = True) -> None:
85
96
  self.settings.ensure_directories()
86
97
  if auto_recover and self.store.path.exists() and self.store.recover_if_corrupted():
@@ -284,15 +295,7 @@ class SynapRuntime:
284
295
  except Exception as e:
285
296
  logger.warning("background_embed_failed", error=str(e))
286
297
 
287
- try:
288
- import asyncio
289
-
290
- loop = asyncio.get_running_loop()
291
- loop.create_task(asyncio.to_thread(_worker))
292
- except RuntimeError:
293
- import threading
294
-
295
- threading.Thread(target=_worker, daemon=True).start()
298
+ self.embedding_executor.submit(_worker)
296
299
 
297
300
  def _first_run_index(self, git_state: GitState) -> str | None:
298
301
  self.logger.info("first_run_indexing_started", branch=git_state.effective_branch)
@@ -598,55 +601,77 @@ class SynapRuntime:
598
601
  # Process additions and modifications
599
602
  registry = CodeParserRegistry()
600
603
  parsed_results = []
601
- for rel_path in filtered_added_or_modified:
602
- self.logger.info("reindexing_file", path=rel_path)
603
- p = self.settings.repository_path / rel_path
604
604
 
605
- try:
606
- content = p.read_text(encoding="utf-8", errors="replace")
607
- except Exception as e:
608
- self.logger.warning("failed_to_read_file", path=rel_path, error=str(e))
609
- continue
605
+ import contextlib
610
606
 
611
- import hashlib
612
-
613
- content_hash = hashlib.sha256(content.encode("utf-8")).hexdigest()
614
- file_id_hash = hashlib.sha256((rel_path + content_hash).encode("utf-8")).hexdigest()
615
-
616
- parse_result = registry.parse(p, relative_path=rel_path, text=content)
617
- symbols_list = [
618
- {
619
- "symbol_id": sym.stable_id,
620
- "name": sym.name,
621
- "kind": sym.kind,
622
- "start_line": sym.start_line,
623
- "end_line": sym.end_line,
624
- "ast_hash": sym.ast_hash,
625
- "metadata": sym.metadata,
626
- }
627
- for sym in parse_result.symbols
628
- ]
629
-
630
- self.store.upsert_file_and_symbols(
631
- file_id=file_id_hash,
632
- path=rel_path,
633
- git_oid=git_oids.get(rel_path, ""),
634
- content_hash=content_hash,
635
- language=parse_result.language,
636
- symbols=symbols_list,
637
- )
638
- self._schedule_embedding(file_id_hash, symbols_list, content_hash)
607
+ from rich.console import Console
639
608
 
640
- self.store.set_wiki_status(rel_path, None, "stale")
641
- self.store.enqueue_wiki(rel_path)
609
+ console = Console(stderr=True)
610
+ show_progress = (
611
+ self.settings.logging_mode.name == "HUMAN" and len(filtered_added_or_modified) > 0
612
+ )
642
613
 
643
- with self.store.connect() as conn:
644
- conn.execute(
645
- "DELETE FROM edges WHERE source_symbol IN (SELECT symbol_id FROM symbols WHERE file_id = ?)",
646
- (file_id_hash,),
614
+ with (
615
+ console.status(
616
+ "[yellow]Parsing incrementally changed files...[/yellow]", spinner="dots"
617
+ )
618
+ if show_progress
619
+ else contextlib.nullcontext()
620
+ ) as status_ctx:
621
+ for idx, rel_path in enumerate(filtered_added_or_modified, 1):
622
+ if show_progress and hasattr(status_ctx, "update"):
623
+ status_ctx.update( # type: ignore[union-attr]
624
+ f"[yellow]Parsing incrementally changed files ([white]{idx}/{len(filtered_added_or_modified)}[/white])...[/yellow]"
625
+ )
626
+
627
+ self.logger.info("reindexing_file", path=rel_path)
628
+ p = self.settings.repository_path / rel_path
629
+
630
+ try:
631
+ content = p.read_text(encoding="utf-8", errors="replace")
632
+ except Exception as e:
633
+ self.logger.warning("failed_to_read_file", path=rel_path, error=str(e))
634
+ continue
635
+
636
+ import hashlib
637
+
638
+ content_hash = hashlib.sha256(content.encode("utf-8")).hexdigest()
639
+ file_id_hash = hashlib.sha256((rel_path + content_hash).encode("utf-8")).hexdigest()
640
+
641
+ parse_result = registry.parse(p, relative_path=rel_path, text=content)
642
+ symbols_list = [
643
+ {
644
+ "symbol_id": sym.stable_id,
645
+ "name": sym.name,
646
+ "kind": sym.kind,
647
+ "start_line": sym.start_line,
648
+ "end_line": sym.end_line,
649
+ "ast_hash": sym.ast_hash,
650
+ "metadata": sym.metadata,
651
+ }
652
+ for sym in parse_result.symbols
653
+ ]
654
+
655
+ self.store.upsert_file_and_symbols(
656
+ file_id=file_id_hash,
657
+ path=rel_path,
658
+ git_oid=git_oids.get(rel_path, ""),
659
+ content_hash=content_hash,
660
+ language=parse_result.language,
661
+ symbols=symbols_list,
647
662
  )
663
+ self._schedule_embedding(file_id_hash, symbols_list, content_hash)
664
+
665
+ self.store.set_wiki_status(rel_path, None, "stale")
666
+ self.store.enqueue_wiki(rel_path)
667
+
668
+ with self.store.connect() as conn:
669
+ conn.execute(
670
+ "DELETE FROM edges WHERE source_symbol IN (SELECT symbol_id FROM symbols WHERE file_id = ?)",
671
+ (file_id_hash,),
672
+ )
648
673
 
649
- parsed_results.append((file_id_hash, rel_path, list(parse_result.imports)))
674
+ parsed_results.append((file_id_hash, rel_path, list(parse_result.imports)))
650
675
 
651
676
  # Re-resolve edges for changed files
652
677
  if parsed_results:
File without changes
File without changes
File without changes
File without changes