loki-mode 5.41.0 → 5.42.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.41.0
6
+ # Loki Mode v5.42.1
7
7
 
8
8
  **You are an autonomous agent. You make decisions. You do not ask questions. You do not stop.**
9
9
 
@@ -258,8 +258,8 @@ The following features are documented in skill modules but not yet fully automat
258
258
  |---------|--------|-------|
259
259
  | PRE-ACT goal drift detection | Planned | Agent-level attention check before each action; no automated enforcement yet |
260
260
  | CONTINUITY.md working memory | Implemented (v5.35.0) | Auto-managed by run.sh, updated each iteration |
261
- | GitHub integration | Implemented (v5.41.0) | Import, sync-back, PR creation, export. CLI: `loki github`, API: `/api/github/*` |
261
+ | GitHub integration | Implemented (v5.42.1) | Import, sync-back, PR creation, export. CLI: `loki github`, API: `/api/github/*` |
262
262
  | Quality gates 3-reviewer system | Implemented (v5.35.0) | 5 specialist reviewers in `skills/quality-gates.md`; execution in run.sh |
263
263
  | Benchmarks (HumanEval, SWE-bench) | Infrastructure only | Runner scripts and datasets exist in `benchmarks/`; no published results |
264
264
 
265
- **v5.41.0 | feat: GitHub sync-back, PR creation, export (fully wired) | ~260 lines core**
265
+ **v5.42.1 | feat: GitHub sync-back, PR creation, export (fully wired) | ~260 lines core**
package/VERSION CHANGED
@@ -1 +1 @@
1
- 5.41.0
1
+ 5.42.1
package/autonomy/run.sh CHANGED
@@ -2705,6 +2705,14 @@ except: print('{\"total\":0,\"unacknowledged\":0}')
2705
2705
  "council": $council_state,
2706
2706
  "budget": $budget_json,
2707
2707
  "context": $context_state,
2708
+ "tokens": $(python3 -c "
2709
+ import json
2710
+ try:
2711
+ t = json.load(open('.loki/context/tracking.json'))
2712
+ totals = t.get('totals', {})
2713
+ print(json.dumps({'input': totals.get('total_input', 0), 'output': totals.get('total_output', 0), 'cost_usd': totals.get('total_cost_usd', 0)}))
2714
+ except: print('null')
2715
+ " 2>/dev/null || echo "null"),
2708
2716
  "notifications": $notification_summary
2709
2717
  }
2710
2718
  EOF
@@ -2857,6 +2865,9 @@ track_iteration_complete() {
2857
2865
  --context "{\"iteration\":$iteration,\"exit_code\":$exit_code}"
2858
2866
  fi
2859
2867
 
2868
+ # Track context window usage FIRST to get token data (v5.42.0)
2869
+ track_context_usage "$iteration"
2870
+
2860
2871
  # Write efficiency tracking file for /api/cost endpoint
2861
2872
  mkdir -p .loki/metrics/efficiency
2862
2873
  local model_tier="sonnet"
@@ -2869,6 +2880,25 @@ track_iteration_complete() {
2869
2880
  fi
2870
2881
  local phase="${LAST_KNOWN_PHASE:-}"
2871
2882
  [ -z "$phase" ] && phase=$(python3 -c "import json; print(json.load(open('.loki/state/orchestrator.json')).get('currentPhase', 'unknown'))" 2>/dev/null || echo "unknown")
2883
+
2884
+ # Read token data from context tracker output (v5.42.0)
2885
+ local iter_input=0 iter_output=0 iter_cost=0
2886
+ if [ -f ".loki/context/tracking.json" ]; then
2887
+ read iter_input iter_output iter_cost < <(python3 -c "
2888
+ import json
2889
+ try:
2890
+ t = json.load(open('.loki/context/tracking.json'))
2891
+ iters = t.get('per_iteration', [])
2892
+ match = [i for i in iters if i.get('iteration') == $iteration]
2893
+ if match:
2894
+ m = match[-1]
2895
+ print(m.get('input_tokens', 0), m.get('output_tokens', 0), m.get('cost_usd', 0))
2896
+ else:
2897
+ print(0, 0, 0)
2898
+ except: print(0, 0, 0)
2899
+ " 2>/dev/null || echo "0 0 0")
2900
+ fi
2901
+
2872
2902
  cat > ".loki/metrics/efficiency/iteration-${iteration}.json" << EFF_EOF
2873
2903
  {
2874
2904
  "iteration": $iteration,
@@ -2877,13 +2907,13 @@ track_iteration_complete() {
2877
2907
  "duration_ms": $duration_ms,
2878
2908
  "provider": "${PROVIDER_NAME:-claude}",
2879
2909
  "status": "$status_str",
2910
+ "input_tokens": ${iter_input:-0},
2911
+ "output_tokens": ${iter_output:-0},
2912
+ "cost_usd": ${iter_cost:-0},
2880
2913
  "timestamp": "$(date -u +%Y-%m-%dT%H:%M:%SZ)"
2881
2914
  }
2882
2915
  EFF_EOF
2883
2916
 
2884
- # Track context window usage (v5.40.0)
2885
- track_context_usage "$iteration"
2886
-
2887
2917
  # Check notification triggers (v5.40.0)
2888
2918
  check_notification_triggers "$iteration"
2889
2919
 
@@ -7,7 +7,7 @@ Modules:
7
7
  control: Session control API (start/stop/pause/resume)
8
8
  """
9
9
 
10
- __version__ = "5.41.0"
10
+ __version__ = "5.42.1"
11
11
 
12
12
  # Expose the control app for easy import
13
13
  try:
@@ -259,14 +259,57 @@ manager = ConnectionManager()
259
259
  start_time = datetime.now(timezone.utc)
260
260
 
261
261
 
262
+ async def _orphan_watchdog():
263
+ """Background task that shuts down dashboard if the parent session dies.
264
+
265
+ When the session process is killed (SIGKILL), the cleanup trap never runs
266
+ and the dashboard is left orphaned. This watchdog checks the session PID
267
+ every 30 seconds and initiates shutdown if the session is gone.
268
+ """
269
+ loki_dir = _get_loki_dir()
270
+ pid_file = loki_dir / "loki.pid"
271
+ # Wait 60s before first check to let session fully start
272
+ await asyncio.sleep(60)
273
+ while True:
274
+ try:
275
+ if pid_file.exists():
276
+ pid = int(pid_file.read_text().strip())
277
+ try:
278
+ os.kill(pid, 0) # Check if process exists
279
+ except OSError:
280
+ # Session PID is dead -- we're orphaned
281
+ logger.warning(
282
+ "Session PID %d is gone. Dashboard shutting down to avoid orphan.", pid
283
+ )
284
+ # Clean up our own PID file
285
+ dash_pid = loki_dir / "dashboard" / "dashboard.pid"
286
+ dash_pid.unlink(missing_ok=True)
287
+ # Give a moment for any in-flight requests
288
+ await asyncio.sleep(2)
289
+ os._exit(0)
290
+ # If PID file doesn't exist and we've been running >2 min, also shut down
291
+ elif time.time() - _dashboard_start_time > 120:
292
+ logger.warning("No session PID file found. Dashboard shutting down.")
293
+ os._exit(0)
294
+ except (ValueError, OSError):
295
+ pass
296
+ await asyncio.sleep(30)
297
+
298
+
299
+ _dashboard_start_time = time.time()
300
+
301
+
262
302
  @asynccontextmanager
263
303
  async def lifespan(app: FastAPI):
264
304
  """Application lifespan handler."""
265
305
  # Startup
266
306
  await init_db()
267
307
  _telemetry.send_telemetry("dashboard_start")
308
+ # Start orphan watchdog
309
+ watchdog_task = asyncio.create_task(_orphan_watchdog())
268
310
  yield
269
311
  # Shutdown
312
+ watchdog_task.cancel()
270
313
  await close_db()
271
314
 
272
315
 
@@ -1500,22 +1543,61 @@ async def get_memory_timeline():
1500
1543
 
1501
1544
 
1502
1545
  # Learning/metrics endpoints
1546
+
1547
+
1548
+ def _read_learning_signals(signal_type: Optional[str] = None, limit: int = 50) -> list:
1549
+ """Read learning signals from .loki/learning/signals/*.json files.
1550
+
1551
+ Learning signals are written as individual JSON files by the learning emitter
1552
+ (learning/emitter.py). Each file contains a single signal object with fields:
1553
+ id, type, source, action, timestamp, confidence, outcome, data, context.
1554
+ """
1555
+ signals_dir = _get_loki_dir() / "learning" / "signals"
1556
+ if not signals_dir.exists() or not signals_dir.is_dir():
1557
+ return []
1558
+
1559
+ signals = []
1560
+ try:
1561
+ for fpath in signals_dir.glob("*.json"):
1562
+ try:
1563
+ raw = fpath.read_text()
1564
+ if not raw.strip():
1565
+ continue
1566
+ sig = json.loads(raw)
1567
+ if signal_type and sig.get("type") != signal_type:
1568
+ continue
1569
+ signals.append(sig)
1570
+ except (json.JSONDecodeError, OSError):
1571
+ continue
1572
+ except OSError:
1573
+ return []
1574
+
1575
+ # Sort by timestamp descending (newest first)
1576
+ signals.sort(key=lambda s: s.get("timestamp", ""), reverse=True)
1577
+ return signals[:limit]
1578
+
1579
+
1503
1580
  @app.get("/api/learning/metrics")
1504
1581
  async def get_learning_metrics(
1505
1582
  timeRange: str = "7d",
1506
1583
  signalType: Optional[str] = None,
1507
1584
  source: Optional[str] = None,
1508
1585
  ):
1509
- """Get learning metrics from events and metrics files."""
1586
+ """Get learning metrics from events, metrics files, and learning signals."""
1510
1587
  events = _read_events(timeRange)
1511
1588
 
1589
+ # Also read from learning signals directory
1590
+ all_signals = _read_learning_signals(limit=10000)
1591
+
1512
1592
  # Filter by type and source
1513
1593
  if signalType:
1514
1594
  events = [e for e in events if e.get("data", {}).get("type") == signalType]
1595
+ all_signals = [s for s in all_signals if s.get("type") == signalType]
1515
1596
  if source:
1516
1597
  events = [e for e in events if e.get("data", {}).get("source") == source]
1598
+ all_signals = [s for s in all_signals if s.get("source") == source]
1517
1599
 
1518
- # Count by type
1600
+ # Count by type from events.jsonl
1519
1601
  by_type: dict = {}
1520
1602
  by_source: dict = {}
1521
1603
  for e in events:
@@ -1524,6 +1606,19 @@ async def get_learning_metrics(
1524
1606
  s = e.get("data", {}).get("source", "unknown")
1525
1607
  by_source[s] = by_source.get(s, 0) + 1
1526
1608
 
1609
+ # Merge counts from learning signals directory
1610
+ for s in all_signals:
1611
+ t = s.get("type", "unknown")
1612
+ by_type[t] = by_type.get(t, 0) + 1
1613
+ src = s.get("source", "unknown")
1614
+ by_source[src] = by_source.get(src, 0) + 1
1615
+
1616
+ total_count = len(events) + len(all_signals)
1617
+
1618
+ # Calculate average confidence across both sources
1619
+ total_conf = sum(e.get("data", {}).get("confidence", 0) for e in events)
1620
+ total_conf += sum(s.get("confidence", 0) for s in all_signals)
1621
+
1527
1622
  # Load aggregation data from file if available
1528
1623
  aggregation = {
1529
1624
  "preferences": [],
@@ -1543,10 +1638,10 @@ async def get_learning_metrics(
1543
1638
  pass
1544
1639
 
1545
1640
  return {
1546
- "totalSignals": len(events),
1641
+ "totalSignals": total_count,
1547
1642
  "signalsByType": by_type,
1548
1643
  "signalsBySource": by_source,
1549
- "avgConfidence": round(sum(e.get("data", {}).get("confidence", 0) for e in events) / max(len(events), 1), 4),
1644
+ "avgConfidence": round(total_conf / max(total_count, 1), 4),
1550
1645
  "aggregation": aggregation,
1551
1646
  }
1552
1647
 
@@ -1579,25 +1674,107 @@ async def get_learning_signals(
1579
1674
  limit: int = 50,
1580
1675
  offset: int = 0,
1581
1676
  ):
1582
- """Get raw learning signals."""
1677
+ """Get raw learning signals from both events.jsonl and learning signals directory."""
1583
1678
  events = _read_events(timeRange)
1584
1679
  if signalType:
1585
1680
  events = [e for e in events if e.get("type") == signalType]
1586
1681
  if source:
1587
1682
  events = [e for e in events if e.get("data", {}).get("source") == source]
1588
- return events[offset:offset + limit]
1683
+
1684
+ # Also read from learning signals directory
1685
+ file_signals = _read_learning_signals(signal_type=signalType, limit=10000)
1686
+ if source:
1687
+ file_signals = [s for s in file_signals if s.get("source") == source]
1688
+
1689
+ # Merge and sort by timestamp (newest first)
1690
+ combined = events + file_signals
1691
+ combined.sort(key=lambda s: s.get("timestamp", ""), reverse=True)
1692
+ return combined[offset:offset + limit]
1589
1693
 
1590
1694
 
1591
1695
  @app.get("/api/learning/aggregation")
1592
1696
  async def get_learning_aggregation():
1593
- """Get latest learning aggregation result."""
1697
+ """Get latest learning aggregation result, merging file-based aggregation with live signals."""
1698
+ result = {"preferences": [], "error_patterns": [], "success_patterns": [], "tool_efficiencies": []}
1699
+
1700
+ # Load pre-computed aggregation from file if available
1594
1701
  agg_file = _get_loki_dir() / "metrics" / "aggregation.json"
1595
1702
  if agg_file.exists():
1596
1703
  try:
1597
- return json.loads(agg_file.read_text())
1704
+ result = json.loads(agg_file.read_text())
1598
1705
  except Exception:
1599
1706
  pass
1600
- return {"preferences": [], "error_patterns": [], "success_patterns": [], "tool_efficiencies": []}
1707
+
1708
+ # Supplement with live data from learning signals directory
1709
+ success_signals = _read_learning_signals(signal_type="success_pattern", limit=500)
1710
+ tool_signals = _read_learning_signals(signal_type="tool_efficiency", limit=500)
1711
+ error_signals = _read_learning_signals(signal_type="error_pattern", limit=500)
1712
+ pref_signals = _read_learning_signals(signal_type="user_preference", limit=500)
1713
+
1714
+ # Merge success patterns from signals if aggregation file had none
1715
+ if not result.get("success_patterns") and success_signals:
1716
+ pattern_counts: dict = {}
1717
+ for s in success_signals:
1718
+ name = s.get("data", {}).get("pattern_name", s.get("action", "unknown"))
1719
+ pattern_counts[name] = pattern_counts.get(name, 0) + 1
1720
+ result["success_patterns"] = [
1721
+ {"pattern_name": k, "frequency": v, "confidence": min(1.0, v / 10)}
1722
+ for k, v in sorted(pattern_counts.items(), key=lambda x: -x[1])
1723
+ ]
1724
+
1725
+ # Merge tool efficiencies from signals if aggregation file had none
1726
+ if not result.get("tool_efficiencies") and tool_signals:
1727
+ tool_stats: dict = {}
1728
+ for s in tool_signals:
1729
+ data = s.get("data", {})
1730
+ tool_name = data.get("tool_name", s.get("action", "unknown"))
1731
+ if tool_name not in tool_stats:
1732
+ tool_stats[tool_name] = {"count": 0, "total_ms": 0, "successes": 0}
1733
+ tool_stats[tool_name]["count"] += 1
1734
+ tool_stats[tool_name]["total_ms"] += data.get("duration_ms", 0)
1735
+ if data.get("success", s.get("outcome") == "success"):
1736
+ tool_stats[tool_name]["successes"] += 1
1737
+ result["tool_efficiencies"] = []
1738
+ for tname, stats in sorted(tool_stats.items(), key=lambda x: -x[1]["count"]):
1739
+ avg_ms = stats["total_ms"] / stats["count"] if stats["count"] else 0
1740
+ sr = round(stats["successes"] / stats["count"], 4) if stats["count"] else 0
1741
+ result["tool_efficiencies"].append({
1742
+ "tool_name": tname, "efficiency_score": sr,
1743
+ "count": stats["count"], "avg_execution_time_ms": round(avg_ms, 2),
1744
+ "success_rate": sr,
1745
+ })
1746
+
1747
+ # Merge error patterns from signals if aggregation file had none
1748
+ if not result.get("error_patterns") and error_signals:
1749
+ error_counts: dict = {}
1750
+ for s in error_signals:
1751
+ etype = s.get("data", {}).get("error_type", s.get("action", "unknown"))
1752
+ error_counts[etype] = error_counts.get(etype, 0) + 1
1753
+ result["error_patterns"] = [
1754
+ {"error_type": k, "resolution_rate": 0.0, "frequency": v, "confidence": min(1.0, v / 10)}
1755
+ for k, v in sorted(error_counts.items(), key=lambda x: -x[1])
1756
+ ]
1757
+
1758
+ # Merge preferences from signals if aggregation file had none
1759
+ if not result.get("preferences") and pref_signals:
1760
+ pref_counts: dict = {}
1761
+ for s in pref_signals:
1762
+ key = s.get("data", {}).get("preference_key", s.get("action", "unknown"))
1763
+ pref_counts[key] = pref_counts.get(key, 0) + 1
1764
+ result["preferences"] = [
1765
+ {"preference_key": k, "preferred_value": k, "frequency": v, "confidence": min(1.0, v / 10)}
1766
+ for k, v in sorted(pref_counts.items(), key=lambda x: -x[1])
1767
+ ]
1768
+
1769
+ # Add signal counts summary
1770
+ result["signal_counts"] = {
1771
+ "success_patterns": len(success_signals),
1772
+ "tool_efficiency": len(tool_signals),
1773
+ "error_patterns": len(error_signals),
1774
+ "user_preferences": len(pref_signals),
1775
+ }
1776
+
1777
+ return result
1601
1778
 
1602
1779
 
1603
1780
  @app.post("/api/learning/aggregate", dependencies=[Depends(auth.require_scope("control"))])
@@ -1690,34 +1867,50 @@ async def trigger_aggregation():
1690
1867
 
1691
1868
  @app.get("/api/learning/preferences")
1692
1869
  async def get_learning_preferences(limit: int = 50):
1693
- """Get aggregated user preferences."""
1870
+ """Get aggregated user preferences from events and learning signals directory."""
1694
1871
  events = _read_events("30d")
1695
1872
  prefs = [e for e in events if e.get("type") == "user_preference"]
1696
- return prefs[:limit]
1873
+ # Also read from learning signals directory
1874
+ file_prefs = _read_learning_signals(signal_type="user_preference", limit=limit)
1875
+ combined = prefs + file_prefs
1876
+ combined.sort(key=lambda s: s.get("timestamp", ""), reverse=True)
1877
+ return combined[:limit]
1697
1878
 
1698
1879
 
1699
1880
  @app.get("/api/learning/errors")
1700
1881
  async def get_learning_errors(limit: int = 50):
1701
- """Get aggregated error patterns."""
1882
+ """Get aggregated error patterns from events and learning signals directory."""
1702
1883
  events = _read_events("30d")
1703
1884
  errors = [e for e in events if e.get("type") == "error_pattern"]
1704
- return errors[:limit]
1885
+ # Also read from learning signals directory
1886
+ file_errors = _read_learning_signals(signal_type="error_pattern", limit=limit)
1887
+ combined = errors + file_errors
1888
+ combined.sort(key=lambda s: s.get("timestamp", ""), reverse=True)
1889
+ return combined[:limit]
1705
1890
 
1706
1891
 
1707
1892
  @app.get("/api/learning/success")
1708
1893
  async def get_learning_success(limit: int = 50):
1709
- """Get aggregated success patterns."""
1894
+ """Get aggregated success patterns from events and learning signals directory."""
1710
1895
  events = _read_events("30d")
1711
1896
  successes = [e for e in events if e.get("type") == "success_pattern"]
1712
- return successes[:limit]
1897
+ # Also read from learning signals directory
1898
+ file_successes = _read_learning_signals(signal_type="success_pattern", limit=limit)
1899
+ combined = successes + file_successes
1900
+ combined.sort(key=lambda s: s.get("timestamp", ""), reverse=True)
1901
+ return combined[:limit]
1713
1902
 
1714
1903
 
1715
1904
  @app.get("/api/learning/tools")
1716
1905
  async def get_tool_efficiency(limit: int = 50):
1717
- """Get tool efficiency rankings."""
1906
+ """Get tool efficiency rankings from events and learning signals directory."""
1718
1907
  events = _read_events("30d")
1719
1908
  tools = [e for e in events if e.get("type") == "tool_efficiency"]
1720
- return tools[:limit]
1909
+ # Also read from learning signals directory
1910
+ file_tools = _read_learning_signals(signal_type="tool_efficiency", limit=limit)
1911
+ combined = tools + file_tools
1912
+ combined.sort(key=lambda s: s.get("timestamp", ""), reverse=True)
1913
+ return combined[:limit]
1721
1914
 
1722
1915
 
1723
1916
  def _parse_time_range(time_range: str) -> Optional[datetime]:
@@ -1957,24 +2150,28 @@ async def get_cost():
1957
2150
  except (json.JSONDecodeError, KeyError, TypeError):
1958
2151
  pass
1959
2152
 
1960
- # Also check dashboard-state.json for token data if efficiency dir is empty
2153
+ # Fallback: read from context tracking if efficiency files have no token data
1961
2154
  if total_input == 0 and total_output == 0:
1962
- state_file = loki_dir / "dashboard-state.json"
1963
- if state_file.exists():
2155
+ ctx_file = loki_dir / "context" / "tracking.json"
2156
+ if ctx_file.exists():
1964
2157
  try:
1965
- state = json.loads(state_file.read_text())
1966
- tokens = state.get("tokens", {})
1967
- total_input = tokens.get("input", 0)
1968
- total_output = tokens.get("output", 0)
1969
- model = state.get("model", "sonnet").lower()
2158
+ ctx = json.loads(ctx_file.read_text())
2159
+ totals = ctx.get("totals", {})
2160
+ total_input = totals.get("total_input", 0)
2161
+ total_output = totals.get("total_output", 0)
1970
2162
  if total_input > 0 or total_output > 0:
1971
- estimated_cost = _calculate_model_cost(model, total_input, total_output)
1972
- if model not in by_model:
1973
- by_model[model] = {
1974
- "input_tokens": total_input,
1975
- "output_tokens": total_output,
1976
- "cost_usd": estimated_cost,
1977
- }
2163
+ estimated_cost = totals.get("total_cost_usd", 0.0)
2164
+ # Rebuild by_model and by_phase from per_iteration data
2165
+ for it in ctx.get("per_iteration", []):
2166
+ inp = it.get("input_tokens", 0)
2167
+ out = it.get("output_tokens", 0)
2168
+ cost = it.get("cost_usd", 0)
2169
+ model = ctx.get("provider", "sonnet").lower()
2170
+ if model not in by_model:
2171
+ by_model[model] = {"input_tokens": 0, "output_tokens": 0, "cost_usd": 0.0}
2172
+ by_model[model]["input_tokens"] += inp
2173
+ by_model[model]["output_tokens"] += out
2174
+ by_model[model]["cost_usd"] += cost
1978
2175
  except (json.JSONDecodeError, KeyError):
1979
2176
  pass
1980
2177
 
@@ -2,7 +2,7 @@
2
2
 
3
3
  Complete installation instructions for all platforms and use cases.
4
4
 
5
- **Version:** v5.41.0
5
+ **Version:** v5.42.1
6
6
 
7
7
  ---
8
8
 
package/mcp/__init__.py CHANGED
@@ -21,4 +21,4 @@ try:
21
21
  except ImportError:
22
22
  __all__ = ['mcp']
23
23
 
24
- __version__ = '5.41.0'
24
+ __version__ = '5.42.1'
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "loki-mode",
3
- "version": "5.41.0",
3
+ "version": "5.42.1",
4
4
  "description": "Multi-agent autonomous startup system for Claude Code, Codex CLI, and Gemini CLI",
5
5
  "keywords": [
6
6
  "claude",