synap-git 2.1.0__tar.gz → 2.1.2__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.0 → synap_git-2.1.2}/CHANGELOG.md +12 -0
  2. {synap_git-2.1.0 → synap_git-2.1.2}/PKG-INFO +1 -1
  3. {synap_git-2.1.0 → synap_git-2.1.2}/src/synap_git/__init__.py +1 -1
  4. {synap_git-2.1.0 → synap_git-2.1.2}/src/synap_git/cli/main.py +1 -1
  5. {synap_git-2.1.0 → synap_git-2.1.2}/src/synap_git/indexer/daemon.py +1 -0
  6. {synap_git-2.1.0 → synap_git-2.1.2}/src/synap_git/indexer/engine.py +13 -10
  7. {synap_git-2.1.0 → synap_git-2.1.2}/src/synap_git/mcp/server.py +5 -1
  8. {synap_git-2.1.0 → synap_git-2.1.2}/src/synap_git/retrieval/engine.py +29 -24
  9. {synap_git-2.1.0 → synap_git-2.1.2}/src/synap_git/storage/sqlite.py +13 -4
  10. {synap_git-2.1.0 → synap_git-2.1.2}/.gitignore +0 -0
  11. {synap_git-2.1.0 → synap_git-2.1.2}/.synap.example/README.md +0 -0
  12. {synap_git-2.1.0 → synap_git-2.1.2}/LICENSE.md +0 -0
  13. {synap_git-2.1.0 → synap_git-2.1.2}/README.md +0 -0
  14. {synap_git-2.1.0 → synap_git-2.1.2}/pyproject.toml +0 -0
  15. {synap_git-2.1.0 → synap_git-2.1.2}/src/synap_git/api/__init__.py +0 -0
  16. {synap_git-2.1.0 → synap_git-2.1.2}/src/synap_git/api/app.py +0 -0
  17. {synap_git-2.1.0 → synap_git-2.1.2}/src/synap_git/api/static/index.html +0 -0
  18. {synap_git-2.1.0 → synap_git-2.1.2}/src/synap_git/cli/__init__.py +0 -0
  19. {synap_git-2.1.0 → synap_git-2.1.2}/src/synap_git/cli/__main__.py +0 -0
  20. {synap_git-2.1.0 → synap_git-2.1.2}/src/synap_git/config.py +0 -0
  21. {synap_git-2.1.0 → synap_git-2.1.2}/src/synap_git/diagnostics/__init__.py +0 -0
  22. {synap_git-2.1.0 → synap_git-2.1.2}/src/synap_git/diagnostics/logger.py +0 -0
  23. {synap_git-2.1.0 → synap_git-2.1.2}/src/synap_git/diagnostics/tracing.py +0 -0
  24. {synap_git-2.1.0 → synap_git-2.1.2}/src/synap_git/embeddings/__init__.py +0 -0
  25. {synap_git-2.1.0 → synap_git-2.1.2}/src/synap_git/git/__init__.py +0 -0
  26. {synap_git-2.1.0 → synap_git-2.1.2}/src/synap_git/git/state.py +0 -0
  27. {synap_git-2.1.0 → synap_git-2.1.2}/src/synap_git/indexer/__init__.py +0 -0
  28. {synap_git-2.1.0 → synap_git-2.1.2}/src/synap_git/indexer/scanner.py +0 -0
  29. {synap_git-2.1.0 → synap_git-2.1.2}/src/synap_git/indexer/wiki.py +0 -0
  30. {synap_git-2.1.0 → synap_git-2.1.2}/src/synap_git/mcp/__init__.py +0 -0
  31. {synap_git-2.1.0 → synap_git-2.1.2}/src/synap_git/parser/__init__.py +0 -0
  32. {synap_git-2.1.0 → synap_git-2.1.2}/src/synap_git/parser/registry.py +0 -0
  33. {synap_git-2.1.0 → synap_git-2.1.2}/src/synap_git/provider/anthropic.py +0 -0
  34. {synap_git-2.1.0 → synap_git-2.1.2}/src/synap_git/provider/base.py +0 -0
  35. {synap_git-2.1.0 → synap_git-2.1.2}/src/synap_git/provider/factory.py +0 -0
  36. {synap_git-2.1.0 → synap_git-2.1.2}/src/synap_git/provider/gemini.py +0 -0
  37. {synap_git-2.1.0 → synap_git-2.1.2}/src/synap_git/provider/ollama.py +0 -0
  38. {synap_git-2.1.0 → synap_git-2.1.2}/src/synap_git/provider/openai.py +0 -0
  39. {synap_git-2.1.0 → synap_git-2.1.2}/src/synap_git/provider/openrouter.py +0 -0
  40. {synap_git-2.1.0 → synap_git-2.1.2}/src/synap_git/py.typed +0 -0
  41. {synap_git-2.1.0 → synap_git-2.1.2}/src/synap_git/retrieval/__init__.py +0 -0
  42. {synap_git-2.1.0 → synap_git-2.1.2}/src/synap_git/retrieval/memory.py +0 -0
  43. {synap_git-2.1.0 → synap_git-2.1.2}/src/synap_git/storage/__init__.py +0 -0
  44. {synap_git-2.1.0 → synap_git-2.1.2}/src/synap_git/utils/__init__.py +0 -0
  45. {synap_git-2.1.0 → synap_git-2.1.2}/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.2] - 2026-06-11
9
+
10
+ ### Fixed
11
+ - **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.
12
+ - **Daemon Lifecycle**: Ensure `embedding_executor` shuts down cleanly without leaking background threads when the daemon loop receives SIGTERM.
13
+
14
+ ## [2.1.1] - 2026-06-11
15
+
16
+ ### Fixed
17
+ - **CRITICAL: MCP StdIO Stream Corruption**: Fixed a bug where `synap mcp start` and `signal_low_context` would accidentally print progress bars or notifications to `sys.stdout` instead of `sys.stderr`, which corrupted the MCP protocol stream and disconnected IDEs like Cursor/Windsurf.
18
+ - **UX Improvements**: Improved the silence logic in CLI initialization to prevent unnecessary rich formatting in JSON/MCP environments.
19
+
8
20
  ## [2.1.0] - 2026-05-29
9
21
 
10
22
  ### Added
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: synap-git
3
- Version: 2.1.0
3
+ Version: 2.1.2
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.0"
5
+ __version__ = "2.1.2"
@@ -1470,7 +1470,7 @@ def mcp_start(
1470
1470
  path: Annotated[str, typer.Argument(help="Repository path.")] = ".",
1471
1471
  ) -> None:
1472
1472
  """Start the MCP server."""
1473
- settings = _settings(path)
1473
+ settings = _settings(path, json_output=True)
1474
1474
  runtime = SynapRuntime(settings)
1475
1475
  runtime.bootstrap()
1476
1476
 
@@ -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)
@@ -317,7 +320,7 @@ class SynapRuntime:
317
320
  from rich.console import Console
318
321
  from rich.progress import BarColumn, Progress, SpinnerColumn, TextColumn, TimeElapsedColumn
319
322
 
320
- console = Console()
323
+ console = Console(stderr=True)
321
324
  show_progress = self.settings.logging_mode.name == "HUMAN" and num_files > 5
322
325
 
323
326
  if show_progress:
@@ -83,7 +83,11 @@ class SynapMCPFacade:
83
83
  if should_checkpoint:
84
84
  message += f"Threshold ({threshold:.1%}) reached. Checkpoint recommended."
85
85
  # Emit CLI notification
86
- print(f"\n[Synapse] ⚠ Agent at {percentage:.0%} context — checkpoint recommended.")
86
+ import sys
87
+
88
+ sys.stderr.write(
89
+ f"\n[Synapse] ⚠ Agent at {percentage:.0%} context — checkpoint recommended.\n"
90
+ )
87
91
  else:
88
92
  message += f"Context is within limits (threshold {threshold:.1%})."
89
93
 
@@ -1,5 +1,6 @@
1
1
  from __future__ import annotations
2
2
 
3
+ import functools
3
4
  from dataclasses import dataclass
4
5
  from datetime import UTC, datetime
5
6
  from pathlib import Path
@@ -22,23 +23,32 @@ class TraceElement:
22
23
  tokens: int
23
24
 
24
25
 
25
- def _get_snippet(repo_path: Path, rel_path: str, start_line: int, end_line: int) -> str:
26
+ @functools.lru_cache(maxsize=256)
27
+ def _get_file_lines(repo_path: Path, rel_path: str) -> list[str] | None:
26
28
  try:
27
29
  p = repo_path / rel_path
28
- lines = p.read_text(encoding="utf-8", errors="replace").splitlines()
29
- s_idx = max(0, start_line - 1)
30
- e_idx = min(len(lines), end_line)
31
- return "\n".join(lines[s_idx:e_idx])
32
- except Exception:
30
+ return p.read_text(encoding="utf-8", errors="replace").splitlines()
31
+ except Exception as e:
32
+ import structlog
33
+
34
+ structlog.get_logger().warning("file_read_failed", path=rel_path, error=str(e))
35
+ return None
36
+
37
+
38
+ def _get_snippet(repo_path: Path, rel_path: str, start_line: int, end_line: int) -> str:
39
+ lines = _get_file_lines(repo_path, rel_path)
40
+ if lines is None:
33
41
  return ""
42
+ s_idx = max(0, start_line - 1)
43
+ e_idx = min(len(lines), end_line)
44
+ return "\n".join(lines[s_idx:e_idx])
34
45
 
35
46
 
36
47
  def _get_full_file(repo_path: Path, rel_path: str) -> str:
37
- try:
38
- p = repo_path / rel_path
39
- return p.read_text(encoding="utf-8", errors="replace")
40
- except Exception:
48
+ lines = _get_file_lines(repo_path, rel_path)
49
+ if lines is None:
41
50
  return ""
51
+ return "\n".join(lines)
42
52
 
43
53
 
44
54
  class HybridRetrievalEngine:
@@ -93,20 +103,15 @@ class HybridRetrievalEngine:
93
103
  # 2. Lexical Matches (BM25)
94
104
  query_words = {word.lower().strip() for word in query.split() if len(word) > 2}
95
105
  lexical_candidates: dict[str, dict[str, Any]] = {}
96
- for word in query_words:
97
- symbols = self.store.get_symbols_by_name(word)
98
- for rank, sym in enumerate(symbols):
99
- sid = sym["symbol_id"]
100
- if sid not in lexical_candidates:
101
- lexical_candidates[sid] = {
102
- **sym,
103
- "reason": f"lexical:'{word}'",
104
- "lexical_rank": rank,
105
- }
106
- else:
107
- lexical_candidates[sid]["lexical_rank"] = min(
108
- lexical_candidates[sid].get("lexical_rank", rank), rank
109
- )
106
+ symbols = self.store.get_symbols_by_lexical_query(query_words)
107
+ for rank, sym in enumerate(symbols):
108
+ sid = sym["symbol_id"]
109
+ if sid not in lexical_candidates:
110
+ lexical_candidates[sid] = {
111
+ **sym,
112
+ "reason": "lexical_match",
113
+ "lexical_rank": rank,
114
+ }
110
115
 
111
116
  t_lexical = time.perf_counter()
112
117
 
@@ -45,7 +45,7 @@ class SynapStore:
45
45
  self.path.parent.mkdir(parents=True, exist_ok=True)
46
46
  with self.connect() as conn:
47
47
  conn.execute("PRAGMA journal_mode=WAL")
48
- conn.execute("PRAGMA synchronous=FULL")
48
+ conn.execute("PRAGMA synchronous=NORMAL")
49
49
  conn.execute("PRAGMA foreign_keys=ON")
50
50
 
51
51
  # Get current user version
@@ -287,7 +287,7 @@ class SynapStore:
287
287
  conn.create_function("cosine_similarity", 2, _cosine_similarity)
288
288
  try:
289
289
  conn.execute("PRAGMA foreign_keys=ON")
290
- conn.execute("PRAGMA synchronous=FULL")
290
+ conn.execute("PRAGMA synchronous=NORMAL")
291
291
  yield conn
292
292
  conn.commit()
293
293
  except Exception:
@@ -395,7 +395,16 @@ class SynapStore:
395
395
  ).fetchall()
396
396
  return [dict(row) for row in rows]
397
397
 
398
- def get_symbols_by_name(self, clean_name: str, limit: int = 50) -> list[dict[str, Any]]:
398
+ def get_symbols_by_lexical_query(
399
+ self, query_words: set[str], limit: int = 50
400
+ ) -> list[dict[str, Any]]:
401
+ if not query_words:
402
+ return []
403
+ parts = []
404
+ for w in query_words:
405
+ parts.append(f'"{w}"*')
406
+ parts.append(f'body:"{w}"*')
407
+ match_expr = " OR ".join(parts)
399
408
  with self.connect() as conn:
400
409
  rows = conn.execute(
401
410
  """
@@ -407,7 +416,7 @@ class SynapStore:
407
416
  ORDER BY rank
408
417
  LIMIT ?
409
418
  """,
410
- (f'"{clean_name}"* OR body:"{clean_name}"*', limit),
419
+ (match_expr, limit),
411
420
  ).fetchall()
412
421
  return [dict(row) for row in rows]
413
422
 
File without changes
File without changes
File without changes
File without changes