loki-mode 6.34.0 → 6.35.1
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 +2 -2
- package/VERSION +1 -1
- package/dashboard/__init__.py +1 -1
- package/docs/INSTALLATION.md +1 -1
- package/mcp/__init__.py +1 -1
- package/package.json +1 -1
- package/web-app/dist/assets/index-DW1e50zX.css +1 -0
- package/web-app/dist/assets/index-HYTmiwkW.js +66 -0
- package/web-app/dist/index.html +2 -2
- package/web-app/server.py +342 -6
- package/web-app/dist/assets/index-0BmKP0xj.js +0 -66
- package/web-app/dist/assets/index-JhqgscwR.css +0 -1
package/web-app/dist/index.html
CHANGED
|
@@ -8,8 +8,8 @@
|
|
|
8
8
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
|
9
9
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
|
10
10
|
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&family=JetBrains+Mono:wght@400;500;600&display=swap" rel="stylesheet">
|
|
11
|
-
<script type="module" crossorigin src="/assets/index-
|
|
12
|
-
<link rel="stylesheet" crossorigin href="/assets/index-
|
|
11
|
+
<script type="module" crossorigin src="/assets/index-HYTmiwkW.js"></script>
|
|
12
|
+
<link rel="stylesheet" crossorigin href="/assets/index-DW1e50zX.css">
|
|
13
13
|
</head>
|
|
14
14
|
<body class="bg-background text-charcoal font-sans antialiased">
|
|
15
15
|
<div id="root"></div>
|
package/web-app/server.py
CHANGED
|
@@ -91,12 +91,30 @@ class StartRequest(BaseModel):
|
|
|
91
91
|
prd: str
|
|
92
92
|
provider: str = "claude"
|
|
93
93
|
projectDir: Optional[str] = None
|
|
94
|
+
mode: Optional[str] = None # "quick" for quick mode
|
|
94
95
|
|
|
95
96
|
|
|
96
97
|
class StopResponse(BaseModel):
|
|
97
98
|
stopped: bool
|
|
98
99
|
message: str
|
|
99
100
|
|
|
101
|
+
|
|
102
|
+
class PlanRequest(BaseModel):
|
|
103
|
+
prd: str
|
|
104
|
+
provider: str = "claude"
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
class ReportRequest(BaseModel):
|
|
108
|
+
format: str = "markdown" # "html" | "markdown"
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
class ProviderSetRequest(BaseModel):
|
|
112
|
+
provider: str
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
class OnboardRequest(BaseModel):
|
|
116
|
+
path: str
|
|
117
|
+
|
|
100
118
|
# ---------------------------------------------------------------------------
|
|
101
119
|
# Helpers
|
|
102
120
|
# ---------------------------------------------------------------------------
|
|
@@ -203,6 +221,8 @@ def _build_file_tree(root: Path, max_depth: int = 4, _depth: int = 0) -> list[di
|
|
|
203
221
|
@app.post("/api/session/start")
|
|
204
222
|
async def start_session(req: StartRequest) -> JSONResponse:
|
|
205
223
|
"""Start a new loki session with the given PRD."""
|
|
224
|
+
if len(req.prd.encode()) > _MAX_PRD_BYTES:
|
|
225
|
+
return JSONResponse(status_code=400, content={"error": "PRD exceeds 1 MB limit"})
|
|
206
226
|
if session.running:
|
|
207
227
|
return JSONResponse(
|
|
208
228
|
status_code=409,
|
|
@@ -221,12 +241,21 @@ async def start_session(req: StartRequest) -> JSONResponse:
|
|
|
221
241
|
f.write(req.prd)
|
|
222
242
|
|
|
223
243
|
# Build the loki start command
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
244
|
+
if req.mode == "quick":
|
|
245
|
+
# Extract first non-blank line as the task description
|
|
246
|
+
first_line = next((l.strip() for l in req.prd.splitlines() if l.strip()), req.prd[:200])
|
|
247
|
+
cmd = [
|
|
248
|
+
str(LOKI_CLI),
|
|
249
|
+
"quick",
|
|
250
|
+
first_line,
|
|
251
|
+
]
|
|
252
|
+
else:
|
|
253
|
+
cmd = [
|
|
254
|
+
str(LOKI_CLI),
|
|
255
|
+
"start",
|
|
256
|
+
"--provider", req.provider,
|
|
257
|
+
prd_path,
|
|
258
|
+
]
|
|
230
259
|
|
|
231
260
|
try:
|
|
232
261
|
proc = subprocess.Popen(
|
|
@@ -534,6 +563,313 @@ async def get_template_content(filename: str) -> JSONResponse:
|
|
|
534
563
|
return JSONResponse(content={"name": filename, "content": content})
|
|
535
564
|
|
|
536
565
|
|
|
566
|
+
# ---------------------------------------------------------------------------
|
|
567
|
+
# New GTM endpoints: plan, report, share, provider, metrics, history, onboard
|
|
568
|
+
# ---------------------------------------------------------------------------
|
|
569
|
+
|
|
570
|
+
def _find_loki_cli() -> Optional[str]:
|
|
571
|
+
"""Locate the loki CLI binary reliably."""
|
|
572
|
+
import shutil
|
|
573
|
+
# 1. Known project-local path
|
|
574
|
+
if LOKI_CLI.exists():
|
|
575
|
+
return str(LOKI_CLI)
|
|
576
|
+
# 2. shutil.which on PATH
|
|
577
|
+
found = shutil.which("loki")
|
|
578
|
+
if found:
|
|
579
|
+
return found
|
|
580
|
+
return None
|
|
581
|
+
|
|
582
|
+
|
|
583
|
+
def _run_loki_cmd(args: list, cwd: Optional[str] = None, timeout: int = 60) -> tuple[int, str]:
|
|
584
|
+
"""Run a loki CLI command and return (returncode, combined output).
|
|
585
|
+
|
|
586
|
+
Uses list form -- never shell=True with user input.
|
|
587
|
+
"""
|
|
588
|
+
loki = _find_loki_cli()
|
|
589
|
+
if loki is None:
|
|
590
|
+
return (1, "loki CLI not found")
|
|
591
|
+
full_cmd = [loki] + args
|
|
592
|
+
try:
|
|
593
|
+
result = subprocess.run(
|
|
594
|
+
full_cmd,
|
|
595
|
+
stdout=subprocess.PIPE,
|
|
596
|
+
stderr=subprocess.STDOUT,
|
|
597
|
+
stdin=subprocess.DEVNULL,
|
|
598
|
+
text=True,
|
|
599
|
+
cwd=cwd or session.project_dir or str(Path.home()),
|
|
600
|
+
timeout=timeout,
|
|
601
|
+
env={**os.environ},
|
|
602
|
+
)
|
|
603
|
+
return (result.returncode, result.stdout or "")
|
|
604
|
+
except subprocess.TimeoutExpired:
|
|
605
|
+
return (1, "Command timed out")
|
|
606
|
+
except Exception as e:
|
|
607
|
+
return (1, str(e))
|
|
608
|
+
|
|
609
|
+
|
|
610
|
+
_MAX_PRD_BYTES = 1_048_576 # 1 MB
|
|
611
|
+
|
|
612
|
+
|
|
613
|
+
@app.post("/api/session/plan")
|
|
614
|
+
async def plan_session(req: PlanRequest) -> JSONResponse:
|
|
615
|
+
"""Run loki plan dry-run analysis and return structured result."""
|
|
616
|
+
if len(req.prd.encode()) > _MAX_PRD_BYTES:
|
|
617
|
+
return JSONResponse(status_code=400, content={"error": "PRD exceeds 1 MB limit"})
|
|
618
|
+
import tempfile
|
|
619
|
+
with tempfile.NamedTemporaryFile(mode="w", suffix=".md", delete=False) as f:
|
|
620
|
+
f.write(req.prd)
|
|
621
|
+
prd_tmp = f.name
|
|
622
|
+
try:
|
|
623
|
+
rc, output = await asyncio.get_event_loop().run_in_executor(
|
|
624
|
+
None, lambda: _run_loki_cmd(["plan", prd_tmp], timeout=90)
|
|
625
|
+
)
|
|
626
|
+
finally:
|
|
627
|
+
try:
|
|
628
|
+
os.unlink(prd_tmp)
|
|
629
|
+
except OSError:
|
|
630
|
+
pass
|
|
631
|
+
|
|
632
|
+
# Parse output for structured fields
|
|
633
|
+
complexity = "standard"
|
|
634
|
+
cost_estimate = "unknown"
|
|
635
|
+
iterations = 5
|
|
636
|
+
phases: list[str] = []
|
|
637
|
+
|
|
638
|
+
for line in output.splitlines():
|
|
639
|
+
lower = line.lower()
|
|
640
|
+
if "complexity" in lower:
|
|
641
|
+
for val in ("simple", "standard", "complex", "expert"):
|
|
642
|
+
if val in lower:
|
|
643
|
+
complexity = val
|
|
644
|
+
break
|
|
645
|
+
if "cost" in lower and ("$" in line or "usd" in lower):
|
|
646
|
+
import re
|
|
647
|
+
m = re.search(r"\$[\d.,]+", line)
|
|
648
|
+
if m:
|
|
649
|
+
cost_estimate = m.group(0)
|
|
650
|
+
if "iteration" in lower:
|
|
651
|
+
import re
|
|
652
|
+
m = re.search(r"(\d+)", line)
|
|
653
|
+
if m:
|
|
654
|
+
iterations = int(m.group(1))
|
|
655
|
+
# Detect phase lines
|
|
656
|
+
for phase in ("planning", "implementation", "testing", "review", "deployment"):
|
|
657
|
+
if phase in lower and phase not in phases:
|
|
658
|
+
phases.append(phase)
|
|
659
|
+
|
|
660
|
+
return JSONResponse(content={
|
|
661
|
+
"complexity": complexity,
|
|
662
|
+
"cost_estimate": cost_estimate,
|
|
663
|
+
"iterations": iterations,
|
|
664
|
+
"phases": phases if phases else ["planning", "implementation", "testing"],
|
|
665
|
+
"output_text": output,
|
|
666
|
+
"returncode": rc,
|
|
667
|
+
})
|
|
668
|
+
|
|
669
|
+
|
|
670
|
+
@app.post("/api/session/report")
|
|
671
|
+
async def generate_report(req: ReportRequest) -> JSONResponse:
|
|
672
|
+
"""Run loki report and return content."""
|
|
673
|
+
fmt = req.format if req.format in ("html", "markdown") else "markdown"
|
|
674
|
+
rc, output = await asyncio.get_event_loop().run_in_executor(
|
|
675
|
+
None, lambda: _run_loki_cmd(["report", "--format", fmt], timeout=60)
|
|
676
|
+
)
|
|
677
|
+
return JSONResponse(content={
|
|
678
|
+
"content": output,
|
|
679
|
+
"format": fmt,
|
|
680
|
+
"returncode": rc,
|
|
681
|
+
})
|
|
682
|
+
|
|
683
|
+
|
|
684
|
+
@app.post("/api/session/share")
|
|
685
|
+
async def share_session() -> JSONResponse:
|
|
686
|
+
"""Run loki share and return Gist URL."""
|
|
687
|
+
rc, output = await asyncio.get_event_loop().run_in_executor(
|
|
688
|
+
None, lambda: _run_loki_cmd(["share"], timeout=60)
|
|
689
|
+
)
|
|
690
|
+
# Try to extract URL from output
|
|
691
|
+
import re
|
|
692
|
+
url_match = re.search(r"https://gist\.github\.com/\S+", output)
|
|
693
|
+
url = url_match.group(0) if url_match else ""
|
|
694
|
+
return JSONResponse(content={
|
|
695
|
+
"url": url,
|
|
696
|
+
"output": output,
|
|
697
|
+
"returncode": rc,
|
|
698
|
+
})
|
|
699
|
+
|
|
700
|
+
|
|
701
|
+
@app.get("/api/provider/current")
|
|
702
|
+
async def get_provider() -> JSONResponse:
|
|
703
|
+
"""Return current provider and model from session state or config."""
|
|
704
|
+
provider = session.provider or os.environ.get("LOKI_PROVIDER", "claude")
|
|
705
|
+
# Try to read from config
|
|
706
|
+
config_file = Path.home() / ".loki" / "config.json"
|
|
707
|
+
model = ""
|
|
708
|
+
if config_file.exists():
|
|
709
|
+
try:
|
|
710
|
+
with open(config_file) as f:
|
|
711
|
+
cfg = json.load(f)
|
|
712
|
+
provider = cfg.get("provider", provider)
|
|
713
|
+
model = cfg.get("model", model)
|
|
714
|
+
except (json.JSONDecodeError, OSError):
|
|
715
|
+
pass
|
|
716
|
+
return JSONResponse(content={"provider": provider, "model": model})
|
|
717
|
+
|
|
718
|
+
|
|
719
|
+
@app.post("/api/provider/set")
|
|
720
|
+
async def set_provider(req: ProviderSetRequest) -> JSONResponse:
|
|
721
|
+
"""Set the default provider for future sessions."""
|
|
722
|
+
allowed = {"claude", "codex", "gemini"}
|
|
723
|
+
if req.provider not in allowed:
|
|
724
|
+
return JSONResponse(
|
|
725
|
+
status_code=400,
|
|
726
|
+
content={"error": f"Invalid provider. Must be one of: {', '.join(sorted(allowed))}"},
|
|
727
|
+
)
|
|
728
|
+
# Persist to config
|
|
729
|
+
config_dir = Path.home() / ".loki"
|
|
730
|
+
config_dir.mkdir(parents=True, exist_ok=True)
|
|
731
|
+
config_file = config_dir / "config.json"
|
|
732
|
+
cfg: dict = {}
|
|
733
|
+
if config_file.exists():
|
|
734
|
+
try:
|
|
735
|
+
with open(config_file) as f:
|
|
736
|
+
cfg = json.load(f)
|
|
737
|
+
except (json.JSONDecodeError, OSError):
|
|
738
|
+
cfg = {}
|
|
739
|
+
cfg["provider"] = req.provider
|
|
740
|
+
with open(config_file, "w") as f:
|
|
741
|
+
json.dump(cfg, f, indent=2)
|
|
742
|
+
# Update session state if not running
|
|
743
|
+
if not session.running:
|
|
744
|
+
session.provider = req.provider
|
|
745
|
+
return JSONResponse(content={"provider": req.provider, "set": True})
|
|
746
|
+
|
|
747
|
+
|
|
748
|
+
@app.get("/api/session/metrics")
|
|
749
|
+
async def get_metrics() -> JSONResponse:
|
|
750
|
+
"""Run loki metrics --json and return parsed output."""
|
|
751
|
+
rc, output = await asyncio.get_event_loop().run_in_executor(
|
|
752
|
+
None, lambda: _run_loki_cmd(["metrics", "--json"], timeout=30)
|
|
753
|
+
)
|
|
754
|
+
# Try JSON parse
|
|
755
|
+
try:
|
|
756
|
+
data = json.loads(output)
|
|
757
|
+
return JSONResponse(content=data)
|
|
758
|
+
except (json.JSONDecodeError, ValueError):
|
|
759
|
+
pass
|
|
760
|
+
# Fallback: parse key metrics from text output
|
|
761
|
+
import re
|
|
762
|
+
metrics: dict = {
|
|
763
|
+
"iterations": 0,
|
|
764
|
+
"quality_gate_pass_rate": 0.0,
|
|
765
|
+
"time_elapsed": "",
|
|
766
|
+
"tokens_used": 0,
|
|
767
|
+
"output_text": output,
|
|
768
|
+
}
|
|
769
|
+
for line in output.splitlines():
|
|
770
|
+
if "iteration" in line.lower():
|
|
771
|
+
m = re.search(r"(\d+)", line)
|
|
772
|
+
if m:
|
|
773
|
+
metrics["iterations"] = int(m.group(1))
|
|
774
|
+
if "pass rate" in line.lower() or "pass_rate" in line.lower():
|
|
775
|
+
m = re.search(r"([\d.]+)%?", line)
|
|
776
|
+
if m:
|
|
777
|
+
metrics["quality_gate_pass_rate"] = float(m.group(1))
|
|
778
|
+
if "token" in line.lower():
|
|
779
|
+
m = re.search(r"(\d+)", line)
|
|
780
|
+
if m:
|
|
781
|
+
metrics["tokens_used"] = int(m.group(1))
|
|
782
|
+
return JSONResponse(content=metrics)
|
|
783
|
+
|
|
784
|
+
|
|
785
|
+
@app.get("/api/sessions/history")
|
|
786
|
+
async def get_sessions_history() -> JSONResponse:
|
|
787
|
+
"""Return list of past loki sessions from ~/.loki-sessions/ or ~/.loki/sessions/."""
|
|
788
|
+
history: list[dict] = []
|
|
789
|
+
search_dirs = [
|
|
790
|
+
Path.home() / ".loki-sessions",
|
|
791
|
+
Path.home() / ".loki" / "sessions",
|
|
792
|
+
Path.home() / "purple-lab-projects",
|
|
793
|
+
]
|
|
794
|
+
for base_dir in search_dirs:
|
|
795
|
+
if not base_dir.is_dir():
|
|
796
|
+
continue
|
|
797
|
+
for entry in sorted(base_dir.iterdir(), reverse=True)[:20]:
|
|
798
|
+
if not entry.is_dir():
|
|
799
|
+
continue
|
|
800
|
+
session_info: dict = {
|
|
801
|
+
"id": entry.name,
|
|
802
|
+
"path": str(entry),
|
|
803
|
+
"date": "",
|
|
804
|
+
"prd_snippet": "",
|
|
805
|
+
"status": "unknown",
|
|
806
|
+
}
|
|
807
|
+
# Read timestamp from directory mtime
|
|
808
|
+
try:
|
|
809
|
+
mtime = entry.stat().st_mtime
|
|
810
|
+
session_info["date"] = time.strftime("%Y-%m-%d %H:%M", time.localtime(mtime))
|
|
811
|
+
except OSError:
|
|
812
|
+
pass
|
|
813
|
+
# Try to read PRD
|
|
814
|
+
for prd_name in ("PRD.md", "prd.md", ".loki/prd.md"):
|
|
815
|
+
prd_file = entry / prd_name
|
|
816
|
+
if prd_file.exists():
|
|
817
|
+
try:
|
|
818
|
+
text = prd_file.read_text(errors="replace")
|
|
819
|
+
lines = [l.strip() for l in text.splitlines() if l.strip()]
|
|
820
|
+
session_info["prd_snippet"] = lines[0][:120] if lines else ""
|
|
821
|
+
except OSError:
|
|
822
|
+
pass
|
|
823
|
+
break
|
|
824
|
+
# Try to read status
|
|
825
|
+
state_file = entry / ".loki" / "state" / "session.json"
|
|
826
|
+
if state_file.exists():
|
|
827
|
+
try:
|
|
828
|
+
with open(state_file) as f:
|
|
829
|
+
st = json.load(f)
|
|
830
|
+
session_info["status"] = st.get("phase", "unknown")
|
|
831
|
+
except (json.JSONDecodeError, OSError):
|
|
832
|
+
pass
|
|
833
|
+
history.append(session_info)
|
|
834
|
+
if history:
|
|
835
|
+
break # Use first directory that has entries
|
|
836
|
+
return JSONResponse(content=history)
|
|
837
|
+
|
|
838
|
+
|
|
839
|
+
@app.post("/api/session/onboard")
|
|
840
|
+
async def onboard_session(req: OnboardRequest) -> JSONResponse:
|
|
841
|
+
"""Run loki onboard on a path and return CLAUDE.md content."""
|
|
842
|
+
# Path traversal protection: must be absolute, exist, and within home directory
|
|
843
|
+
try:
|
|
844
|
+
target = Path(req.path).resolve()
|
|
845
|
+
except (ValueError, OSError):
|
|
846
|
+
return JSONResponse(status_code=400, content={"error": "Invalid path"})
|
|
847
|
+
home = Path.home().resolve()
|
|
848
|
+
if not str(target).startswith(str(home)):
|
|
849
|
+
return JSONResponse(status_code=400, content={"error": "Path must be within your home directory"})
|
|
850
|
+
if not target.exists():
|
|
851
|
+
return JSONResponse(status_code=400, content={"error": "Path does not exist"})
|
|
852
|
+
if not target.is_dir():
|
|
853
|
+
return JSONResponse(status_code=400, content={"error": "Path must be a directory"})
|
|
854
|
+
|
|
855
|
+
rc, output = await asyncio.get_event_loop().run_in_executor(
|
|
856
|
+
None, lambda: _run_loki_cmd(["onboard", str(target)], cwd=str(target), timeout=120)
|
|
857
|
+
)
|
|
858
|
+
# Try to read generated CLAUDE.md
|
|
859
|
+
claude_md = target / "CLAUDE.md"
|
|
860
|
+
claude_content = ""
|
|
861
|
+
if claude_md.exists():
|
|
862
|
+
try:
|
|
863
|
+
claude_content = claude_md.read_text(errors="replace")
|
|
864
|
+
except OSError:
|
|
865
|
+
pass
|
|
866
|
+
return JSONResponse(content={
|
|
867
|
+
"output": output,
|
|
868
|
+
"claude_md": claude_content,
|
|
869
|
+
"returncode": rc,
|
|
870
|
+
})
|
|
871
|
+
|
|
872
|
+
|
|
537
873
|
# ---------------------------------------------------------------------------
|
|
538
874
|
# WebSocket
|
|
539
875
|
# ---------------------------------------------------------------------------
|