loki-mode 6.27.0 → 6.27.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/README.md +2 -2
- package/VERSION +1 -1
- package/dashboard/__init__.py +1 -1
- package/docs/INSTALLATION.md +1 -1
- package/package.json +3 -2
- package/web-app/server.py +581 -0
package/README.md
CHANGED
|
@@ -10,11 +10,11 @@
|
|
|
10
10
|
[](https://www.autonomi.dev/)
|
|
11
11
|
[](https://hub.docker.com/r/asklokesh/loki-mode)
|
|
12
12
|
|
|
13
|
-
**Current Version: v6.
|
|
13
|
+
**Current Version: v6.27.0**
|
|
14
14
|
|
|
15
15
|
### Traction
|
|
16
16
|
|
|
17
|
-
**737 stars** | **150 forks** | **10,
|
|
17
|
+
**737 stars** | **150 forks** | **10,500+ Docker pulls** | **19,000+ npm downloads** | **588 commits** | **173 npm versions published** | **17 releases in a single day (March 18, 2026)**
|
|
18
18
|
|
|
19
19
|
---
|
|
20
20
|
|
package/VERSION
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
6.27.
|
|
1
|
+
6.27.1
|
package/dashboard/__init__.py
CHANGED
package/docs/INSTALLATION.md
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "loki-mode",
|
|
3
|
-
"version": "6.27.
|
|
3
|
+
"version": "6.27.1",
|
|
4
4
|
"description": "Loki Mode by Autonomi - Multi-agent autonomous startup system for Claude Code, Codex CLI, and Gemini CLI",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"agent",
|
|
@@ -82,7 +82,8 @@
|
|
|
82
82
|
"mcp/",
|
|
83
83
|
"completions/",
|
|
84
84
|
"src/",
|
|
85
|
-
"web-app/dist/"
|
|
85
|
+
"web-app/dist/",
|
|
86
|
+
"web-app/server.py"
|
|
86
87
|
],
|
|
87
88
|
"scripts": {
|
|
88
89
|
"postinstall": "node bin/postinstall.js",
|
|
@@ -0,0 +1,581 @@
|
|
|
1
|
+
"""Purple Lab - Standalone product backend for Loki Mode.
|
|
2
|
+
|
|
3
|
+
A Replit-like web UI where users input PRDs and watch agents work.
|
|
4
|
+
Separate from the dashboard (which monitors existing sessions).
|
|
5
|
+
Purple Lab IS the product -- it starts and manages loki sessions.
|
|
6
|
+
|
|
7
|
+
Runs on port 57375 (dashboard uses 57374).
|
|
8
|
+
"""
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import asyncio
|
|
12
|
+
import inspect
|
|
13
|
+
import json
|
|
14
|
+
import os
|
|
15
|
+
import signal
|
|
16
|
+
import subprocess
|
|
17
|
+
import sys
|
|
18
|
+
import time
|
|
19
|
+
from pathlib import Path
|
|
20
|
+
from typing import Optional
|
|
21
|
+
|
|
22
|
+
from fastapi import FastAPI, WebSocket, WebSocketDisconnect
|
|
23
|
+
from fastapi.middleware.cors import CORSMiddleware
|
|
24
|
+
from fastapi.responses import FileResponse, JSONResponse
|
|
25
|
+
from fastapi.staticfiles import StaticFiles
|
|
26
|
+
from pydantic import BaseModel
|
|
27
|
+
|
|
28
|
+
# ---------------------------------------------------------------------------
|
|
29
|
+
# Configuration
|
|
30
|
+
# ---------------------------------------------------------------------------
|
|
31
|
+
|
|
32
|
+
HOST = os.environ.get("PURPLE_LAB_HOST", "127.0.0.1")
|
|
33
|
+
PORT = int(os.environ.get("PURPLE_LAB_PORT", "57375"))
|
|
34
|
+
|
|
35
|
+
# Resolve paths
|
|
36
|
+
SCRIPT_DIR = Path(__file__).resolve().parent
|
|
37
|
+
PROJECT_ROOT = SCRIPT_DIR.parent
|
|
38
|
+
LOKI_CLI = PROJECT_ROOT / "autonomy" / "loki"
|
|
39
|
+
DIST_DIR = SCRIPT_DIR / "dist"
|
|
40
|
+
|
|
41
|
+
# ---------------------------------------------------------------------------
|
|
42
|
+
# App setup
|
|
43
|
+
# ---------------------------------------------------------------------------
|
|
44
|
+
|
|
45
|
+
app = FastAPI(title="Purple Lab", docs_url=None, redoc_url=None)
|
|
46
|
+
|
|
47
|
+
app.add_middleware(
|
|
48
|
+
CORSMiddleware,
|
|
49
|
+
allow_origins=["*"],
|
|
50
|
+
allow_methods=["*"],
|
|
51
|
+
allow_headers=["*"],
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
# ---------------------------------------------------------------------------
|
|
55
|
+
# Session state
|
|
56
|
+
# ---------------------------------------------------------------------------
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
class SessionState:
|
|
60
|
+
"""Tracks the active loki session."""
|
|
61
|
+
|
|
62
|
+
def __init__(self) -> None:
|
|
63
|
+
self.process: Optional[subprocess.Popen] = None
|
|
64
|
+
self.running = False
|
|
65
|
+
self.provider = ""
|
|
66
|
+
self.prd_text = ""
|
|
67
|
+
self.project_dir = ""
|
|
68
|
+
self.start_time: float = 0
|
|
69
|
+
self.log_lines: list[str] = []
|
|
70
|
+
self.ws_clients: set[WebSocket] = set()
|
|
71
|
+
self._reader_task: Optional[asyncio.Task] = None
|
|
72
|
+
|
|
73
|
+
def reset(self) -> None:
|
|
74
|
+
self.process = None
|
|
75
|
+
self.running = False
|
|
76
|
+
self.provider = ""
|
|
77
|
+
self.prd_text = ""
|
|
78
|
+
self.project_dir = ""
|
|
79
|
+
self.start_time = 0
|
|
80
|
+
self.log_lines = []
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
session = SessionState()
|
|
84
|
+
|
|
85
|
+
# ---------------------------------------------------------------------------
|
|
86
|
+
# Request / Response models
|
|
87
|
+
# ---------------------------------------------------------------------------
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
class StartRequest(BaseModel):
|
|
91
|
+
prd: str
|
|
92
|
+
provider: str = "claude"
|
|
93
|
+
projectDir: Optional[str] = None
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
class StopResponse(BaseModel):
|
|
97
|
+
stopped: bool
|
|
98
|
+
message: str
|
|
99
|
+
|
|
100
|
+
# ---------------------------------------------------------------------------
|
|
101
|
+
# Helpers
|
|
102
|
+
# ---------------------------------------------------------------------------
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def _loki_dir() -> Path:
|
|
106
|
+
"""Return the .loki/ directory for the current session project."""
|
|
107
|
+
if session.project_dir:
|
|
108
|
+
return Path(session.project_dir) / ".loki"
|
|
109
|
+
return Path.home() / ".loki"
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def _safe_resolve(base: Path, requested: str) -> Optional[Path]:
|
|
113
|
+
"""Resolve a path ensuring it stays within base (path traversal protection)."""
|
|
114
|
+
try:
|
|
115
|
+
resolved = (base / requested).resolve()
|
|
116
|
+
base_resolved = base.resolve()
|
|
117
|
+
if str(resolved).startswith(str(base_resolved)):
|
|
118
|
+
return resolved
|
|
119
|
+
except (ValueError, OSError):
|
|
120
|
+
pass
|
|
121
|
+
return None
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
async def _broadcast(msg: dict) -> None:
|
|
125
|
+
"""Send a JSON message to all connected WebSocket clients."""
|
|
126
|
+
data = json.dumps(msg)
|
|
127
|
+
dead: list[WebSocket] = []
|
|
128
|
+
for ws in session.ws_clients:
|
|
129
|
+
try:
|
|
130
|
+
await ws.send_text(data)
|
|
131
|
+
except Exception:
|
|
132
|
+
dead.append(ws)
|
|
133
|
+
for ws in dead:
|
|
134
|
+
session.ws_clients.discard(ws)
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
async def _read_process_output() -> None:
|
|
138
|
+
"""Background task: read loki stdout/stderr and broadcast lines."""
|
|
139
|
+
proc = session.process
|
|
140
|
+
if proc is None or proc.stdout is None:
|
|
141
|
+
return
|
|
142
|
+
|
|
143
|
+
loop = asyncio.get_event_loop()
|
|
144
|
+
|
|
145
|
+
try:
|
|
146
|
+
while session.running and proc.poll() is None:
|
|
147
|
+
line = await loop.run_in_executor(None, proc.stdout.readline)
|
|
148
|
+
if not line:
|
|
149
|
+
break
|
|
150
|
+
text = line.rstrip("\n")
|
|
151
|
+
session.log_lines.append(text)
|
|
152
|
+
# Keep last 5000 lines
|
|
153
|
+
if len(session.log_lines) > 5000:
|
|
154
|
+
session.log_lines = session.log_lines[-5000:]
|
|
155
|
+
await _broadcast({
|
|
156
|
+
"type": "log",
|
|
157
|
+
"data": {"line": text, "timestamp": time.strftime("%H:%M:%S")},
|
|
158
|
+
})
|
|
159
|
+
except Exception:
|
|
160
|
+
pass
|
|
161
|
+
finally:
|
|
162
|
+
# Process ended
|
|
163
|
+
session.running = False
|
|
164
|
+
await _broadcast({"type": "session_end", "data": {"message": "Session ended"}})
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def _build_file_tree(root: Path, max_depth: int = 4, _depth: int = 0) -> list[dict]:
|
|
168
|
+
"""Recursively build a file tree from a directory."""
|
|
169
|
+
if _depth >= max_depth or not root.is_dir():
|
|
170
|
+
return []
|
|
171
|
+
|
|
172
|
+
entries = []
|
|
173
|
+
try:
|
|
174
|
+
items = sorted(root.iterdir(), key=lambda p: (not p.is_dir(), p.name.lower()))
|
|
175
|
+
except PermissionError:
|
|
176
|
+
return []
|
|
177
|
+
|
|
178
|
+
for item in items:
|
|
179
|
+
# Skip hidden dirs and common noise
|
|
180
|
+
if item.name.startswith(".") and item.name not in (".loki",):
|
|
181
|
+
continue
|
|
182
|
+
if item.name in ("node_modules", "__pycache__", ".git", "venv", ".venv"):
|
|
183
|
+
continue
|
|
184
|
+
|
|
185
|
+
node: dict = {"name": item.name, "path": str(item.relative_to(root))}
|
|
186
|
+
if item.is_dir():
|
|
187
|
+
node["type"] = "directory"
|
|
188
|
+
node["children"] = _build_file_tree(item, max_depth, _depth + 1)
|
|
189
|
+
else:
|
|
190
|
+
node["type"] = "file"
|
|
191
|
+
try:
|
|
192
|
+
node["size"] = item.stat().st_size
|
|
193
|
+
except OSError:
|
|
194
|
+
node["size"] = 0
|
|
195
|
+
entries.append(node)
|
|
196
|
+
return entries
|
|
197
|
+
|
|
198
|
+
# ---------------------------------------------------------------------------
|
|
199
|
+
# API endpoints
|
|
200
|
+
# ---------------------------------------------------------------------------
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
@app.post("/api/session/start")
|
|
204
|
+
async def start_session(req: StartRequest) -> JSONResponse:
|
|
205
|
+
"""Start a new loki session with the given PRD."""
|
|
206
|
+
if session.running:
|
|
207
|
+
return JSONResponse(
|
|
208
|
+
status_code=409,
|
|
209
|
+
content={"error": "A session is already running. Stop it first."},
|
|
210
|
+
)
|
|
211
|
+
|
|
212
|
+
# Determine project directory
|
|
213
|
+
project_dir = req.projectDir
|
|
214
|
+
if not project_dir:
|
|
215
|
+
project_dir = os.path.join(Path.home(), "purple-lab-projects", f"project-{int(time.time())}")
|
|
216
|
+
os.makedirs(project_dir, exist_ok=True)
|
|
217
|
+
|
|
218
|
+
# Write PRD to a temp file in the project dir
|
|
219
|
+
prd_path = os.path.join(project_dir, "PRD.md")
|
|
220
|
+
with open(prd_path, "w") as f:
|
|
221
|
+
f.write(req.prd)
|
|
222
|
+
|
|
223
|
+
# Build the loki start command
|
|
224
|
+
cmd = [
|
|
225
|
+
str(LOKI_CLI),
|
|
226
|
+
"start",
|
|
227
|
+
"--provider", req.provider,
|
|
228
|
+
prd_path,
|
|
229
|
+
]
|
|
230
|
+
|
|
231
|
+
try:
|
|
232
|
+
proc = subprocess.Popen(
|
|
233
|
+
cmd,
|
|
234
|
+
stdout=subprocess.PIPE,
|
|
235
|
+
stderr=subprocess.STDOUT,
|
|
236
|
+
stdin=subprocess.DEVNULL,
|
|
237
|
+
text=True,
|
|
238
|
+
cwd=project_dir,
|
|
239
|
+
env={**os.environ, "LOKI_DIR": os.path.join(project_dir, ".loki")},
|
|
240
|
+
)
|
|
241
|
+
except FileNotFoundError:
|
|
242
|
+
return JSONResponse(
|
|
243
|
+
status_code=500,
|
|
244
|
+
content={"error": f"loki CLI not found at {LOKI_CLI}"},
|
|
245
|
+
)
|
|
246
|
+
except Exception as e:
|
|
247
|
+
return JSONResponse(
|
|
248
|
+
status_code=500,
|
|
249
|
+
content={"error": f"Failed to start session: {e}"},
|
|
250
|
+
)
|
|
251
|
+
|
|
252
|
+
# Update session state
|
|
253
|
+
session.reset()
|
|
254
|
+
session.process = proc
|
|
255
|
+
session.running = True
|
|
256
|
+
session.provider = req.provider
|
|
257
|
+
session.prd_text = req.prd
|
|
258
|
+
session.project_dir = project_dir
|
|
259
|
+
session.start_time = time.time()
|
|
260
|
+
|
|
261
|
+
# Start background output reader
|
|
262
|
+
session._reader_task = asyncio.create_task(_read_process_output())
|
|
263
|
+
|
|
264
|
+
await _broadcast({"type": "session_start", "data": {
|
|
265
|
+
"provider": req.provider,
|
|
266
|
+
"projectDir": project_dir,
|
|
267
|
+
"pid": proc.pid,
|
|
268
|
+
}})
|
|
269
|
+
|
|
270
|
+
return JSONResponse(content={
|
|
271
|
+
"started": True,
|
|
272
|
+
"pid": proc.pid,
|
|
273
|
+
"projectDir": project_dir,
|
|
274
|
+
"provider": req.provider,
|
|
275
|
+
})
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
@app.post("/api/session/stop")
|
|
279
|
+
async def stop_session() -> JSONResponse:
|
|
280
|
+
"""Stop the current loki session."""
|
|
281
|
+
if not session.running or session.process is None:
|
|
282
|
+
return JSONResponse(content={"stopped": False, "message": "No session running"})
|
|
283
|
+
|
|
284
|
+
try:
|
|
285
|
+
# Send SIGTERM, then SIGKILL after 5s
|
|
286
|
+
session.process.terminate()
|
|
287
|
+
try:
|
|
288
|
+
session.process.wait(timeout=5)
|
|
289
|
+
except subprocess.TimeoutExpired:
|
|
290
|
+
session.process.kill()
|
|
291
|
+
session.process.wait(timeout=3)
|
|
292
|
+
except Exception:
|
|
293
|
+
pass
|
|
294
|
+
|
|
295
|
+
session.running = False
|
|
296
|
+
await _broadcast({"type": "session_end", "data": {"message": "Session stopped by user"}})
|
|
297
|
+
|
|
298
|
+
return JSONResponse(content={"stopped": True, "message": "Session stopped"})
|
|
299
|
+
|
|
300
|
+
|
|
301
|
+
@app.get("/api/session/status")
|
|
302
|
+
async def get_status() -> JSONResponse:
|
|
303
|
+
"""Get current session status."""
|
|
304
|
+
# Check if process is still alive
|
|
305
|
+
if session.process and session.running:
|
|
306
|
+
if session.process.poll() is not None:
|
|
307
|
+
session.running = False
|
|
308
|
+
|
|
309
|
+
# Try to read .loki state files for richer status
|
|
310
|
+
loki_dir = _loki_dir()
|
|
311
|
+
phase = "idle"
|
|
312
|
+
iteration = 0
|
|
313
|
+
complexity = "standard"
|
|
314
|
+
current_task = ""
|
|
315
|
+
pending_tasks = 0
|
|
316
|
+
|
|
317
|
+
state_file = loki_dir / "state" / "session.json"
|
|
318
|
+
if state_file.exists():
|
|
319
|
+
try:
|
|
320
|
+
with open(state_file) as f:
|
|
321
|
+
state = json.load(f)
|
|
322
|
+
phase = state.get("phase", phase)
|
|
323
|
+
iteration = state.get("iteration", iteration)
|
|
324
|
+
complexity = state.get("complexity", complexity)
|
|
325
|
+
current_task = state.get("current_task", current_task)
|
|
326
|
+
pending_tasks = state.get("pending_tasks", pending_tasks)
|
|
327
|
+
except (json.JSONDecodeError, OSError):
|
|
328
|
+
pass
|
|
329
|
+
|
|
330
|
+
uptime = time.time() - session.start_time if session.running else 0
|
|
331
|
+
|
|
332
|
+
return JSONResponse(content={
|
|
333
|
+
"running": session.running,
|
|
334
|
+
"paused": False,
|
|
335
|
+
"phase": phase,
|
|
336
|
+
"iteration": iteration,
|
|
337
|
+
"complexity": complexity,
|
|
338
|
+
"mode": "autonomous",
|
|
339
|
+
"provider": session.provider,
|
|
340
|
+
"current_task": current_task,
|
|
341
|
+
"pending_tasks": pending_tasks,
|
|
342
|
+
"running_agents": 0,
|
|
343
|
+
"uptime": round(uptime),
|
|
344
|
+
"version": "",
|
|
345
|
+
"pid": str(session.process.pid) if session.process else "",
|
|
346
|
+
"projectDir": session.project_dir,
|
|
347
|
+
})
|
|
348
|
+
|
|
349
|
+
|
|
350
|
+
@app.get("/api/session/logs")
|
|
351
|
+
async def get_logs(lines: int = 200) -> JSONResponse:
|
|
352
|
+
"""Get recent log lines."""
|
|
353
|
+
recent = session.log_lines[-lines:] if session.log_lines else []
|
|
354
|
+
entries = []
|
|
355
|
+
for line in recent:
|
|
356
|
+
level = "info"
|
|
357
|
+
lower = line.lower()
|
|
358
|
+
if "error" in lower or "fail" in lower:
|
|
359
|
+
level = "error"
|
|
360
|
+
elif "warn" in lower:
|
|
361
|
+
level = "warning"
|
|
362
|
+
elif "debug" in lower:
|
|
363
|
+
level = "debug"
|
|
364
|
+
entries.append({
|
|
365
|
+
"timestamp": "",
|
|
366
|
+
"level": level,
|
|
367
|
+
"message": line,
|
|
368
|
+
"source": "loki",
|
|
369
|
+
})
|
|
370
|
+
return JSONResponse(content=entries)
|
|
371
|
+
|
|
372
|
+
|
|
373
|
+
@app.get("/api/session/agents")
|
|
374
|
+
async def get_agents() -> JSONResponse:
|
|
375
|
+
"""Get agent status from .loki state."""
|
|
376
|
+
loki_dir = _loki_dir()
|
|
377
|
+
agents_file = loki_dir / "state" / "agents.json"
|
|
378
|
+
if agents_file.exists():
|
|
379
|
+
try:
|
|
380
|
+
with open(agents_file) as f:
|
|
381
|
+
agents = json.load(f)
|
|
382
|
+
if isinstance(agents, list):
|
|
383
|
+
return JSONResponse(content=agents)
|
|
384
|
+
except (json.JSONDecodeError, OSError):
|
|
385
|
+
pass
|
|
386
|
+
return JSONResponse(content=[])
|
|
387
|
+
|
|
388
|
+
|
|
389
|
+
@app.get("/api/session/files")
|
|
390
|
+
async def get_files() -> JSONResponse:
|
|
391
|
+
"""Get the project file tree."""
|
|
392
|
+
if not session.project_dir:
|
|
393
|
+
return JSONResponse(content=[])
|
|
394
|
+
|
|
395
|
+
root = Path(session.project_dir)
|
|
396
|
+
if not root.is_dir():
|
|
397
|
+
return JSONResponse(content=[])
|
|
398
|
+
|
|
399
|
+
tree = _build_file_tree(root)
|
|
400
|
+
return JSONResponse(content=tree)
|
|
401
|
+
|
|
402
|
+
|
|
403
|
+
@app.get("/api/session/files/content")
|
|
404
|
+
async def get_file_content(path: str = "") -> JSONResponse:
|
|
405
|
+
"""Get file content with path traversal protection."""
|
|
406
|
+
if not session.project_dir or not path:
|
|
407
|
+
return JSONResponse(status_code=400, content={"error": "No active session or path"})
|
|
408
|
+
|
|
409
|
+
base = Path(session.project_dir).resolve()
|
|
410
|
+
resolved = _safe_resolve(base, path)
|
|
411
|
+
if resolved is None or not resolved.is_file():
|
|
412
|
+
return JSONResponse(status_code=404, content={"error": "File not found"})
|
|
413
|
+
|
|
414
|
+
# Limit file size to 1MB
|
|
415
|
+
try:
|
|
416
|
+
size = resolved.stat().st_size
|
|
417
|
+
if size > 1_048_576:
|
|
418
|
+
return JSONResponse(content={"content": f"[File too large: {size} bytes]"})
|
|
419
|
+
content = resolved.read_text(errors="replace")
|
|
420
|
+
except (OSError, UnicodeDecodeError) as e:
|
|
421
|
+
return JSONResponse(content={"content": f"[Cannot read file: {e}]"})
|
|
422
|
+
|
|
423
|
+
return JSONResponse(content={"content": content})
|
|
424
|
+
|
|
425
|
+
|
|
426
|
+
@app.get("/api/session/memory")
|
|
427
|
+
async def get_memory() -> JSONResponse:
|
|
428
|
+
"""Get memory summary from .loki state."""
|
|
429
|
+
loki_dir = _loki_dir()
|
|
430
|
+
memory_dir = loki_dir / "memory"
|
|
431
|
+
if not memory_dir.is_dir():
|
|
432
|
+
return JSONResponse(content={
|
|
433
|
+
"episodic_count": 0,
|
|
434
|
+
"semantic_count": 0,
|
|
435
|
+
"skill_count": 0,
|
|
436
|
+
"total_tokens": 0,
|
|
437
|
+
"last_consolidation": None,
|
|
438
|
+
})
|
|
439
|
+
|
|
440
|
+
episodic = len(list((memory_dir / "episodic").glob("*.json"))) if (memory_dir / "episodic").is_dir() else 0
|
|
441
|
+
semantic = len(list((memory_dir / "semantic").glob("*.json"))) if (memory_dir / "semantic").is_dir() else 0
|
|
442
|
+
skills = len(list((memory_dir / "skills").glob("*.json"))) if (memory_dir / "skills").is_dir() else 0
|
|
443
|
+
|
|
444
|
+
return JSONResponse(content={
|
|
445
|
+
"episodic_count": episodic,
|
|
446
|
+
"semantic_count": semantic,
|
|
447
|
+
"skill_count": skills,
|
|
448
|
+
"total_tokens": 0,
|
|
449
|
+
"last_consolidation": None,
|
|
450
|
+
})
|
|
451
|
+
|
|
452
|
+
|
|
453
|
+
@app.get("/api/session/checklist")
|
|
454
|
+
async def get_checklist() -> JSONResponse:
|
|
455
|
+
"""Get quality gates checklist from .loki state."""
|
|
456
|
+
loki_dir = _loki_dir()
|
|
457
|
+
checklist_file = loki_dir / "state" / "checklist.json"
|
|
458
|
+
if checklist_file.exists():
|
|
459
|
+
try:
|
|
460
|
+
with open(checklist_file) as f:
|
|
461
|
+
data = json.load(f)
|
|
462
|
+
return JSONResponse(content=data)
|
|
463
|
+
except (json.JSONDecodeError, OSError):
|
|
464
|
+
pass
|
|
465
|
+
return JSONResponse(content={
|
|
466
|
+
"total": 0, "passed": 0, "failed": 0, "skipped": 0, "pending": 0, "items": [],
|
|
467
|
+
})
|
|
468
|
+
|
|
469
|
+
|
|
470
|
+
@app.get("/api/templates")
|
|
471
|
+
async def get_templates() -> JSONResponse:
|
|
472
|
+
"""List available PRD templates."""
|
|
473
|
+
templates_dir = PROJECT_ROOT / "templates"
|
|
474
|
+
if not templates_dir.is_dir():
|
|
475
|
+
return JSONResponse(content=[])
|
|
476
|
+
|
|
477
|
+
templates = []
|
|
478
|
+
for f in sorted(templates_dir.glob("*.md")):
|
|
479
|
+
name = f.stem.replace("-", " ").replace("_", " ").title()
|
|
480
|
+
templates.append({"name": name, "filename": f.name})
|
|
481
|
+
return JSONResponse(content=templates)
|
|
482
|
+
|
|
483
|
+
|
|
484
|
+
@app.get("/api/templates/{filename}")
|
|
485
|
+
async def get_template_content(filename: str) -> JSONResponse:
|
|
486
|
+
"""Get a specific template's content."""
|
|
487
|
+
templates_dir = PROJECT_ROOT / "templates"
|
|
488
|
+
resolved = _safe_resolve(templates_dir, filename)
|
|
489
|
+
if resolved is None or not resolved.is_file():
|
|
490
|
+
return JSONResponse(status_code=404, content={"error": "Template not found"})
|
|
491
|
+
|
|
492
|
+
try:
|
|
493
|
+
content = resolved.read_text()
|
|
494
|
+
except OSError:
|
|
495
|
+
return JSONResponse(status_code=500, content={"error": "Cannot read template"})
|
|
496
|
+
|
|
497
|
+
return JSONResponse(content={"name": filename, "content": content})
|
|
498
|
+
|
|
499
|
+
|
|
500
|
+
# ---------------------------------------------------------------------------
|
|
501
|
+
# WebSocket
|
|
502
|
+
# ---------------------------------------------------------------------------
|
|
503
|
+
|
|
504
|
+
|
|
505
|
+
@app.websocket("/ws")
|
|
506
|
+
async def websocket_endpoint(ws: WebSocket) -> None:
|
|
507
|
+
"""Real-time stream of loki output and events."""
|
|
508
|
+
await ws.accept()
|
|
509
|
+
session.ws_clients.add(ws)
|
|
510
|
+
|
|
511
|
+
# Send current state on connect
|
|
512
|
+
await ws.send_text(json.dumps({
|
|
513
|
+
"type": "connected",
|
|
514
|
+
"data": {"running": session.running, "provider": session.provider},
|
|
515
|
+
}))
|
|
516
|
+
|
|
517
|
+
# Send recent log lines as backfill
|
|
518
|
+
for line in session.log_lines[-100:]:
|
|
519
|
+
await ws.send_text(json.dumps({
|
|
520
|
+
"type": "log",
|
|
521
|
+
"data": {"line": line, "timestamp": ""},
|
|
522
|
+
}))
|
|
523
|
+
|
|
524
|
+
try:
|
|
525
|
+
while True:
|
|
526
|
+
# Keep connection alive; handle client messages if needed
|
|
527
|
+
data = await ws.receive_text()
|
|
528
|
+
# Could handle commands here (e.g., stop session)
|
|
529
|
+
try:
|
|
530
|
+
msg = json.loads(data)
|
|
531
|
+
if msg.get("type") == "ping":
|
|
532
|
+
await ws.send_text(json.dumps({"type": "pong"}))
|
|
533
|
+
except json.JSONDecodeError:
|
|
534
|
+
pass
|
|
535
|
+
except WebSocketDisconnect:
|
|
536
|
+
pass
|
|
537
|
+
finally:
|
|
538
|
+
session.ws_clients.discard(ws)
|
|
539
|
+
|
|
540
|
+
|
|
541
|
+
# ---------------------------------------------------------------------------
|
|
542
|
+
# Static file serving (built React app)
|
|
543
|
+
# ---------------------------------------------------------------------------
|
|
544
|
+
|
|
545
|
+
# Mount assets directory if dist exists
|
|
546
|
+
if DIST_DIR.is_dir() and (DIST_DIR / "assets").is_dir():
|
|
547
|
+
app.mount("/assets", StaticFiles(directory=str(DIST_DIR / "assets")), name="assets")
|
|
548
|
+
|
|
549
|
+
|
|
550
|
+
@app.get("/{full_path:path}")
|
|
551
|
+
async def serve_spa(full_path: str) -> FileResponse:
|
|
552
|
+
"""Serve the React SPA. All non-API routes return index.html."""
|
|
553
|
+
index = DIST_DIR / "index.html"
|
|
554
|
+
if not index.exists():
|
|
555
|
+
return JSONResponse(
|
|
556
|
+
status_code=503,
|
|
557
|
+
content={"error": "Web app not built. Run: cd web-app && npm run build"},
|
|
558
|
+
)
|
|
559
|
+
# Try to serve static file first
|
|
560
|
+
requested = DIST_DIR / full_path
|
|
561
|
+
if full_path and requested.is_file() and str(requested.resolve()).startswith(str(DIST_DIR.resolve())):
|
|
562
|
+
return FileResponse(str(requested))
|
|
563
|
+
# Fallback to SPA index
|
|
564
|
+
return FileResponse(str(index))
|
|
565
|
+
|
|
566
|
+
|
|
567
|
+
# ---------------------------------------------------------------------------
|
|
568
|
+
# Entrypoint
|
|
569
|
+
# ---------------------------------------------------------------------------
|
|
570
|
+
|
|
571
|
+
|
|
572
|
+
def main() -> None:
|
|
573
|
+
import uvicorn
|
|
574
|
+
host = os.environ.get("PURPLE_LAB_HOST", HOST)
|
|
575
|
+
port = int(os.environ.get("PURPLE_LAB_PORT", str(PORT)))
|
|
576
|
+
print(f"Purple Lab starting on http://{host}:{port}")
|
|
577
|
+
uvicorn.run(app, host=host, port=port, log_level="info")
|
|
578
|
+
|
|
579
|
+
|
|
580
|
+
if __name__ == "__main__":
|
|
581
|
+
main()
|