loki-mode 7.12.0 → 7.14.0

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.
@@ -5396,6 +5396,100 @@ async def create_checkpoint(body: CheckpointCreate = None):
5396
5396
  return metadata
5397
5397
 
5398
5398
 
5399
+ @app.post(
5400
+ "/api/checkpoints/{checkpoint_id}/rollback",
5401
+ dependencies=[Depends(auth.require_scope("control"))],
5402
+ )
5403
+ async def rollback_checkpoint(checkpoint_id: str):
5404
+ """Restore .loki/ state from a checkpoint (R6: un-deads the dashboard
5405
+ rollback button, which already POSTed here).
5406
+
5407
+ Safety:
5408
+ - require_scope("control"): destructive, so it needs the control scope.
5409
+ - _sanitize_checkpoint_id: blocks path traversal.
5410
+ - Re-undoability invariant: a forced pre-rollback snapshot of current state
5411
+ is captured BEFORE overwriting, so the human can undo the undo. The
5412
+ pre_rollback_snapshot id is returned in the response so the caller can
5413
+ surface it to the user.
5414
+ - Glob-restore: copies back whatever files the checkpoint dir contains, so it
5415
+ works regardless of which writer (run.sh / loki / dashboard) created it.
5416
+ """
5417
+ import shutil
5418
+
5419
+ checkpoint_id = _sanitize_checkpoint_id(checkpoint_id)
5420
+ loki_dir = _get_loki_dir()
5421
+ checkpoints_dir = loki_dir / "state" / "checkpoints"
5422
+ cp_dir = checkpoints_dir / checkpoint_id
5423
+
5424
+ if not cp_dir.is_dir():
5425
+ raise HTTPException(status_code=404, detail="Checkpoint not found")
5426
+
5427
+ # 1. Forced pre-rollback snapshot of current state (re-undoability).
5428
+ now = datetime.now(timezone.utc)
5429
+ pre_id = now.strftime("rb-pre-%Y%m%d-%H%M%S")
5430
+ pre_dir = checkpoints_dir / pre_id
5431
+ pre_dir.mkdir(parents=True, exist_ok=True)
5432
+ for name in ("session.json", "dashboard-state.json", "CONTINUITY.md", "autonomy-state.json"):
5433
+ src = loki_dir / name
5434
+ if src.exists() and src.is_file():
5435
+ try:
5436
+ shutil.copy2(str(src), str(pre_dir / name))
5437
+ except Exception:
5438
+ pass
5439
+ for dname in ("state", "queue"):
5440
+ src = loki_dir / dname
5441
+ if src.exists() and src.is_dir():
5442
+ try:
5443
+ shutil.copytree(str(src), str(pre_dir / dname), dirs_exist_ok=True)
5444
+ except Exception:
5445
+ pass
5446
+ pre_meta = {
5447
+ "id": pre_id,
5448
+ "created_at": now.isoformat(),
5449
+ "message": f"pre-rollback snapshot (before restoring {checkpoint_id})",
5450
+ "created_by": "dashboard rollback",
5451
+ }
5452
+ try:
5453
+ (pre_dir / "metadata.json").write_text(json.dumps(pre_meta, indent=2))
5454
+ with open(str(checkpoints_dir / "index.jsonl"), "a") as f:
5455
+ f.write(json.dumps(pre_meta) + "\n")
5456
+ except Exception:
5457
+ pass
5458
+
5459
+ # 2. Glob-restore the checkpoint contents back into .loki/.
5460
+ # IMPORTANT: never rmtree a destination dir wholesale -- the checkpoint store
5461
+ # itself lives under .loki/state/checkpoints/, so deleting .loki/state/ would
5462
+ # destroy every checkpoint (including the one being restored AND the
5463
+ # pre-rollback snapshot we just made). Merge directories with dirs_exist_ok
5464
+ # so the checkpoints store survives.
5465
+ restored = 0
5466
+ errors = []
5467
+ for item in cp_dir.iterdir():
5468
+ if item.name in ("metadata.json", "worktree-snapshot.txt"):
5469
+ continue
5470
+ dest = loki_dir / item.name
5471
+ try:
5472
+ if item.is_dir():
5473
+ shutil.copytree(str(item), str(dest), dirs_exist_ok=True)
5474
+ else:
5475
+ dest.parent.mkdir(parents=True, exist_ok=True)
5476
+ shutil.copy2(str(item), str(dest))
5477
+ restored += 1
5478
+ except Exception as e: # noqa: BLE001 -- report, do not abort other files
5479
+ errors.append(f"{item.name}: {e}")
5480
+
5481
+ return {
5482
+ "id": checkpoint_id,
5483
+ "restored": restored,
5484
+ "pre_rollback_snapshot": pre_id,
5485
+ "errors": errors,
5486
+ "message": (
5487
+ f"Restored {restored} item(s) from {checkpoint_id}. "
5488
+ f"Prior state saved as {pre_id} (undo this rollback by restoring it)."
5489
+ ),
5490
+ }
5491
+
5492
+
5399
5493
  # =============================================================================
5400
5494
  # Agent Management API (v5.25.0)
5401
5495
  # =============================================================================
@@ -7507,6 +7601,114 @@ async def get_proof_html(run_id: str):
7507
7601
  return FileResponse(str(index_html), media_type="text/html")
7508
7602
 
7509
7603
 
7604
+ # ---------------------------------------------------------------------------
7605
+ # R5: Auto-wiki + cited codebase Q&A (Loki's DeepWiki).
7606
+ #
7607
+ # Surfaces the per-project wiki generated by autonomy/lib/wiki-generator.py
7608
+ # (stored under <project>/.loki/wiki/) and the grounded `ask` flow
7609
+ # (autonomy/lib/wiki-ask.py). Citations are file:line and always point at real
7610
+ # code -- the generator/ask scripts validate every citation against the
7611
+ # filesystem before emitting it, so the dashboard never shows a fabricated one.
7612
+ #
7613
+ # The section param is traversal-safe, mirroring _safe_proof_run_dir: only the
7614
+ # known section ids are accepted, so no arbitrary path can be read.
7615
+ # ---------------------------------------------------------------------------
7616
+ _WIKI_SECTIONS = {"architecture", "modules", "data-flow"}
7617
+
7618
+
7619
+ def _wiki_dir() -> _Path:
7620
+ return _get_loki_dir() / "wiki"
7621
+
7622
+
7623
+ def _project_root() -> _Path:
7624
+ """Resolve the active project root (.loki's parent)."""
7625
+ return _get_loki_dir().parent
7626
+
7627
+
7628
+ @app.get("/api/wiki", dependencies=[Depends(auth.require_scope("read"))])
7629
+ async def get_wiki():
7630
+ """Return the wiki manifest + section list for the active project."""
7631
+ wiki_dir = _wiki_dir()
7632
+ wiki_json = wiki_dir / "wiki.json"
7633
+ if not wiki_json.is_file():
7634
+ return {"generated": False, "sections": [],
7635
+ "message": "No wiki generated. Run 'loki wiki generate'."}
7636
+ data = _safe_json_read(wiki_json, default=None)
7637
+ if not isinstance(data, dict):
7638
+ raise HTTPException(status_code=500, detail="wiki.json unreadable")
7639
+ manifest = _safe_json_read(wiki_dir / "wiki-manifest.json", default={}) or {}
7640
+ sections = [
7641
+ {"id": s.get("id"), "title": s.get("title"),
7642
+ "citation_count": len(s.get("citations") or [])}
7643
+ for s in data.get("sections", [])
7644
+ if isinstance(s, dict)
7645
+ ]
7646
+ return {
7647
+ "generated": True,
7648
+ "project": data.get("project"),
7649
+ "generated_at": data.get("generated_at"),
7650
+ "file_count": data.get("file_count"),
7651
+ "signature": manifest.get("signature"),
7652
+ "sections": sections,
7653
+ }
7654
+
7655
+
7656
+ @app.get("/api/wiki/{section}", dependencies=[Depends(auth.require_scope("read"))])
7657
+ async def get_wiki_section(section: str):
7658
+ """Return one wiki section (body + validated file:line citations)."""
7659
+ if section not in _WIKI_SECTIONS:
7660
+ raise HTTPException(status_code=400, detail=f"unknown section: {section}")
7661
+ wiki_json = _wiki_dir() / "wiki.json"
7662
+ if not wiki_json.is_file():
7663
+ raise HTTPException(status_code=404, detail="wiki not generated")
7664
+ data = _safe_json_read(wiki_json, default=None)
7665
+ if not isinstance(data, dict):
7666
+ raise HTTPException(status_code=500, detail="wiki.json unreadable")
7667
+ for s in data.get("sections", []):
7668
+ if isinstance(s, dict) and s.get("id") == section:
7669
+ return JSONResponse(content=s)
7670
+ raise HTTPException(status_code=404, detail=f"section not found: {section}")
7671
+
7672
+
7673
+ class WikiAskRequest(BaseModel):
7674
+ question: str = Field(..., min_length=1, max_length=2000)
7675
+ k: int = Field(default=6, ge=1, le=20)
7676
+
7677
+
7678
+ @app.post("/api/wiki/ask", dependencies=[Depends(auth.require_scope("read"))])
7679
+ async def post_wiki_ask(req: WikiAskRequest):
7680
+ """Grounded, cited codebase Q&A.
7681
+
7682
+ Shells out to autonomy/lib/wiki-ask.py (the single source of truth for the
7683
+ grounding + citation-validation contract) and returns its JSON. Every
7684
+ citation in the response resolves to a real file:line.
7685
+ """
7686
+ project_root = _project_root()
7687
+ repo_root = _Path(__file__).resolve().parent.parent
7688
+ ask_script = repo_root / "autonomy" / "lib" / "wiki-ask.py"
7689
+ if not ask_script.is_file():
7690
+ raise HTTPException(status_code=503, detail="wiki-ask backend missing")
7691
+ try:
7692
+ proc = subprocess.run(
7693
+ ["python3", str(ask_script), "--root", str(project_root),
7694
+ "--question", req.question, "--k", str(req.k), "--json"],
7695
+ capture_output=True, text=True, timeout=180,
7696
+ cwd=str(project_root),
7697
+ )
7698
+ except (OSError, subprocess.SubprocessError) as e:
7699
+ raise HTTPException(status_code=503, detail=f"wiki ask failed: {e}")
7700
+ if proc.returncode == 3:
7701
+ return {"question": req.question, "answer": "",
7702
+ "citations": [], "note": "no relevant code found"}
7703
+ if proc.returncode != 0:
7704
+ raise HTTPException(status_code=500,
7705
+ detail=(proc.stderr or "wiki ask error").strip())
7706
+ try:
7707
+ return JSONResponse(content=json.loads(proc.stdout))
7708
+ except json.JSONDecodeError:
7709
+ raise HTTPException(status_code=500, detail="wiki ask returned bad JSON")
7710
+
7711
+
7510
7712
  # ---------------------------------------------------------------------------
7511
7713
  # SPA catch-all: serve index.html for any path not matched by API routes
7512
7714
  # or static asset mounts. This lets the dashboard UI handle client-side routing.