synaptiq 0.3.0__py3-none-any.whl
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.
- synaptiq/__init__.py +3 -0
- synaptiq/cli/__init__.py +1 -0
- synaptiq/cli/main.py +475 -0
- synaptiq/config/__init__.py +13 -0
- synaptiq/config/ignore.py +135 -0
- synaptiq/config/languages.py +27 -0
- synaptiq/core/__init__.py +1 -0
- synaptiq/core/daemon/__init__.py +1 -0
- synaptiq/core/daemon/lock.py +152 -0
- synaptiq/core/daemon/socket_client.py +110 -0
- synaptiq/core/daemon/socket_server.py +124 -0
- synaptiq/core/diff.py +246 -0
- synaptiq/core/embeddings/__init__.py +1 -0
- synaptiq/core/embeddings/embedder.py +87 -0
- synaptiq/core/embeddings/text.py +206 -0
- synaptiq/core/graph/__init__.py +1 -0
- synaptiq/core/graph/graph.py +175 -0
- synaptiq/core/graph/model.py +97 -0
- synaptiq/core/ingestion/__init__.py +1 -0
- synaptiq/core/ingestion/calls.py +360 -0
- synaptiq/core/ingestion/community.py +193 -0
- synaptiq/core/ingestion/coupling.py +224 -0
- synaptiq/core/ingestion/dead_code.py +345 -0
- synaptiq/core/ingestion/heritage.py +141 -0
- synaptiq/core/ingestion/imports.py +256 -0
- synaptiq/core/ingestion/parser_phase.py +216 -0
- synaptiq/core/ingestion/pipeline.py +214 -0
- synaptiq/core/ingestion/processes.py +315 -0
- synaptiq/core/ingestion/structure.py +110 -0
- synaptiq/core/ingestion/symbol_lookup.py +139 -0
- synaptiq/core/ingestion/types.py +139 -0
- synaptiq/core/ingestion/walker.py +123 -0
- synaptiq/core/ingestion/watcher.py +153 -0
- synaptiq/core/parsers/__init__.py +1 -0
- synaptiq/core/parsers/base.py +70 -0
- synaptiq/core/parsers/python_lang.py +589 -0
- synaptiq/core/parsers/typescript.py +656 -0
- synaptiq/core/search/__init__.py +1 -0
- synaptiq/core/search/hybrid.py +102 -0
- synaptiq/core/storage/__init__.py +1 -0
- synaptiq/core/storage/base.py +122 -0
- synaptiq/core/storage/kuzu_backend.py +843 -0
- synaptiq/mcp/__init__.py +1 -0
- synaptiq/mcp/resources.py +150 -0
- synaptiq/mcp/server.py +297 -0
- synaptiq/mcp/tools.py +336 -0
- synaptiq/py.typed +0 -0
- synaptiq-0.3.0.dist-info/METADATA +588 -0
- synaptiq-0.3.0.dist-info/RECORD +51 -0
- synaptiq-0.3.0.dist-info/WHEEL +4 -0
- synaptiq-0.3.0.dist-info/entry_points.txt +3 -0
synaptiq/__init__.py
ADDED
synaptiq/cli/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
synaptiq/cli/main.py
ADDED
|
@@ -0,0 +1,475 @@
|
|
|
1
|
+
"""Synaptiq CLI — Graph-powered code intelligence engine."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import shutil
|
|
7
|
+
from datetime import datetime, timezone
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Optional
|
|
10
|
+
|
|
11
|
+
import typer
|
|
12
|
+
from rich.console import Console
|
|
13
|
+
from rich.progress import Progress, SpinnerColumn, TextColumn
|
|
14
|
+
|
|
15
|
+
from synaptiq import __version__
|
|
16
|
+
|
|
17
|
+
console = Console()
|
|
18
|
+
|
|
19
|
+
def _load_storage(repo_path: Path | None = None) -> "KuzuBackend": # noqa: F821
|
|
20
|
+
"""Load the KuzuDB backend for the given or current repo."""
|
|
21
|
+
from synaptiq.core.storage.kuzu_backend import KuzuBackend
|
|
22
|
+
|
|
23
|
+
target = (repo_path or Path.cwd()).resolve()
|
|
24
|
+
db_path = target / ".synaptiq" / "kuzu"
|
|
25
|
+
if not db_path.exists():
|
|
26
|
+
console.print(
|
|
27
|
+
f"[red]Error:[/red] No index found at {target}. Run 'synaptiq analyze' first."
|
|
28
|
+
)
|
|
29
|
+
raise typer.Exit(code=1)
|
|
30
|
+
|
|
31
|
+
storage = KuzuBackend()
|
|
32
|
+
storage.initialize(db_path, read_only=True)
|
|
33
|
+
return storage
|
|
34
|
+
|
|
35
|
+
app = typer.Typer(
|
|
36
|
+
name="synaptiq",
|
|
37
|
+
help="Synaptiq — Graph-powered code intelligence engine.",
|
|
38
|
+
no_args_is_help=True,
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
def _version_callback(value: bool) -> None:
|
|
42
|
+
"""Print the version and exit."""
|
|
43
|
+
if value:
|
|
44
|
+
console.print(f"Synaptiq v{__version__}")
|
|
45
|
+
raise typer.Exit()
|
|
46
|
+
|
|
47
|
+
@app.callback()
|
|
48
|
+
def main(
|
|
49
|
+
version: Optional[bool] = typer.Option( # noqa: N803
|
|
50
|
+
None,
|
|
51
|
+
"--version",
|
|
52
|
+
"-v",
|
|
53
|
+
help="Show version and exit.",
|
|
54
|
+
callback=_version_callback,
|
|
55
|
+
is_eager=True,
|
|
56
|
+
),
|
|
57
|
+
) -> None:
|
|
58
|
+
"""Synaptiq — Graph-powered code intelligence engine."""
|
|
59
|
+
|
|
60
|
+
@app.command()
|
|
61
|
+
def analyze(
|
|
62
|
+
path: Path = typer.Argument(Path("."), help="Path to the repository to index."),
|
|
63
|
+
full: bool = typer.Option(False, "--full", help="Perform a full re-index."),
|
|
64
|
+
) -> None:
|
|
65
|
+
"""Index a repository into a knowledge graph."""
|
|
66
|
+
from synaptiq.core.ingestion.pipeline import PipelineResult, run_pipeline
|
|
67
|
+
from synaptiq.core.storage.kuzu_backend import KuzuBackend
|
|
68
|
+
|
|
69
|
+
repo_path = path.resolve()
|
|
70
|
+
if not repo_path.is_dir():
|
|
71
|
+
console.print(f"[red]Error:[/red] {repo_path} is not a directory.")
|
|
72
|
+
raise typer.Exit(code=1)
|
|
73
|
+
|
|
74
|
+
console.print(f"[bold]Indexing[/bold] {repo_path}")
|
|
75
|
+
|
|
76
|
+
data_dir = repo_path / ".synaptiq"
|
|
77
|
+
data_dir.mkdir(parents=True, exist_ok=True)
|
|
78
|
+
db_path = data_dir / "kuzu"
|
|
79
|
+
|
|
80
|
+
storage = KuzuBackend()
|
|
81
|
+
storage.initialize(db_path)
|
|
82
|
+
|
|
83
|
+
result: PipelineResult | None = None
|
|
84
|
+
with Progress(
|
|
85
|
+
SpinnerColumn(),
|
|
86
|
+
TextColumn("[progress.description]{task.description}"),
|
|
87
|
+
console=console,
|
|
88
|
+
transient=True,
|
|
89
|
+
) as progress:
|
|
90
|
+
task = progress.add_task("Starting...", total=None)
|
|
91
|
+
|
|
92
|
+
def on_progress(phase: str, pct: float) -> None:
|
|
93
|
+
progress.update(task, description=f"{phase} ({pct:.0%})")
|
|
94
|
+
|
|
95
|
+
_, result = run_pipeline(
|
|
96
|
+
repo_path=repo_path,
|
|
97
|
+
storage=storage,
|
|
98
|
+
full=full,
|
|
99
|
+
progress_callback=on_progress,
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
meta = {
|
|
103
|
+
"version": __version__,
|
|
104
|
+
"name": repo_path.name,
|
|
105
|
+
"path": str(repo_path),
|
|
106
|
+
"stats": {
|
|
107
|
+
"files": result.files,
|
|
108
|
+
"symbols": result.symbols,
|
|
109
|
+
"relationships": result.relationships,
|
|
110
|
+
"clusters": result.clusters,
|
|
111
|
+
"flows": result.processes,
|
|
112
|
+
"dead_code": result.dead_code,
|
|
113
|
+
"coupled_pairs": result.coupled_pairs,
|
|
114
|
+
},
|
|
115
|
+
"last_indexed_at": datetime.now(tz=timezone.utc).isoformat(),
|
|
116
|
+
}
|
|
117
|
+
meta_path = data_dir / "meta.json"
|
|
118
|
+
meta_path.write_text(json.dumps(meta, indent=2) + "\n", encoding="utf-8")
|
|
119
|
+
|
|
120
|
+
console.print()
|
|
121
|
+
console.print("[bold green]Indexing complete.[/bold green]")
|
|
122
|
+
console.print(f" Files: {result.files}")
|
|
123
|
+
console.print(f" Symbols: {result.symbols}")
|
|
124
|
+
console.print(f" Relationships: {result.relationships}")
|
|
125
|
+
if result.clusters > 0:
|
|
126
|
+
console.print(f" Clusters: {result.clusters}")
|
|
127
|
+
if result.processes > 0:
|
|
128
|
+
console.print(f" Flows: {result.processes}")
|
|
129
|
+
if result.dead_code > 0:
|
|
130
|
+
console.print(f" Dead code: {result.dead_code}")
|
|
131
|
+
if result.coupled_pairs > 0:
|
|
132
|
+
console.print(f" Coupled pairs: {result.coupled_pairs}")
|
|
133
|
+
console.print(f" Duration: {result.duration_seconds:.2f}s")
|
|
134
|
+
|
|
135
|
+
storage.close()
|
|
136
|
+
|
|
137
|
+
@app.command()
|
|
138
|
+
def status() -> None:
|
|
139
|
+
"""Show index status for current repository."""
|
|
140
|
+
repo_path = Path.cwd().resolve()
|
|
141
|
+
meta_path = repo_path / ".synaptiq" / "meta.json"
|
|
142
|
+
|
|
143
|
+
if not meta_path.exists():
|
|
144
|
+
console.print(
|
|
145
|
+
f"[red]Error:[/red] No index found at {repo_path}. Run 'synaptiq analyze' first."
|
|
146
|
+
)
|
|
147
|
+
raise typer.Exit(code=1)
|
|
148
|
+
|
|
149
|
+
meta = json.loads(meta_path.read_text(encoding="utf-8"))
|
|
150
|
+
stats = meta.get("stats", {})
|
|
151
|
+
|
|
152
|
+
console.print(f"[bold]Index status for[/bold] {repo_path}")
|
|
153
|
+
console.print(f" Version: {meta.get('version', '?')}")
|
|
154
|
+
console.print(f" Last indexed: {meta.get('last_indexed_at', '?')}")
|
|
155
|
+
console.print(f" Files: {stats.get('files', '?')}")
|
|
156
|
+
console.print(f" Symbols: {stats.get('symbols', '?')}")
|
|
157
|
+
console.print(f" Relationships: {stats.get('relationships', '?')}")
|
|
158
|
+
|
|
159
|
+
if stats.get("clusters", 0) > 0:
|
|
160
|
+
console.print(f" Clusters: {stats['clusters']}")
|
|
161
|
+
if stats.get("flows", 0) > 0:
|
|
162
|
+
console.print(f" Flows: {stats['flows']}")
|
|
163
|
+
if stats.get("dead_code", 0) > 0:
|
|
164
|
+
console.print(f" Dead code: {stats['dead_code']}")
|
|
165
|
+
if stats.get("coupled_pairs", 0) > 0:
|
|
166
|
+
console.print(f" Coupled pairs: {stats['coupled_pairs']}")
|
|
167
|
+
|
|
168
|
+
@app.command(name="list")
|
|
169
|
+
def list_repos() -> None:
|
|
170
|
+
"""List all indexed repositories."""
|
|
171
|
+
from synaptiq.mcp.tools import handle_list_repos
|
|
172
|
+
|
|
173
|
+
result = handle_list_repos()
|
|
174
|
+
console.print(result)
|
|
175
|
+
|
|
176
|
+
@app.command()
|
|
177
|
+
def clean(
|
|
178
|
+
force: bool = typer.Option(False, "--force", "-f", help="Skip confirmation prompt."),
|
|
179
|
+
) -> None:
|
|
180
|
+
"""Delete index for current repository."""
|
|
181
|
+
repo_path = Path.cwd().resolve()
|
|
182
|
+
data_dir = repo_path / ".synaptiq"
|
|
183
|
+
|
|
184
|
+
if not data_dir.exists():
|
|
185
|
+
console.print(
|
|
186
|
+
f"[red]Error:[/red] No index found at {repo_path}. Nothing to clean."
|
|
187
|
+
)
|
|
188
|
+
raise typer.Exit(code=1)
|
|
189
|
+
|
|
190
|
+
if not force:
|
|
191
|
+
confirm = typer.confirm(f"Delete index at {data_dir}?")
|
|
192
|
+
if not confirm:
|
|
193
|
+
console.print("Aborted.")
|
|
194
|
+
raise typer.Exit()
|
|
195
|
+
|
|
196
|
+
shutil.rmtree(data_dir)
|
|
197
|
+
console.print(f"[green]Deleted[/green] {data_dir}")
|
|
198
|
+
|
|
199
|
+
@app.command()
|
|
200
|
+
def query(
|
|
201
|
+
q: str = typer.Argument(..., help="Search query for the knowledge graph."),
|
|
202
|
+
limit: int = typer.Option(20, "--limit", "-n", help="Maximum number of results."),
|
|
203
|
+
) -> None:
|
|
204
|
+
"""Search the knowledge graph."""
|
|
205
|
+
from synaptiq.mcp.tools import handle_query
|
|
206
|
+
|
|
207
|
+
storage = _load_storage()
|
|
208
|
+
result = handle_query(storage, q, limit=limit)
|
|
209
|
+
console.print(result)
|
|
210
|
+
storage.close()
|
|
211
|
+
|
|
212
|
+
@app.command()
|
|
213
|
+
def context(
|
|
214
|
+
name: str = typer.Argument(..., help="Symbol name to inspect."),
|
|
215
|
+
) -> None:
|
|
216
|
+
"""Show 360-degree view of a symbol."""
|
|
217
|
+
from synaptiq.mcp.tools import handle_context
|
|
218
|
+
|
|
219
|
+
storage = _load_storage()
|
|
220
|
+
result = handle_context(storage, name)
|
|
221
|
+
console.print(result)
|
|
222
|
+
storage.close()
|
|
223
|
+
|
|
224
|
+
@app.command()
|
|
225
|
+
def impact(
|
|
226
|
+
target: str = typer.Argument(..., help="Symbol to analyze blast radius for."),
|
|
227
|
+
depth: int = typer.Option(3, "--depth", "-d", help="Traversal depth."),
|
|
228
|
+
) -> None:
|
|
229
|
+
"""Show blast radius of changing a symbol."""
|
|
230
|
+
from synaptiq.mcp.tools import handle_impact
|
|
231
|
+
|
|
232
|
+
storage = _load_storage()
|
|
233
|
+
result = handle_impact(storage, target, depth=depth)
|
|
234
|
+
console.print(result)
|
|
235
|
+
storage.close()
|
|
236
|
+
|
|
237
|
+
@app.command(name="dead-code")
|
|
238
|
+
def dead_code() -> None:
|
|
239
|
+
"""List all detected dead code."""
|
|
240
|
+
from synaptiq.mcp.tools import handle_dead_code
|
|
241
|
+
|
|
242
|
+
storage = _load_storage()
|
|
243
|
+
result = handle_dead_code(storage)
|
|
244
|
+
console.print(result)
|
|
245
|
+
storage.close()
|
|
246
|
+
|
|
247
|
+
@app.command()
|
|
248
|
+
def cypher(
|
|
249
|
+
query: str = typer.Argument(..., help="Raw Cypher query to execute."),
|
|
250
|
+
) -> None:
|
|
251
|
+
"""Execute raw Cypher against the knowledge graph."""
|
|
252
|
+
from synaptiq.mcp.tools import handle_cypher
|
|
253
|
+
|
|
254
|
+
storage = _load_storage()
|
|
255
|
+
result = handle_cypher(storage, query)
|
|
256
|
+
console.print(result)
|
|
257
|
+
storage.close()
|
|
258
|
+
|
|
259
|
+
@app.command()
|
|
260
|
+
def setup(
|
|
261
|
+
claude: bool = typer.Option(False, "--claude", help="Configure MCP for Claude Code."),
|
|
262
|
+
cursor: bool = typer.Option(False, "--cursor", help="Configure MCP for Cursor."),
|
|
263
|
+
) -> None:
|
|
264
|
+
"""Configure MCP for Claude Code / Cursor."""
|
|
265
|
+
mcp_config = {
|
|
266
|
+
"command": "synaptiq",
|
|
267
|
+
"args": ["serve", "--watch"],
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
if claude or (not claude and not cursor):
|
|
271
|
+
console.print("[bold]Add to your Claude Code MCP config:[/bold]")
|
|
272
|
+
console.print(json.dumps({"synaptiq": mcp_config}, indent=2))
|
|
273
|
+
|
|
274
|
+
if cursor or (not claude and not cursor):
|
|
275
|
+
console.print("[bold]Add to your Cursor MCP config:[/bold]")
|
|
276
|
+
console.print(json.dumps({"synaptiq": mcp_config}, indent=2))
|
|
277
|
+
|
|
278
|
+
@app.command()
|
|
279
|
+
def watch() -> None:
|
|
280
|
+
"""Watch mode — re-index on file changes."""
|
|
281
|
+
import asyncio
|
|
282
|
+
|
|
283
|
+
from synaptiq.core.ingestion.pipeline import run_pipeline
|
|
284
|
+
from synaptiq.core.ingestion.watcher import watch_repo
|
|
285
|
+
from synaptiq.core.storage.kuzu_backend import KuzuBackend
|
|
286
|
+
|
|
287
|
+
repo_path = Path.cwd().resolve()
|
|
288
|
+
data_dir = repo_path / ".synaptiq"
|
|
289
|
+
data_dir.mkdir(parents=True, exist_ok=True)
|
|
290
|
+
db_path = data_dir / "kuzu"
|
|
291
|
+
|
|
292
|
+
storage = KuzuBackend()
|
|
293
|
+
storage.initialize(db_path)
|
|
294
|
+
|
|
295
|
+
if not (data_dir / "meta.json").exists():
|
|
296
|
+
console.print("[bold]Running initial index...[/bold]")
|
|
297
|
+
run_pipeline(repo_path, storage, full=True)
|
|
298
|
+
|
|
299
|
+
console.print(f"[bold]Watching[/bold] {repo_path} for changes (Ctrl+C to stop)")
|
|
300
|
+
|
|
301
|
+
try:
|
|
302
|
+
asyncio.run(watch_repo(repo_path, storage))
|
|
303
|
+
except KeyboardInterrupt:
|
|
304
|
+
console.print("\n[bold]Watch stopped.[/bold]")
|
|
305
|
+
finally:
|
|
306
|
+
storage.close()
|
|
307
|
+
|
|
308
|
+
@app.command()
|
|
309
|
+
def diff(
|
|
310
|
+
branch_range: str = typer.Argument(
|
|
311
|
+
..., help="Branch range for comparison (e.g. main..feature)."
|
|
312
|
+
),
|
|
313
|
+
) -> None:
|
|
314
|
+
"""Structural branch comparison."""
|
|
315
|
+
from synaptiq.core.diff import diff_branches, format_diff
|
|
316
|
+
|
|
317
|
+
repo_path = Path.cwd().resolve()
|
|
318
|
+
try:
|
|
319
|
+
result = diff_branches(repo_path, branch_range)
|
|
320
|
+
except (ValueError, RuntimeError) as exc:
|
|
321
|
+
console.print(f"[red]Error:[/red] {exc}")
|
|
322
|
+
raise typer.Exit(code=1) from exc
|
|
323
|
+
|
|
324
|
+
console.print(format_diff(result))
|
|
325
|
+
|
|
326
|
+
@app.command()
|
|
327
|
+
def mcp() -> None:
|
|
328
|
+
"""Start MCP server (stdio transport)."""
|
|
329
|
+
import asyncio
|
|
330
|
+
|
|
331
|
+
from synaptiq.mcp.server import main as mcp_main
|
|
332
|
+
|
|
333
|
+
asyncio.run(mcp_main())
|
|
334
|
+
|
|
335
|
+
@app.command()
|
|
336
|
+
def serve(
|
|
337
|
+
watch: bool = typer.Option(
|
|
338
|
+
False, "--watch", "-w", help="Enable file watching with auto-reindex."
|
|
339
|
+
),
|
|
340
|
+
) -> None:
|
|
341
|
+
"""Start MCP server, optionally with live file watching."""
|
|
342
|
+
import asyncio
|
|
343
|
+
import sys
|
|
344
|
+
|
|
345
|
+
from synaptiq.mcp.server import main as mcp_main
|
|
346
|
+
|
|
347
|
+
if not watch:
|
|
348
|
+
asyncio.run(mcp_main())
|
|
349
|
+
return
|
|
350
|
+
|
|
351
|
+
from synaptiq.core.daemon.lock import LockManager
|
|
352
|
+
|
|
353
|
+
repo_path = Path.cwd().resolve()
|
|
354
|
+
data_dir = repo_path / ".synaptiq"
|
|
355
|
+
data_dir.mkdir(parents=True, exist_ok=True)
|
|
356
|
+
|
|
357
|
+
lock_mgr = LockManager(data_dir)
|
|
358
|
+
lock_info = lock_mgr.try_acquire()
|
|
359
|
+
|
|
360
|
+
if lock_info is None:
|
|
361
|
+
# Another instance holds the lock — check if healthy or stale
|
|
362
|
+
existing = lock_mgr.read_existing()
|
|
363
|
+
if existing is not None and existing.is_stale():
|
|
364
|
+
lock_mgr.force_cleanup()
|
|
365
|
+
lock_info = lock_mgr.try_acquire()
|
|
366
|
+
|
|
367
|
+
if lock_info is not None:
|
|
368
|
+
_serve_primary(repo_path, data_dir, lock_mgr)
|
|
369
|
+
else:
|
|
370
|
+
# Re-read in case the lock changed hands during stale cleanup.
|
|
371
|
+
existing = lock_mgr.read_existing()
|
|
372
|
+
if existing is None:
|
|
373
|
+
print("Error: cannot read lock info from primary", file=sys.stderr)
|
|
374
|
+
raise typer.Exit(code=1)
|
|
375
|
+
_serve_proxy(existing.socket)
|
|
376
|
+
|
|
377
|
+
|
|
378
|
+
def _serve_primary(repo_path: Path, data_dir: Path, lock_mgr) -> None:
|
|
379
|
+
"""Run as primary: DB + watcher + MCP + socket server."""
|
|
380
|
+
import asyncio
|
|
381
|
+
import sys
|
|
382
|
+
|
|
383
|
+
from synaptiq.core.daemon.socket_server import SocketServer
|
|
384
|
+
from synaptiq.core.ingestion.pipeline import run_pipeline
|
|
385
|
+
from synaptiq.core.ingestion.watcher import watch_repo
|
|
386
|
+
from synaptiq.core.storage.kuzu_backend import KuzuBackend
|
|
387
|
+
from synaptiq.mcp.server import dispatch_resource, dispatch_tool, set_lock, set_storage
|
|
388
|
+
from synaptiq.mcp.server import server as mcp_server
|
|
389
|
+
|
|
390
|
+
db_path = data_dir / "kuzu"
|
|
391
|
+
storage = KuzuBackend()
|
|
392
|
+
storage.initialize(db_path)
|
|
393
|
+
|
|
394
|
+
if not (data_dir / "meta.json").exists():
|
|
395
|
+
print("Running initial index...", file=sys.stderr)
|
|
396
|
+
run_pipeline(repo_path, storage, full=True)
|
|
397
|
+
|
|
398
|
+
lock = asyncio.Lock()
|
|
399
|
+
set_storage(storage)
|
|
400
|
+
set_lock(lock)
|
|
401
|
+
|
|
402
|
+
def dispatch(method: str, params: dict) -> str:
|
|
403
|
+
if method == "ping":
|
|
404
|
+
return "pong"
|
|
405
|
+
if method == "tool":
|
|
406
|
+
return dispatch_tool(params.get("name", ""), params.get("arguments", {}), storage)
|
|
407
|
+
if method == "resource":
|
|
408
|
+
return dispatch_resource(params.get("uri", ""), storage)
|
|
409
|
+
return f"Unknown method: {method}"
|
|
410
|
+
|
|
411
|
+
socket_server = SocketServer(lock_mgr.socket_path, dispatch, lock=lock)
|
|
412
|
+
|
|
413
|
+
async def _run() -> None:
|
|
414
|
+
from mcp.server.stdio import stdio_server
|
|
415
|
+
stop = asyncio.Event()
|
|
416
|
+
await socket_server.start()
|
|
417
|
+
try:
|
|
418
|
+
async with stdio_server() as (read, write):
|
|
419
|
+
async def _mcp_then_stop():
|
|
420
|
+
await mcp_server.run(read, write, mcp_server.create_initialization_options())
|
|
421
|
+
stop.set()
|
|
422
|
+
await asyncio.gather(
|
|
423
|
+
_mcp_then_stop(),
|
|
424
|
+
watch_repo(repo_path, storage, stop_event=stop, lock=lock),
|
|
425
|
+
)
|
|
426
|
+
finally:
|
|
427
|
+
await socket_server.stop()
|
|
428
|
+
|
|
429
|
+
try:
|
|
430
|
+
asyncio.run(_run())
|
|
431
|
+
except KeyboardInterrupt:
|
|
432
|
+
pass
|
|
433
|
+
finally:
|
|
434
|
+
storage.close()
|
|
435
|
+
lock_mgr.release()
|
|
436
|
+
|
|
437
|
+
|
|
438
|
+
def _serve_proxy(socket_path: str) -> None:
|
|
439
|
+
"""Run as proxy: MCP over stdio, forwarding to primary via socket."""
|
|
440
|
+
import asyncio
|
|
441
|
+
|
|
442
|
+
from synaptiq.core.daemon.socket_client import SocketClient
|
|
443
|
+
from synaptiq.mcp.server import set_proxy_client
|
|
444
|
+
|
|
445
|
+
client = SocketClient(Path(socket_path))
|
|
446
|
+
|
|
447
|
+
async def _run() -> None:
|
|
448
|
+
import sys
|
|
449
|
+
|
|
450
|
+
from mcp.server.stdio import stdio_server
|
|
451
|
+
|
|
452
|
+
from synaptiq.mcp.server import server as mcp_server
|
|
453
|
+
|
|
454
|
+
# Retry connection — the primary may still be starting its socket server.
|
|
455
|
+
for attempt in range(5):
|
|
456
|
+
try:
|
|
457
|
+
await client.connect()
|
|
458
|
+
break
|
|
459
|
+
except ConnectionError:
|
|
460
|
+
if attempt == 4:
|
|
461
|
+
print("Error: could not connect to primary socket", file=sys.stderr)
|
|
462
|
+
raise
|
|
463
|
+
await asyncio.sleep(0.2 * (attempt + 1))
|
|
464
|
+
|
|
465
|
+
set_proxy_client(client)
|
|
466
|
+
try:
|
|
467
|
+
async with stdio_server() as (read, write):
|
|
468
|
+
await mcp_server.run(read, write, mcp_server.create_initialization_options())
|
|
469
|
+
finally:
|
|
470
|
+
await client.close()
|
|
471
|
+
|
|
472
|
+
try:
|
|
473
|
+
asyncio.run(_run())
|
|
474
|
+
except KeyboardInterrupt:
|
|
475
|
+
pass
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
"""Synaptiq configuration — ignore patterns and language detection."""
|
|
2
|
+
|
|
3
|
+
from synaptiq.config.ignore import DEFAULT_IGNORE_PATTERNS, load_gitignore, should_ignore
|
|
4
|
+
from synaptiq.config.languages import SUPPORTED_EXTENSIONS, get_language, is_supported
|
|
5
|
+
|
|
6
|
+
__all__ = [
|
|
7
|
+
"DEFAULT_IGNORE_PATTERNS",
|
|
8
|
+
"SUPPORTED_EXTENSIONS",
|
|
9
|
+
"get_language",
|
|
10
|
+
"is_supported",
|
|
11
|
+
"load_gitignore",
|
|
12
|
+
"should_ignore",
|
|
13
|
+
]
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
"""Ignore-pattern handling for Synaptiq's file discovery."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import fnmatch
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
DEFAULT_IGNORE_PATTERNS: frozenset[str] = frozenset(
|
|
9
|
+
{
|
|
10
|
+
# Directories
|
|
11
|
+
"node_modules",
|
|
12
|
+
"__pycache__",
|
|
13
|
+
".git",
|
|
14
|
+
".synaptiq",
|
|
15
|
+
".venv",
|
|
16
|
+
"venv",
|
|
17
|
+
".env",
|
|
18
|
+
"dist",
|
|
19
|
+
"build",
|
|
20
|
+
".idea",
|
|
21
|
+
".vscode",
|
|
22
|
+
".mypy_cache",
|
|
23
|
+
".pytest_cache",
|
|
24
|
+
".ruff_cache",
|
|
25
|
+
".tox",
|
|
26
|
+
"egg-info",
|
|
27
|
+
".eggs",
|
|
28
|
+
"coverage",
|
|
29
|
+
"htmlcov",
|
|
30
|
+
# Files (exact names)
|
|
31
|
+
".DS_Store",
|
|
32
|
+
".coverage",
|
|
33
|
+
"package-lock.json",
|
|
34
|
+
"yarn.lock",
|
|
35
|
+
"uv.lock",
|
|
36
|
+
"poetry.lock",
|
|
37
|
+
# File globs
|
|
38
|
+
"*.pyc",
|
|
39
|
+
"*.pyo",
|
|
40
|
+
"*.so",
|
|
41
|
+
"*.dylib",
|
|
42
|
+
"*.min.js",
|
|
43
|
+
"*.bundle.js",
|
|
44
|
+
"*.map",
|
|
45
|
+
}
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
# Separate glob patterns (contain wildcards) from literal names at module load
|
|
49
|
+
# so we only compute this once.
|
|
50
|
+
_GLOB_PATTERNS: frozenset[str] = frozenset(
|
|
51
|
+
p for p in DEFAULT_IGNORE_PATTERNS if "*" in p or "?" in p
|
|
52
|
+
)
|
|
53
|
+
_LITERAL_PATTERNS: frozenset[str] = DEFAULT_IGNORE_PATTERNS - _GLOB_PATTERNS
|
|
54
|
+
|
|
55
|
+
def _matches_default_patterns(path: Path) -> bool:
|
|
56
|
+
"""Check whether *path* (relative) matches any default ignore pattern."""
|
|
57
|
+
for part in path.parts:
|
|
58
|
+
if part in _LITERAL_PATTERNS:
|
|
59
|
+
return True
|
|
60
|
+
# Also check globs against every component (e.g. *.pyc — unlikely but consistent)
|
|
61
|
+
for pattern in _GLOB_PATTERNS:
|
|
62
|
+
if fnmatch.fnmatch(part, pattern):
|
|
63
|
+
return True
|
|
64
|
+
return False
|
|
65
|
+
|
|
66
|
+
_pathspec_cache: dict[tuple[str, ...], object] = {}
|
|
67
|
+
|
|
68
|
+
def _matches_gitignore(path: Path, gitignore_patterns: list[str]) -> bool:
|
|
69
|
+
"""Check *path* against a list of gitignore-style patterns.
|
|
70
|
+
|
|
71
|
+
Uses ``pathspec`` when available for full gitignore semantics; falls back to
|
|
72
|
+
fnmatch per-pattern otherwise. The compiled pathspec is cached by the
|
|
73
|
+
pattern content so it is only built once per unique pattern set.
|
|
74
|
+
"""
|
|
75
|
+
if not gitignore_patterns:
|
|
76
|
+
return False
|
|
77
|
+
|
|
78
|
+
try:
|
|
79
|
+
import pathspec
|
|
80
|
+
|
|
81
|
+
cache_key = tuple(gitignore_patterns)
|
|
82
|
+
spec = _pathspec_cache.get(cache_key)
|
|
83
|
+
if spec is None:
|
|
84
|
+
spec = pathspec.PathSpec.from_lines("gitignore", gitignore_patterns)
|
|
85
|
+
_pathspec_cache[cache_key] = spec
|
|
86
|
+
return spec.match_file(str(path)) # type: ignore[union-attr]
|
|
87
|
+
except ImportError: # pragma: no cover — pathspec is a declared dependency
|
|
88
|
+
path_str = str(path)
|
|
89
|
+
for pattern in gitignore_patterns:
|
|
90
|
+
if fnmatch.fnmatch(path_str, pattern):
|
|
91
|
+
return True
|
|
92
|
+
if fnmatch.fnmatch(path.name, pattern):
|
|
93
|
+
return True
|
|
94
|
+
return False
|
|
95
|
+
|
|
96
|
+
def should_ignore(
|
|
97
|
+
path: str | Path,
|
|
98
|
+
gitignore_patterns: list[str] | None = None,
|
|
99
|
+
) -> bool:
|
|
100
|
+
"""Return ``True`` if *path* should be ignored during file discovery.
|
|
101
|
+
|
|
102
|
+
Parameters
|
|
103
|
+
----------
|
|
104
|
+
path:
|
|
105
|
+
A relative file path (e.g. ``src/main.py`` or ``node_modules/pkg/index.js``).
|
|
106
|
+
gitignore_patterns:
|
|
107
|
+
Optional list of gitignore-style patterns loaded via :func:`load_gitignore`.
|
|
108
|
+
"""
|
|
109
|
+
p = Path(path)
|
|
110
|
+
|
|
111
|
+
if _matches_default_patterns(p):
|
|
112
|
+
return True
|
|
113
|
+
|
|
114
|
+
if gitignore_patterns and _matches_gitignore(p, gitignore_patterns):
|
|
115
|
+
return True
|
|
116
|
+
|
|
117
|
+
return False
|
|
118
|
+
|
|
119
|
+
def load_gitignore(repo_path: Path) -> list[str]:
|
|
120
|
+
"""Read ``.gitignore`` from *repo_path* and return a list of patterns.
|
|
121
|
+
|
|
122
|
+
Blank lines and comments (lines starting with ``#``) are stripped.
|
|
123
|
+
Returns an empty list when the file does not exist.
|
|
124
|
+
"""
|
|
125
|
+
gitignore = repo_path / ".gitignore"
|
|
126
|
+
if not gitignore.is_file():
|
|
127
|
+
return []
|
|
128
|
+
|
|
129
|
+
lines: list[str] = []
|
|
130
|
+
text = gitignore.read_text(encoding="utf-8")
|
|
131
|
+
for raw_line in text.splitlines():
|
|
132
|
+
line = raw_line.strip()
|
|
133
|
+
if line and not line.startswith("#"):
|
|
134
|
+
lines.append(line)
|
|
135
|
+
return lines
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
"""Language detection based on file extensions."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
SUPPORTED_EXTENSIONS: dict[str, str] = {
|
|
8
|
+
".py": "python",
|
|
9
|
+
".ts": "typescript",
|
|
10
|
+
".tsx": "typescript",
|
|
11
|
+
".js": "javascript",
|
|
12
|
+
".jsx": "javascript",
|
|
13
|
+
".mjs": "javascript",
|
|
14
|
+
".cjs": "javascript",
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
def get_language(file_path: str | Path) -> str | None:
|
|
18
|
+
"""Return the language name for *file_path* based on its extension.
|
|
19
|
+
|
|
20
|
+
Returns ``None`` when the extension is not in :data:`SUPPORTED_EXTENSIONS`.
|
|
21
|
+
"""
|
|
22
|
+
suffix = Path(file_path).suffix
|
|
23
|
+
return SUPPORTED_EXTENSIONS.get(suffix)
|
|
24
|
+
|
|
25
|
+
def is_supported(file_path: str | Path) -> bool:
|
|
26
|
+
"""Return ``True`` if *file_path* has a supported extension."""
|
|
27
|
+
return Path(file_path).suffix in SUPPORTED_EXTENSIONS
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Daemon coordination for multi-instance synaptiq serve --watch."""
|