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.
@@ -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-0BmKP0xj.js"></script>
12
- <link rel="stylesheet" crossorigin href="/assets/index-JhqgscwR.css">
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
- cmd = [
225
- str(LOKI_CLI),
226
- "start",
227
- "--provider", req.provider,
228
- prd_path,
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
  # ---------------------------------------------------------------------------