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.
Files changed (46) hide show
  1. {synap_git-1.1.0 → synap_git-1.2.1}/.gitignore +3 -0
  2. {synap_git-1.1.0 → synap_git-1.2.1}/CHANGELOG.md +22 -0
  3. {synap_git-1.1.0 → synap_git-1.2.1}/PKG-INFO +2 -1
  4. {synap_git-1.1.0 → synap_git-1.2.1}/README.md +1 -0
  5. {synap_git-1.1.0 → synap_git-1.2.1}/src/synap_git/__init__.py +1 -1
  6. {synap_git-1.1.0 → synap_git-1.2.1}/src/synap_git/api/app.py +9 -0
  7. {synap_git-1.1.0 → synap_git-1.2.1}/src/synap_git/cli/main.py +181 -67
  8. {synap_git-1.1.0 → synap_git-1.2.1}/src/synap_git/config.py +13 -0
  9. {synap_git-1.1.0 → synap_git-1.2.1}/src/synap_git/git/state.py +1 -1
  10. {synap_git-1.1.0 → synap_git-1.2.1}/src/synap_git/indexer/daemon.py +114 -27
  11. {synap_git-1.1.0 → synap_git-1.2.1}/src/synap_git/indexer/engine.py +174 -50
  12. {synap_git-1.1.0 → synap_git-1.2.1}/src/synap_git/indexer/scanner.py +14 -1
  13. {synap_git-1.1.0 → synap_git-1.2.1}/src/synap_git/indexer/wiki.py +7 -0
  14. {synap_git-1.1.0 → synap_git-1.2.1}/src/synap_git/mcp/server.py +26 -0
  15. {synap_git-1.1.0 → synap_git-1.2.1}/src/synap_git/parser/registry.py +3 -2
  16. {synap_git-1.1.0 → synap_git-1.2.1}/src/synap_git/provider/factory.py +2 -9
  17. {synap_git-1.1.0 → synap_git-1.2.1}/src/synap_git/storage/sqlite.py +33 -9
  18. synap_git-1.1.0/src/synap_git/provider/mock.py +0 -81
  19. {synap_git-1.1.0 → synap_git-1.2.1}/.synap.example/README.md +0 -0
  20. {synap_git-1.1.0 → synap_git-1.2.1}/LICENSE.md +0 -0
  21. {synap_git-1.1.0 → synap_git-1.2.1}/pyproject.toml +0 -0
  22. {synap_git-1.1.0 → synap_git-1.2.1}/src/synap_git/api/__init__.py +0 -0
  23. {synap_git-1.1.0 → synap_git-1.2.1}/src/synap_git/api/static/index.html +0 -0
  24. {synap_git-1.1.0 → synap_git-1.2.1}/src/synap_git/cli/__init__.py +0 -0
  25. {synap_git-1.1.0 → synap_git-1.2.1}/src/synap_git/cli/__main__.py +0 -0
  26. {synap_git-1.1.0 → synap_git-1.2.1}/src/synap_git/diagnostics/__init__.py +0 -0
  27. {synap_git-1.1.0 → synap_git-1.2.1}/src/synap_git/diagnostics/logger.py +0 -0
  28. {synap_git-1.1.0 → synap_git-1.2.1}/src/synap_git/diagnostics/tracing.py +0 -0
  29. {synap_git-1.1.0 → synap_git-1.2.1}/src/synap_git/embeddings/__init__.py +0 -0
  30. {synap_git-1.1.0 → synap_git-1.2.1}/src/synap_git/git/__init__.py +0 -0
  31. {synap_git-1.1.0 → synap_git-1.2.1}/src/synap_git/indexer/__init__.py +0 -0
  32. {synap_git-1.1.0 → synap_git-1.2.1}/src/synap_git/mcp/__init__.py +0 -0
  33. {synap_git-1.1.0 → synap_git-1.2.1}/src/synap_git/parser/__init__.py +0 -0
  34. {synap_git-1.1.0 → synap_git-1.2.1}/src/synap_git/provider/anthropic.py +0 -0
  35. {synap_git-1.1.0 → synap_git-1.2.1}/src/synap_git/provider/base.py +0 -0
  36. {synap_git-1.1.0 → synap_git-1.2.1}/src/synap_git/provider/gemini.py +0 -0
  37. {synap_git-1.1.0 → synap_git-1.2.1}/src/synap_git/provider/ollama.py +0 -0
  38. {synap_git-1.1.0 → synap_git-1.2.1}/src/synap_git/provider/openai.py +0 -0
  39. {synap_git-1.1.0 → synap_git-1.2.1}/src/synap_git/provider/openrouter.py +0 -0
  40. {synap_git-1.1.0 → synap_git-1.2.1}/src/synap_git/py.typed +0 -0
  41. {synap_git-1.1.0 → synap_git-1.2.1}/src/synap_git/retrieval/__init__.py +0 -0
  42. {synap_git-1.1.0 → synap_git-1.2.1}/src/synap_git/retrieval/engine.py +0 -0
  43. {synap_git-1.1.0 → synap_git-1.2.1}/src/synap_git/retrieval/memory.py +0 -0
  44. {synap_git-1.1.0 → synap_git-1.2.1}/src/synap_git/storage/__init__.py +0 -0
  45. {synap_git-1.1.0 → synap_git-1.2.1}/src/synap_git/utils/__init__.py +0 -0
  46. {synap_git-1.1.0 → synap_git-1.2.1}/src/synap_git/utils/serialization.py +0 -0
@@ -22,3 +22,6 @@ build/
22
22
  .env
23
23
  .env.*
24
24
  .env.local
25
+ .synapse/
26
+ .synapse/*-wal
27
+ .synapse/*-shm
@@ -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.0
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.
@@ -2,4 +2,4 @@
2
2
 
3
3
  __all__ = ["__version__"]
4
4
 
5
- __version__ = "1.1.0"
5
+ __version__ = "1.2.1"
@@ -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(".synap/\n")
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
- if ".synap/" not in lines and ".synap" not in lines:
491
- if content and not content.endswith("\n"):
492
- content += "\n"
493
- content += ".synap/\n"
494
- gitignore_path.write_text(content)
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
- lines = f.readlines()
904
- for line in lines[-50:]:
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
- def _simulate_mcp_call(tool_name: str, *args: Any, **kwargs: Any) -> bool:
1430
- start = time.monotonic()
1431
- try:
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
- results = [
1471
- _simulate_mcp_call("get_status"),
1472
- _simulate_mcp_call("verify_system"),
1473
- _simulate_mcp_call("search", "User"),
1474
- _simulate_mcp_call("create_checkpoint", "Testing MCP", ["test.py"], "Next", "None"),
1475
- _simulate_mcp_call("restore_checkpoint", "latest"),
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[str, typer.Option(help="What the agent is currently doing.")] = "",
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 if it's running
100
- if self._ui_server.started:
101
- try:
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
- if not wiki_worker_task.done():
116
- wiki_worker_task.cancel()
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 wiki_worker_task
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.set()
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
- await asyncio.to_thread(self.runtime.wiki.ensure_wiki_page, file_path)
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
- self.logger.info("wiki_worker_completed_task", path=file_path)
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
- await asyncio.to_thread(
331
- self.runtime.store.update_wiki_queue_status,
332
- task_id,
333
- "pending",
334
- attempts + 1,
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: