loki-mode 5.41.0 → 5.42.0

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.0
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.0) | 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.0 | feat: GitHub sync-back, PR creation, export (fully wired) | ~260 lines core**
package/VERSION CHANGED
@@ -1 +1 @@
1
- 5.41.0
1
+ 5.42.0
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.0"
11
11
 
12
12
  # Expose the control app for easy import
13
13
  try:
@@ -1500,22 +1500,61 @@ async def get_memory_timeline():
1500
1500
 
1501
1501
 
1502
1502
  # Learning/metrics endpoints
1503
+
1504
+
1505
+ def _read_learning_signals(signal_type: Optional[str] = None, limit: int = 50) -> list:
1506
+ """Read learning signals from .loki/learning/signals/*.json files.
1507
+
1508
+ Learning signals are written as individual JSON files by the learning emitter
1509
+ (learning/emitter.py). Each file contains a single signal object with fields:
1510
+ id, type, source, action, timestamp, confidence, outcome, data, context.
1511
+ """
1512
+ signals_dir = _get_loki_dir() / "learning" / "signals"
1513
+ if not signals_dir.exists() or not signals_dir.is_dir():
1514
+ return []
1515
+
1516
+ signals = []
1517
+ try:
1518
+ for fpath in signals_dir.glob("*.json"):
1519
+ try:
1520
+ raw = fpath.read_text()
1521
+ if not raw.strip():
1522
+ continue
1523
+ sig = json.loads(raw)
1524
+ if signal_type and sig.get("type") != signal_type:
1525
+ continue
1526
+ signals.append(sig)
1527
+ except (json.JSONDecodeError, OSError):
1528
+ continue
1529
+ except OSError:
1530
+ return []
1531
+
1532
+ # Sort by timestamp descending (newest first)
1533
+ signals.sort(key=lambda s: s.get("timestamp", ""), reverse=True)
1534
+ return signals[:limit]
1535
+
1536
+
1503
1537
  @app.get("/api/learning/metrics")
1504
1538
  async def get_learning_metrics(
1505
1539
  timeRange: str = "7d",
1506
1540
  signalType: Optional[str] = None,
1507
1541
  source: Optional[str] = None,
1508
1542
  ):
1509
- """Get learning metrics from events and metrics files."""
1543
+ """Get learning metrics from events, metrics files, and learning signals."""
1510
1544
  events = _read_events(timeRange)
1511
1545
 
1546
+ # Also read from learning signals directory
1547
+ all_signals = _read_learning_signals(limit=10000)
1548
+
1512
1549
  # Filter by type and source
1513
1550
  if signalType:
1514
1551
  events = [e for e in events if e.get("data", {}).get("type") == signalType]
1552
+ all_signals = [s for s in all_signals if s.get("type") == signalType]
1515
1553
  if source:
1516
1554
  events = [e for e in events if e.get("data", {}).get("source") == source]
1555
+ all_signals = [s for s in all_signals if s.get("source") == source]
1517
1556
 
1518
- # Count by type
1557
+ # Count by type from events.jsonl
1519
1558
  by_type: dict = {}
1520
1559
  by_source: dict = {}
1521
1560
  for e in events:
@@ -1524,6 +1563,19 @@ async def get_learning_metrics(
1524
1563
  s = e.get("data", {}).get("source", "unknown")
1525
1564
  by_source[s] = by_source.get(s, 0) + 1
1526
1565
 
1566
+ # Merge counts from learning signals directory
1567
+ for s in all_signals:
1568
+ t = s.get("type", "unknown")
1569
+ by_type[t] = by_type.get(t, 0) + 1
1570
+ src = s.get("source", "unknown")
1571
+ by_source[src] = by_source.get(src, 0) + 1
1572
+
1573
+ total_count = len(events) + len(all_signals)
1574
+
1575
+ # Calculate average confidence across both sources
1576
+ total_conf = sum(e.get("data", {}).get("confidence", 0) for e in events)
1577
+ total_conf += sum(s.get("confidence", 0) for s in all_signals)
1578
+
1527
1579
  # Load aggregation data from file if available
1528
1580
  aggregation = {
1529
1581
  "preferences": [],
@@ -1543,10 +1595,10 @@ async def get_learning_metrics(
1543
1595
  pass
1544
1596
 
1545
1597
  return {
1546
- "totalSignals": len(events),
1598
+ "totalSignals": total_count,
1547
1599
  "signalsByType": by_type,
1548
1600
  "signalsBySource": by_source,
1549
- "avgConfidence": round(sum(e.get("data", {}).get("confidence", 0) for e in events) / max(len(events), 1), 4),
1601
+ "avgConfidence": round(total_conf / max(total_count, 1), 4),
1550
1602
  "aggregation": aggregation,
1551
1603
  }
1552
1604
 
@@ -1579,25 +1631,107 @@ async def get_learning_signals(
1579
1631
  limit: int = 50,
1580
1632
  offset: int = 0,
1581
1633
  ):
1582
- """Get raw learning signals."""
1634
+ """Get raw learning signals from both events.jsonl and learning signals directory."""
1583
1635
  events = _read_events(timeRange)
1584
1636
  if signalType:
1585
1637
  events = [e for e in events if e.get("type") == signalType]
1586
1638
  if source:
1587
1639
  events = [e for e in events if e.get("data", {}).get("source") == source]
1588
- return events[offset:offset + limit]
1640
+
1641
+ # Also read from learning signals directory
1642
+ file_signals = _read_learning_signals(signal_type=signalType, limit=10000)
1643
+ if source:
1644
+ file_signals = [s for s in file_signals if s.get("source") == source]
1645
+
1646
+ # Merge and sort by timestamp (newest first)
1647
+ combined = events + file_signals
1648
+ combined.sort(key=lambda s: s.get("timestamp", ""), reverse=True)
1649
+ return combined[offset:offset + limit]
1589
1650
 
1590
1651
 
1591
1652
  @app.get("/api/learning/aggregation")
1592
1653
  async def get_learning_aggregation():
1593
- """Get latest learning aggregation result."""
1654
+ """Get latest learning aggregation result, merging file-based aggregation with live signals."""
1655
+ result = {"preferences": [], "error_patterns": [], "success_patterns": [], "tool_efficiencies": []}
1656
+
1657
+ # Load pre-computed aggregation from file if available
1594
1658
  agg_file = _get_loki_dir() / "metrics" / "aggregation.json"
1595
1659
  if agg_file.exists():
1596
1660
  try:
1597
- return json.loads(agg_file.read_text())
1661
+ result = json.loads(agg_file.read_text())
1598
1662
  except Exception:
1599
1663
  pass
1600
- return {"preferences": [], "error_patterns": [], "success_patterns": [], "tool_efficiencies": []}
1664
+
1665
+ # Supplement with live data from learning signals directory
1666
+ success_signals = _read_learning_signals(signal_type="success_pattern", limit=500)
1667
+ tool_signals = _read_learning_signals(signal_type="tool_efficiency", limit=500)
1668
+ error_signals = _read_learning_signals(signal_type="error_pattern", limit=500)
1669
+ pref_signals = _read_learning_signals(signal_type="user_preference", limit=500)
1670
+
1671
+ # Merge success patterns from signals if aggregation file had none
1672
+ if not result.get("success_patterns") and success_signals:
1673
+ pattern_counts: dict = {}
1674
+ for s in success_signals:
1675
+ name = s.get("data", {}).get("pattern_name", s.get("action", "unknown"))
1676
+ pattern_counts[name] = pattern_counts.get(name, 0) + 1
1677
+ result["success_patterns"] = [
1678
+ {"pattern_name": k, "frequency": v, "confidence": min(1.0, v / 10)}
1679
+ for k, v in sorted(pattern_counts.items(), key=lambda x: -x[1])
1680
+ ]
1681
+
1682
+ # Merge tool efficiencies from signals if aggregation file had none
1683
+ if not result.get("tool_efficiencies") and tool_signals:
1684
+ tool_stats: dict = {}
1685
+ for s in tool_signals:
1686
+ data = s.get("data", {})
1687
+ tool_name = data.get("tool_name", s.get("action", "unknown"))
1688
+ if tool_name not in tool_stats:
1689
+ tool_stats[tool_name] = {"count": 0, "total_ms": 0, "successes": 0}
1690
+ tool_stats[tool_name]["count"] += 1
1691
+ tool_stats[tool_name]["total_ms"] += data.get("duration_ms", 0)
1692
+ if data.get("success", s.get("outcome") == "success"):
1693
+ tool_stats[tool_name]["successes"] += 1
1694
+ result["tool_efficiencies"] = []
1695
+ for tname, stats in sorted(tool_stats.items(), key=lambda x: -x[1]["count"]):
1696
+ avg_ms = stats["total_ms"] / stats["count"] if stats["count"] else 0
1697
+ sr = round(stats["successes"] / stats["count"], 4) if stats["count"] else 0
1698
+ result["tool_efficiencies"].append({
1699
+ "tool_name": tname, "efficiency_score": sr,
1700
+ "count": stats["count"], "avg_execution_time_ms": round(avg_ms, 2),
1701
+ "success_rate": sr,
1702
+ })
1703
+
1704
+ # Merge error patterns from signals if aggregation file had none
1705
+ if not result.get("error_patterns") and error_signals:
1706
+ error_counts: dict = {}
1707
+ for s in error_signals:
1708
+ etype = s.get("data", {}).get("error_type", s.get("action", "unknown"))
1709
+ error_counts[etype] = error_counts.get(etype, 0) + 1
1710
+ result["error_patterns"] = [
1711
+ {"error_type": k, "resolution_rate": 0.0, "frequency": v, "confidence": min(1.0, v / 10)}
1712
+ for k, v in sorted(error_counts.items(), key=lambda x: -x[1])
1713
+ ]
1714
+
1715
+ # Merge preferences from signals if aggregation file had none
1716
+ if not result.get("preferences") and pref_signals:
1717
+ pref_counts: dict = {}
1718
+ for s in pref_signals:
1719
+ key = s.get("data", {}).get("preference_key", s.get("action", "unknown"))
1720
+ pref_counts[key] = pref_counts.get(key, 0) + 1
1721
+ result["preferences"] = [
1722
+ {"preference_key": k, "preferred_value": k, "frequency": v, "confidence": min(1.0, v / 10)}
1723
+ for k, v in sorted(pref_counts.items(), key=lambda x: -x[1])
1724
+ ]
1725
+
1726
+ # Add signal counts summary
1727
+ result["signal_counts"] = {
1728
+ "success_patterns": len(success_signals),
1729
+ "tool_efficiency": len(tool_signals),
1730
+ "error_patterns": len(error_signals),
1731
+ "user_preferences": len(pref_signals),
1732
+ }
1733
+
1734
+ return result
1601
1735
 
1602
1736
 
1603
1737
  @app.post("/api/learning/aggregate", dependencies=[Depends(auth.require_scope("control"))])
@@ -1690,34 +1824,50 @@ async def trigger_aggregation():
1690
1824
 
1691
1825
  @app.get("/api/learning/preferences")
1692
1826
  async def get_learning_preferences(limit: int = 50):
1693
- """Get aggregated user preferences."""
1827
+ """Get aggregated user preferences from events and learning signals directory."""
1694
1828
  events = _read_events("30d")
1695
1829
  prefs = [e for e in events if e.get("type") == "user_preference"]
1696
- return prefs[:limit]
1830
+ # Also read from learning signals directory
1831
+ file_prefs = _read_learning_signals(signal_type="user_preference", limit=limit)
1832
+ combined = prefs + file_prefs
1833
+ combined.sort(key=lambda s: s.get("timestamp", ""), reverse=True)
1834
+ return combined[:limit]
1697
1835
 
1698
1836
 
1699
1837
  @app.get("/api/learning/errors")
1700
1838
  async def get_learning_errors(limit: int = 50):
1701
- """Get aggregated error patterns."""
1839
+ """Get aggregated error patterns from events and learning signals directory."""
1702
1840
  events = _read_events("30d")
1703
1841
  errors = [e for e in events if e.get("type") == "error_pattern"]
1704
- return errors[:limit]
1842
+ # Also read from learning signals directory
1843
+ file_errors = _read_learning_signals(signal_type="error_pattern", limit=limit)
1844
+ combined = errors + file_errors
1845
+ combined.sort(key=lambda s: s.get("timestamp", ""), reverse=True)
1846
+ return combined[:limit]
1705
1847
 
1706
1848
 
1707
1849
  @app.get("/api/learning/success")
1708
1850
  async def get_learning_success(limit: int = 50):
1709
- """Get aggregated success patterns."""
1851
+ """Get aggregated success patterns from events and learning signals directory."""
1710
1852
  events = _read_events("30d")
1711
1853
  successes = [e for e in events if e.get("type") == "success_pattern"]
1712
- return successes[:limit]
1854
+ # Also read from learning signals directory
1855
+ file_successes = _read_learning_signals(signal_type="success_pattern", limit=limit)
1856
+ combined = successes + file_successes
1857
+ combined.sort(key=lambda s: s.get("timestamp", ""), reverse=True)
1858
+ return combined[:limit]
1713
1859
 
1714
1860
 
1715
1861
  @app.get("/api/learning/tools")
1716
1862
  async def get_tool_efficiency(limit: int = 50):
1717
- """Get tool efficiency rankings."""
1863
+ """Get tool efficiency rankings from events and learning signals directory."""
1718
1864
  events = _read_events("30d")
1719
1865
  tools = [e for e in events if e.get("type") == "tool_efficiency"]
1720
- return tools[:limit]
1866
+ # Also read from learning signals directory
1867
+ file_tools = _read_learning_signals(signal_type="tool_efficiency", limit=limit)
1868
+ combined = tools + file_tools
1869
+ combined.sort(key=lambda s: s.get("timestamp", ""), reverse=True)
1870
+ return combined[:limit]
1721
1871
 
1722
1872
 
1723
1873
  def _parse_time_range(time_range: str) -> Optional[datetime]:
@@ -1957,24 +2107,28 @@ async def get_cost():
1957
2107
  except (json.JSONDecodeError, KeyError, TypeError):
1958
2108
  pass
1959
2109
 
1960
- # Also check dashboard-state.json for token data if efficiency dir is empty
2110
+ # Fallback: read from context tracking if efficiency files have no token data
1961
2111
  if total_input == 0 and total_output == 0:
1962
- state_file = loki_dir / "dashboard-state.json"
1963
- if state_file.exists():
2112
+ ctx_file = loki_dir / "context" / "tracking.json"
2113
+ if ctx_file.exists():
1964
2114
  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()
2115
+ ctx = json.loads(ctx_file.read_text())
2116
+ totals = ctx.get("totals", {})
2117
+ total_input = totals.get("total_input", 0)
2118
+ total_output = totals.get("total_output", 0)
1970
2119
  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
- }
2120
+ estimated_cost = totals.get("total_cost_usd", 0.0)
2121
+ # Rebuild by_model and by_phase from per_iteration data
2122
+ for it in ctx.get("per_iteration", []):
2123
+ inp = it.get("input_tokens", 0)
2124
+ out = it.get("output_tokens", 0)
2125
+ cost = it.get("cost_usd", 0)
2126
+ model = ctx.get("provider", "sonnet").lower()
2127
+ if model not in by_model:
2128
+ by_model[model] = {"input_tokens": 0, "output_tokens": 0, "cost_usd": 0.0}
2129
+ by_model[model]["input_tokens"] += inp
2130
+ by_model[model]["output_tokens"] += out
2131
+ by_model[model]["cost_usd"] += cost
1978
2132
  except (json.JSONDecodeError, KeyError):
1979
2133
  pass
1980
2134
 
@@ -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.0
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.0'
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "loki-mode",
3
- "version": "5.41.0",
3
+ "version": "5.42.0",
4
4
  "description": "Multi-agent autonomous startup system for Claude Code, Codex CLI, and Gemini CLI",
5
5
  "keywords": [
6
6
  "claude",