loki-mode 5.28.0 → 5.28.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/autonomy/completion-council.sh +1 -0
- package/autonomy/loki +5 -7
- package/autonomy/run.sh +65 -8
- package/dashboard/__init__.py +1 -1
- package/dashboard/server.py +152 -49
- package/dashboard/static/index.html +109 -102
- package/docs/INSTALLATION.md +1 -1
- package/package.json +1 -1
package/SKILL.md
CHANGED
|
@@ -3,7 +3,7 @@ name: loki-mode
|
|
|
3
3
|
description: Multi-agent autonomous startup system. Triggers on "Loki Mode". Takes PRD to deployed product with zero human intervention. Requires --dangerously-skip-permissions flag.
|
|
4
4
|
---
|
|
5
5
|
|
|
6
|
-
# Loki Mode v5.28.
|
|
6
|
+
# Loki Mode v5.28.1
|
|
7
7
|
|
|
8
8
|
**You are an autonomous agent. You make decisions. You do not ask questions. You do not stop.**
|
|
9
9
|
|
|
@@ -260,4 +260,4 @@ Auto-detected or force with `LOKI_COMPLEXITY`:
|
|
|
260
260
|
|
|
261
261
|
---
|
|
262
262
|
|
|
263
|
-
**v5.28.
|
|
263
|
+
**v5.28.1 | Demo, quick mode, init, cost dashboard, 12 templates, GitHub Action | ~270 lines core**
|
package/VERSION
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
5.28.
|
|
1
|
+
5.28.1
|
package/autonomy/loki
CHANGED
|
@@ -1280,7 +1280,7 @@ cmd_dashboard_start() {
|
|
|
1280
1280
|
|
|
1281
1281
|
# Start the dashboard server in background
|
|
1282
1282
|
# LOKI_SKILL_DIR tells server.py where to find static files
|
|
1283
|
-
LOKI_SKILL_DIR="$SKILL_DIR" PYTHONPATH="$SKILL_DIR" LOKI_DASHBOARD_HOST="$host" LOKI_DASHBOARD_PORT="$port" \
|
|
1283
|
+
LOKI_DIR="$LOKI_DIR" LOKI_SKILL_DIR="$SKILL_DIR" PYTHONPATH="$SKILL_DIR" LOKI_DASHBOARD_HOST="$host" LOKI_DASHBOARD_PORT="$port" \
|
|
1284
1284
|
nohup "$python_cmd" -m dashboard.server > "$log_file" 2>&1 &
|
|
1285
1285
|
local new_pid=$!
|
|
1286
1286
|
|
|
@@ -3853,13 +3853,11 @@ if count == 0:
|
|
|
3853
3853
|
echo -e "${BOLD}Search Results for: $query${NC}"
|
|
3854
3854
|
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
|
3855
3855
|
|
|
3856
|
-
|
|
3857
|
-
|
|
3858
|
-
import json
|
|
3859
|
-
import os
|
|
3856
|
+
LOKI_SEARCH_QUERY="$query" LOKI_LEARNINGS_DIR="$learnings_dir" python3 -c "
|
|
3857
|
+
import json, os
|
|
3860
3858
|
|
|
3861
|
-
learnings_dir = '
|
|
3862
|
-
query = '
|
|
3859
|
+
learnings_dir = os.environ['LOKI_LEARNINGS_DIR']
|
|
3860
|
+
query = os.environ['LOKI_SEARCH_QUERY'].lower()
|
|
3863
3861
|
|
|
3864
3862
|
for filename in ['patterns.jsonl', 'mistakes.jsonl', 'successes.jsonl']:
|
|
3865
3863
|
filepath = os.path.join(learnings_dir, filename)
|
package/autonomy/run.sh
CHANGED
|
@@ -1467,7 +1467,7 @@ create_worktree() {
|
|
|
1467
1467
|
|
|
1468
1468
|
# Initialize environment (detect and run appropriate install)
|
|
1469
1469
|
(
|
|
1470
|
-
cd "$worktree_path"
|
|
1470
|
+
cd "$worktree_path" || exit 1
|
|
1471
1471
|
if [ -f "package.json" ]; then
|
|
1472
1472
|
npm install --silent 2>/dev/null || true
|
|
1473
1473
|
elif [ -f "requirements.txt" ]; then
|
|
@@ -1558,7 +1558,7 @@ spawn_worktree_session() {
|
|
|
1558
1558
|
log_step "Spawning ${PROVIDER_DISPLAY_NAME:-Claude} session: $stream_name"
|
|
1559
1559
|
|
|
1560
1560
|
(
|
|
1561
|
-
cd "$worktree_path"
|
|
1561
|
+
cd "$worktree_path" || exit 1
|
|
1562
1562
|
# Provider-specific invocation for parallel sessions
|
|
1563
1563
|
case "${PROVIDER_NAME:-claude}" in
|
|
1564
1564
|
claude)
|
|
@@ -1695,7 +1695,7 @@ merge_feature() {
|
|
|
1695
1695
|
log_step "Merging feature: $feature"
|
|
1696
1696
|
|
|
1697
1697
|
(
|
|
1698
|
-
cd "$TARGET_DIR"
|
|
1698
|
+
cd "$TARGET_DIR" || exit 1
|
|
1699
1699
|
|
|
1700
1700
|
# Ensure we're on main
|
|
1701
1701
|
git checkout main 2>/dev/null
|
|
@@ -2079,9 +2079,36 @@ init_loki_dir() {
|
|
|
2079
2079
|
EOF
|
|
2080
2080
|
fi
|
|
2081
2081
|
|
|
2082
|
+
# Write pricing.json with provider-specific model rates
|
|
2083
|
+
_write_pricing_json
|
|
2084
|
+
|
|
2082
2085
|
log_info "Loki directory initialized: .loki/"
|
|
2083
2086
|
}
|
|
2084
2087
|
|
|
2088
|
+
# Write .loki/pricing.json based on active provider
|
|
2089
|
+
_write_pricing_json() {
|
|
2090
|
+
local provider="${LOKI_PROVIDER:-claude}"
|
|
2091
|
+
local updated
|
|
2092
|
+
updated=$(date -u +%Y-%m-%d)
|
|
2093
|
+
|
|
2094
|
+
cat > ".loki/pricing.json" << PRICING_EOF
|
|
2095
|
+
{
|
|
2096
|
+
"provider": "${provider}",
|
|
2097
|
+
"updated": "${updated}",
|
|
2098
|
+
"source": "static",
|
|
2099
|
+
"models": {
|
|
2100
|
+
"opus": {"input": 5.00, "output": 25.00, "label": "Opus 4.6", "provider": "claude"},
|
|
2101
|
+
"sonnet": {"input": 3.00, "output": 15.00, "label": "Sonnet 4.5", "provider": "claude"},
|
|
2102
|
+
"haiku": {"input": 1.00, "output": 5.00, "label": "Haiku 4.5", "provider": "claude"},
|
|
2103
|
+
"gpt-5.3-codex": {"input": 1.50, "output": 12.00, "label": "GPT-5.3 Codex", "provider": "codex"},
|
|
2104
|
+
"gemini-3-pro": {"input": 1.25, "output": 10.00, "label": "Gemini 3 Pro", "provider": "gemini"},
|
|
2105
|
+
"gemini-3-flash": {"input": 0.10, "output": 0.40, "label": "Gemini 3 Flash", "provider": "gemini"}
|
|
2106
|
+
}
|
|
2107
|
+
}
|
|
2108
|
+
PRICING_EOF
|
|
2109
|
+
log_info "Pricing data written: .loki/pricing.json (provider: ${provider})"
|
|
2110
|
+
}
|
|
2111
|
+
|
|
2085
2112
|
#===============================================================================
|
|
2086
2113
|
# Gemini Invocation with Rate Limit Fallback
|
|
2087
2114
|
#===============================================================================
|
|
@@ -2384,6 +2411,7 @@ write_dashboard_state() {
|
|
|
2384
2411
|
"path": "$project_path"
|
|
2385
2412
|
},
|
|
2386
2413
|
"mode": "$mode",
|
|
2414
|
+
"provider": "${PROVIDER_NAME:-claude}",
|
|
2387
2415
|
"phase": "$current_phase",
|
|
2388
2416
|
"complexity": "$complexity",
|
|
2389
2417
|
"iteration": $ITERATION_COUNT,
|
|
@@ -2539,6 +2567,30 @@ track_iteration_complete() {
|
|
|
2539
2567
|
--context "{\"iteration\":$iteration,\"exit_code\":$exit_code}"
|
|
2540
2568
|
fi
|
|
2541
2569
|
|
|
2570
|
+
# Write efficiency tracking file for /api/cost endpoint
|
|
2571
|
+
mkdir -p .loki/metrics/efficiency
|
|
2572
|
+
local model_tier="sonnet"
|
|
2573
|
+
if [ "${PROVIDER_NAME:-claude}" = "claude" ]; then
|
|
2574
|
+
model_tier="sonnet"
|
|
2575
|
+
elif [ "${PROVIDER_NAME:-claude}" = "codex" ]; then
|
|
2576
|
+
model_tier="gpt-5.3-codex"
|
|
2577
|
+
elif [ "${PROVIDER_NAME:-claude}" = "gemini" ]; then
|
|
2578
|
+
model_tier="gemini-3-pro"
|
|
2579
|
+
fi
|
|
2580
|
+
local phase="$current_phase"
|
|
2581
|
+
[ -z "$phase" ] && phase=$(python3 -c "import json; print(json.load(open('.loki/state/orchestrator.json')).get('currentPhase', 'unknown'))" 2>/dev/null || echo "unknown")
|
|
2582
|
+
cat > ".loki/metrics/efficiency/iteration-${iteration}.json" << EFF_EOF
|
|
2583
|
+
{
|
|
2584
|
+
"iteration": $iteration,
|
|
2585
|
+
"model": "$model_tier",
|
|
2586
|
+
"phase": "$phase",
|
|
2587
|
+
"duration_ms": $duration_ms,
|
|
2588
|
+
"provider": "${PROVIDER_NAME:-claude}",
|
|
2589
|
+
"status": "$status_str",
|
|
2590
|
+
"timestamp": "$(date -u +%Y-%m-%dT%H:%M:%SZ)"
|
|
2591
|
+
}
|
|
2592
|
+
EFF_EOF
|
|
2593
|
+
|
|
2542
2594
|
# Get task from in-progress
|
|
2543
2595
|
local in_progress_file=".loki/queue/in-progress.json"
|
|
2544
2596
|
local completed_file=".loki/queue/completed.json"
|
|
@@ -3562,11 +3614,16 @@ start_dashboard() {
|
|
|
3562
3614
|
# Check if it's our own dashboard
|
|
3563
3615
|
local existing_pid=$(lsof -ti :$DASHBOARD_PORT 2>/dev/null | head -1)
|
|
3564
3616
|
if [ -n "$existing_pid" ]; then
|
|
3565
|
-
#
|
|
3566
|
-
|
|
3567
|
-
|
|
3568
|
-
|
|
3569
|
-
|
|
3617
|
+
# Only kill if it's a Python/uvicorn dashboard process
|
|
3618
|
+
local proc_cmd=$(ps -p "$existing_pid" -o comm= 2>/dev/null || true)
|
|
3619
|
+
if [[ "$proc_cmd" == *python* ]] || [[ "$proc_cmd" == *uvicorn* ]]; then
|
|
3620
|
+
log_step "Killing existing dashboard on port $DASHBOARD_PORT (PID: $existing_pid)..."
|
|
3621
|
+
kill "$existing_pid" 2>/dev/null || true
|
|
3622
|
+
sleep 1
|
|
3623
|
+
break
|
|
3624
|
+
else
|
|
3625
|
+
log_info "Port $DASHBOARD_PORT in use by non-dashboard process ($proc_cmd), skipping..."
|
|
3626
|
+
fi
|
|
3570
3627
|
fi
|
|
3571
3628
|
((DASHBOARD_PORT++))
|
|
3572
3629
|
if [ "$DASHBOARD_PORT" -gt 65535 ]; then
|
package/dashboard/__init__.py
CHANGED
package/dashboard/server.py
CHANGED
|
@@ -539,7 +539,7 @@ async def list_tasks(
|
|
|
539
539
|
payload = task.get("payload", {})
|
|
540
540
|
all_tasks.append({
|
|
541
541
|
"id": task_id,
|
|
542
|
-
"title": payload.get("action", task.get("type", "Task")),
|
|
542
|
+
"title": task.get("title", payload.get("action", task.get("type", "Task"))),
|
|
543
543
|
"description": payload.get("description", ""),
|
|
544
544
|
"status": mapped_status,
|
|
545
545
|
"priority": payload.get("priority", "medium"),
|
|
@@ -556,6 +556,8 @@ async def list_tasks(
|
|
|
556
556
|
("pending.json", "pending"),
|
|
557
557
|
("in-progress.json", "in_progress"),
|
|
558
558
|
("completed.json", "done"),
|
|
559
|
+
("failed.json", "done"),
|
|
560
|
+
("dead-letter.json", "done"),
|
|
559
561
|
]:
|
|
560
562
|
fpath = queue_dir / queue_file
|
|
561
563
|
if fpath.exists():
|
|
@@ -1139,13 +1141,10 @@ def _sanitize_agent_id(agent_id: str) -> str:
|
|
|
1139
1141
|
)
|
|
1140
1142
|
return agent_id
|
|
1141
1143
|
|
|
1142
|
-
_LOKI_DIR = _get_loki_dir()
|
|
1143
|
-
|
|
1144
|
-
|
|
1145
1144
|
@app.get("/api/memory/summary")
|
|
1146
1145
|
async def get_memory_summary():
|
|
1147
1146
|
"""Get memory system summary from .loki/memory/."""
|
|
1148
|
-
memory_dir =
|
|
1147
|
+
memory_dir = _get_loki_dir() / "memory"
|
|
1149
1148
|
summary = {
|
|
1150
1149
|
"episodic": {"count": 0, "latestDate": None},
|
|
1151
1150
|
"semantic": {"patterns": 0, "antiPatterns": 0},
|
|
@@ -1206,7 +1205,7 @@ async def get_memory_summary():
|
|
|
1206
1205
|
@app.get("/api/memory/episodes")
|
|
1207
1206
|
async def list_episodes(limit: int = 50):
|
|
1208
1207
|
"""List episodic memory entries."""
|
|
1209
|
-
ep_dir =
|
|
1208
|
+
ep_dir = _get_loki_dir() / "memory" / "episodic"
|
|
1210
1209
|
episodes = []
|
|
1211
1210
|
if ep_dir.exists():
|
|
1212
1211
|
files = sorted(ep_dir.glob("*.json"), reverse=True)[:limit]
|
|
@@ -1221,7 +1220,7 @@ async def list_episodes(limit: int = 50):
|
|
|
1221
1220
|
@app.get("/api/memory/episodes/{episode_id}")
|
|
1222
1221
|
async def get_episode(episode_id: str):
|
|
1223
1222
|
"""Get a specific episodic memory entry."""
|
|
1224
|
-
ep_dir =
|
|
1223
|
+
ep_dir = _get_loki_dir() / "memory" / "episodic"
|
|
1225
1224
|
if not ep_dir.exists():
|
|
1226
1225
|
raise HTTPException(status_code=404, detail="Episode not found")
|
|
1227
1226
|
# Try direct filename match
|
|
@@ -1238,7 +1237,7 @@ async def get_episode(episode_id: str):
|
|
|
1238
1237
|
@app.get("/api/memory/patterns")
|
|
1239
1238
|
async def list_patterns():
|
|
1240
1239
|
"""List semantic patterns."""
|
|
1241
|
-
sem_dir =
|
|
1240
|
+
sem_dir = _get_loki_dir() / "memory" / "semantic"
|
|
1242
1241
|
patterns_file = sem_dir / "patterns.json"
|
|
1243
1242
|
if patterns_file.exists():
|
|
1244
1243
|
try:
|
|
@@ -1262,7 +1261,7 @@ async def get_pattern(pattern_id: str):
|
|
|
1262
1261
|
@app.get("/api/memory/skills")
|
|
1263
1262
|
async def list_skills():
|
|
1264
1263
|
"""List procedural skills."""
|
|
1265
|
-
skills_dir =
|
|
1264
|
+
skills_dir = _get_loki_dir() / "memory" / "skills"
|
|
1266
1265
|
skills = []
|
|
1267
1266
|
if skills_dir.exists():
|
|
1268
1267
|
for f in sorted(skills_dir.glob("*.json")):
|
|
@@ -1276,7 +1275,7 @@ async def list_skills():
|
|
|
1276
1275
|
@app.get("/api/memory/skills/{skill_id}")
|
|
1277
1276
|
async def get_skill(skill_id: str):
|
|
1278
1277
|
"""Get a specific procedural skill."""
|
|
1279
|
-
skills_dir =
|
|
1278
|
+
skills_dir = _get_loki_dir() / "memory" / "skills"
|
|
1280
1279
|
if not skills_dir.exists():
|
|
1281
1280
|
raise HTTPException(status_code=404, detail="Skill not found")
|
|
1282
1281
|
for f in skills_dir.glob("*.json"):
|
|
@@ -1292,7 +1291,7 @@ async def get_skill(skill_id: str):
|
|
|
1292
1291
|
@app.get("/api/memory/economics")
|
|
1293
1292
|
async def get_token_economics():
|
|
1294
1293
|
"""Get token usage economics."""
|
|
1295
|
-
econ_file =
|
|
1294
|
+
econ_file = _get_loki_dir() / "memory" / "token_economics.json"
|
|
1296
1295
|
if econ_file.exists():
|
|
1297
1296
|
try:
|
|
1298
1297
|
return json.loads(econ_file.read_text())
|
|
@@ -1304,7 +1303,7 @@ async def get_token_economics():
|
|
|
1304
1303
|
@app.post("/api/memory/consolidate")
|
|
1305
1304
|
async def consolidate_memory(hours: int = 24):
|
|
1306
1305
|
"""Trigger memory consolidation (stub - returns current state)."""
|
|
1307
|
-
return {"status": "ok", "message": f"Consolidation for last {hours}h", "consolidated": 0}
|
|
1306
|
+
return {"status": "ok", "message": f"Consolidation for last {hours}h", "consolidated": 0, "patternsCreated": 0, "patternsMerged": 0, "episodesProcessed": 0}
|
|
1308
1307
|
|
|
1309
1308
|
|
|
1310
1309
|
@app.post("/api/memory/retrieve")
|
|
@@ -1316,7 +1315,7 @@ async def retrieve_memory(query: dict = None):
|
|
|
1316
1315
|
@app.get("/api/memory/index")
|
|
1317
1316
|
async def get_memory_index():
|
|
1318
1317
|
"""Get memory index (Layer 1 - lightweight discovery)."""
|
|
1319
|
-
index_file =
|
|
1318
|
+
index_file = _get_loki_dir() / "memory" / "index.json"
|
|
1320
1319
|
if index_file.exists():
|
|
1321
1320
|
try:
|
|
1322
1321
|
return json.loads(index_file.read_text())
|
|
@@ -1328,7 +1327,7 @@ async def get_memory_index():
|
|
|
1328
1327
|
@app.get("/api/memory/timeline")
|
|
1329
1328
|
async def get_memory_timeline():
|
|
1330
1329
|
"""Get memory timeline (Layer 2 - progressive disclosure)."""
|
|
1331
|
-
timeline_file =
|
|
1330
|
+
timeline_file = _get_loki_dir() / "memory" / "timeline.json"
|
|
1332
1331
|
if timeline_file.exists():
|
|
1333
1332
|
try:
|
|
1334
1333
|
return json.loads(timeline_file.read_text())
|
|
@@ -1371,7 +1370,7 @@ async def get_learning_metrics(
|
|
|
1371
1370
|
"success_patterns": [],
|
|
1372
1371
|
"tool_efficiencies": [],
|
|
1373
1372
|
}
|
|
1374
|
-
agg_file =
|
|
1373
|
+
agg_file = _get_loki_dir() / "metrics" / "aggregation.json"
|
|
1375
1374
|
if agg_file.exists():
|
|
1376
1375
|
try:
|
|
1377
1376
|
agg_data = json.loads(agg_file.read_text())
|
|
@@ -1386,7 +1385,7 @@ async def get_learning_metrics(
|
|
|
1386
1385
|
"totalSignals": len(events),
|
|
1387
1386
|
"signalsByType": by_type,
|
|
1388
1387
|
"signalsBySource": by_source,
|
|
1389
|
-
"avgConfidence": 0,
|
|
1388
|
+
"avgConfidence": round(sum(e.get("data", {}).get("confidence", 0) for e in events) / max(len(events), 1), 4),
|
|
1390
1389
|
"aggregation": aggregation,
|
|
1391
1390
|
}
|
|
1392
1391
|
|
|
@@ -1431,7 +1430,7 @@ async def get_learning_signals(
|
|
|
1431
1430
|
@app.get("/api/learning/aggregation")
|
|
1432
1431
|
async def get_learning_aggregation():
|
|
1433
1432
|
"""Get latest learning aggregation result."""
|
|
1434
|
-
agg_file =
|
|
1433
|
+
agg_file = _get_loki_dir() / "metrics" / "aggregation.json"
|
|
1435
1434
|
if agg_file.exists():
|
|
1436
1435
|
try:
|
|
1437
1436
|
return json.loads(agg_file.read_text())
|
|
@@ -1443,7 +1442,7 @@ async def get_learning_aggregation():
|
|
|
1443
1442
|
@app.post("/api/learning/aggregate")
|
|
1444
1443
|
async def trigger_aggregation():
|
|
1445
1444
|
"""Aggregate learning signals from events.jsonl into structured metrics."""
|
|
1446
|
-
events_file =
|
|
1445
|
+
events_file = _get_loki_dir() / "events.jsonl"
|
|
1447
1446
|
preferences: dict = {}
|
|
1448
1447
|
error_patterns: dict = {}
|
|
1449
1448
|
success_patterns: dict = {}
|
|
@@ -1491,17 +1490,19 @@ async def trigger_aggregation():
|
|
|
1491
1490
|
pass
|
|
1492
1491
|
|
|
1493
1492
|
# Build structured result
|
|
1494
|
-
pref_list = [{"
|
|
1495
|
-
error_list = [{"
|
|
1496
|
-
success_list = [{"
|
|
1493
|
+
pref_list = [{"preference_key": k, "preferred_value": k, "frequency": v, "confidence": min(1.0, v / 10)} for k, v in sorted(preferences.items(), key=lambda x: -x[1])]
|
|
1494
|
+
error_list = [{"error_type": k, "resolution_rate": 0.0, "frequency": v, "confidence": min(1.0, v / 10)} for k, v in sorted(error_patterns.items(), key=lambda x: -x[1])]
|
|
1495
|
+
success_list = [{"pattern_name": k, "avg_duration_seconds": 0, "frequency": v, "confidence": min(1.0, v / 10)} for k, v in sorted(success_patterns.items(), key=lambda x: -x[1])]
|
|
1497
1496
|
tool_list = []
|
|
1498
1497
|
for tname, stats in sorted(tool_stats.items(), key=lambda x: -x[1]["count"]):
|
|
1499
1498
|
avg_ms = stats["total_ms"] / stats["count"] if stats["count"] else 0
|
|
1499
|
+
sr = round(stats["successes"] / stats["count"], 4) if stats["count"] else 0
|
|
1500
1500
|
tool_list.append({
|
|
1501
|
-
"
|
|
1501
|
+
"tool_name": tname,
|
|
1502
|
+
"efficiency_score": sr,
|
|
1502
1503
|
"count": stats["count"],
|
|
1503
|
-
"
|
|
1504
|
-
"success_rate":
|
|
1504
|
+
"avg_execution_time_ms": round(avg_ms, 2),
|
|
1505
|
+
"success_rate": sr,
|
|
1505
1506
|
})
|
|
1506
1507
|
|
|
1507
1508
|
result = {
|
|
@@ -1513,7 +1514,7 @@ async def trigger_aggregation():
|
|
|
1513
1514
|
}
|
|
1514
1515
|
|
|
1515
1516
|
# Write to metrics directory
|
|
1516
|
-
metrics_dir =
|
|
1517
|
+
metrics_dir = _get_loki_dir() / "metrics"
|
|
1517
1518
|
metrics_dir.mkdir(parents=True, exist_ok=True)
|
|
1518
1519
|
try:
|
|
1519
1520
|
(metrics_dir / "aggregation.json").write_text(json.dumps(result, indent=2))
|
|
@@ -1574,7 +1575,7 @@ def _parse_time_range(time_range: str) -> Optional[datetime]:
|
|
|
1574
1575
|
|
|
1575
1576
|
def _read_events(time_range: str = "7d") -> list:
|
|
1576
1577
|
"""Read events from .loki/events.jsonl with time filter."""
|
|
1577
|
-
events_file =
|
|
1578
|
+
events_file = _get_loki_dir() / "events.jsonl"
|
|
1578
1579
|
if not events_file.exists():
|
|
1579
1580
|
return []
|
|
1580
1581
|
|
|
@@ -1607,7 +1608,7 @@ def _read_events(time_range: str = "7d") -> list:
|
|
|
1607
1608
|
@app.post("/api/control/pause")
|
|
1608
1609
|
async def pause_session():
|
|
1609
1610
|
"""Pause the current session by creating PAUSE file."""
|
|
1610
|
-
pause_file =
|
|
1611
|
+
pause_file = _get_loki_dir() / "PAUSE"
|
|
1611
1612
|
pause_file.parent.mkdir(parents=True, exist_ok=True)
|
|
1612
1613
|
pause_file.write_text(datetime.now().isoformat())
|
|
1613
1614
|
return {"success": True, "message": "Session paused"}
|
|
@@ -1617,7 +1618,7 @@ async def pause_session():
|
|
|
1617
1618
|
async def resume_session():
|
|
1618
1619
|
"""Resume a paused session by removing PAUSE/STOP files."""
|
|
1619
1620
|
for fname in ["PAUSE", "STOP"]:
|
|
1620
|
-
fpath =
|
|
1621
|
+
fpath = _get_loki_dir() / fname
|
|
1621
1622
|
try:
|
|
1622
1623
|
fpath.unlink(missing_ok=True)
|
|
1623
1624
|
except Exception:
|
|
@@ -1628,12 +1629,12 @@ async def resume_session():
|
|
|
1628
1629
|
@app.post("/api/control/stop")
|
|
1629
1630
|
async def stop_session():
|
|
1630
1631
|
"""Stop the session by creating STOP file and sending SIGTERM."""
|
|
1631
|
-
stop_file =
|
|
1632
|
+
stop_file = _get_loki_dir() / "STOP"
|
|
1632
1633
|
stop_file.parent.mkdir(parents=True, exist_ok=True)
|
|
1633
1634
|
stop_file.write_text(datetime.now().isoformat())
|
|
1634
1635
|
|
|
1635
1636
|
# Try to kill the process
|
|
1636
|
-
pid_file =
|
|
1637
|
+
pid_file = _get_loki_dir() / "loki.pid"
|
|
1637
1638
|
if pid_file.exists():
|
|
1638
1639
|
try:
|
|
1639
1640
|
pid = int(pid_file.read_text().strip())
|
|
@@ -1642,7 +1643,7 @@ async def stop_session():
|
|
|
1642
1643
|
pass
|
|
1643
1644
|
|
|
1644
1645
|
# Mark session.json as stopped
|
|
1645
|
-
session_file =
|
|
1646
|
+
session_file = _get_loki_dir() / "session.json"
|
|
1646
1647
|
if session_file.exists():
|
|
1647
1648
|
try:
|
|
1648
1649
|
sd = json.loads(session_file.read_text())
|
|
@@ -1658,17 +1659,53 @@ async def stop_session():
|
|
|
1658
1659
|
# Cost Visibility API
|
|
1659
1660
|
# =============================================================================
|
|
1660
1661
|
|
|
1661
|
-
#
|
|
1662
|
-
|
|
1663
|
-
|
|
1664
|
-
|
|
1665
|
-
"
|
|
1662
|
+
# Static fallback pricing per million tokens (USD) - updated 2026-02-07
|
|
1663
|
+
# At runtime, overridden by .loki/pricing.json if available
|
|
1664
|
+
_DEFAULT_PRICING = {
|
|
1665
|
+
# Claude (Anthropic)
|
|
1666
|
+
"opus": {"input": 5.00, "output": 25.00},
|
|
1667
|
+
"sonnet": {"input": 3.00, "output": 15.00},
|
|
1668
|
+
"haiku": {"input": 1.00, "output": 5.00},
|
|
1669
|
+
# OpenAI Codex
|
|
1670
|
+
"gpt-5.3-codex": {"input": 1.50, "output": 12.00},
|
|
1671
|
+
# Google Gemini
|
|
1672
|
+
"gemini-3-pro": {"input": 1.25, "output": 10.00},
|
|
1673
|
+
"gemini-3-flash": {"input": 0.10, "output": 0.40},
|
|
1666
1674
|
}
|
|
1667
1675
|
|
|
1676
|
+
# Active pricing - starts with defaults, updated from .loki/pricing.json
|
|
1677
|
+
_MODEL_PRICING = dict(_DEFAULT_PRICING)
|
|
1678
|
+
|
|
1679
|
+
|
|
1680
|
+
def _load_pricing_from_file() -> dict:
|
|
1681
|
+
"""Load pricing from .loki/pricing.json if available."""
|
|
1682
|
+
loki_dir = _get_loki_dir()
|
|
1683
|
+
pricing_file = loki_dir / "pricing.json"
|
|
1684
|
+
if pricing_file.exists():
|
|
1685
|
+
try:
|
|
1686
|
+
data = json.loads(pricing_file.read_text())
|
|
1687
|
+
models = data.get("models", {})
|
|
1688
|
+
if models:
|
|
1689
|
+
return models
|
|
1690
|
+
except (json.JSONDecodeError, IOError):
|
|
1691
|
+
pass
|
|
1692
|
+
return {}
|
|
1693
|
+
|
|
1694
|
+
|
|
1695
|
+
def _get_model_pricing() -> dict:
|
|
1696
|
+
"""Get current model pricing, preferring .loki/pricing.json over defaults."""
|
|
1697
|
+
file_pricing = _load_pricing_from_file()
|
|
1698
|
+
if file_pricing:
|
|
1699
|
+
merged = dict(_DEFAULT_PRICING)
|
|
1700
|
+
merged.update(file_pricing)
|
|
1701
|
+
return merged
|
|
1702
|
+
return _MODEL_PRICING
|
|
1703
|
+
|
|
1668
1704
|
|
|
1669
1705
|
def _calculate_model_cost(model: str, input_tokens: int, output_tokens: int) -> float:
|
|
1670
1706
|
"""Calculate USD cost for a model's token usage."""
|
|
1671
|
-
|
|
1707
|
+
pricing_table = _get_model_pricing()
|
|
1708
|
+
pricing = pricing_table.get(model.lower(), pricing_table.get("sonnet", {}))
|
|
1672
1709
|
input_cost = (input_tokens / 1_000_000) * pricing.get("input", 3.00)
|
|
1673
1710
|
output_cost = (output_tokens / 1_000_000) * pricing.get("output", 15.00)
|
|
1674
1711
|
return input_cost + output_cost
|
|
@@ -1773,6 +1810,72 @@ async def get_cost():
|
|
|
1773
1810
|
}
|
|
1774
1811
|
|
|
1775
1812
|
|
|
1813
|
+
# =============================================================================
|
|
1814
|
+
# Pricing API
|
|
1815
|
+
# =============================================================================
|
|
1816
|
+
|
|
1817
|
+
_PROVIDER_LABELS = {
|
|
1818
|
+
"opus": "Opus 4.6",
|
|
1819
|
+
"sonnet": "Sonnet 4.5",
|
|
1820
|
+
"haiku": "Haiku 4.5",
|
|
1821
|
+
"gpt-5.3-codex": "GPT-5.3 Codex",
|
|
1822
|
+
"gemini-3-pro": "Gemini 3 Pro",
|
|
1823
|
+
"gemini-3-flash": "Gemini 3 Flash",
|
|
1824
|
+
}
|
|
1825
|
+
|
|
1826
|
+
_MODEL_PROVIDERS = {
|
|
1827
|
+
"opus": "claude",
|
|
1828
|
+
"sonnet": "claude",
|
|
1829
|
+
"haiku": "claude",
|
|
1830
|
+
"gpt-5.3-codex": "codex",
|
|
1831
|
+
"gemini-3-pro": "gemini",
|
|
1832
|
+
"gemini-3-flash": "gemini",
|
|
1833
|
+
}
|
|
1834
|
+
|
|
1835
|
+
|
|
1836
|
+
@app.get("/api/pricing")
|
|
1837
|
+
async def get_pricing():
|
|
1838
|
+
"""Get current model pricing. Reads from .loki/pricing.json if available, falls back to static defaults."""
|
|
1839
|
+
loki_dir = _get_loki_dir()
|
|
1840
|
+
pricing_file = loki_dir / "pricing.json"
|
|
1841
|
+
|
|
1842
|
+
# Try to read from .loki/pricing.json first
|
|
1843
|
+
if pricing_file.exists():
|
|
1844
|
+
try:
|
|
1845
|
+
data = json.loads(pricing_file.read_text())
|
|
1846
|
+
if data.get("models"):
|
|
1847
|
+
return data
|
|
1848
|
+
except (json.JSONDecodeError, IOError):
|
|
1849
|
+
pass
|
|
1850
|
+
|
|
1851
|
+
# Determine active provider
|
|
1852
|
+
provider = "claude"
|
|
1853
|
+
provider_file = loki_dir / "state" / "provider"
|
|
1854
|
+
if provider_file.exists():
|
|
1855
|
+
try:
|
|
1856
|
+
provider = provider_file.read_text().strip()
|
|
1857
|
+
except IOError:
|
|
1858
|
+
pass
|
|
1859
|
+
|
|
1860
|
+
# Build response from static defaults
|
|
1861
|
+
pricing_table = _get_model_pricing()
|
|
1862
|
+
models = {}
|
|
1863
|
+
for model_key, rates in pricing_table.items():
|
|
1864
|
+
models[model_key] = {
|
|
1865
|
+
"input": rates["input"],
|
|
1866
|
+
"output": rates["output"],
|
|
1867
|
+
"label": _PROVIDER_LABELS.get(model_key, model_key),
|
|
1868
|
+
"provider": _MODEL_PROVIDERS.get(model_key, "unknown"),
|
|
1869
|
+
}
|
|
1870
|
+
|
|
1871
|
+
return {
|
|
1872
|
+
"provider": provider,
|
|
1873
|
+
"updated": "2026-02-07",
|
|
1874
|
+
"source": "static",
|
|
1875
|
+
"models": models,
|
|
1876
|
+
}
|
|
1877
|
+
|
|
1878
|
+
|
|
1776
1879
|
# =============================================================================
|
|
1777
1880
|
# Completion Council API (v5.25.0)
|
|
1778
1881
|
# =============================================================================
|
|
@@ -1780,7 +1883,7 @@ async def get_cost():
|
|
|
1780
1883
|
@app.get("/api/council/state")
|
|
1781
1884
|
async def get_council_state():
|
|
1782
1885
|
"""Get current Completion Council state."""
|
|
1783
|
-
state_file =
|
|
1886
|
+
state_file = _get_loki_dir() / "council" / "state.json"
|
|
1784
1887
|
if state_file.exists():
|
|
1785
1888
|
try:
|
|
1786
1889
|
return json.loads(state_file.read_text())
|
|
@@ -1792,7 +1895,7 @@ async def get_council_state():
|
|
|
1792
1895
|
@app.get("/api/council/verdicts")
|
|
1793
1896
|
async def get_council_verdicts(limit: int = 20):
|
|
1794
1897
|
"""Get council vote history (decision log)."""
|
|
1795
|
-
state_file =
|
|
1898
|
+
state_file = _get_loki_dir() / "council" / "state.json"
|
|
1796
1899
|
verdicts = []
|
|
1797
1900
|
if state_file.exists():
|
|
1798
1901
|
try:
|
|
@@ -1802,7 +1905,7 @@ async def get_council_verdicts(limit: int = 20):
|
|
|
1802
1905
|
pass
|
|
1803
1906
|
|
|
1804
1907
|
# Also read individual vote files for detail
|
|
1805
|
-
votes_dir =
|
|
1908
|
+
votes_dir = _get_loki_dir() / "council" / "votes"
|
|
1806
1909
|
detailed_verdicts = []
|
|
1807
1910
|
if votes_dir.exists():
|
|
1808
1911
|
for vote_dir in sorted(votes_dir.iterdir(), reverse=True):
|
|
@@ -1841,7 +1944,7 @@ async def get_council_verdicts(limit: int = 20):
|
|
|
1841
1944
|
@app.get("/api/council/convergence")
|
|
1842
1945
|
async def get_council_convergence():
|
|
1843
1946
|
"""Get convergence tracking data for visualization."""
|
|
1844
|
-
convergence_file =
|
|
1947
|
+
convergence_file = _get_loki_dir() / "council" / "convergence.log"
|
|
1845
1948
|
data_points = []
|
|
1846
1949
|
if convergence_file.exists():
|
|
1847
1950
|
try:
|
|
@@ -1863,7 +1966,7 @@ async def get_council_convergence():
|
|
|
1863
1966
|
@app.get("/api/council/report")
|
|
1864
1967
|
async def get_council_report():
|
|
1865
1968
|
"""Get the final council completion report."""
|
|
1866
|
-
report_file =
|
|
1969
|
+
report_file = _get_loki_dir() / "council" / "report.md"
|
|
1867
1970
|
if report_file.exists():
|
|
1868
1971
|
return {"report": report_file.read_text()}
|
|
1869
1972
|
return {"report": None}
|
|
@@ -1872,7 +1975,7 @@ async def get_council_report():
|
|
|
1872
1975
|
@app.post("/api/council/force-review")
|
|
1873
1976
|
async def force_council_review():
|
|
1874
1977
|
"""Force an immediate council review (writes signal file)."""
|
|
1875
|
-
signal_dir =
|
|
1978
|
+
signal_dir = _get_loki_dir() / "signals"
|
|
1876
1979
|
signal_dir.mkdir(parents=True, exist_ok=True)
|
|
1877
1980
|
(signal_dir / "COUNCIL_REVIEW_REQUESTED").write_text(
|
|
1878
1981
|
datetime.now().isoformat()
|
|
@@ -1887,7 +1990,7 @@ async def force_council_review():
|
|
|
1887
1990
|
@app.get("/api/agents")
|
|
1888
1991
|
async def get_agents():
|
|
1889
1992
|
"""Get all active and recent agents."""
|
|
1890
|
-
agents_file =
|
|
1993
|
+
agents_file = _get_loki_dir() / "state" / "agents.json"
|
|
1891
1994
|
agents = []
|
|
1892
1995
|
if agents_file.exists():
|
|
1893
1996
|
try:
|
|
@@ -1909,7 +2012,7 @@ async def get_agents():
|
|
|
1909
2012
|
|
|
1910
2013
|
# Fallback: read agents from dashboard-state.json if agents.json is empty
|
|
1911
2014
|
if not agents:
|
|
1912
|
-
state_file =
|
|
2015
|
+
state_file = _get_loki_dir() / "dashboard-state.json"
|
|
1913
2016
|
if state_file.exists():
|
|
1914
2017
|
try:
|
|
1915
2018
|
state = json.loads(state_file.read_text())
|
|
@@ -1943,7 +2046,7 @@ async def get_agents():
|
|
|
1943
2046
|
@app.post("/api/agents/{agent_id}/kill")
|
|
1944
2047
|
async def kill_agent(agent_id: str):
|
|
1945
2048
|
"""Kill a specific agent by ID."""
|
|
1946
|
-
agents_file =
|
|
2049
|
+
agents_file = _get_loki_dir() / "state" / "agents.json"
|
|
1947
2050
|
if not agents_file.exists():
|
|
1948
2051
|
raise HTTPException(404, "No agents file found")
|
|
1949
2052
|
|
|
@@ -1990,7 +2093,7 @@ async def kill_agent(agent_id: str):
|
|
|
1990
2093
|
async def pause_agent(agent_id: str):
|
|
1991
2094
|
"""Pause a specific agent by writing a pause signal."""
|
|
1992
2095
|
agent_id = _sanitize_agent_id(agent_id)
|
|
1993
|
-
signal_dir =
|
|
2096
|
+
signal_dir = _get_loki_dir() / "signals"
|
|
1994
2097
|
signal_dir.mkdir(parents=True, exist_ok=True)
|
|
1995
2098
|
(signal_dir / f"PAUSE_AGENT_{agent_id}").write_text(
|
|
1996
2099
|
datetime.now().isoformat()
|
|
@@ -2002,7 +2105,7 @@ async def pause_agent(agent_id: str):
|
|
|
2002
2105
|
async def resume_agent(agent_id: str):
|
|
2003
2106
|
"""Resume a paused agent."""
|
|
2004
2107
|
agent_id = _sanitize_agent_id(agent_id)
|
|
2005
|
-
signal_file =
|
|
2108
|
+
signal_file = _get_loki_dir() / "signals" / f"PAUSE_AGENT_{agent_id}"
|
|
2006
2109
|
try:
|
|
2007
2110
|
signal_file.unlink(missing_ok=True)
|
|
2008
2111
|
except Exception:
|
|
@@ -2013,7 +2116,7 @@ async def resume_agent(agent_id: str):
|
|
|
2013
2116
|
@app.get("/api/logs")
|
|
2014
2117
|
async def get_logs(lines: int = 100):
|
|
2015
2118
|
"""Get recent log entries from session log files."""
|
|
2016
|
-
log_dir =
|
|
2119
|
+
log_dir = _get_loki_dir() / "logs"
|
|
2017
2120
|
entries = []
|
|
2018
2121
|
|
|
2019
2122
|
# Regex for full timestamp: [2026-02-07T01:32:00] [INFO] msg or 2026-02-07 01:32:00 INFO msg
|