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.
- package/SKILL.md +4 -2
- package/VERSION +1 -1
- package/autonomy/lib/wiki-ask.py +137 -0
- package/autonomy/lib/wiki-generator.py +322 -0
- package/autonomy/lib/wiki_index.py +258 -0
- package/autonomy/lib/wiki_llm.py +140 -0
- package/autonomy/loki +304 -11
- package/autonomy/run.sh +62 -12
- package/bin/loki +1 -1
- package/dashboard/__init__.py +1 -1
- package/dashboard/server.py +202 -0
- package/dashboard/static/index.html +405 -329
- package/docs/INSTALLATION.md +1 -1
- package/docs/R5-AUTO-WIKI-DESIGN.md +137 -0
- package/docs/R6-ROLLBACK-CHECKPOINT-PLAN.md +107 -0
- package/loki-ts/dist/loki.js +245 -206
- package/mcp/__init__.py +1 -1
- package/package.json +1 -1
package/dashboard/server.py
CHANGED
|
@@ -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.
|