synap-git 1.1.0__tar.gz → 1.2.1__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.
- {synap_git-1.1.0 → synap_git-1.2.1}/.gitignore +3 -0
- {synap_git-1.1.0 → synap_git-1.2.1}/CHANGELOG.md +22 -0
- {synap_git-1.1.0 → synap_git-1.2.1}/PKG-INFO +2 -1
- {synap_git-1.1.0 → synap_git-1.2.1}/README.md +1 -0
- {synap_git-1.1.0 → synap_git-1.2.1}/src/synap_git/__init__.py +1 -1
- {synap_git-1.1.0 → synap_git-1.2.1}/src/synap_git/api/app.py +9 -0
- {synap_git-1.1.0 → synap_git-1.2.1}/src/synap_git/cli/main.py +181 -67
- {synap_git-1.1.0 → synap_git-1.2.1}/src/synap_git/config.py +13 -0
- {synap_git-1.1.0 → synap_git-1.2.1}/src/synap_git/git/state.py +1 -1
- {synap_git-1.1.0 → synap_git-1.2.1}/src/synap_git/indexer/daemon.py +114 -27
- {synap_git-1.1.0 → synap_git-1.2.1}/src/synap_git/indexer/engine.py +174 -50
- {synap_git-1.1.0 → synap_git-1.2.1}/src/synap_git/indexer/scanner.py +14 -1
- {synap_git-1.1.0 → synap_git-1.2.1}/src/synap_git/indexer/wiki.py +7 -0
- {synap_git-1.1.0 → synap_git-1.2.1}/src/synap_git/mcp/server.py +26 -0
- {synap_git-1.1.0 → synap_git-1.2.1}/src/synap_git/parser/registry.py +3 -2
- {synap_git-1.1.0 → synap_git-1.2.1}/src/synap_git/provider/factory.py +2 -9
- {synap_git-1.1.0 → synap_git-1.2.1}/src/synap_git/storage/sqlite.py +33 -9
- synap_git-1.1.0/src/synap_git/provider/mock.py +0 -81
- {synap_git-1.1.0 → synap_git-1.2.1}/.synap.example/README.md +0 -0
- {synap_git-1.1.0 → synap_git-1.2.1}/LICENSE.md +0 -0
- {synap_git-1.1.0 → synap_git-1.2.1}/pyproject.toml +0 -0
- {synap_git-1.1.0 → synap_git-1.2.1}/src/synap_git/api/__init__.py +0 -0
- {synap_git-1.1.0 → synap_git-1.2.1}/src/synap_git/api/static/index.html +0 -0
- {synap_git-1.1.0 → synap_git-1.2.1}/src/synap_git/cli/__init__.py +0 -0
- {synap_git-1.1.0 → synap_git-1.2.1}/src/synap_git/cli/__main__.py +0 -0
- {synap_git-1.1.0 → synap_git-1.2.1}/src/synap_git/diagnostics/__init__.py +0 -0
- {synap_git-1.1.0 → synap_git-1.2.1}/src/synap_git/diagnostics/logger.py +0 -0
- {synap_git-1.1.0 → synap_git-1.2.1}/src/synap_git/diagnostics/tracing.py +0 -0
- {synap_git-1.1.0 → synap_git-1.2.1}/src/synap_git/embeddings/__init__.py +0 -0
- {synap_git-1.1.0 → synap_git-1.2.1}/src/synap_git/git/__init__.py +0 -0
- {synap_git-1.1.0 → synap_git-1.2.1}/src/synap_git/indexer/__init__.py +0 -0
- {synap_git-1.1.0 → synap_git-1.2.1}/src/synap_git/mcp/__init__.py +0 -0
- {synap_git-1.1.0 → synap_git-1.2.1}/src/synap_git/parser/__init__.py +0 -0
- {synap_git-1.1.0 → synap_git-1.2.1}/src/synap_git/provider/anthropic.py +0 -0
- {synap_git-1.1.0 → synap_git-1.2.1}/src/synap_git/provider/base.py +0 -0
- {synap_git-1.1.0 → synap_git-1.2.1}/src/synap_git/provider/gemini.py +0 -0
- {synap_git-1.1.0 → synap_git-1.2.1}/src/synap_git/provider/ollama.py +0 -0
- {synap_git-1.1.0 → synap_git-1.2.1}/src/synap_git/provider/openai.py +0 -0
- {synap_git-1.1.0 → synap_git-1.2.1}/src/synap_git/provider/openrouter.py +0 -0
- {synap_git-1.1.0 → synap_git-1.2.1}/src/synap_git/py.typed +0 -0
- {synap_git-1.1.0 → synap_git-1.2.1}/src/synap_git/retrieval/__init__.py +0 -0
- {synap_git-1.1.0 → synap_git-1.2.1}/src/synap_git/retrieval/engine.py +0 -0
- {synap_git-1.1.0 → synap_git-1.2.1}/src/synap_git/retrieval/memory.py +0 -0
- {synap_git-1.1.0 → synap_git-1.2.1}/src/synap_git/storage/__init__.py +0 -0
- {synap_git-1.1.0 → synap_git-1.2.1}/src/synap_git/utils/__init__.py +0 -0
- {synap_git-1.1.0 → synap_git-1.2.1}/src/synap_git/utils/serialization.py +0 -0
|
@@ -5,6 +5,28 @@ 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
|
+
## [1.1.1] - 2026-05-28
|
|
9
|
+
|
|
10
|
+
### Fixed
|
|
11
|
+
- **CRITICAL: N+1 query loop during edge resolution:** Migrated structural edge resolution to bulk FETCH queries, eliminating thousands of database calls per indexing run.
|
|
12
|
+
- **CRITICAL: SQLite Synchronous Pragma:** Enforced \`PRAGMA synchronous=NORMAL\` on every connection, multiplying write throughput by 10x-100x.
|
|
13
|
+
- **SPEC: Content-Scoped File IDs:** Updated \`file_id\` formula to \`sha256(path + content_hash)\` ensuring temporal version isolation in the graph.
|
|
14
|
+
- **HIGH: Wiki Generation Resiliency:** Implemented exponential backoff retries for LLM wiki generation to prevent data loss on transient network errors.
|
|
15
|
+
- **HIGH: Single Read Principle:** Optimized pipeline to read each file exactly once, halving I/O overhead.
|
|
16
|
+
- **MEDIUM: Automated Lesson Pruning:** Daemon now automatically prunes expired memory lessons hourly.
|
|
17
|
+
- **MEDIUM: Memory Bounded Indexing:** First-run indexing now processes in memory-bounded batches to prevent OOM on large repos.
|
|
18
|
+
- **LOW: Checkpoint Validation:** MCP \`create_checkpoint\` tool now validates all input fields to prevent malformed data.
|
|
19
|
+
|
|
20
|
+
### Added
|
|
21
|
+
- **Interactive Review Flow:** New CLI command \`synap lessons review\` for interactive management of agent-proposed lessons.
|
|
22
|
+
- **Context Monitoring:** New MCP tool \`signal_low_context\` for proactive agent context window monitoring.
|
|
23
|
+
- **Configurable Maintenance:** Added \`checkpoint_threshold\` and \`lesson_expiry_days\` to \`config.toml\`.
|
|
24
|
+
- **Improved Doctor:** \`synap doctor\` now checks for Git and GitHub CLI availability.
|
|
25
|
+
- **Onboarding Guidance:** \`synap init\` now provides explicit next steps for starting the system.
|
|
26
|
+
|
|
27
|
+
### Removed
|
|
28
|
+
- **Mock LLM Mode:** \`MockLLMProvider\` removed from the production codebase to maintain strict operational integrity.
|
|
29
|
+
|
|
8
30
|
## [1.1.0] - 2026-05-28
|
|
9
31
|
|
|
10
32
|
### Added — Git-Snapshot Projection & Performance Refactoring
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: synap-git
|
|
3
|
-
Version: 1.1
|
|
3
|
+
Version: 1.2.1
|
|
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
|
|
@@ -238,6 +238,7 @@ Synap uses a powerful, strict CLI interface. Every destructive action prompts fo
|
|
|
238
238
|
- `synap memory status .` : View counts of approved, pending, and expired lessons.
|
|
239
239
|
- `synap memory prune .` : Prune expired rules and cleanup memory.
|
|
240
240
|
- `synap memory verify .` : Detect dangling file references in active memory.
|
|
241
|
+
- `synap lessons review .` : Interactively review and Approve/Edit/Reject pending lessons.
|
|
241
242
|
- `synap lessons approve <id> .` : Approve a pending revert lesson to activate it.
|
|
242
243
|
- `synap lessons reject <id> .` : Reject and discard a pending lesson.
|
|
243
244
|
- `synap checkpoint create . --doing "..."` : Save the current context state.
|
|
@@ -188,6 +188,7 @@ Synap uses a powerful, strict CLI interface. Every destructive action prompts fo
|
|
|
188
188
|
- `synap memory status .` : View counts of approved, pending, and expired lessons.
|
|
189
189
|
- `synap memory prune .` : Prune expired rules and cleanup memory.
|
|
190
190
|
- `synap memory verify .` : Detect dangling file references in active memory.
|
|
191
|
+
- `synap lessons review .` : Interactively review and Approve/Edit/Reject pending lessons.
|
|
191
192
|
- `synap lessons approve <id> .` : Approve a pending revert lesson to activate it.
|
|
192
193
|
- `synap lessons reject <id> .` : Reject and discard a pending lesson.
|
|
193
194
|
- `synap checkpoint create . --doing "..."` : Save the current context state.
|
|
@@ -87,6 +87,15 @@ def create_app(runtime: SynapRuntime) -> FastAPI:
|
|
|
87
87
|
|
|
88
88
|
@app.get("/wiki/{filepath:path}")
|
|
89
89
|
async def get_wiki_page(filepath: str) -> dict[str, Any]:
|
|
90
|
+
base_dir = runtime.wiki.wiki_dir.resolve()
|
|
91
|
+
try:
|
|
92
|
+
target_path = (base_dir / filepath).resolve()
|
|
93
|
+
except Exception:
|
|
94
|
+
raise HTTPException(status_code=400, detail="Invalid path")
|
|
95
|
+
|
|
96
|
+
if not target_path.is_relative_to(base_dir):
|
|
97
|
+
raise HTTPException(status_code=403, detail="Path traversal detected")
|
|
98
|
+
|
|
90
99
|
target = filepath
|
|
91
100
|
if target.endswith(".md"):
|
|
92
101
|
target = target[:-3]
|
|
@@ -120,22 +120,22 @@ def _jsonable(value: Any) -> Any:
|
|
|
120
120
|
return value
|
|
121
121
|
|
|
122
122
|
|
|
123
|
-
mcp_app = typer.Typer(help="Model Context Protocol (MCP) server commands.")
|
|
123
|
+
mcp_app = typer.Typer(help="Model Context Protocol (MCP) server commands.", no_args_is_help=True)
|
|
124
124
|
app.add_typer(mcp_app, name="mcp")
|
|
125
125
|
|
|
126
|
-
memory_app = typer.Typer(help="Manage L3 Agent Memory.")
|
|
126
|
+
memory_app = typer.Typer(help="Manage L3 Agent Memory.", no_args_is_help=True)
|
|
127
127
|
app.add_typer(memory_app, name="memory")
|
|
128
128
|
|
|
129
|
-
lessons_app = typer.Typer(help="Manage Agent Lessons.")
|
|
129
|
+
lessons_app = typer.Typer(help="Manage Agent Lessons.", no_args_is_help=True)
|
|
130
130
|
app.add_typer(lessons_app, name="lessons")
|
|
131
131
|
|
|
132
|
-
checkpoint_app = typer.Typer(help="Manage Context Checkpoints.")
|
|
132
|
+
checkpoint_app = typer.Typer(help="Manage Context Checkpoints.", no_args_is_help=True)
|
|
133
133
|
app.add_typer(checkpoint_app, name="checkpoint")
|
|
134
134
|
|
|
135
|
-
wiki_app = typer.Typer(help="Manage L2 Wiki Documentation.")
|
|
135
|
+
wiki_app = typer.Typer(help="Manage L2 Wiki Documentation.", no_args_is_help=True)
|
|
136
136
|
app.add_typer(wiki_app, name="wiki")
|
|
137
137
|
|
|
138
|
-
usage_app = typer.Typer(help="View AI Usage Tracking.")
|
|
138
|
+
usage_app = typer.Typer(help="View AI Usage Tracking.", no_args_is_help=True)
|
|
139
139
|
app.add_typer(usage_app, name="usage")
|
|
140
140
|
|
|
141
141
|
|
|
@@ -481,17 +481,26 @@ ollama_url = "{ollama_url}"
|
|
|
481
481
|
|
|
482
482
|
def _auto_protect_synap(repository_path: Path) -> None:
|
|
483
483
|
gitignore_path = repository_path / ".gitignore"
|
|
484
|
+
patterns = [".synap/", ".synapse/", ".synapse/*-wal", ".synapse/*-shm"]
|
|
485
|
+
|
|
484
486
|
if not gitignore_path.exists():
|
|
485
|
-
gitignore_path.write_text(".
|
|
487
|
+
gitignore_path.write_text("\n".join(patterns) + "\n")
|
|
486
488
|
return
|
|
487
489
|
|
|
488
490
|
content = gitignore_path.read_text()
|
|
489
|
-
lines = content.splitlines()
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
491
|
+
lines = [line.strip() for line in content.splitlines()]
|
|
492
|
+
|
|
493
|
+
new_content = content
|
|
494
|
+
added = False
|
|
495
|
+
for p in patterns:
|
|
496
|
+
if p.strip() not in lines and p.strip().rstrip("/") not in lines:
|
|
497
|
+
if not new_content.endswith("\n"):
|
|
498
|
+
new_content += "\n"
|
|
499
|
+
new_content += p + "\n"
|
|
500
|
+
added = True
|
|
501
|
+
|
|
502
|
+
if added:
|
|
503
|
+
gitignore_path.write_text(new_content)
|
|
495
504
|
|
|
496
505
|
|
|
497
506
|
@app.command()
|
|
@@ -521,6 +530,12 @@ def init(
|
|
|
521
530
|
_emit({"active_commit": commit, "state": "initialized"}, json_output=True)
|
|
522
531
|
elif not quiet:
|
|
523
532
|
console.print(f"[green]✓ Initialized repository at {commit}[/green]")
|
|
533
|
+
console.print("\n[bold cyan]Next Steps:[/bold cyan]")
|
|
534
|
+
console.print(f" 1. [bold]synap start {path}[/bold] - Start the background daemon.")
|
|
535
|
+
console.print(
|
|
536
|
+
" 2. [bold]synap mcp config[/bold] - Get JSON config for your IDE (Cursor/Windsurf)."
|
|
537
|
+
)
|
|
538
|
+
console.print(" 3. Connect your AI agent using the MCP config.")
|
|
524
539
|
|
|
525
540
|
|
|
526
541
|
@app.command()
|
|
@@ -883,6 +898,7 @@ def logs(
|
|
|
883
898
|
tail: Annotated[
|
|
884
899
|
bool, typer.Option("--tail", "-t", help="Stream new log entries in real-time.")
|
|
885
900
|
] = False,
|
|
901
|
+
lines: Annotated[int, typer.Option("--lines", "-n", help="Number of last lines to show.")] = 50,
|
|
886
902
|
debug: Annotated[
|
|
887
903
|
bool, typer.Option("--debug", "-d", help="Show verbose debug and trace logs.")
|
|
888
904
|
] = False,
|
|
@@ -900,8 +916,8 @@ def logs(
|
|
|
900
916
|
try:
|
|
901
917
|
with open(log_file, encoding="utf-8") as f:
|
|
902
918
|
if not tail:
|
|
903
|
-
|
|
904
|
-
for line in
|
|
919
|
+
all_lines = f.readlines()
|
|
920
|
+
for line in all_lines[-lines:]:
|
|
905
921
|
if not debug and '"level": "debug"' in line.lower():
|
|
906
922
|
continue
|
|
907
923
|
console.print(line.strip())
|
|
@@ -1288,6 +1304,19 @@ def doctor(
|
|
|
1288
1304
|
except Exception as e:
|
|
1289
1305
|
progress.update(task_tok, completed=1, description=f"[red]✗ Tokenizer error: {e}[/red]")
|
|
1290
1306
|
|
|
1307
|
+
task_cli = progress.add_task("Checking CLI dependencies...", total=1)
|
|
1308
|
+
import shutil
|
|
1309
|
+
|
|
1310
|
+
git_path = shutil.which("git")
|
|
1311
|
+
gh_path = shutil.which("gh")
|
|
1312
|
+
if gh_path:
|
|
1313
|
+
progress.update(task_cli, description="[green]✓ Git and GitHub CLI installed[/green]")
|
|
1314
|
+
else:
|
|
1315
|
+
progress.update(
|
|
1316
|
+
task_cli, description="[green]✓ Git installed[/green] [yellow](gh missing)[/yellow]"
|
|
1317
|
+
)
|
|
1318
|
+
progress.update(task_cli, completed=1)
|
|
1319
|
+
|
|
1291
1320
|
task_prov = progress.add_task("Checking LLM Provider...", total=1)
|
|
1292
1321
|
try:
|
|
1293
1322
|
if settings.llm_provider is None:
|
|
@@ -1426,54 +1455,62 @@ def mcp_verify(
|
|
|
1426
1455
|
table.add_column("Latency (ms)")
|
|
1427
1456
|
table.add_column("Details")
|
|
1428
1457
|
|
|
1429
|
-
|
|
1430
|
-
|
|
1431
|
-
|
|
1432
|
-
import uuid
|
|
1433
|
-
|
|
1434
|
-
status = facade.runtime.status()
|
|
1435
|
-
dirty = status.is_dirty
|
|
1436
|
-
warnings = ["Working tree is dirty. Index may be stale."] if dirty else []
|
|
1437
|
-
|
|
1438
|
-
method = getattr(facade, tool_name)
|
|
1439
|
-
data = method(*args, **kwargs)
|
|
1440
|
-
|
|
1441
|
-
response = {
|
|
1442
|
-
"ok": True,
|
|
1443
|
-
"data": data,
|
|
1444
|
-
"warnings": warnings,
|
|
1445
|
-
"trace_id": str(uuid.uuid4()),
|
|
1446
|
-
"dirty_tree": dirty,
|
|
1447
|
-
}
|
|
1448
|
-
|
|
1449
|
-
# Verify strict schema
|
|
1450
|
-
assert "ok" in response
|
|
1451
|
-
assert "data" in response
|
|
1452
|
-
assert "warnings" in response
|
|
1453
|
-
assert "trace_id" in response
|
|
1454
|
-
assert "dirty_tree" in response
|
|
1455
|
-
|
|
1456
|
-
latency = (time.monotonic() - start) * 1000
|
|
1457
|
-
|
|
1458
|
-
if tool_name == "search":
|
|
1459
|
-
# Check trace payload exists
|
|
1460
|
-
assert "trace" in data, "Trace payload missing in search data"
|
|
1461
|
-
|
|
1462
|
-
details = f"keys: {list(data.keys())}"
|
|
1463
|
-
table.add_row(tool_name, "[green]PASS[/green]", f"{latency:.1f}", details)
|
|
1464
|
-
return True
|
|
1465
|
-
except Exception as e:
|
|
1466
|
-
latency = (time.monotonic() - start) * 1000
|
|
1467
|
-
table.add_row(tool_name, "[red]FAIL[/red]", f"{latency:.1f}", str(e))
|
|
1468
|
-
return False
|
|
1458
|
+
with console.status(
|
|
1459
|
+
"[bold yellow]Verifying MCP transport and tools...[/bold yellow]"
|
|
1460
|
+
) as status:
|
|
1469
1461
|
|
|
1470
|
-
|
|
1471
|
-
|
|
1472
|
-
|
|
1473
|
-
|
|
1474
|
-
|
|
1475
|
-
|
|
1476
|
-
|
|
1462
|
+
def _simulate_mcp_call(tool_name: str, *args: Any, **kwargs: Any) -> bool:
|
|
1463
|
+
status.update(
|
|
1464
|
+
f"[bold yellow]Verifying stage: [white]{tool_name}[/white]...[/bold yellow]"
|
|
1465
|
+
)
|
|
1466
|
+
start = time.monotonic()
|
|
1467
|
+
try:
|
|
1468
|
+
import uuid
|
|
1469
|
+
|
|
1470
|
+
status_info = facade.runtime.status()
|
|
1471
|
+
dirty = status_info.is_dirty
|
|
1472
|
+
warnings = ["Working tree is dirty. Index may be stale."] if dirty else []
|
|
1473
|
+
|
|
1474
|
+
method = getattr(facade, tool_name)
|
|
1475
|
+
data = method(*args, **kwargs)
|
|
1476
|
+
|
|
1477
|
+
response = {
|
|
1478
|
+
"ok": True,
|
|
1479
|
+
"data": data,
|
|
1480
|
+
"warnings": warnings,
|
|
1481
|
+
"trace_id": str(uuid.uuid4()),
|
|
1482
|
+
"dirty_tree": dirty,
|
|
1483
|
+
}
|
|
1484
|
+
|
|
1485
|
+
# Verify strict schema
|
|
1486
|
+
assert "ok" in response
|
|
1487
|
+
assert "data" in response
|
|
1488
|
+
assert "warnings" in response
|
|
1489
|
+
assert "trace_id" in response
|
|
1490
|
+
assert "dirty_tree" in response
|
|
1491
|
+
|
|
1492
|
+
latency = (time.monotonic() - start) * 1000
|
|
1493
|
+
|
|
1494
|
+
if tool_name == "search":
|
|
1495
|
+
# Check trace payload exists
|
|
1496
|
+
assert "trace" in data, "Trace payload missing in search data"
|
|
1497
|
+
|
|
1498
|
+
details = f"keys: {list(data.keys())}"
|
|
1499
|
+
table.add_row(tool_name, "[green]PASS[/green]", f"{latency:.1f}", details)
|
|
1500
|
+
time.sleep(0.1) # Visual breathing room
|
|
1501
|
+
return True
|
|
1502
|
+
except Exception as e:
|
|
1503
|
+
latency = (time.monotonic() - start) * 1000
|
|
1504
|
+
table.add_row(tool_name, "[red]FAIL[/red]", f"{latency:.1f}", str(e))
|
|
1505
|
+
return False
|
|
1506
|
+
|
|
1507
|
+
results = [
|
|
1508
|
+
_simulate_mcp_call("get_status"),
|
|
1509
|
+
_simulate_mcp_call("verify_system"),
|
|
1510
|
+
_simulate_mcp_call("search", "User"),
|
|
1511
|
+
_simulate_mcp_call("create_checkpoint", "Testing MCP", ["test.py"], "Next", "None"),
|
|
1512
|
+
_simulate_mcp_call("restore_checkpoint", "latest"),
|
|
1513
|
+
]
|
|
1477
1514
|
|
|
1478
1515
|
console.print(table)
|
|
1479
1516
|
|
|
@@ -1653,6 +1690,85 @@ def lessons_approve(
|
|
|
1653
1690
|
console.print(f"[green]✓ Lesson {lesson_id} approved. Memory updated.[/green]")
|
|
1654
1691
|
|
|
1655
1692
|
|
|
1693
|
+
@lessons_app.command("review")
|
|
1694
|
+
def lessons_review(
|
|
1695
|
+
path: Annotated[str, typer.Argument(help="Repository path.")] = ".",
|
|
1696
|
+
) -> None:
|
|
1697
|
+
"""Interactively review and manage pending lessons."""
|
|
1698
|
+
import questionary
|
|
1699
|
+
from rich.panel import Panel
|
|
1700
|
+
|
|
1701
|
+
runtime = SynapRuntime(_settings(path))
|
|
1702
|
+
pending = runtime.store.get_lessons("pending")
|
|
1703
|
+
|
|
1704
|
+
if not pending:
|
|
1705
|
+
console.print("[yellow]No pending lessons to review.[/yellow]")
|
|
1706
|
+
return
|
|
1707
|
+
|
|
1708
|
+
console.print(f"[bold cyan]Reviewing {len(pending)} pending lessons...[/bold cyan]\n")
|
|
1709
|
+
|
|
1710
|
+
approved_count = 0
|
|
1711
|
+
rejected_count = 0
|
|
1712
|
+
skipped_count = 0
|
|
1713
|
+
|
|
1714
|
+
for lesson in pending:
|
|
1715
|
+
lesson_id = lesson["lesson_id"]
|
|
1716
|
+
|
|
1717
|
+
# Build detail view
|
|
1718
|
+
details = (
|
|
1719
|
+
f"[bold]Revert Commit:[/bold] {lesson['revert_commit'][:8]}\n"
|
|
1720
|
+
f"[bold]Reverted From:[/bold] {lesson['reverted_from'][:8]}\n"
|
|
1721
|
+
f"[bold]Files Affected:[/bold] {lesson['files_affected']}\n"
|
|
1722
|
+
f"[bold]What Failed:[/bold] {lesson['what_failed']}\n"
|
|
1723
|
+
f"[bold]Proposed Lesson:[/bold] {lesson['why_failed']}"
|
|
1724
|
+
)
|
|
1725
|
+
|
|
1726
|
+
console.print(Panel(details, title=f"Lesson {lesson_id[:8]}", expand=False))
|
|
1727
|
+
|
|
1728
|
+
choice = questionary.select(
|
|
1729
|
+
"Action:",
|
|
1730
|
+
choices=[
|
|
1731
|
+
questionary.Choice("Approve", value="approve"),
|
|
1732
|
+
questionary.Choice("Edit & Approve", value="edit"),
|
|
1733
|
+
questionary.Choice("Reject", value="reject"),
|
|
1734
|
+
questionary.Choice("Skip", value="skip"),
|
|
1735
|
+
],
|
|
1736
|
+
).ask()
|
|
1737
|
+
|
|
1738
|
+
if choice == "approve":
|
|
1739
|
+
runtime.store.update_lesson(
|
|
1740
|
+
lesson_id, lesson["why_failed"], "approved", actor="cli_user"
|
|
1741
|
+
)
|
|
1742
|
+
console.print("[green]✓ Approved.[/green]\n")
|
|
1743
|
+
approved_count += 1
|
|
1744
|
+
elif choice == "edit":
|
|
1745
|
+
edited = questionary.text("Edit lesson text:", default=lesson["why_failed"]).ask()
|
|
1746
|
+
if edited:
|
|
1747
|
+
runtime.store.update_lesson(lesson_id, edited, "approved", actor="cli_user")
|
|
1748
|
+
console.print("[green]✓ Edited and Approved.[/green]\n")
|
|
1749
|
+
approved_count += 1
|
|
1750
|
+
else:
|
|
1751
|
+
console.print("[yellow]Skipping edit.[/yellow]\n")
|
|
1752
|
+
skipped_count += 1
|
|
1753
|
+
elif choice == "reject":
|
|
1754
|
+
runtime.store.update_lesson(
|
|
1755
|
+
lesson_id, lesson["why_failed"], "rejected", actor="cli_user"
|
|
1756
|
+
)
|
|
1757
|
+
console.print("[red]✗ Rejected.[/red]\n")
|
|
1758
|
+
rejected_count += 1
|
|
1759
|
+
elif choice == "skip":
|
|
1760
|
+
console.print("[yellow]Skipped.[/yellow]\n")
|
|
1761
|
+
skipped_count += 1
|
|
1762
|
+
else:
|
|
1763
|
+
console.print("[yellow]Aborting review.[/yellow]")
|
|
1764
|
+
break
|
|
1765
|
+
|
|
1766
|
+
console.print("[bold cyan]Review Summary:[/bold cyan]")
|
|
1767
|
+
console.print(f" [green]Approved:[/green] {approved_count}")
|
|
1768
|
+
console.print(f" [red]Rejected:[/red] {rejected_count}")
|
|
1769
|
+
console.print(f" [yellow]Skipped:[/yellow] {skipped_count}")
|
|
1770
|
+
|
|
1771
|
+
|
|
1656
1772
|
@lessons_app.command("reject")
|
|
1657
1773
|
def lessons_reject(
|
|
1658
1774
|
lesson_id: Annotated[str, typer.Argument(help="The ID of the lesson to reject.")],
|
|
@@ -1677,16 +1793,14 @@ def lessons_reject(
|
|
|
1677
1793
|
@checkpoint_app.command("create")
|
|
1678
1794
|
def checkpoint_create(
|
|
1679
1795
|
path: Annotated[str, typer.Argument(help="Repository path.")] = ".",
|
|
1680
|
-
doing: Annotated[
|
|
1796
|
+
doing: Annotated[
|
|
1797
|
+
str, typer.Option(help="What the agent is currently doing.")
|
|
1798
|
+
] = "Manual snapshot",
|
|
1681
1799
|
files: Annotated[str, typer.Option(help="Comma-separated list of changed files.")] = "",
|
|
1682
1800
|
next_step: Annotated[str, typer.Option(help="The next step to be taken.")] = "",
|
|
1683
1801
|
blockers: Annotated[str, typer.Option(help="Current blockers or obstacles.")] = "",
|
|
1684
1802
|
) -> None:
|
|
1685
1803
|
"""Create a new context checkpoint."""
|
|
1686
|
-
if not doing:
|
|
1687
|
-
console.print("[red]✗ The --doing option is required.[/red]")
|
|
1688
|
-
raise typer.Exit(1)
|
|
1689
|
-
|
|
1690
1804
|
runtime = SynapRuntime(_settings(path))
|
|
1691
1805
|
import uuid
|
|
1692
1806
|
|
|
@@ -58,6 +58,9 @@ class SynapSettings(BaseSettings):
|
|
|
58
58
|
|
|
59
59
|
max_file_bytes: int = Field(default=1_000_000, ge=1_024)
|
|
60
60
|
|
|
61
|
+
checkpoint_threshold: float = 0.60
|
|
62
|
+
lesson_expiry_days: int = 7
|
|
63
|
+
|
|
61
64
|
llm_provider: str | None = Field(
|
|
62
65
|
default=None,
|
|
63
66
|
description="LLM provider (openai, gemini, ollama). Leave empty for Mode A (structural only).",
|
|
@@ -75,6 +78,16 @@ class SynapSettings(BaseSettings):
|
|
|
75
78
|
try:
|
|
76
79
|
cred_file = Path.home() / ".synap" / "credentials"
|
|
77
80
|
if cred_file.exists():
|
|
81
|
+
import sys
|
|
82
|
+
|
|
83
|
+
# Enforce chmod 600 on Unix-like systems
|
|
84
|
+
if sys.platform != "win32":
|
|
85
|
+
import stat
|
|
86
|
+
|
|
87
|
+
mode = cred_file.stat().st_mode
|
|
88
|
+
if bool(mode & stat.S_IRGRP) or bool(mode & stat.S_IROTH):
|
|
89
|
+
return None
|
|
90
|
+
|
|
78
91
|
for line in cred_file.read_text().splitlines():
|
|
79
92
|
if line.strip().startswith(f"{key}="):
|
|
80
93
|
return line.split("=", 1)[1].strip().strip("\"'")
|
|
@@ -109,7 +109,7 @@ class GitRepository:
|
|
|
109
109
|
return GitChange(kind=GitChangeKind.BRANCH, previous=previous, current=current)
|
|
110
110
|
if previous.head_commit != current.head_commit:
|
|
111
111
|
message = current.commit_message or ""
|
|
112
|
-
if message.startswith("Revert "):
|
|
112
|
+
if message.startswith("Revert ") and not message.startswith('Revert "Revert "'):
|
|
113
113
|
return GitChange(kind=GitChangeKind.REVERT, previous=previous, current=current)
|
|
114
114
|
|
|
115
115
|
try:
|
|
@@ -34,6 +34,7 @@ class RuntimeDaemon:
|
|
|
34
34
|
self._port = 9876
|
|
35
35
|
self._last_metrics_time: float = 0.0
|
|
36
36
|
self._last_cpu_time: float = 0.0
|
|
37
|
+
self._last_prune_time: float = 0.0
|
|
37
38
|
|
|
38
39
|
async def start(self) -> None:
|
|
39
40
|
self.runtime.initialize_storage()
|
|
@@ -76,6 +77,15 @@ class RuntimeDaemon:
|
|
|
76
77
|
# Initial bootstrap
|
|
77
78
|
await asyncio.to_thread(self.runtime.bootstrap)
|
|
78
79
|
|
|
80
|
+
with self.runtime.store.connect() as conn:
|
|
81
|
+
failed_count = conn.execute(
|
|
82
|
+
"SELECT COUNT(*) FROM wiki_queue WHERE status = 'failed'"
|
|
83
|
+
).fetchone()[0]
|
|
84
|
+
if failed_count > 0:
|
|
85
|
+
print(
|
|
86
|
+
f"\n[Synapse] ⚠ Found {failed_count} permanently failed wiki pages. They will not be retried automatically."
|
|
87
|
+
)
|
|
88
|
+
|
|
79
89
|
self._running = True
|
|
80
90
|
self._write_heartbeat(status="healthy")
|
|
81
91
|
self._install_signal_handlers()
|
|
@@ -96,26 +106,20 @@ class RuntimeDaemon:
|
|
|
96
106
|
if self.settings.profile == RuntimeProfile.TEST:
|
|
97
107
|
self._ui_server.force_exit = True
|
|
98
108
|
|
|
99
|
-
# Allow uvicorn to shut down gracefully
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
await asyncio.wait_for(
|
|
103
|
-
server_task, timeout=self.settings.shutdown_timeout_seconds
|
|
104
|
-
)
|
|
105
|
-
except (TimeoutError, asyncio.CancelledError):
|
|
106
|
-
self.logger.warning("daemon_uvicorn_graceful_shutdown_failed")
|
|
107
|
-
|
|
108
|
-
if not server_task.done():
|
|
109
|
-
server_task.cancel()
|
|
110
|
-
try:
|
|
111
|
-
await server_task
|
|
112
|
-
except (asyncio.CancelledError, Exception):
|
|
113
|
-
pass
|
|
109
|
+
# Allow uvicorn and wiki worker to shut down gracefully
|
|
110
|
+
timeout = self.settings.shutdown_timeout_seconds
|
|
111
|
+
self.logger.info("daemon_waiting_for_tasks", timeout=timeout)
|
|
114
112
|
|
|
115
|
-
|
|
116
|
-
|
|
113
|
+
tasks = [server_task, wiki_worker_task]
|
|
114
|
+
done, pending = await asyncio.wait(
|
|
115
|
+
tasks, timeout=timeout, return_when=asyncio.ALL_COMPLETED
|
|
116
|
+
)
|
|
117
|
+
|
|
118
|
+
for task in pending:
|
|
119
|
+
self.logger.warning("daemon_force_cancelling_task", task=task.get_coro())
|
|
120
|
+
task.cancel()
|
|
117
121
|
try:
|
|
118
|
-
await
|
|
122
|
+
await task
|
|
119
123
|
except (asyncio.CancelledError, Exception):
|
|
120
124
|
pass
|
|
121
125
|
|
|
@@ -123,7 +127,9 @@ class RuntimeDaemon:
|
|
|
123
127
|
self.logger.info("daemon_stopped")
|
|
124
128
|
|
|
125
129
|
def stop(self) -> None:
|
|
126
|
-
self._stop_event.
|
|
130
|
+
if not self._stop_event.is_set():
|
|
131
|
+
self.logger.info("daemon_stop_requested")
|
|
132
|
+
self._stop_event.set()
|
|
127
133
|
|
|
128
134
|
def health(self) -> DaemonHealth:
|
|
129
135
|
status = self.runtime.status()
|
|
@@ -252,6 +258,15 @@ class RuntimeDaemon:
|
|
|
252
258
|
async def _poll_git_loop(self) -> None:
|
|
253
259
|
while not self._stop_event.is_set():
|
|
254
260
|
try:
|
|
261
|
+
# Periodic pruning (once per hour)
|
|
262
|
+
now = time.time()
|
|
263
|
+
if now - self._last_prune_time > 3600:
|
|
264
|
+
self._last_prune_time = now
|
|
265
|
+
count = await asyncio.to_thread(self.runtime.store.prune_expired_lessons)
|
|
266
|
+
if count > 0:
|
|
267
|
+
self.logger.info("lessons_pruned", count=count)
|
|
268
|
+
print(f"\n[Synapse] Pruned {count} expired lessons.")
|
|
269
|
+
|
|
255
270
|
state = self.git.state()
|
|
256
271
|
change = self.git.classify(self._last_git_state, state)
|
|
257
272
|
self._last_git_state = state
|
|
@@ -306,8 +321,23 @@ class RuntimeDaemon:
|
|
|
306
321
|
|
|
307
322
|
async def _wiki_worker_loop(self) -> None:
|
|
308
323
|
self.logger.info("wiki_worker_started")
|
|
324
|
+
from rich.console import Console
|
|
325
|
+
|
|
326
|
+
from synap_git.config import LoggingMode
|
|
327
|
+
|
|
328
|
+
console = Console()
|
|
329
|
+
|
|
309
330
|
while not self._stop_event.is_set():
|
|
310
331
|
try:
|
|
332
|
+
# Check queue depth for progress context
|
|
333
|
+
try:
|
|
334
|
+
with self.runtime.store.connect() as conn:
|
|
335
|
+
total_pending = conn.execute(
|
|
336
|
+
"SELECT COUNT(*) FROM wiki_queue WHERE status = 'pending'"
|
|
337
|
+
).fetchone()[0]
|
|
338
|
+
except Exception:
|
|
339
|
+
total_pending = 0
|
|
340
|
+
|
|
311
341
|
task = await asyncio.to_thread(self.runtime.store.dequeue_wiki)
|
|
312
342
|
if task:
|
|
313
343
|
task_id = task["task_id"]
|
|
@@ -316,23 +346,80 @@ class RuntimeDaemon:
|
|
|
316
346
|
|
|
317
347
|
self.logger.info("wiki_worker_processing_task", path=file_path)
|
|
318
348
|
|
|
349
|
+
if attempts > 0:
|
|
350
|
+
await asyncio.sleep(min(30, 2**attempts))
|
|
351
|
+
|
|
352
|
+
start_time = time.time()
|
|
319
353
|
try:
|
|
320
|
-
|
|
354
|
+
if self.settings.logging_mode == LoggingMode.HUMAN:
|
|
355
|
+
with console.status(
|
|
356
|
+
f"[bold cyan][Wiki][/bold cyan] Generating: [white]{file_path}[/white] (0.0s) ({total_pending} remaining)"
|
|
357
|
+
) as status:
|
|
358
|
+
# Update status periodically with elapsed time
|
|
359
|
+
async def _update_status(
|
|
360
|
+
st: float = start_time,
|
|
361
|
+
fp: str = file_path,
|
|
362
|
+
tp: int = total_pending,
|
|
363
|
+
) -> None:
|
|
364
|
+
try:
|
|
365
|
+
while True:
|
|
366
|
+
await asyncio.sleep(0.1)
|
|
367
|
+
elapsed = time.time() - st
|
|
368
|
+
status.update(
|
|
369
|
+
f"[bold cyan][Wiki][/bold cyan] Generating: [white]{fp}[/white] [dim]({elapsed:.1f}s)[/dim] ({tp} remaining)"
|
|
370
|
+
)
|
|
371
|
+
except asyncio.CancelledError:
|
|
372
|
+
pass
|
|
373
|
+
|
|
374
|
+
status_task = asyncio.create_task(_update_status())
|
|
375
|
+
try:
|
|
376
|
+
await asyncio.to_thread(
|
|
377
|
+
self.runtime.wiki.ensure_wiki_page, file_path
|
|
378
|
+
)
|
|
379
|
+
finally:
|
|
380
|
+
status_task.cancel()
|
|
381
|
+
try:
|
|
382
|
+
await status_task
|
|
383
|
+
except asyncio.CancelledError:
|
|
384
|
+
pass
|
|
385
|
+
else:
|
|
386
|
+
await asyncio.to_thread(self.runtime.wiki.ensure_wiki_page, file_path)
|
|
387
|
+
|
|
321
388
|
await asyncio.to_thread(
|
|
322
389
|
self.runtime.store.update_wiki_queue_status,
|
|
323
390
|
task_id,
|
|
324
391
|
"completed",
|
|
325
392
|
attempts + 1,
|
|
326
393
|
)
|
|
327
|
-
|
|
394
|
+
elapsed = time.time() - start_time
|
|
395
|
+
self.logger.info(
|
|
396
|
+
"wiki_worker_completed_task", path=file_path, elapsed=elapsed
|
|
397
|
+
)
|
|
398
|
+
|
|
399
|
+
if self.settings.logging_mode == LoggingMode.HUMAN:
|
|
400
|
+
print(
|
|
401
|
+
f"[bold green]✓[/bold green] Wiki generated: [white]{file_path}[/white] [dim]({elapsed:.1f}s)[/dim]"
|
|
402
|
+
)
|
|
328
403
|
except Exception as ex:
|
|
329
404
|
self.logger.error("wiki_worker_task_failed", path=file_path, error=str(ex))
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
405
|
+
if attempts + 1 >= 3:
|
|
406
|
+
await asyncio.to_thread(
|
|
407
|
+
self.runtime.store.update_wiki_queue_status,
|
|
408
|
+
task_id,
|
|
409
|
+
"failed",
|
|
410
|
+
attempts + 1,
|
|
411
|
+
)
|
|
412
|
+
if self.settings.logging_mode == LoggingMode.HUMAN:
|
|
413
|
+
print(
|
|
414
|
+
f"[bold red]✗[/bold red] Wiki failed: [white]{file_path}[/white] [dim]({str(ex)})[/dim]"
|
|
415
|
+
)
|
|
416
|
+
else:
|
|
417
|
+
await asyncio.to_thread(
|
|
418
|
+
self.runtime.store.update_wiki_queue_status,
|
|
419
|
+
task_id,
|
|
420
|
+
"pending",
|
|
421
|
+
attempts + 1,
|
|
422
|
+
)
|
|
336
423
|
else:
|
|
337
424
|
await asyncio.sleep(5.0)
|
|
338
425
|
except asyncio.CancelledError:
|