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 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.0
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.0 | Demo, quick mode, init, cost dashboard, 12 templates, GitHub Action | ~270 lines core**
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.0
1
+ 5.28.1
@@ -76,6 +76,7 @@ council_init() {
76
76
  cat > "$COUNCIL_STATE_DIR/state.json" << 'COUNCIL_EOF'
77
77
  {
78
78
  "initialized": true,
79
+ "enabled": true,
79
80
  "total_votes": 0,
80
81
  "approve_votes": 0,
81
82
  "reject_votes": 0,
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
- local query_escaped="${query//\'/\'\\\'\'}"
3857
- python3 -c "
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 = '$learnings_dir'
3862
- query = '${query_escaped}'.lower()
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
- # Kill existing dashboard on this port
3566
- log_step "Killing existing dashboard on port $DASHBOARD_PORT..."
3567
- kill "$existing_pid" 2>/dev/null || true
3568
- sleep 1
3569
- break
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
@@ -7,7 +7,7 @@ Modules:
7
7
  control: Session control API (start/stop/pause/resume)
8
8
  """
9
9
 
10
- __version__ = "5.28.0"
10
+ __version__ = "5.28.1"
11
11
 
12
12
  # Expose the control app for easy import
13
13
  try:
@@ -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 = _LOKI_DIR / "memory"
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 = _LOKI_DIR / "memory" / "episodic"
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 = _LOKI_DIR / "memory" / "episodic"
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 = _LOKI_DIR / "memory" / "semantic"
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 = _LOKI_DIR / "memory" / "skills"
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 = _LOKI_DIR / "memory" / "skills"
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 = _LOKI_DIR / "memory" / "token_economics.json"
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 = _LOKI_DIR / "memory" / "index.json"
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 = _LOKI_DIR / "memory" / "timeline.json"
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 = _LOKI_DIR / "metrics" / "aggregation.json"
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 = _LOKI_DIR / "metrics" / "aggregation.json"
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 = _LOKI_DIR / "events.jsonl"
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 = [{"key": k, "count": v} for k, v in sorted(preferences.items(), key=lambda x: -x[1])]
1495
- error_list = [{"type": k, "count": v} for k, v in sorted(error_patterns.items(), key=lambda x: -x[1])]
1496
- success_list = [{"name": k, "count": v} for k, v in sorted(success_patterns.items(), key=lambda x: -x[1])]
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
- "tool": tname,
1501
+ "tool_name": tname,
1502
+ "efficiency_score": sr,
1502
1503
  "count": stats["count"],
1503
- "avg_duration_ms": round(avg_ms, 2),
1504
- "success_rate": round(stats["successes"] / stats["count"], 4) if stats["count"] else 0,
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 = _LOKI_DIR / "metrics"
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 = _LOKI_DIR / "events.jsonl"
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 = _LOKI_DIR / "PAUSE"
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 = _LOKI_DIR / fname
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 = _LOKI_DIR / "STOP"
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 = _LOKI_DIR / "loki.pid"
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 = _LOKI_DIR / "session.json"
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
- # Standard API pricing per million tokens (USD)
1662
- _MODEL_PRICING = {
1663
- "opus": {"input": 15.00, "output": 75.00},
1664
- "sonnet": {"input": 3.00, "output": 15.00},
1665
- "haiku": {"input": 0.25, "output": 1.25},
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
- pricing = _MODEL_PRICING.get(model.lower(), _MODEL_PRICING.get("sonnet", {}))
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 = _LOKI_DIR / "council" / "state.json"
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 = _LOKI_DIR / "council" / "state.json"
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 = _LOKI_DIR / "council" / "votes"
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 = _LOKI_DIR / "council" / "convergence.log"
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 = _LOKI_DIR / "council" / "report.md"
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 = _LOKI_DIR / "signals"
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 = _LOKI_DIR / "state" / "agents.json"
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 = _LOKI_DIR / "dashboard-state.json"
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 = _LOKI_DIR / "state" / "agents.json"
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 = _LOKI_DIR / "signals"
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 = _LOKI_DIR / "signals" / f"PAUSE_AGENT_{agent_id}"
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 = _LOKI_DIR / "logs"
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