synap-git 0.2.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.
- synap_git/__init__.py +5 -0
- synap_git/api/__init__.py +5 -0
- synap_git/api/app.py +111 -0
- synap_git/api/static/index.html +615 -0
- synap_git/cli/__init__.py +3 -0
- synap_git/cli/__main__.py +4 -0
- synap_git/cli/main.py +1174 -0
- synap_git/config.py +169 -0
- synap_git/diagnostics/__init__.py +0 -0
- synap_git/diagnostics/logger.py +82 -0
- synap_git/diagnostics/tracing.py +71 -0
- synap_git/embeddings/__init__.py +0 -0
- synap_git/git/__init__.py +11 -0
- synap_git/git/state.py +139 -0
- synap_git/indexer/__init__.py +0 -0
- synap_git/indexer/daemon.py +143 -0
- synap_git/indexer/engine.py +327 -0
- synap_git/indexer/scanner.py +463 -0
- synap_git/indexer/wiki.py +78 -0
- synap_git/mcp/__init__.py +5 -0
- synap_git/mcp/server.py +232 -0
- synap_git/parser/__init__.py +0 -0
- synap_git/parser/registry.py +209 -0
- synap_git/provider/anthropic.py +86 -0
- synap_git/provider/base.py +44 -0
- synap_git/provider/factory.py +101 -0
- synap_git/provider/gemini.py +98 -0
- synap_git/provider/mock.py +66 -0
- synap_git/provider/ollama.py +98 -0
- synap_git/provider/openai.py +90 -0
- synap_git/py.typed +1 -0
- synap_git/retrieval/__init__.py +0 -0
- synap_git/retrieval/engine.py +258 -0
- synap_git/retrieval/memory.py +123 -0
- synap_git/storage/__init__.py +3 -0
- synap_git/storage/sqlite.py +575 -0
- synap_git/utils/__init__.py +0 -0
- synap_git/utils/serialization.py +68 -0
- synap_git-0.2.0.dist-info/METADATA +251 -0
- synap_git-0.2.0.dist-info/RECORD +43 -0
- synap_git-0.2.0.dist-info/WHEEL +4 -0
- synap_git-0.2.0.dist-info/entry_points.txt +2 -0
- synap_git-0.2.0.dist-info/licenses/LICENSE.md +201 -0
synap_git/cli/main.py
ADDED
|
@@ -0,0 +1,1174 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import json
|
|
5
|
+
from dataclasses import asdict, is_dataclass
|
|
6
|
+
from datetime import date, datetime
|
|
7
|
+
from enum import Enum
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Annotated, Any, cast
|
|
10
|
+
|
|
11
|
+
import typer
|
|
12
|
+
from rich.console import Console
|
|
13
|
+
from rich.progress import Progress, SpinnerColumn, TextColumn
|
|
14
|
+
from rich.table import Table
|
|
15
|
+
|
|
16
|
+
from synap_git.config import LoggingMode, RuntimeMode, RuntimeProfile, SynapSettings
|
|
17
|
+
from synap_git.diagnostics.logger import configure_logging
|
|
18
|
+
from synap_git.indexer.daemon import RuntimeDaemon
|
|
19
|
+
from synap_git.indexer.engine import SynapRuntime
|
|
20
|
+
|
|
21
|
+
app = typer.Typer(
|
|
22
|
+
name="synap",
|
|
23
|
+
help="Deterministic Git-aware structural retrieval engine for AI coding agents.",
|
|
24
|
+
no_args_is_help=True,
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
# Exception handling wrapper for clean CLI diagnostics
|
|
28
|
+
_original_call = app.__call__
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _custom_call(*args: Any, **kwargs: Any) -> Any:
|
|
32
|
+
try:
|
|
33
|
+
return _original_call(*args, **kwargs)
|
|
34
|
+
except ValueError as e:
|
|
35
|
+
err_msg = str(e)
|
|
36
|
+
if (
|
|
37
|
+
"missing" in err_msg.lower()
|
|
38
|
+
or "api key" in err_msg.lower()
|
|
39
|
+
or "provider" in err_msg.lower()
|
|
40
|
+
):
|
|
41
|
+
console.print(f"[bold red]Configuration Error:[/bold red] {e}")
|
|
42
|
+
console.print(
|
|
43
|
+
"\n[yellow]Suggestion:[/yellow] Run [bold]synap setup[/bold] to configure your LLM provider and credentials."
|
|
44
|
+
)
|
|
45
|
+
else:
|
|
46
|
+
console.print(f"[bold red]Error:[/bold red] {e}")
|
|
47
|
+
import sys
|
|
48
|
+
|
|
49
|
+
sys.exit(1)
|
|
50
|
+
except Exception as e:
|
|
51
|
+
console.print(f"[bold red]Unexpected Error:[/bold red] {e}")
|
|
52
|
+
import sys
|
|
53
|
+
|
|
54
|
+
sys.exit(1)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
app.__call__ = _custom_call # type: ignore[method-assign]
|
|
58
|
+
|
|
59
|
+
JSON_OPTION = typer.Option("--json", help="Emit machine-readable JSON.")
|
|
60
|
+
console = Console()
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _settings(
|
|
64
|
+
path: str,
|
|
65
|
+
*,
|
|
66
|
+
profile: RuntimeProfile = RuntimeProfile.DEV,
|
|
67
|
+
json_output: bool = False,
|
|
68
|
+
mode: RuntimeMode = RuntimeMode.ACTIVE,
|
|
69
|
+
) -> SynapSettings:
|
|
70
|
+
settings = SynapSettings(
|
|
71
|
+
repository_path=Path(path),
|
|
72
|
+
profile=profile,
|
|
73
|
+
logging_mode=LoggingMode.JSON if json_output else LoggingMode.HUMAN,
|
|
74
|
+
mode=mode,
|
|
75
|
+
)
|
|
76
|
+
configure_logging(settings)
|
|
77
|
+
return settings
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _emit(value: Any, *, json_output: bool) -> None:
|
|
81
|
+
if json_output:
|
|
82
|
+
console.print(json.dumps(_jsonable(value), indent=2, sort_keys=True))
|
|
83
|
+
return
|
|
84
|
+
if isinstance(value, str):
|
|
85
|
+
console.print(value)
|
|
86
|
+
return
|
|
87
|
+
for key, item in _jsonable(value).items():
|
|
88
|
+
console.print(f"{key}: {item}")
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _jsonable(value: Any) -> Any:
|
|
92
|
+
model_dump = getattr(value, "model_dump", None)
|
|
93
|
+
if callable(model_dump):
|
|
94
|
+
return _jsonable(model_dump(mode="json"))
|
|
95
|
+
if isinstance(value, Enum):
|
|
96
|
+
return value.value
|
|
97
|
+
if isinstance(value, Path):
|
|
98
|
+
return value.as_posix()
|
|
99
|
+
if isinstance(value, datetime | date):
|
|
100
|
+
return value.isoformat()
|
|
101
|
+
if is_dataclass(value):
|
|
102
|
+
return _jsonable(asdict(cast(Any, value)))
|
|
103
|
+
if isinstance(value, dict):
|
|
104
|
+
return {str(key): _jsonable(item) for key, item in value.items()}
|
|
105
|
+
if isinstance(value, tuple | list):
|
|
106
|
+
return [_jsonable(item) for item in value]
|
|
107
|
+
if hasattr(value, "__dict__"):
|
|
108
|
+
return _jsonable(value.__dict__)
|
|
109
|
+
return value
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
mcp_app = typer.Typer(help="Model Context Protocol (MCP) server commands.")
|
|
113
|
+
app.add_typer(mcp_app, name="mcp")
|
|
114
|
+
|
|
115
|
+
memory_app = typer.Typer(help="Manage L3 Agent Memory.")
|
|
116
|
+
app.add_typer(memory_app, name="memory")
|
|
117
|
+
|
|
118
|
+
lessons_app = typer.Typer(help="Manage Agent Lessons.")
|
|
119
|
+
app.add_typer(lessons_app, name="lessons")
|
|
120
|
+
|
|
121
|
+
checkpoint_app = typer.Typer(help="Manage Context Checkpoints.")
|
|
122
|
+
app.add_typer(checkpoint_app, name="checkpoint")
|
|
123
|
+
|
|
124
|
+
wiki_app = typer.Typer(help="Manage L2 Wiki Documentation.")
|
|
125
|
+
app.add_typer(wiki_app, name="wiki")
|
|
126
|
+
|
|
127
|
+
cost_app = typer.Typer(help="View AI Cost Tracking.")
|
|
128
|
+
app.add_typer(cost_app, name="cost")
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
@app.command()
|
|
132
|
+
def setup(
|
|
133
|
+
path: Annotated[str, typer.Argument(help="Repository path.")] = ".",
|
|
134
|
+
) -> None:
|
|
135
|
+
"""Interactive first-run setup and onboarding."""
|
|
136
|
+
console.print("[bold cyan]Welcome to Synap AI Context Runtime[/bold cyan]")
|
|
137
|
+
console.print("This wizard will bootstrap your local AI environment.\n")
|
|
138
|
+
|
|
139
|
+
config_dir = Path("~/.config/synap").expanduser()
|
|
140
|
+
config_dir.mkdir(parents=True, exist_ok=True)
|
|
141
|
+
config_file = config_dir / "config.toml"
|
|
142
|
+
|
|
143
|
+
console.print("[yellow]Synap stores API keys securely in your system keyring.[/yellow]")
|
|
144
|
+
console.print(f"Global configuration: {config_file.as_posix()}\n")
|
|
145
|
+
|
|
146
|
+
# 1. Choose Provider
|
|
147
|
+
provider = typer.prompt(
|
|
148
|
+
"Which primary LLM provider would you like to use?",
|
|
149
|
+
default="ollama",
|
|
150
|
+
).lower()
|
|
151
|
+
|
|
152
|
+
# 2. Configure LLM
|
|
153
|
+
llm_model = typer.prompt(
|
|
154
|
+
f"Which model would you like to use for {provider}?",
|
|
155
|
+
default="qwen2.5-coder:14b" if provider == "ollama" else "gpt-4o",
|
|
156
|
+
)
|
|
157
|
+
|
|
158
|
+
# Gather Keys if needed
|
|
159
|
+
ollama_url = "http://localhost:11434"
|
|
160
|
+
|
|
161
|
+
import keyring
|
|
162
|
+
|
|
163
|
+
if provider == "openai":
|
|
164
|
+
key = typer.prompt("Enter OpenAI API Key", hide_input=True)
|
|
165
|
+
keyring.set_password("synap", "openai_api_key", key)
|
|
166
|
+
elif provider == "gemini":
|
|
167
|
+
key = typer.prompt("Enter Gemini API Key", hide_input=True)
|
|
168
|
+
keyring.set_password("synap", "gemini_api_key", key)
|
|
169
|
+
elif provider == "anthropic":
|
|
170
|
+
key = typer.prompt("Enter Anthropic API Key", hide_input=True)
|
|
171
|
+
keyring.set_password("synap", "anthropic_api_key", key)
|
|
172
|
+
elif provider == "ollama":
|
|
173
|
+
ollama_url = typer.prompt("Enter Ollama URL", default="http://localhost:11434")
|
|
174
|
+
|
|
175
|
+
# Build TOML config (non-sensitive only)
|
|
176
|
+
config_content = f"""[llm]
|
|
177
|
+
llm_provider = "{provider}"
|
|
178
|
+
llm_model = "{llm_model}"
|
|
179
|
+
ollama_url = "{ollama_url}"
|
|
180
|
+
"""
|
|
181
|
+
config_file.write_text(config_content)
|
|
182
|
+
console.print(f"\n[green]✓ Configuration saved to {config_file.as_posix()}[/green]")
|
|
183
|
+
console.print("[green]✓ Secrets saved to system keyring.[/green]")
|
|
184
|
+
|
|
185
|
+
# Initialize storage
|
|
186
|
+
runtime = SynapRuntime(_settings(path))
|
|
187
|
+
runtime.bootstrap(force=True)
|
|
188
|
+
console.print("[green]✓ Storage initialized.[/green]")
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def _auto_protect_synapse(repository_path: Path) -> None:
|
|
192
|
+
gitignore_path = repository_path / ".gitignore"
|
|
193
|
+
if not gitignore_path.exists():
|
|
194
|
+
gitignore_path.write_text(".synap/\n")
|
|
195
|
+
return
|
|
196
|
+
|
|
197
|
+
content = gitignore_path.read_text()
|
|
198
|
+
lines = content.splitlines()
|
|
199
|
+
if ".synap/" not in lines and ".synap" not in lines:
|
|
200
|
+
if content and not content.endswith("\n"):
|
|
201
|
+
content += "\n"
|
|
202
|
+
content += ".synap/\n"
|
|
203
|
+
gitignore_path.write_text(content)
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
@app.command()
|
|
207
|
+
def init(
|
|
208
|
+
path: Annotated[str, typer.Argument(help="Repository path to initialize.")] = ".",
|
|
209
|
+
force: Annotated[bool, typer.Option(help="Force reindexing.")] = False,
|
|
210
|
+
skip_llm: Annotated[
|
|
211
|
+
bool, typer.Option("--skip-llm", help="Run in Mode A (structural only).")
|
|
212
|
+
] = False,
|
|
213
|
+
skip_wiki: Annotated[
|
|
214
|
+
bool, typer.Option("--skip-wiki", help="Skip L2 documentation generation.")
|
|
215
|
+
] = False,
|
|
216
|
+
quiet: Annotated[bool, typer.Option("--quiet", help="Suppress output.")] = False,
|
|
217
|
+
json_output: Annotated[bool, JSON_OPTION] = False,
|
|
218
|
+
) -> None:
|
|
219
|
+
"""Initialize local Synap state and perform first scan."""
|
|
220
|
+
settings = _settings(path, json_output=json_output)
|
|
221
|
+
if skip_llm:
|
|
222
|
+
settings.llm_provider = None
|
|
223
|
+
|
|
224
|
+
_auto_protect_synapse(settings.repository_path)
|
|
225
|
+
|
|
226
|
+
runtime = SynapRuntime(settings)
|
|
227
|
+
commit = runtime.bootstrap(force=force)
|
|
228
|
+
|
|
229
|
+
if json_output:
|
|
230
|
+
_emit({"active_commit": commit, "state": "initialized"}, json_output=True)
|
|
231
|
+
elif not quiet:
|
|
232
|
+
console.print(f"[green]✓ Initialized repository at {commit}[/green]")
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
@app.command()
|
|
236
|
+
def wipe(
|
|
237
|
+
path: Annotated[str, typer.Argument(help="Repository path to wipe.")] = ".",
|
|
238
|
+
) -> None:
|
|
239
|
+
"""Completely purge the local index for a fresh rebuild."""
|
|
240
|
+
if not typer.confirm("This will delete all indexed symbols and embeddings. Continue?"):
|
|
241
|
+
raise typer.Abort()
|
|
242
|
+
runtime = SynapRuntime(_settings(path))
|
|
243
|
+
runtime.wipe_index()
|
|
244
|
+
console.print("[green]✓ Index wiped.[/green]")
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
@app.command()
|
|
248
|
+
def start(
|
|
249
|
+
path: Annotated[str, typer.Argument(help="Repository path to watch.")] = ".",
|
|
250
|
+
json_output: Annotated[bool, JSON_OPTION] = False,
|
|
251
|
+
) -> None:
|
|
252
|
+
"""Start the simplified repository context daemon."""
|
|
253
|
+
settings = _settings(path, json_output=json_output)
|
|
254
|
+
daemon = RuntimeDaemon(settings)
|
|
255
|
+
asyncio.run(daemon.start())
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
def _read_daemon_heartbeat(repository_path: Path) -> dict[str, Any] | None:
|
|
259
|
+
heartbeat_path = repository_path / ".synap" / "daemon_heartbeat.json"
|
|
260
|
+
if not heartbeat_path.exists():
|
|
261
|
+
return None
|
|
262
|
+
try:
|
|
263
|
+
import time
|
|
264
|
+
|
|
265
|
+
data = cast(
|
|
266
|
+
dict[str, Any],
|
|
267
|
+
json.loads(heartbeat_path.read_text(encoding="utf-8")),
|
|
268
|
+
)
|
|
269
|
+
mtime = heartbeat_path.stat().st_mtime
|
|
270
|
+
if time.time() - mtime > 10.0:
|
|
271
|
+
data["status"] = "stale"
|
|
272
|
+
return data
|
|
273
|
+
except Exception:
|
|
274
|
+
return None
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
@app.command()
|
|
278
|
+
def status(
|
|
279
|
+
path: Annotated[str, typer.Argument(help="Repository path to inspect.")] = ".",
|
|
280
|
+
json_output: Annotated[bool, JSON_OPTION] = False,
|
|
281
|
+
) -> None:
|
|
282
|
+
"""Show current repository context status."""
|
|
283
|
+
settings = _settings(path, json_output=json_output)
|
|
284
|
+
runtime = SynapRuntime(settings)
|
|
285
|
+
status_info = runtime.status()
|
|
286
|
+
|
|
287
|
+
daemon_info = _read_daemon_heartbeat(settings.repository_path)
|
|
288
|
+
approved_cnt = len(runtime.store.get_lessons("approved"))
|
|
289
|
+
pending_cnt = len(runtime.store.get_lessons("pending"))
|
|
290
|
+
|
|
291
|
+
if json_output:
|
|
292
|
+
out = _jsonable(status_info)
|
|
293
|
+
out["daemon"] = daemon_info
|
|
294
|
+
out["l3_approved_memory"] = approved_cnt
|
|
295
|
+
out["l3_pending_memory"] = pending_cnt
|
|
296
|
+
_emit(out, json_output=True)
|
|
297
|
+
else:
|
|
298
|
+
table = Table(title="Synap Repository Status", show_header=True, header_style="bold cyan")
|
|
299
|
+
table.add_column("Property", style="dim")
|
|
300
|
+
table.add_column("Value")
|
|
301
|
+
|
|
302
|
+
table.add_row("Repository", status_info.repository_path)
|
|
303
|
+
table.add_row("Branch", status_info.branch)
|
|
304
|
+
table.add_row("Git Commit", status_info.git_commit or "None")
|
|
305
|
+
table.add_row("Indexed Commit", status_info.active_commit or "None")
|
|
306
|
+
table.add_row("Files Indexed", str(status_info.files))
|
|
307
|
+
table.add_row("Symbols Indexed", str(status_info.symbols))
|
|
308
|
+
table.add_row("Mode", status_info.mode)
|
|
309
|
+
|
|
310
|
+
if daemon_info:
|
|
311
|
+
status_str = daemon_info["status"].upper()
|
|
312
|
+
if status_str == "HEALTHY":
|
|
313
|
+
daemon_status = f"[green]ACTIVE (PID {daemon_info['pid']}, uptime {daemon_info['uptime_seconds']}s)[/green]"
|
|
314
|
+
elif status_str == "STALE":
|
|
315
|
+
daemon_status = f"[yellow]STALE (PID {daemon_info['pid']} inactive)[/yellow]"
|
|
316
|
+
else:
|
|
317
|
+
daemon_status = f"[red]{status_str} (PID {daemon_info['pid']})[/red]"
|
|
318
|
+
else:
|
|
319
|
+
daemon_status = "[dim]INACTIVE[/dim]"
|
|
320
|
+
table.add_row("Daemon State", daemon_status)
|
|
321
|
+
|
|
322
|
+
table.add_row("L3 Approved Memory", str(approved_cnt))
|
|
323
|
+
table.add_row("L3 Pending Memory", str(pending_cnt))
|
|
324
|
+
|
|
325
|
+
console.print(table)
|
|
326
|
+
|
|
327
|
+
if status_info.is_dirty:
|
|
328
|
+
console.print(
|
|
329
|
+
"[bold yellow]Warning: Working tree is dirty. Uncommitted changes are not indexed.[/bold yellow]"
|
|
330
|
+
)
|
|
331
|
+
|
|
332
|
+
|
|
333
|
+
@app.command()
|
|
334
|
+
def rollback(
|
|
335
|
+
path: Annotated[str, typer.Argument(help="Repository path.")] = ".",
|
|
336
|
+
) -> None:
|
|
337
|
+
"""Rollback active state to a previous commit."""
|
|
338
|
+
import subprocess
|
|
339
|
+
|
|
340
|
+
settings = _settings(path)
|
|
341
|
+
runtime = SynapRuntime(settings)
|
|
342
|
+
|
|
343
|
+
# 1. Get recent commits
|
|
344
|
+
try:
|
|
345
|
+
log_out = subprocess.check_output( # noqa: S603
|
|
346
|
+
["git", "log", "-n", "5", "--pretty=format:%h|%ar|%s"], # noqa: S607
|
|
347
|
+
cwd=settings.repository_path,
|
|
348
|
+
text=True,
|
|
349
|
+
)
|
|
350
|
+
commits = []
|
|
351
|
+
for line in log_out.splitlines():
|
|
352
|
+
if line.strip():
|
|
353
|
+
parts = line.split("|", 2)
|
|
354
|
+
if len(parts) == 3:
|
|
355
|
+
commits.append((parts[0], parts[1], parts[2]))
|
|
356
|
+
except Exception as e:
|
|
357
|
+
console.print(f"[red]✗ Failed to read git history: {e}[/red]")
|
|
358
|
+
raise typer.Abort()
|
|
359
|
+
|
|
360
|
+
if not commits:
|
|
361
|
+
console.print("[yellow]No commits found in git log.[/yellow]")
|
|
362
|
+
return
|
|
363
|
+
|
|
364
|
+
console.print("Recent commits:")
|
|
365
|
+
for idx, (h, ar, s) in enumerate(commits, 1):
|
|
366
|
+
suffix = " ← current" if idx == 1 else ""
|
|
367
|
+
console.print(f' [{idx}] {h} {ar} "{s}"{suffix}')
|
|
368
|
+
|
|
369
|
+
choice = typer.prompt("\nRoll back to which commit? [1-5]", default="1")
|
|
370
|
+
try:
|
|
371
|
+
choice_idx = int(choice) - 1
|
|
372
|
+
if choice_idx < 0 or choice_idx >= len(commits):
|
|
373
|
+
raise ValueError()
|
|
374
|
+
except ValueError:
|
|
375
|
+
console.print("[red]Invalid choice.[/red]")
|
|
376
|
+
raise typer.Abort()
|
|
377
|
+
|
|
378
|
+
selected_commit = commits[choice_idx][0]
|
|
379
|
+
|
|
380
|
+
console.print(f"\n[bold yellow]⚠ Rolling back to {selected_commit} will:[/bold yellow]")
|
|
381
|
+
console.print(" - Restore index to that commit's state (via git checkout)")
|
|
382
|
+
console.print(" - Preserve all approved lessons (they survive rollbacks)")
|
|
383
|
+
console.print(" - Clear current checkpoint")
|
|
384
|
+
|
|
385
|
+
if not typer.confirm("\nProceed?", default=False):
|
|
386
|
+
raise typer.Abort()
|
|
387
|
+
|
|
388
|
+
# Clear current checkpoint
|
|
389
|
+
try:
|
|
390
|
+
with runtime.store.connect() as conn:
|
|
391
|
+
conn.execute(
|
|
392
|
+
"DELETE FROM checkpoints WHERE branch = ?",
|
|
393
|
+
(runtime.git.state().effective_branch,),
|
|
394
|
+
)
|
|
395
|
+
except Exception:
|
|
396
|
+
pass
|
|
397
|
+
|
|
398
|
+
# git checkout
|
|
399
|
+
try:
|
|
400
|
+
subprocess.check_call( # noqa: S603
|
|
401
|
+
["git", "checkout", selected_commit], # noqa: S607
|
|
402
|
+
cwd=settings.repository_path,
|
|
403
|
+
)
|
|
404
|
+
except Exception as e:
|
|
405
|
+
console.print(f"[red]✗ Git checkout failed: {e}[/red]")
|
|
406
|
+
raise typer.Abort()
|
|
407
|
+
|
|
408
|
+
runtime.bootstrap(force=True)
|
|
409
|
+
console.print(f"[green]✓ Rolled back successfully to {selected_commit}[/green]")
|
|
410
|
+
|
|
411
|
+
|
|
412
|
+
@app.command()
|
|
413
|
+
def recover(
|
|
414
|
+
path: Annotated[str, typer.Argument(help="Repository path.")] = ".",
|
|
415
|
+
) -> None:
|
|
416
|
+
"""Recover from a broken index state."""
|
|
417
|
+
settings = _settings(path)
|
|
418
|
+
runtime = SynapRuntime(settings)
|
|
419
|
+
|
|
420
|
+
console.print("Checking database integrity...")
|
|
421
|
+
corrupted = False
|
|
422
|
+
try:
|
|
423
|
+
runtime.initialize_storage()
|
|
424
|
+
integrity = runtime.store.integrity_check()
|
|
425
|
+
if integrity != "ok":
|
|
426
|
+
corrupted = True
|
|
427
|
+
except Exception:
|
|
428
|
+
corrupted = True
|
|
429
|
+
|
|
430
|
+
if corrupted:
|
|
431
|
+
console.print("[red]✗ synap.db appears corrupted[/red]\n")
|
|
432
|
+
else:
|
|
433
|
+
console.print("[green]✓ Database file is healthy.[/green]")
|
|
434
|
+
if not typer.confirm("Do you want to force rebuild the index anyway?"):
|
|
435
|
+
return
|
|
436
|
+
|
|
437
|
+
console.print("Rebuilding from git history...")
|
|
438
|
+
console.print("[1/3] Restoring file structure from HEAD")
|
|
439
|
+
try:
|
|
440
|
+
runtime.wipe_index()
|
|
441
|
+
except Exception:
|
|
442
|
+
if settings.sqlite_path:
|
|
443
|
+
for ext in ["", "-wal", "-shm"]:
|
|
444
|
+
p = Path(f"{settings.sqlite_path}{ext}")
|
|
445
|
+
if p.exists():
|
|
446
|
+
try:
|
|
447
|
+
p.unlink()
|
|
448
|
+
except OSError:
|
|
449
|
+
pass
|
|
450
|
+
|
|
451
|
+
runtime.initialize_storage()
|
|
452
|
+
console.print("[2/3] Reindexing all symbols")
|
|
453
|
+
runtime.bootstrap(force=True)
|
|
454
|
+
|
|
455
|
+
console.print("[3/3] Regenerating wiki from last known state")
|
|
456
|
+
try:
|
|
457
|
+
runtime.wiki.generate_project_wiki()
|
|
458
|
+
except Exception:
|
|
459
|
+
pass
|
|
460
|
+
|
|
461
|
+
status_info = runtime.status()
|
|
462
|
+
console.print(f"\n[green]✓ Recovery complete. {status_info.files} files restored.[/green]")
|
|
463
|
+
console.print("[yellow]⚠ Agent memory (L3) could not be recovered — starting fresh.[/yellow]")
|
|
464
|
+
|
|
465
|
+
|
|
466
|
+
@app.command()
|
|
467
|
+
def doctor(
|
|
468
|
+
path: Annotated[str, typer.Argument(help="Repository path.")] = ".",
|
|
469
|
+
fix: Annotated[
|
|
470
|
+
bool, typer.Option("--fix", help="Attempt to automatically fix detected issues.")
|
|
471
|
+
] = False,
|
|
472
|
+
context: Annotated[
|
|
473
|
+
bool, typer.Option("--context", help="Output current LLM injection context.")
|
|
474
|
+
] = False,
|
|
475
|
+
) -> None:
|
|
476
|
+
"""Validate environment and system health."""
|
|
477
|
+
console.print("[bold cyan]Synap Doctor: System Health Check[/bold cyan]\n")
|
|
478
|
+
|
|
479
|
+
settings = _settings(path)
|
|
480
|
+
runtime = SynapRuntime(settings)
|
|
481
|
+
|
|
482
|
+
with Progress(
|
|
483
|
+
SpinnerColumn(),
|
|
484
|
+
TextColumn("[progress.description]{task.description}"),
|
|
485
|
+
console=console,
|
|
486
|
+
) as progress:
|
|
487
|
+
task_db = progress.add_task("Checking SQLite Database...", total=1)
|
|
488
|
+
try:
|
|
489
|
+
runtime.initialize_storage()
|
|
490
|
+
res = runtime.doctor()
|
|
491
|
+
progress.update(
|
|
492
|
+
task_db,
|
|
493
|
+
completed=1,
|
|
494
|
+
description=f"[green]✓ Database integrity: {res['database_integrity']}[/green]",
|
|
495
|
+
)
|
|
496
|
+
except Exception as e:
|
|
497
|
+
progress.update(task_db, completed=1, description=f"[red]✗ Database error: {e}[/red]")
|
|
498
|
+
|
|
499
|
+
task_parsers = progress.add_task("Checking Tree-Sitter Parsers...", total=1)
|
|
500
|
+
try:
|
|
501
|
+
from synap_git.parser.registry import CodeParserRegistry
|
|
502
|
+
|
|
503
|
+
registry = CodeParserRegistry()
|
|
504
|
+
test_file = Path(path) / ".synap_test.py"
|
|
505
|
+
test_file.write_text("def test(): pass", encoding="utf-8")
|
|
506
|
+
registry.parse(test_file, relative_path=".synap_test.py")
|
|
507
|
+
test_file.unlink()
|
|
508
|
+
progress.update(
|
|
509
|
+
task_parsers,
|
|
510
|
+
completed=1,
|
|
511
|
+
description="[green]✓ Tree-sitter parsers functional[/green]",
|
|
512
|
+
)
|
|
513
|
+
except Exception as e:
|
|
514
|
+
progress.update(
|
|
515
|
+
task_parsers, completed=1, description=f"[red]✗ Parser error: {e}[/red]"
|
|
516
|
+
)
|
|
517
|
+
|
|
518
|
+
task_tok = progress.add_task("Checking Tokenizer...", total=1)
|
|
519
|
+
try:
|
|
520
|
+
import tiktoken
|
|
521
|
+
|
|
522
|
+
tiktoken.get_encoding("cl100k_base")
|
|
523
|
+
progress.update(
|
|
524
|
+
task_tok, completed=1, description="[green]✓ Tokenizer (tiktoken) ready[/green]"
|
|
525
|
+
)
|
|
526
|
+
except Exception as e:
|
|
527
|
+
progress.update(task_tok, completed=1, description=f"[red]✗ Tokenizer error: {e}[/red]")
|
|
528
|
+
|
|
529
|
+
task_prov = progress.add_task("Checking LLM Provider...", total=1)
|
|
530
|
+
try:
|
|
531
|
+
if settings.llm_provider is None:
|
|
532
|
+
progress.update(
|
|
533
|
+
task_prov,
|
|
534
|
+
completed=1,
|
|
535
|
+
description="[yellow]⚠ No LLM Provider configured (running in structural Mode A)[/yellow]",
|
|
536
|
+
)
|
|
537
|
+
else:
|
|
538
|
+
errors = settings.validate_configuration()
|
|
539
|
+
if errors:
|
|
540
|
+
progress.update(
|
|
541
|
+
task_prov,
|
|
542
|
+
completed=1,
|
|
543
|
+
description=f"[red]✗ Config error: {errors[0]}[/red]",
|
|
544
|
+
)
|
|
545
|
+
else:
|
|
546
|
+
conn_errors = settings.test_connectivity()
|
|
547
|
+
if conn_errors:
|
|
548
|
+
progress.update(
|
|
549
|
+
task_prov,
|
|
550
|
+
completed=1,
|
|
551
|
+
description=f"[red]✗ Connectivity error: {conn_errors[0]}[/red]",
|
|
552
|
+
)
|
|
553
|
+
else:
|
|
554
|
+
progress.update(
|
|
555
|
+
task_prov,
|
|
556
|
+
completed=1,
|
|
557
|
+
description=f"[green]✓ Provider ({settings.llm_provider}) connectivity verified[/green]",
|
|
558
|
+
)
|
|
559
|
+
except Exception as e:
|
|
560
|
+
progress.update(
|
|
561
|
+
task_prov, completed=1, description=f"[red]✗ Unexpected error: {e}[/red]"
|
|
562
|
+
)
|
|
563
|
+
|
|
564
|
+
task_daemon = progress.add_task("Checking Daemon Status...", total=1)
|
|
565
|
+
try:
|
|
566
|
+
daemon_info = _read_daemon_heartbeat(settings.repository_path)
|
|
567
|
+
if daemon_info:
|
|
568
|
+
status_str = daemon_info["status"].upper()
|
|
569
|
+
if status_str == "HEALTHY":
|
|
570
|
+
progress.update(
|
|
571
|
+
task_daemon,
|
|
572
|
+
completed=1,
|
|
573
|
+
description=f"[green]✓ Daemon active and healthy (PID {daemon_info['pid']}, uptime {daemon_info['uptime_seconds']}s)[/green]",
|
|
574
|
+
)
|
|
575
|
+
elif status_str == "STALE":
|
|
576
|
+
progress.update(
|
|
577
|
+
task_daemon,
|
|
578
|
+
completed=1,
|
|
579
|
+
description="[yellow]⚠ Daemon heartbeat file exists but is stale (not running?)[/yellow]",
|
|
580
|
+
)
|
|
581
|
+
else:
|
|
582
|
+
progress.update(
|
|
583
|
+
task_daemon,
|
|
584
|
+
completed=1,
|
|
585
|
+
description=f"[red]✗ Daemon degraded: {daemon_info.get('last_error')}[/red]",
|
|
586
|
+
)
|
|
587
|
+
else:
|
|
588
|
+
progress.update(
|
|
589
|
+
task_daemon,
|
|
590
|
+
completed=1,
|
|
591
|
+
description="[yellow]⚠ Daemon inactive (not running). Start it using `synap start`[/yellow]",
|
|
592
|
+
)
|
|
593
|
+
except Exception as e:
|
|
594
|
+
progress.update(
|
|
595
|
+
task_daemon, completed=1, description=f"[red]✗ Daemon status error: {e}[/red]"
|
|
596
|
+
)
|
|
597
|
+
|
|
598
|
+
console.print("\n[bold]All checks complete.[/bold]")
|
|
599
|
+
|
|
600
|
+
|
|
601
|
+
@app.command()
|
|
602
|
+
def run(
|
|
603
|
+
path: Annotated[str, typer.Argument(help="Repository path to watch.")] = ".",
|
|
604
|
+
host: Annotated[str, typer.Option(help="Host for UI server.")] = "127.0.0.1",
|
|
605
|
+
port: Annotated[int, typer.Option(help="Port for UI server.")] = 9876,
|
|
606
|
+
) -> None:
|
|
607
|
+
"""Start all Synap services (Daemon, MCP, UI)."""
|
|
608
|
+
import subprocess
|
|
609
|
+
import sys
|
|
610
|
+
import time
|
|
611
|
+
|
|
612
|
+
settings = _settings(path)
|
|
613
|
+
runtime = SynapRuntime(settings)
|
|
614
|
+
runtime.bootstrap()
|
|
615
|
+
|
|
616
|
+
commands = [
|
|
617
|
+
("Daemon", [sys.executable, "-m", "synap_git.cli", "start", path]),
|
|
618
|
+
("MCP Server", [sys.executable, "-m", "synap_git.cli", "mcp", "start", path]),
|
|
619
|
+
(
|
|
620
|
+
"UI Server",
|
|
621
|
+
[
|
|
622
|
+
sys.executable,
|
|
623
|
+
"-m",
|
|
624
|
+
"synap_git.cli",
|
|
625
|
+
"ui",
|
|
626
|
+
path,
|
|
627
|
+
"--host",
|
|
628
|
+
host,
|
|
629
|
+
"--port",
|
|
630
|
+
str(port),
|
|
631
|
+
],
|
|
632
|
+
),
|
|
633
|
+
]
|
|
634
|
+
|
|
635
|
+
processes = []
|
|
636
|
+
try:
|
|
637
|
+
for name, cmd in commands:
|
|
638
|
+
p = subprocess.Popen(cmd, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) # noqa: S603
|
|
639
|
+
processes.append((name, p))
|
|
640
|
+
time.sleep(0.5)
|
|
641
|
+
|
|
642
|
+
console.print("\n[bold green]✓ Synap Runtime active![/bold green]")
|
|
643
|
+
console.print(f"[cyan]UI: http://{host}:{port}[/cyan]")
|
|
644
|
+
while True:
|
|
645
|
+
time.sleep(1)
|
|
646
|
+
except KeyboardInterrupt:
|
|
647
|
+
for _, p in processes:
|
|
648
|
+
p.terminate()
|
|
649
|
+
|
|
650
|
+
|
|
651
|
+
@mcp_app.command("start")
|
|
652
|
+
def mcp_start(
|
|
653
|
+
path: Annotated[str, typer.Argument(help="Repository path.")] = ".",
|
|
654
|
+
) -> None:
|
|
655
|
+
"""Start the MCP server."""
|
|
656
|
+
settings = _settings(path)
|
|
657
|
+
runtime = SynapRuntime(settings)
|
|
658
|
+
runtime.bootstrap()
|
|
659
|
+
|
|
660
|
+
from synap_git.mcp.server import SynapMCPServer
|
|
661
|
+
|
|
662
|
+
server = SynapMCPServer(runtime)
|
|
663
|
+
asyncio.run(server.run())
|
|
664
|
+
|
|
665
|
+
|
|
666
|
+
@mcp_app.command("config")
|
|
667
|
+
def mcp_config(
|
|
668
|
+
path: Annotated[str, typer.Argument(help="Repository path.")] = ".",
|
|
669
|
+
) -> None:
|
|
670
|
+
"""Output the MCP server configuration for manual IDE setup."""
|
|
671
|
+
abs_path = Path(path).resolve().as_posix()
|
|
672
|
+
import sys
|
|
673
|
+
|
|
674
|
+
config = {
|
|
675
|
+
"mcpServers": {
|
|
676
|
+
"synap": {
|
|
677
|
+
"command": sys.executable,
|
|
678
|
+
"args": ["-m", "synap_git.cli", "mcp", "start", abs_path],
|
|
679
|
+
"autoConnect": True,
|
|
680
|
+
}
|
|
681
|
+
}
|
|
682
|
+
}
|
|
683
|
+
typer.echo(json.dumps(config, indent=2))
|
|
684
|
+
|
|
685
|
+
|
|
686
|
+
@mcp_app.command("verify")
|
|
687
|
+
def mcp_verify(
|
|
688
|
+
path: Annotated[str, typer.Argument(help="Repository path.")] = ".",
|
|
689
|
+
) -> None:
|
|
690
|
+
"""Verify MCP tools, schemas, and transport stability."""
|
|
691
|
+
import time
|
|
692
|
+
|
|
693
|
+
console.print("[bold cyan]Synap MCP Verification[/bold cyan]\n")
|
|
694
|
+
|
|
695
|
+
settings = _settings(path)
|
|
696
|
+
runtime = SynapRuntime(settings)
|
|
697
|
+
runtime.bootstrap()
|
|
698
|
+
|
|
699
|
+
from synap_git.mcp.server import SynapMCPFacade
|
|
700
|
+
|
|
701
|
+
facade = SynapMCPFacade(runtime)
|
|
702
|
+
|
|
703
|
+
table = Table(show_header=True, header_style="bold magenta")
|
|
704
|
+
table.add_column("Stage")
|
|
705
|
+
table.add_column("Status")
|
|
706
|
+
table.add_column("Latency (ms)")
|
|
707
|
+
table.add_column("Details")
|
|
708
|
+
|
|
709
|
+
def _simulate_mcp_call(tool_name: str, *args: Any, **kwargs: Any) -> bool:
|
|
710
|
+
start = time.monotonic()
|
|
711
|
+
try:
|
|
712
|
+
import uuid
|
|
713
|
+
|
|
714
|
+
status = facade.runtime.status()
|
|
715
|
+
dirty = status.is_dirty
|
|
716
|
+
warnings = ["Working tree is dirty. Index may be stale."] if dirty else []
|
|
717
|
+
|
|
718
|
+
method = getattr(facade, tool_name)
|
|
719
|
+
data = method(*args, **kwargs)
|
|
720
|
+
|
|
721
|
+
response = {
|
|
722
|
+
"ok": True,
|
|
723
|
+
"data": data,
|
|
724
|
+
"warnings": warnings,
|
|
725
|
+
"trace_id": str(uuid.uuid4()),
|
|
726
|
+
"dirty_tree": dirty,
|
|
727
|
+
}
|
|
728
|
+
|
|
729
|
+
# Verify strict schema
|
|
730
|
+
assert "ok" in response
|
|
731
|
+
assert "data" in response
|
|
732
|
+
assert "warnings" in response
|
|
733
|
+
assert "trace_id" in response
|
|
734
|
+
assert "dirty_tree" in response
|
|
735
|
+
|
|
736
|
+
latency = (time.monotonic() - start) * 1000
|
|
737
|
+
|
|
738
|
+
if tool_name == "search":
|
|
739
|
+
# Check trace payload exists
|
|
740
|
+
assert "trace" in data, "Trace payload missing in search data"
|
|
741
|
+
|
|
742
|
+
details = f"keys: {list(data.keys())}"
|
|
743
|
+
table.add_row(tool_name, "[green]PASS[/green]", f"{latency:.1f}", details)
|
|
744
|
+
return True
|
|
745
|
+
except Exception as e:
|
|
746
|
+
latency = (time.monotonic() - start) * 1000
|
|
747
|
+
table.add_row(tool_name, "[red]FAIL[/red]", f"{latency:.1f}", str(e))
|
|
748
|
+
return False
|
|
749
|
+
|
|
750
|
+
results = [
|
|
751
|
+
_simulate_mcp_call("get_status"),
|
|
752
|
+
_simulate_mcp_call("verify_system"),
|
|
753
|
+
_simulate_mcp_call("search", "User"),
|
|
754
|
+
_simulate_mcp_call("create_checkpoint", "Testing MCP", ["test.py"], "Next", "None"),
|
|
755
|
+
_simulate_mcp_call("restore_checkpoint", "latest"),
|
|
756
|
+
]
|
|
757
|
+
|
|
758
|
+
console.print(table)
|
|
759
|
+
|
|
760
|
+
if all(results):
|
|
761
|
+
console.print("\n[bold green]✓ MCP Protocol Verified. All contracts passed.[/bold green]")
|
|
762
|
+
else:
|
|
763
|
+
console.print("\n[bold red]✗ MCP Protocol Verification Failed.[/bold red]")
|
|
764
|
+
raise typer.Exit(1)
|
|
765
|
+
|
|
766
|
+
|
|
767
|
+
@app.command()
|
|
768
|
+
def ui(
|
|
769
|
+
path: Annotated[str, typer.Argument(help="Repository path.")] = ".",
|
|
770
|
+
host: Annotated[str, typer.Option()] = "127.0.0.1",
|
|
771
|
+
port: Annotated[int, typer.Option()] = 9876,
|
|
772
|
+
) -> None:
|
|
773
|
+
"""Start the diagnostic UI."""
|
|
774
|
+
import uvicorn
|
|
775
|
+
|
|
776
|
+
from synap_git.api.app import create_app
|
|
777
|
+
|
|
778
|
+
settings = _settings(path)
|
|
779
|
+
runtime = SynapRuntime(settings)
|
|
780
|
+
runtime.bootstrap()
|
|
781
|
+
|
|
782
|
+
api_app = create_app(runtime)
|
|
783
|
+
uvicorn.run(api_app, host=host, port=port)
|
|
784
|
+
|
|
785
|
+
|
|
786
|
+
@memory_app.command("status")
|
|
787
|
+
def memory_status(
|
|
788
|
+
path: Annotated[str, typer.Argument(help="Repository path.")] = ".",
|
|
789
|
+
) -> None:
|
|
790
|
+
"""Show current memory trust status."""
|
|
791
|
+
runtime = SynapRuntime(_settings(path))
|
|
792
|
+
|
|
793
|
+
approved = runtime.store.get_lessons("approved")
|
|
794
|
+
pending = runtime.store.get_lessons("pending")
|
|
795
|
+
expired = runtime.store.get_lessons("expired")
|
|
796
|
+
|
|
797
|
+
table = Table(title="Synap Memory Status", show_header=True, header_style="bold cyan")
|
|
798
|
+
table.add_column("State")
|
|
799
|
+
table.add_column("Count")
|
|
800
|
+
|
|
801
|
+
table.add_row("[green]Approved[/green]", str(len(approved)))
|
|
802
|
+
table.add_row("[yellow]Pending[/yellow]", str(len(pending)))
|
|
803
|
+
table.add_row("[dim]Expired[/dim]", str(len(expired)))
|
|
804
|
+
|
|
805
|
+
console.print(table)
|
|
806
|
+
|
|
807
|
+
|
|
808
|
+
@memory_app.command("prune")
|
|
809
|
+
def memory_prune(
|
|
810
|
+
path: Annotated[str, typer.Argument(help="Repository path.")] = ".",
|
|
811
|
+
) -> None:
|
|
812
|
+
"""Evaluate expiry rules and prune dead memory."""
|
|
813
|
+
runtime = SynapRuntime(_settings(path))
|
|
814
|
+
pruned_count = runtime.store.prune_expired_lessons()
|
|
815
|
+
console.print(f"[green]✓ Evaluated memory expiry. Pruned {pruned_count} lessons.[/green]")
|
|
816
|
+
|
|
817
|
+
|
|
818
|
+
@memory_app.command("verify")
|
|
819
|
+
def memory_verify(
|
|
820
|
+
path: Annotated[str, typer.Argument(help="Repository path.")] = ".",
|
|
821
|
+
json_output: Annotated[bool, JSON_OPTION] = False,
|
|
822
|
+
) -> None:
|
|
823
|
+
"""Check for dangling file references in approved memory lessons."""
|
|
824
|
+
import json as json_lib
|
|
825
|
+
|
|
826
|
+
runtime = SynapRuntime(_settings(path, json_output=json_output))
|
|
827
|
+
approved = runtime.store.get_lessons("approved")
|
|
828
|
+
|
|
829
|
+
dangling: list[dict[str, Any]] = []
|
|
830
|
+
healthy: list[dict[str, Any]] = []
|
|
831
|
+
|
|
832
|
+
repo_root = runtime.settings.repository_path
|
|
833
|
+
|
|
834
|
+
for lesson in approved:
|
|
835
|
+
lesson_id = lesson["lesson_id"]
|
|
836
|
+
try:
|
|
837
|
+
files: list[str] = json_lib.loads(lesson.get("files_affected") or "[]")
|
|
838
|
+
except Exception:
|
|
839
|
+
files = []
|
|
840
|
+
|
|
841
|
+
missing = [f for f in files if not (repo_root / f).exists()]
|
|
842
|
+
if missing:
|
|
843
|
+
dangling.append({"lesson_id": lesson_id, "missing_files": missing})
|
|
844
|
+
else:
|
|
845
|
+
healthy.append({"lesson_id": lesson_id, "files": files})
|
|
846
|
+
|
|
847
|
+
if json_output:
|
|
848
|
+
_emit({"dangling": dangling, "healthy": healthy}, json_output=True)
|
|
849
|
+
return
|
|
850
|
+
|
|
851
|
+
if not approved:
|
|
852
|
+
console.print("[dim]No approved lessons to verify.[/dim]")
|
|
853
|
+
return
|
|
854
|
+
|
|
855
|
+
table = Table(title="Approved Memory Verification", show_header=True, header_style="bold cyan")
|
|
856
|
+
table.add_column("Lesson ID")
|
|
857
|
+
table.add_column("Status")
|
|
858
|
+
table.add_column("Details")
|
|
859
|
+
|
|
860
|
+
for item in healthy:
|
|
861
|
+
table.add_row(
|
|
862
|
+
item["lesson_id"][:16] + "…",
|
|
863
|
+
"[green]HEALTHY[/green]",
|
|
864
|
+
f"{len(item['files'])} file(s) intact",
|
|
865
|
+
)
|
|
866
|
+
for item in dangling:
|
|
867
|
+
table.add_row(
|
|
868
|
+
item["lesson_id"][:16] + "…",
|
|
869
|
+
"[red]DANGLING[/red]",
|
|
870
|
+
f"Missing: {', '.join(item['missing_files'])}",
|
|
871
|
+
)
|
|
872
|
+
|
|
873
|
+
console.print(table)
|
|
874
|
+
|
|
875
|
+
if dangling:
|
|
876
|
+
console.print(
|
|
877
|
+
f"\n[bold yellow]⚠ {len(dangling)} lesson(s) reference files no longer in the repository.[/bold yellow]"
|
|
878
|
+
)
|
|
879
|
+
console.print("[dim]Run `synap lessons reject <id>` to clean up stale memory.[/dim]")
|
|
880
|
+
else:
|
|
881
|
+
console.print("\n[green]✓ All approved memory references are valid.[/green]")
|
|
882
|
+
|
|
883
|
+
|
|
884
|
+
@lessons_app.command("approve")
|
|
885
|
+
def lessons_approve(
|
|
886
|
+
lesson_id: Annotated[str, typer.Argument(help="The ID of the lesson to approve.")],
|
|
887
|
+
path: Annotated[str, typer.Argument(help="Repository path.")] = ".",
|
|
888
|
+
) -> None:
|
|
889
|
+
"""Approve a pending lesson."""
|
|
890
|
+
runtime = SynapRuntime(_settings(path))
|
|
891
|
+
# Fetch to ensure it exists and is pending
|
|
892
|
+
pending = runtime.store.get_lessons("pending")
|
|
893
|
+
target = next((lesson for lesson in pending if lesson["lesson_id"] == lesson_id), None)
|
|
894
|
+
|
|
895
|
+
if not target:
|
|
896
|
+
console.print(f"[red]✗ Pending lesson {lesson_id} not found.[/red]")
|
|
897
|
+
raise typer.Exit(1)
|
|
898
|
+
|
|
899
|
+
runtime.store.update_lesson(lesson_id, target["why_failed"], "approved", actor="cli_user")
|
|
900
|
+
console.print(f"[green]✓ Lesson {lesson_id} approved. Memory updated.[/green]")
|
|
901
|
+
|
|
902
|
+
|
|
903
|
+
@lessons_app.command("reject")
|
|
904
|
+
def lessons_reject(
|
|
905
|
+
lesson_id: Annotated[str, typer.Argument(help="The ID of the lesson to reject.")],
|
|
906
|
+
path: Annotated[str, typer.Argument(help="Repository path.")] = ".",
|
|
907
|
+
) -> None:
|
|
908
|
+
"""Reject a pending lesson."""
|
|
909
|
+
runtime = SynapRuntime(_settings(path))
|
|
910
|
+
pending = runtime.store.get_lessons("pending")
|
|
911
|
+
target = next((lesson for lesson in pending if lesson["lesson_id"] == lesson_id), None)
|
|
912
|
+
|
|
913
|
+
if not target:
|
|
914
|
+
console.print(f"[red]✗ Pending lesson {lesson_id} not found.[/red]")
|
|
915
|
+
raise typer.Exit(1)
|
|
916
|
+
|
|
917
|
+
runtime.store.update_lesson(lesson_id, target["why_failed"], "rejected", actor="cli_user")
|
|
918
|
+
console.print(f"[yellow]✓ Lesson {lesson_id} rejected. Memory updated.[/yellow]")
|
|
919
|
+
|
|
920
|
+
|
|
921
|
+
@checkpoint_app.command("create")
|
|
922
|
+
def checkpoint_create(
|
|
923
|
+
path: Annotated[str, typer.Argument(help="Repository path.")] = ".",
|
|
924
|
+
doing: Annotated[str, typer.Option(help="What the agent is currently doing.")] = "",
|
|
925
|
+
files: Annotated[str, typer.Option(help="Comma-separated list of changed files.")] = "",
|
|
926
|
+
next_step: Annotated[str, typer.Option(help="The next step to be taken.")] = "",
|
|
927
|
+
blockers: Annotated[str, typer.Option(help="Current blockers or obstacles.")] = "",
|
|
928
|
+
) -> None:
|
|
929
|
+
"""Create a new context checkpoint."""
|
|
930
|
+
if not doing:
|
|
931
|
+
console.print("[red]✗ The --doing option is required.[/red]")
|
|
932
|
+
raise typer.Exit(1)
|
|
933
|
+
|
|
934
|
+
runtime = SynapRuntime(_settings(path))
|
|
935
|
+
import uuid
|
|
936
|
+
|
|
937
|
+
checkpoint_id = str(uuid.uuid4())
|
|
938
|
+
status = runtime.status()
|
|
939
|
+
branch = status.branch
|
|
940
|
+
commit = status.git_commit or "unknown"
|
|
941
|
+
|
|
942
|
+
file_list = [f.strip() for f in files.split(",") if f.strip()]
|
|
943
|
+
|
|
944
|
+
runtime.store.put_checkpoint(
|
|
945
|
+
checkpoint_id=checkpoint_id,
|
|
946
|
+
branch=branch,
|
|
947
|
+
commit_hash=commit,
|
|
948
|
+
doing=doing,
|
|
949
|
+
changed_files=json.dumps(file_list),
|
|
950
|
+
next_step=next_step,
|
|
951
|
+
blockers=blockers,
|
|
952
|
+
)
|
|
953
|
+
console.print(f"[green]✓ Checkpoint {checkpoint_id} created for branch '{branch}'.[/green]")
|
|
954
|
+
|
|
955
|
+
|
|
956
|
+
@checkpoint_app.command("list")
|
|
957
|
+
def checkpoint_list(
|
|
958
|
+
path: Annotated[str, typer.Argument(help="Repository path.")] = ".",
|
|
959
|
+
) -> None:
|
|
960
|
+
"""List checkpoints for the active branch."""
|
|
961
|
+
runtime = SynapRuntime(_settings(path))
|
|
962
|
+
status = runtime.status()
|
|
963
|
+
branch = status.branch
|
|
964
|
+
cps = runtime.store.get_checkpoints(branch)
|
|
965
|
+
|
|
966
|
+
if not cps:
|
|
967
|
+
console.print(f"[yellow]No checkpoints found for branch '{branch}'.[/yellow]")
|
|
968
|
+
return
|
|
969
|
+
|
|
970
|
+
table = Table(
|
|
971
|
+
title=f"Checkpoints for branch '{branch}'", show_header=True, header_style="bold cyan"
|
|
972
|
+
)
|
|
973
|
+
table.add_column("Checkpoint ID")
|
|
974
|
+
table.add_column("Commit Hash")
|
|
975
|
+
table.add_column("Doing")
|
|
976
|
+
table.add_column("Changed Files")
|
|
977
|
+
table.add_column("Created At")
|
|
978
|
+
|
|
979
|
+
for cp in cps:
|
|
980
|
+
created = datetime.fromtimestamp(cp["created_at"]).isoformat()
|
|
981
|
+
try:
|
|
982
|
+
ch_files = ", ".join(json.loads(cp["changed_files"]))
|
|
983
|
+
except Exception:
|
|
984
|
+
ch_files = cp["changed_files"]
|
|
985
|
+
table.add_row(
|
|
986
|
+
cp["checkpoint_id"][:16] + "…",
|
|
987
|
+
cp["commit_hash"][:7],
|
|
988
|
+
cp["doing"],
|
|
989
|
+
ch_files if ch_files else "None",
|
|
990
|
+
created,
|
|
991
|
+
)
|
|
992
|
+
console.print(table)
|
|
993
|
+
|
|
994
|
+
|
|
995
|
+
@checkpoint_app.command("restore")
|
|
996
|
+
def checkpoint_restore(
|
|
997
|
+
checkpoint_id: Annotated[
|
|
998
|
+
str, typer.Argument(help="The ID of the checkpoint to restore (defaults to latest).")
|
|
999
|
+
] = "latest",
|
|
1000
|
+
path: Annotated[str, typer.Argument(help="Repository path.")] = ".",
|
|
1001
|
+
) -> None:
|
|
1002
|
+
"""Restore and show details of a checkpoint."""
|
|
1003
|
+
runtime = SynapRuntime(_settings(path))
|
|
1004
|
+
status = runtime.status()
|
|
1005
|
+
branch = status.branch
|
|
1006
|
+
|
|
1007
|
+
if checkpoint_id == "latest":
|
|
1008
|
+
cp = runtime.store.get_latest_checkpoint(branch)
|
|
1009
|
+
else:
|
|
1010
|
+
cp = runtime.store.get_checkpoint(checkpoint_id)
|
|
1011
|
+
|
|
1012
|
+
if not cp:
|
|
1013
|
+
console.print(f"[red]✗ Checkpoint '{checkpoint_id}' not found.[/red]")
|
|
1014
|
+
raise typer.Exit(1)
|
|
1015
|
+
|
|
1016
|
+
console.print(f"[bold green]✓ Checkpoint '{cp['checkpoint_id']}' details:[/bold green]")
|
|
1017
|
+
console.print(f" [bold]Branch:[/bold] {cp['branch']}")
|
|
1018
|
+
console.print(f" [bold]Commit:[/bold] {cp['commit_hash']}")
|
|
1019
|
+
console.print(f" [bold]Doing:[/bold] {cp['doing']}")
|
|
1020
|
+
try:
|
|
1021
|
+
ch_files = ", ".join(json.loads(cp["changed_files"]))
|
|
1022
|
+
except Exception:
|
|
1023
|
+
ch_files = cp["changed_files"]
|
|
1024
|
+
console.print(f" [bold]Changed Files:[/bold] {ch_files if ch_files else 'None'}")
|
|
1025
|
+
console.print(f" [bold]Next Step:[/bold] {cp['next_step'] or 'None'}")
|
|
1026
|
+
console.print(f" [bold]Blockers:[/bold] {cp['blockers'] or 'None'}")
|
|
1027
|
+
console.print(
|
|
1028
|
+
f" [bold]Created At:[/bold] {datetime.fromtimestamp(cp['created_at']).isoformat()}"
|
|
1029
|
+
)
|
|
1030
|
+
|
|
1031
|
+
|
|
1032
|
+
@cost_app.command("show")
|
|
1033
|
+
def cost_show(
|
|
1034
|
+
path: Annotated[str, typer.Argument(help="Repository path.")] = ".",
|
|
1035
|
+
) -> None:
|
|
1036
|
+
"""Show detailed LLM call and cost tracking metrics."""
|
|
1037
|
+
runtime = SynapRuntime(_settings(path))
|
|
1038
|
+
calls = runtime.store.get_llm_calls()
|
|
1039
|
+
|
|
1040
|
+
if not calls:
|
|
1041
|
+
console.print("[yellow]No LLM calls recorded yet.[/yellow]")
|
|
1042
|
+
return
|
|
1043
|
+
|
|
1044
|
+
# Aggregate by provider/model/purpose
|
|
1045
|
+
agg: dict[tuple[str, str, str], dict[str, Any]] = {}
|
|
1046
|
+
total_input = 0
|
|
1047
|
+
total_output = 0
|
|
1048
|
+
total_cost = 0.0
|
|
1049
|
+
|
|
1050
|
+
for c in calls:
|
|
1051
|
+
key = (c["provider"], c["model"], c["purpose"])
|
|
1052
|
+
if key not in agg:
|
|
1053
|
+
agg[key] = {
|
|
1054
|
+
"calls": 0,
|
|
1055
|
+
"input_tokens": 0,
|
|
1056
|
+
"output_tokens": 0,
|
|
1057
|
+
"cost": 0.0,
|
|
1058
|
+
}
|
|
1059
|
+
agg[key]["calls"] += 1
|
|
1060
|
+
agg[key]["input_tokens"] += c["input_tokens"]
|
|
1061
|
+
agg[key]["output_tokens"] += c["output_tokens"]
|
|
1062
|
+
agg[key]["cost"] += c["cost_usd"]
|
|
1063
|
+
|
|
1064
|
+
total_input += c["input_tokens"]
|
|
1065
|
+
total_output += c["output_tokens"]
|
|
1066
|
+
total_cost += c["cost_usd"]
|
|
1067
|
+
|
|
1068
|
+
# Render summary table
|
|
1069
|
+
table = Table(title="LLM Call Aggregated Costs", show_header=True, header_style="bold cyan")
|
|
1070
|
+
table.add_column("Provider")
|
|
1071
|
+
table.add_column("Model")
|
|
1072
|
+
table.add_column("Purpose")
|
|
1073
|
+
table.add_column("Calls", justify="right")
|
|
1074
|
+
table.add_column("Input Tokens", justify="right")
|
|
1075
|
+
table.add_column("Output Tokens", justify="right")
|
|
1076
|
+
table.add_column("Cost (USD)", justify="right")
|
|
1077
|
+
|
|
1078
|
+
for (prov, model, purpose), data in sorted(agg.items()):
|
|
1079
|
+
table.add_row(
|
|
1080
|
+
prov,
|
|
1081
|
+
model,
|
|
1082
|
+
purpose,
|
|
1083
|
+
str(data["calls"]),
|
|
1084
|
+
f"{data['input_tokens']:,}",
|
|
1085
|
+
f"{data['output_tokens']:,}",
|
|
1086
|
+
f"${data['cost']:.6f}",
|
|
1087
|
+
)
|
|
1088
|
+
|
|
1089
|
+
console.print(table)
|
|
1090
|
+
|
|
1091
|
+
# Grand total box
|
|
1092
|
+
from rich.panel import Panel
|
|
1093
|
+
|
|
1094
|
+
grand_total_text = (
|
|
1095
|
+
f"[bold]Total Calls:[/bold] {len(calls)}\n"
|
|
1096
|
+
f"[bold]Total Input Tokens:[/bold] {total_input:,}\n"
|
|
1097
|
+
f"[bold]Total Output Tokens:[/bold] {total_output:,}\n"
|
|
1098
|
+
f"[bold]Total Cost (USD):[/bold] [green]${total_cost:.6f}[/green]"
|
|
1099
|
+
)
|
|
1100
|
+
console.print(Panel(grand_total_text, title="Operational Cost Summary", expand=False))
|
|
1101
|
+
|
|
1102
|
+
|
|
1103
|
+
@cost_app.command("clear")
|
|
1104
|
+
def cost_clear(
|
|
1105
|
+
path: Annotated[str, typer.Argument(help="Repository path.")] = ".",
|
|
1106
|
+
) -> None:
|
|
1107
|
+
"""Clear all LLM call cost history."""
|
|
1108
|
+
runtime = SynapRuntime(_settings(path))
|
|
1109
|
+
runtime.store.clear_llm_calls()
|
|
1110
|
+
console.print("[green]✓ LLM cost history cleared successfully.[/green]")
|
|
1111
|
+
|
|
1112
|
+
|
|
1113
|
+
@wiki_app.command("list")
|
|
1114
|
+
def wiki_list(
|
|
1115
|
+
path: Annotated[str, typer.Argument(help="Repository path.")] = ".",
|
|
1116
|
+
) -> None:
|
|
1117
|
+
"""List all generated wiki documentation pages."""
|
|
1118
|
+
runtime = SynapRuntime(_settings(path))
|
|
1119
|
+
wiki_dir = runtime.settings.state_path / "wiki"
|
|
1120
|
+
|
|
1121
|
+
if not wiki_dir.exists():
|
|
1122
|
+
console.print("[yellow]No wiki documentation directory found.[/yellow]")
|
|
1123
|
+
return
|
|
1124
|
+
|
|
1125
|
+
files = sorted(wiki_dir.glob("**/*.md"))
|
|
1126
|
+
if not files:
|
|
1127
|
+
console.print("[yellow]No wiki documentation pages found.[/yellow]")
|
|
1128
|
+
return
|
|
1129
|
+
|
|
1130
|
+
table = Table(title="Generated Wiki Documentation", show_header=True, header_style="bold cyan")
|
|
1131
|
+
table.add_column("Wiki Page (Relative Path)")
|
|
1132
|
+
table.add_column("Size (Bytes)", justify="right")
|
|
1133
|
+
table.add_column("Last Modified")
|
|
1134
|
+
|
|
1135
|
+
for f in files:
|
|
1136
|
+
rel_path = f.relative_to(wiki_dir).as_posix()
|
|
1137
|
+
stats = f.stat()
|
|
1138
|
+
mtime = datetime.fromtimestamp(stats.st_mtime).isoformat()
|
|
1139
|
+
table.add_row(
|
|
1140
|
+
rel_path,
|
|
1141
|
+
f"{stats.st_size:,}",
|
|
1142
|
+
mtime,
|
|
1143
|
+
)
|
|
1144
|
+
|
|
1145
|
+
console.print(table)
|
|
1146
|
+
|
|
1147
|
+
|
|
1148
|
+
@wiki_app.command("show")
|
|
1149
|
+
def wiki_show(
|
|
1150
|
+
filepath: Annotated[
|
|
1151
|
+
str,
|
|
1152
|
+
typer.Argument(help="The relative path of the wiki page (e.g. src/utils.py.md)."),
|
|
1153
|
+
],
|
|
1154
|
+
path: Annotated[str, typer.Argument(help="Repository path.")] = ".",
|
|
1155
|
+
) -> None:
|
|
1156
|
+
"""Show a specific wiki documentation page rendered in Markdown."""
|
|
1157
|
+
from rich.markdown import Markdown
|
|
1158
|
+
|
|
1159
|
+
runtime = SynapRuntime(_settings(path))
|
|
1160
|
+
wiki_dir = runtime.settings.state_path / "wiki"
|
|
1161
|
+
|
|
1162
|
+
target = filepath
|
|
1163
|
+
if not target.endswith(".md"):
|
|
1164
|
+
target += ".md"
|
|
1165
|
+
|
|
1166
|
+
wiki_path = wiki_dir / target
|
|
1167
|
+
if not wiki_path.exists():
|
|
1168
|
+
wiki_path = wiki_dir / filepath
|
|
1169
|
+
if not wiki_path.exists():
|
|
1170
|
+
console.print(f"[red]✗ Wiki page '{filepath}' not found.[/red]")
|
|
1171
|
+
raise typer.Exit(1)
|
|
1172
|
+
|
|
1173
|
+
content = wiki_path.read_text(encoding="utf-8")
|
|
1174
|
+
console.print(Markdown(content))
|