@matt82198/aesop 0.1.0-beta.3 → 0.1.0-beta.5

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.
Files changed (234) hide show
  1. package/CHANGELOG.md +79 -95
  2. package/CLAUDE-TEMPLATE.md +115 -0
  3. package/README.md +126 -245
  4. package/aesop.config.example.json +34 -26
  5. package/bin/CLAUDE.md +67 -0
  6. package/bin/cli.js +593 -36
  7. package/daemons/CLAUDE.md +36 -0
  8. package/daemons/backup-fleet.sh +92 -21
  9. package/daemons/run-watchdog.sh +95 -12
  10. package/dash/CLAUDE.md +32 -0
  11. package/dash/dash-extra.mjs +194 -21
  12. package/dash/watchdog-gui.sh +23 -6
  13. package/docs/BEHAVIORAL-PR-REVIEW.md +86 -0
  14. package/docs/CHECKPOINTING.md +100 -0
  15. package/docs/DISPATCH-MODEL.md +11 -7
  16. package/docs/FORENSICS.md +204 -0
  17. package/docs/GOVERNANCE.md +4 -2
  18. package/docs/HOOK-INSTALL.md +307 -0
  19. package/docs/HOW-THE-LOOP-WORKS.md +175 -0
  20. package/docs/MEMORY-TEMPLATE.md +61 -0
  21. package/docs/README.md +44 -0
  22. package/docs/RELEASING.md +159 -0
  23. package/docs/RELIABILITY.md +61 -0
  24. package/docs/RESTORE.md +397 -0
  25. package/docs/SCRIPTS-POLICY.md +97 -0
  26. package/docs/archive/README.md +3 -0
  27. package/docs/archive/spikes/tiered-cognition/ACTIVATION.md +125 -0
  28. package/docs/archive/spikes/tiered-cognition/DESIGN.md +287 -0
  29. package/docs/archive/spikes/tiered-cognition/FINDINGS.md +113 -0
  30. package/docs/archive/spikes/tiered-cognition/README.md +27 -0
  31. package/docs/archive/spikes/tiered-cognition/aesop-cognition.example.md +32 -0
  32. package/docs/archive/spikes/tiered-cognition/force-model-policy.merged.mjs +673 -0
  33. package/docs/archive/spikes/tiered-cognition/strip-tools-hook.mjs +434 -0
  34. package/docs/case-study-portfolio.md +61 -0
  35. package/docs/self-stats-data.json +11 -0
  36. package/docs/templates/FLEET-OPS-ANALYSIS.example.md +27 -0
  37. package/docs/templates/FLEET-OPS-RECOMMENDATIONS.example.md +24 -0
  38. package/docs/templates/PROPOSALS-LOG.example.md +64 -0
  39. package/hooks/CLAUDE.md +61 -0
  40. package/hooks/claude/force-model-policy.mjs +159 -0
  41. package/hooks/pre-push-policy.sh +753 -0
  42. package/mcp/CLAUDE.md +213 -0
  43. package/mcp/package.json +26 -0
  44. package/mcp/server.mjs +543 -0
  45. package/monitor/.signal-state.json +3 -0
  46. package/monitor/ACTIONS.log +1 -0
  47. package/monitor/BRIEF.md +24 -0
  48. package/monitor/CHARTER.md +75 -88
  49. package/monitor/CLAUDE.md +40 -0
  50. package/monitor/SIGNALS.json +54 -0
  51. package/monitor/collect-signals.mjs +613 -61
  52. package/package.json +15 -3
  53. package/scan/fleet-scan.example.mjs +292 -0
  54. package/skills/CLAUDE.md +3 -0
  55. package/skills/healthcheck/SKILL.md +44 -0
  56. package/skills/power/SKILL.md +161 -0
  57. package/state_store/CLAUDE.md +39 -0
  58. package/state_store/__init__.py +24 -0
  59. package/state_store/__pycache__/__init__.cpython-314.pyc +0 -0
  60. package/state_store/__pycache__/api.cpython-314.pyc +0 -0
  61. package/state_store/__pycache__/export.cpython-314.pyc +0 -0
  62. package/state_store/__pycache__/ingest.cpython-314.pyc +0 -0
  63. package/state_store/__pycache__/projections.cpython-314.pyc +0 -0
  64. package/state_store/__pycache__/store.cpython-314.pyc +0 -0
  65. package/state_store/api.py +35 -0
  66. package/state_store/export.py +19 -0
  67. package/state_store/ingest.py +24 -0
  68. package/state_store/projections.py +52 -0
  69. package/state_store/store.py +102 -0
  70. package/tools/CLAUDE.md +72 -0
  71. package/tools/__pycache__/alert_bridge.cpython-314.pyc +0 -0
  72. package/tools/__pycache__/buildlog.cpython-314.pyc +0 -0
  73. package/tools/__pycache__/ci_merge_wait.cpython-314.pyc +0 -0
  74. package/tools/__pycache__/ensure_state.cpython-314.pyc +0 -0
  75. package/tools/__pycache__/eod_sweep.cpython-314.pyc +0 -0
  76. package/tools/__pycache__/fleet_ledger.cpython-314.pyc +0 -0
  77. package/tools/__pycache__/fleet_prompt_extractor.cpython-314.pyc +0 -0
  78. package/tools/__pycache__/healthcheck.cpython-314.pyc +0 -0
  79. package/tools/__pycache__/heartbeat.cpython-314.pyc +0 -0
  80. package/tools/__pycache__/inbox_drain.cpython-314.pyc +0 -0
  81. package/tools/__pycache__/launch_tui.cpython-314.pyc +0 -0
  82. package/tools/__pycache__/metrics_gate.cpython-314.pyc +0 -0
  83. package/tools/__pycache__/orchestrator_status.cpython-314.pyc +0 -0
  84. package/tools/__pycache__/power_selftest.cpython-314.pyc +0 -0
  85. package/tools/__pycache__/prepublish_scan.cpython-314.pyc +0 -0
  86. package/tools/__pycache__/rotate_logs.cpython-314.pyc +0 -0
  87. package/tools/__pycache__/scanner_selftest.cpython-314.pyc +0 -0
  88. package/tools/__pycache__/secret_scan.cpython-314.pyc +0 -0
  89. package/tools/__pycache__/self_stats.cpython-314.pyc +0 -0
  90. package/tools/__pycache__/session_usage_summary.cpython-314.pyc +0 -0
  91. package/tools/__pycache__/stall_check.cpython-314.pyc +0 -0
  92. package/tools/__pycache__/transcript_replay.cpython-314.pyc +0 -0
  93. package/tools/__pycache__/transcript_timeline.cpython-314.pyc +0 -0
  94. package/tools/__pycache__/verify_dash.cpython-314.pyc +0 -0
  95. package/tools/__pycache__/verify_submit_encoding.cpython-314.pyc +0 -0
  96. package/tools/agent-forensics.sh +186 -0
  97. package/tools/alert_bridge.py +449 -0
  98. package/tools/buildlog.py +182 -0
  99. package/tools/ci_merge_wait.py +317 -0
  100. package/tools/ensure_state.py +94 -0
  101. package/tools/eod_sweep.py +239 -0
  102. package/tools/fleet_ledger.py +272 -0
  103. package/tools/fleet_prompt_extractor.py +134 -0
  104. package/tools/healthcheck.py +296 -0
  105. package/tools/heartbeat.py +131 -0
  106. package/tools/inbox_drain.py +249 -0
  107. package/tools/launch_tui.py +51 -3
  108. package/tools/lock.mjs +211 -0
  109. package/tools/metrics_gate.py +205 -0
  110. package/tools/orchestrator_status.py +99 -0
  111. package/tools/power_selftest.py +386 -0
  112. package/tools/prepublish_scan.py +84 -0
  113. package/tools/proposals.mjs +248 -0
  114. package/tools/reconstitute.sh +467 -0
  115. package/tools/rotate_logs.py +228 -0
  116. package/tools/scanner_selftest.py +303 -0
  117. package/tools/secret_scan.py +138 -31
  118. package/tools/self_stats.py +509 -0
  119. package/tools/session_usage_summary.py +198 -0
  120. package/tools/stall_check.py +172 -0
  121. package/tools/svg_to_png.mjs +50 -0
  122. package/tools/transcript_replay.py +236 -0
  123. package/tools/transcript_timeline.py +184 -0
  124. package/tools/verify_dash.py +513 -0
  125. package/tools/verify_submit_encoding.py +202 -0
  126. package/ui/CLAUDE.md +125 -0
  127. package/ui/__pycache__/agents.cpython-314.pyc +0 -0
  128. package/ui/__pycache__/collectors.cpython-314.pyc +0 -0
  129. package/ui/__pycache__/config.cpython-314.pyc +0 -0
  130. package/ui/__pycache__/cost.cpython-314.pyc +0 -0
  131. package/ui/__pycache__/csrf.cpython-314.pyc +0 -0
  132. package/ui/__pycache__/handler.cpython-314.pyc +0 -0
  133. package/ui/__pycache__/render.cpython-314.pyc +0 -0
  134. package/ui/__pycache__/serve.cpython-314.pyc +0 -0
  135. package/ui/__pycache__/sse.cpython-314.pyc +0 -0
  136. package/ui/agents.py +183 -0
  137. package/ui/api/__init__.py +84 -0
  138. package/ui/api/__pycache__/__init__.cpython-314.pyc +0 -0
  139. package/ui/api/__pycache__/submit.cpython-314.pyc +0 -0
  140. package/ui/api/__pycache__/tracker.cpython-314.pyc +0 -0
  141. package/ui/api/submit.py +97 -0
  142. package/ui/api/tracker.py +116 -0
  143. package/ui/collectors.py +735 -0
  144. package/ui/config.py +123 -0
  145. package/ui/cost.py +260 -0
  146. package/ui/csrf.py +144 -0
  147. package/ui/handler.py +676 -0
  148. package/ui/render.py +49 -0
  149. package/ui/serve.py +68 -796
  150. package/ui/sse.py +183 -0
  151. package/ui/web/.gitattributes +13 -0
  152. package/ui/web/dist/assets/index-2LZDQirC.js +9 -0
  153. package/ui/web/dist/assets/index-D4M1qyOv.css +1 -0
  154. package/ui/web/dist/index.html +14 -0
  155. package/ui/web/index.html +13 -0
  156. package/ui/web/package-lock.json +2225 -0
  157. package/ui/web/package.json +26 -0
  158. package/ui/web/src/App.test.tsx +74 -0
  159. package/ui/web/src/App.tsx +142 -0
  160. package/ui/web/src/CONTRIBUTING-UI.md +49 -0
  161. package/ui/web/src/components/AgentRow.css +187 -0
  162. package/ui/web/src/components/AgentRow.test.tsx +209 -0
  163. package/ui/web/src/components/AgentRow.tsx +207 -0
  164. package/ui/web/src/components/AgentsPanel.css +108 -0
  165. package/ui/web/src/components/AgentsPanel.test.tsx +41 -0
  166. package/ui/web/src/components/AgentsPanel.tsx +58 -0
  167. package/ui/web/src/components/AlertsPanel.css +88 -0
  168. package/ui/web/src/components/AlertsPanel.test.tsx +51 -0
  169. package/ui/web/src/components/AlertsPanel.tsx +67 -0
  170. package/ui/web/src/components/BacklogPanel.test.tsx +126 -0
  171. package/ui/web/src/components/BacklogPanel.tsx +122 -0
  172. package/ui/web/src/components/CostChart.css +110 -0
  173. package/ui/web/src/components/CostChart.test.tsx +144 -0
  174. package/ui/web/src/components/CostChart.tsx +152 -0
  175. package/ui/web/src/components/CostTable.css +93 -0
  176. package/ui/web/src/components/CostTable.test.tsx +165 -0
  177. package/ui/web/src/components/CostTable.tsx +94 -0
  178. package/ui/web/src/components/EventsFeed.css +68 -0
  179. package/ui/web/src/components/EventsFeed.test.tsx +36 -0
  180. package/ui/web/src/components/EventsFeed.tsx +31 -0
  181. package/ui/web/src/components/HealthHeader.css +137 -0
  182. package/ui/web/src/components/HealthHeader.test.tsx +278 -0
  183. package/ui/web/src/components/HealthHeader.tsx +281 -0
  184. package/ui/web/src/components/InboxForm.css +135 -0
  185. package/ui/web/src/components/InboxForm.test.tsx +208 -0
  186. package/ui/web/src/components/InboxForm.tsx +116 -0
  187. package/ui/web/src/components/MessagesTail.module.css +144 -0
  188. package/ui/web/src/components/MessagesTail.test.tsx +176 -0
  189. package/ui/web/src/components/MessagesTail.tsx +94 -0
  190. package/ui/web/src/components/ReposPanel.css +90 -0
  191. package/ui/web/src/components/ReposPanel.test.tsx +45 -0
  192. package/ui/web/src/components/ReposPanel.tsx +67 -0
  193. package/ui/web/src/components/Scorecard.css +106 -0
  194. package/ui/web/src/components/Scorecard.test.tsx +117 -0
  195. package/ui/web/src/components/Scorecard.tsx +85 -0
  196. package/ui/web/src/components/Timeline.module.css +151 -0
  197. package/ui/web/src/components/Timeline.test.tsx +215 -0
  198. package/ui/web/src/components/Timeline.tsx +99 -0
  199. package/ui/web/src/components/TrackerBoard.test.tsx +121 -0
  200. package/ui/web/src/components/TrackerBoard.tsx +107 -0
  201. package/ui/web/src/components/TrackerCard.test.tsx +180 -0
  202. package/ui/web/src/components/TrackerCard.tsx +160 -0
  203. package/ui/web/src/components/TrackerForm.test.tsx +189 -0
  204. package/ui/web/src/components/TrackerForm.tsx +144 -0
  205. package/ui/web/src/lib/api.ts +218 -0
  206. package/ui/web/src/lib/format.test.ts +89 -0
  207. package/ui/web/src/lib/format.ts +103 -0
  208. package/ui/web/src/lib/sanitizeUrl.test.ts +84 -0
  209. package/ui/web/src/lib/sanitizeUrl.ts +38 -0
  210. package/ui/web/src/lib/types.ts +230 -0
  211. package/ui/web/src/lib/useHashRoute.test.ts +60 -0
  212. package/ui/web/src/lib/useHashRoute.ts +23 -0
  213. package/ui/web/src/lib/useSSE.ts +175 -0
  214. package/ui/web/src/main.tsx +10 -0
  215. package/ui/web/src/styles/global.css +179 -0
  216. package/ui/web/src/styles/theme.css +184 -0
  217. package/ui/web/src/styles/work.css +572 -0
  218. package/ui/web/src/test/fixtures.ts +385 -0
  219. package/ui/web/src/test/setup.ts +49 -0
  220. package/ui/web/src/views/Activity.module.css +43 -0
  221. package/ui/web/src/views/Activity.test.tsx +89 -0
  222. package/ui/web/src/views/Activity.tsx +31 -0
  223. package/ui/web/src/views/Cost.css +87 -0
  224. package/ui/web/src/views/Cost.test.tsx +142 -0
  225. package/ui/web/src/views/Cost.tsx +54 -0
  226. package/ui/web/src/views/Overview.css +51 -0
  227. package/ui/web/src/views/Overview.test.tsx +76 -0
  228. package/ui/web/src/views/Overview.tsx +46 -0
  229. package/ui/web/src/views/Work.test.tsx +82 -0
  230. package/ui/web/src/views/Work.tsx +79 -0
  231. package/ui/web/src/vite-env.d.ts +10 -0
  232. package/ui/web/tsconfig.json +22 -0
  233. package/ui/web/vite.config.ts +25 -0
  234. package/ui/web/vitest.config.ts +12 -0
package/ui/config.py ADDED
@@ -0,0 +1,123 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ Aesop UI Configuration — Path and environment resolution.
4
+
5
+ This module centralizes all path configuration, environment variable resolution,
6
+ and config file loading for the UI dashboard. It provides a reload() function to
7
+ recompute all paths when environment changes (e.g., between test fixtures).
8
+
9
+ Config precedence: env vars > aesop.config.json > built-in defaults.
10
+ """
11
+ import json
12
+ import os
13
+ import sys
14
+ from pathlib import Path
15
+
16
+
17
+ # ==============================================================================
18
+ # Path Resolution Functions
19
+ # ==============================================================================
20
+
21
+ def reload():
22
+ """Recompute all configuration from current environment.
23
+
24
+ Called at module load and whenever environment changes (test fixtures).
25
+ Mutates module-level globals in place so that importers see the current state.
26
+ """
27
+ global PORT, AESOP_ROOT, CONFIG_FILE, STATE_DIR, TRANSCRIPTS_ROOT
28
+ global WATCHDOG_HEARTBEAT, MONITOR_HEARTBEAT, REPOS_JSON, BACKUP_LOG
29
+ global ALERTS_LOG, INBOX_FILE, AUDIT_BACKLOG_FILE
30
+ global UI_SESSION_TOKEN_FILE, TRACKER_FILE, ORCH_STATUS_FILE
31
+ global WEB_DIST, LEDGER_FILE
32
+ global COLLECTOR_INTERVAL, SSE_KEEPALIVE_SECONDS, SSE_MAX_CLIENTS, SSE_QUEUE_MAXSIZE, SSE_WRITE_TIMEOUT
33
+
34
+ # PORT: env PORT > default 8770
35
+ PORT = int(os.getenv("PORT", "8770"))
36
+
37
+ # Determine AESOP_ROOT: env AESOP_ROOT > default $HOME/aesop
38
+ AESOP_ROOT = Path(os.getenv("AESOP_ROOT", Path.home() / "aesop"))
39
+
40
+ # Try to load config file for additional settings
41
+ CONFIG_FILE = AESOP_ROOT / "aesop.config.json"
42
+ config_data = {}
43
+ if CONFIG_FILE.exists():
44
+ try:
45
+ with open(CONFIG_FILE) as f:
46
+ config_data = json.load(f)
47
+ except Exception as e:
48
+ print(f"[config] Failed to load {CONFIG_FILE}: {e}", file=sys.stderr)
49
+
50
+ # Derive paths with precedence: env var > config file > built-in default
51
+ # STATE_DIR: env AESOP_STATE_ROOT > config state_root > AESOP_ROOT/state
52
+ STATE_DIR = Path(
53
+ os.getenv(
54
+ "AESOP_STATE_ROOT",
55
+ config_data.get("state_root", str(AESOP_ROOT / "state"))
56
+ )
57
+ )
58
+
59
+ # TRANSCRIPTS_ROOT: env AESOP_TRANSCRIPTS_ROOT > config transcripts_root > ~/.claude/projects
60
+ TRANSCRIPTS_ROOT = Path(
61
+ os.getenv(
62
+ "AESOP_TRANSCRIPTS_ROOT",
63
+ config_data.get("transcripts_root", "~/.claude/projects")
64
+ )
65
+ ).expanduser()
66
+
67
+ # Data file paths (all derived from STATE_DIR and AESOP_ROOT)
68
+ WATCHDOG_HEARTBEAT = STATE_DIR / ".watchdog-heartbeat"
69
+ MONITOR_HEARTBEAT = STATE_DIR / ".monitor-heartbeat"
70
+ REPOS_JSON = STATE_DIR / ".watchdog-repos.json"
71
+ BACKUP_LOG = STATE_DIR / "FLEET-BACKUP.log"
72
+ ALERTS_LOG = STATE_DIR / "SECURITY-ALERTS.log"
73
+ INBOX_FILE = STATE_DIR / "ui-inbox.md"
74
+ AUDIT_BACKLOG_FILE = AESOP_ROOT / "AUDIT-BACKLOG.md"
75
+ UI_SESSION_TOKEN_FILE = STATE_DIR / ".ui-session-token"
76
+ TRACKER_FILE = STATE_DIR / "tracker.json"
77
+ ORCH_STATUS_FILE = STATE_DIR / "orchestrator-status.json"
78
+
79
+ # Wave-14 dashboard rewrite (plan D3): built frontend + cost ledger paths.
80
+ # WEB_DIST: the committed Vite build output served at / and /assets/*.
81
+ WEB_DIST = AESOP_ROOT / "ui" / "web" / "dist"
82
+ # LEDGER_FILE: outcomes ledger parsed by ui/cost.py; sse.py mtime-gates on it.
83
+ LEDGER_FILE = STATE_DIR / "ledger" / "OUTCOMES-LEDGER.md"
84
+
85
+ # Collector and SSE configuration
86
+ COLLECTOR_INTERVAL = float(os.getenv("AESOP_UI_COLLECT_INTERVAL", "1.0"))
87
+ SSE_KEEPALIVE_SECONDS = 15
88
+ SSE_MAX_CLIENTS = 100 # Resource cap: reject new connections past this
89
+ SSE_QUEUE_MAXSIZE = 50 # Per-client bounded queue (drops oldest on overflow)
90
+ SSE_WRITE_TIMEOUT = 5.0 # Write timeout in seconds to prevent stalled clients
91
+
92
+
93
+ # ==============================================================================
94
+ # Module-level initialization
95
+ # ==============================================================================
96
+
97
+ # Initialize configuration at module load time
98
+ # These globals are recomputed by reload() and accessed by other modules
99
+ PORT = 8770
100
+ AESOP_ROOT = Path.home() / "aesop"
101
+ CONFIG_FILE = AESOP_ROOT / "aesop.config.json"
102
+ STATE_DIR = AESOP_ROOT / "state"
103
+ TRANSCRIPTS_ROOT = Path("~/.claude/projects").expanduser()
104
+ WATCHDOG_HEARTBEAT = STATE_DIR / ".watchdog-heartbeat"
105
+ MONITOR_HEARTBEAT = STATE_DIR / ".monitor-heartbeat"
106
+ REPOS_JSON = STATE_DIR / ".watchdog-repos.json"
107
+ BACKUP_LOG = STATE_DIR / "FLEET-BACKUP.log"
108
+ ALERTS_LOG = STATE_DIR / "SECURITY-ALERTS.log"
109
+ INBOX_FILE = STATE_DIR / "ui-inbox.md"
110
+ AUDIT_BACKLOG_FILE = AESOP_ROOT / "AUDIT-BACKLOG.md"
111
+ UI_SESSION_TOKEN_FILE = STATE_DIR / ".ui-session-token"
112
+ TRACKER_FILE = STATE_DIR / "tracker.json"
113
+ ORCH_STATUS_FILE = STATE_DIR / "orchestrator-status.json"
114
+ WEB_DIST = AESOP_ROOT / "ui" / "web" / "dist"
115
+ LEDGER_FILE = STATE_DIR / "ledger" / "OUTCOMES-LEDGER.md"
116
+ COLLECTOR_INTERVAL = 1.0
117
+ SSE_KEEPALIVE_SECONDS = 15
118
+ SSE_MAX_CLIENTS = 100
119
+ SSE_QUEUE_MAXSIZE = 50
120
+ SSE_WRITE_TIMEOUT = 5.0
121
+
122
+ # Perform initial load from environment
123
+ reload()
package/ui/cost.py ADDED
@@ -0,0 +1,260 @@
1
+ #!/usr/bin/env python3
2
+ """Cost/scorecard collector — parse ledger and aggregate costs.
3
+
4
+ This module provides get_cost_summary() which parses the outcomes ledger
5
+ markdown table and returns per-model, per-day, and overall cost/token aggregations
6
+ with optional pricing estimates.
7
+
8
+ Ledger format (markdown table):
9
+ | ISO timestamp | agent_type | model | duration | tokens_in | tokens_out | verdict |
10
+ | 2026-07-11T22:08:17 | Agent | claude-haiku-4-5-20251001 | 0 | 8 | 186 | OK |
11
+
12
+ CostSummary JSON shape (returned by get_cost_summary()):
13
+ {
14
+ "models": {
15
+ "model-id": {
16
+ "runs": int,
17
+ "tokens_in": int,
18
+ "tokens_out": int,
19
+ "verdicts": {"OK": int, "FAILED": int, "EMPTY": int, "HUNG": int}
20
+ },
21
+ ...
22
+ },
23
+ "daily_totals": {
24
+ "YYYY-MM-DD": {"tokens_in": int, "tokens_out": int},
25
+ ...
26
+ },
27
+ "overall_scorecard": {
28
+ "total_runs": int,
29
+ "ok_count": int,
30
+ "failed_count": int,
31
+ "empty_count": int,
32
+ "hung_count": int,
33
+ "ok_rate": float (0.0-1.0),
34
+ "failed_rate": float,
35
+ "empty_rate": float,
36
+ "hung_rate": float
37
+ },
38
+ "skipped_lines": int,
39
+ "has_pricing": bool,
40
+ "estimates_by_model": {
41
+ "model-id": {
42
+ "input_cost": float (dollars),
43
+ "output_cost": float (dollars),
44
+ "total_cost": float (dollars)
45
+ },
46
+ ...
47
+ }
48
+ }
49
+
50
+ Key behavior:
51
+ - Missing ledger file: returns empty summary with documented shape.
52
+ - Malformed lines: skipped and counted in skipped_lines field.
53
+ - Config read at CALL time (not import time) for test isolation.
54
+ - UTF-8 explicit encoding for all file operations.
55
+ - No external dependencies (pure stdlib).
56
+ - Pricing estimates ONLY if aesop.config.json has a "pricing" map.
57
+ """
58
+ import json
59
+ from pathlib import Path
60
+
61
+ # Note: import config at module level, but read config.X at CALL time
62
+ # (not at import time) to ensure test-fixture isolation works.
63
+ import config
64
+
65
+
66
+ def get_cost_summary():
67
+ """Parse the outcomes ledger and return cost/token/verdict aggregations.
68
+
69
+ Reads from the ledger path exposed by config (config.STATE_DIR/ledger/OUTCOMES-LEDGER.md).
70
+ Returns an empty summary with documented shape if ledger is missing or empty.
71
+ Malformed lines are skipped and counted in skipped_lines.
72
+
73
+ All config paths are read at call time (not import time) to ensure
74
+ test-fixture isolation via config.reload().
75
+
76
+ Returns:
77
+ dict: CostSummary with models, daily_totals, overall_scorecard,
78
+ skipped_lines, has_pricing, estimates_by_model.
79
+ """
80
+ # Read ledger path at call time
81
+ ledger_file = config.STATE_DIR / "ledger" / "OUTCOMES-LEDGER.md"
82
+
83
+ # Initialize result structure
84
+ result = {
85
+ "models": {},
86
+ "daily_totals": {},
87
+ "overall_scorecard": {
88
+ "total_runs": 0,
89
+ "ok_count": 0,
90
+ "failed_count": 0,
91
+ "empty_count": 0,
92
+ "hung_count": 0,
93
+ "ok_rate": 0.0,
94
+ "failed_rate": 0.0,
95
+ "empty_rate": 0.0,
96
+ "hung_rate": 0.0,
97
+ },
98
+ "skipped_lines": 0,
99
+ "has_pricing": False,
100
+ "estimates_by_model": {},
101
+ }
102
+
103
+ # If ledger file doesn't exist, return empty summary
104
+ if not ledger_file.exists():
105
+ return result
106
+
107
+ # Read and parse ledger with explicit UTF-8 encoding
108
+ try:
109
+ content = ledger_file.read_text(encoding='utf-8')
110
+ except Exception:
111
+ # Graceful: if read fails, return empty summary
112
+ return result
113
+
114
+ lines = content.strip().split('\n')
115
+
116
+ # Parse each line
117
+ for line in lines:
118
+ line = line.strip()
119
+
120
+ # Skip empty lines
121
+ if not line:
122
+ continue
123
+
124
+ # Skip header separator lines (all dashes and pipes) — silently, don't count as skipped
125
+ if all(c in '|-' for c in line):
126
+ continue
127
+
128
+ # Parse pipe-delimited row
129
+ if not line.startswith('|') or not line.endswith('|'):
130
+ result["skipped_lines"] += 1
131
+ continue
132
+
133
+ # Split by pipe and strip whitespace
134
+ parts = [p.strip() for p in line.split('|')]
135
+
136
+ # Markdown tables have empty strings at start and end after split
137
+ # Format: | col1 | col2 | col3 | col4 | col5 | col6 | col7 |
138
+ # After split: ['', 'col1', 'col2', 'col3', 'col4', 'col5', 'col6', 'col7', '']
139
+ if len(parts) < 9: # Need at least 9 parts (empty + 7 columns + empty)
140
+ result["skipped_lines"] += 1
141
+ continue
142
+
143
+ # Extract columns (skip leading/trailing empty)
144
+ try:
145
+ timestamp = parts[1] # ISO timestamp
146
+ agent_type = parts[2] # "Agent"
147
+ model = parts[3]
148
+ duration_str = parts[4]
149
+ tokens_in_str = parts[5]
150
+ tokens_out_str = parts[6]
151
+ verdict = parts[7]
152
+ except IndexError:
153
+ result["skipped_lines"] += 1
154
+ continue
155
+
156
+ # Parse numeric fields
157
+ try:
158
+ tokens_in = int(tokens_in_str)
159
+ tokens_out = int(tokens_out_str)
160
+ except ValueError:
161
+ result["skipped_lines"] += 1
162
+ continue
163
+
164
+ # Validate verdict
165
+ if verdict not in ("OK", "FAILED", "EMPTY", "HUNG"):
166
+ result["skipped_lines"] += 1
167
+ continue
168
+
169
+ # Extract date from ISO timestamp (YYYY-MM-DD)
170
+ try:
171
+ date_str = timestamp.split('T')[0]
172
+ except IndexError:
173
+ result["skipped_lines"] += 1
174
+ continue
175
+
176
+ # Aggregate by model
177
+ if model not in result["models"]:
178
+ result["models"][model] = {
179
+ "runs": 0,
180
+ "tokens_in": 0,
181
+ "tokens_out": 0,
182
+ "verdicts": {"OK": 0, "FAILED": 0, "EMPTY": 0, "HUNG": 0},
183
+ }
184
+
185
+ result["models"][model]["runs"] += 1
186
+ result["models"][model]["tokens_in"] += tokens_in
187
+ result["models"][model]["tokens_out"] += tokens_out
188
+ result["models"][model]["verdicts"][verdict] += 1
189
+
190
+ # Aggregate by date
191
+ if date_str not in result["daily_totals"]:
192
+ result["daily_totals"][date_str] = {"tokens_in": 0, "tokens_out": 0}
193
+
194
+ result["daily_totals"][date_str]["tokens_in"] += tokens_in
195
+ result["daily_totals"][date_str]["tokens_out"] += tokens_out
196
+
197
+ # Count for overall scorecard
198
+ result["overall_scorecard"]["total_runs"] += 1
199
+ if verdict == "OK":
200
+ result["overall_scorecard"]["ok_count"] += 1
201
+ elif verdict == "FAILED":
202
+ result["overall_scorecard"]["failed_count"] += 1
203
+ elif verdict == "EMPTY":
204
+ result["overall_scorecard"]["empty_count"] += 1
205
+ elif verdict == "HUNG":
206
+ result["overall_scorecard"]["hung_count"] += 1
207
+
208
+ # Calculate rates
209
+ total = result["overall_scorecard"]["total_runs"]
210
+ if total > 0:
211
+ result["overall_scorecard"]["ok_rate"] = result["overall_scorecard"]["ok_count"] / total
212
+ result["overall_scorecard"]["failed_rate"] = result["overall_scorecard"]["failed_count"] / total
213
+ result["overall_scorecard"]["empty_rate"] = result["overall_scorecard"]["empty_count"] / total
214
+ result["overall_scorecard"]["hung_rate"] = result["overall_scorecard"]["hung_count"] / total
215
+
216
+ # Load pricing config at call time and compute estimates
217
+ pricing_map = _load_pricing_config()
218
+ if pricing_map:
219
+ result["has_pricing"] = True
220
+ for model, stats in result["models"].items():
221
+ if model in pricing_map:
222
+ pricing = pricing_map[model]
223
+ input_price = pricing.get("input_per_mtok", 0.0)
224
+ output_price = pricing.get("output_per_mtok", 0.0)
225
+
226
+ # Calculate costs: (tokens / 1_000_000) * price_per_mtok
227
+ input_cost = (stats["tokens_in"] * input_price) / 1_000_000
228
+ output_cost = (stats["tokens_out"] * output_price) / 1_000_000
229
+ total_cost = input_cost + output_cost
230
+
231
+ result["estimates_by_model"][model] = {
232
+ "input_cost": input_cost,
233
+ "output_cost": output_cost,
234
+ "total_cost": total_cost,
235
+ }
236
+
237
+ return result
238
+
239
+
240
+ def _load_pricing_config():
241
+ """Load pricing map from aesop.config.json at call time.
242
+
243
+ Returns:
244
+ dict or None: pricing map {model: {input_per_mtok: float, output_per_mtok: float}}
245
+ or None if no pricing config found.
246
+ """
247
+ # Read config file at call time (not import time)
248
+ config_file = config.CONFIG_FILE
249
+
250
+ if not config_file.exists():
251
+ return None
252
+
253
+ try:
254
+ with open(config_file, encoding='utf-8') as f:
255
+ config_data = json.load(f)
256
+ except Exception:
257
+ # Graceful: if config read fails, no pricing
258
+ return None
259
+
260
+ return config_data.get("pricing", None)
package/ui/csrf.py ADDED
@@ -0,0 +1,144 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ Aesop UI CSRF Protection — Token generation and validation.
4
+
5
+ Session token is generated once at startup and persisted to state/.ui-session-token (0600).
6
+ Token is used to validate mutations via /submit POST endpoint.
7
+
8
+ CRITICAL: This module reads config paths LIVE at call time via 'import config',
9
+ never 'from config import <path>'. This ensures paths are recomputed when
10
+ config.reload() is called (e.g., between test fixtures).
11
+ """
12
+ import os
13
+ import secrets
14
+ import sys
15
+ from pathlib import Path
16
+
17
+ # Must import config module, never "from config import <paths>"
18
+ import config
19
+
20
+
21
+ # Module-level session token (set by init())
22
+ SESSION_TOKEN = None
23
+
24
+
25
+ def init():
26
+ """Initialize CSRF protection: generate or load the per-session token.
27
+
28
+ This should be called once at startup (after config.reload()).
29
+ Token is persisted to state/.ui-session-token (mode 0600) and reused
30
+ across server restarts.
31
+
32
+ SECURITY: File is created atomically with restricted permissions using
33
+ os.open(O_CREAT|O_EXCL) to avoid TOCTOU window where file exists with
34
+ world-readable permissions.
35
+ """
36
+ global SESSION_TOKEN
37
+ SESSION_TOKEN = _generate_session_token()
38
+
39
+
40
+ def _generate_session_token():
41
+ """Generate or load the per-session CSRF token.
42
+
43
+ Token is generated once at startup and persisted to state/.ui-session-token (mode 0600).
44
+ Subsequent calls return the same token (in-memory).
45
+
46
+ SECURITY: File is created atomically with restricted permissions using os.open(O_CREAT|O_EXCL)
47
+ to avoid TOCTOU window where file exists with world-readable permissions.
48
+
49
+ Returns:
50
+ str: 43-character base64-like random token (256 bits / 3 bytes per char = ~43 chars)
51
+ """
52
+ # LIVE CONFIG READ: access config.UI_SESSION_TOKEN_FILE at call time
53
+ token_file = config.UI_SESSION_TOKEN_FILE
54
+
55
+ # Check if token file exists and is readable
56
+ if token_file.exists():
57
+ try:
58
+ token = token_file.read_text().strip()
59
+ if token and len(token) >= 32:
60
+ return token
61
+ except Exception as e:
62
+ print(f"[csrf] Failed to read token file: {e}", file=sys.stderr)
63
+
64
+ # Generate new token: 32 random bytes → 43-char base64-like string
65
+ token = secrets.token_urlsafe(32)
66
+
67
+ # Persist to file with restricted permissions (0600) using atomic creation
68
+ try:
69
+ # LIVE CONFIG READ: access config.STATE_DIR at call time
70
+ config.STATE_DIR.mkdir(parents=True, exist_ok=True)
71
+
72
+ # Atomically create file with 0600 permissions using os.open with O_CREAT|O_EXCL.
73
+ # This ensures the file is never world-readable (no TOCTOU window).
74
+ # On Windows, mode bits are largely ignored, which is fine.
75
+ try:
76
+ fd = os.open(
77
+ str(token_file),
78
+ os.O_CREAT | os.O_EXCL | os.O_WRONLY,
79
+ 0o600
80
+ )
81
+ # Write token via the file descriptor (no separate chmod needed)
82
+ with os.fdopen(fd, 'w') as f:
83
+ f.write(token)
84
+ except FileExistsError:
85
+ # File already exists (race condition or previous run).
86
+ # Try to read it and use that token instead.
87
+ try:
88
+ token = token_file.read_text().strip()
89
+ if token and len(token) >= 32:
90
+ return token
91
+ except Exception as e:
92
+ print(f"[csrf] Failed to read existing token file: {e}", file=sys.stderr)
93
+ # If we can't read the existing file, fall back to in-memory token
94
+ except Exception:
95
+ pass # Fail-open: token exists in memory even if file write fails
96
+
97
+ return token
98
+
99
+
100
+ def validate_csrf_request(headers):
101
+ """Validate CSRF protections on /submit POST request.
102
+
103
+ Performs two checks:
104
+ 1. Origin/Referer validation: if Origin or Referer header is present, must be local
105
+ (http[s]://127.0.0.1:<port>, http[s]://localhost:<port>, http[s]://[::1]:<port>)
106
+ 2. X-Aesop-Token validation: must match SESSION_TOKEN
107
+
108
+ Args:
109
+ headers: dict-like object with HTTP headers (case-insensitive)
110
+
111
+ Returns:
112
+ tuple: (is_valid: bool, reason: str or None)
113
+ - (True, None) if CSRF checks pass
114
+ - (False, reason) if either check fails
115
+ """
116
+ # Check 1: Origin/Referer header validation
117
+ origin = headers.get("Origin", "").strip()
118
+ referer = headers.get("Referer", "").strip()
119
+
120
+ # If Origin or Referer is present, validate it's local
121
+ if origin or referer:
122
+ check_value = origin or referer
123
+ # Check if it's a local origin: http[s]://127.0.0.1:<PORT>, http[s]://localhost:<PORT>,
124
+ # or http[s]://[::1]:<PORT> (both http:// and https:// schemes accepted for same hosts)
125
+ is_local = (
126
+ check_value.startswith("http://127.0.0.1:") or
127
+ check_value.startswith("https://127.0.0.1:") or
128
+ check_value.startswith("http://localhost:") or
129
+ check_value.startswith("https://localhost:") or
130
+ check_value.startswith("http://[::1]:") or # IPv6 localhost
131
+ check_value.startswith("https://[::1]:") # IPv6 localhost HTTPS
132
+ )
133
+ if not is_local:
134
+ return (False, "Foreign Origin/Referer rejected")
135
+
136
+ # Check 2: X-Aesop-Token validation
137
+ token = headers.get("X-Aesop-Token", "").strip()
138
+ if not token:
139
+ return (False, "Missing X-Aesop-Token header")
140
+
141
+ if token != SESSION_TOKEN:
142
+ return (False, "Invalid X-Aesop-Token")
143
+
144
+ return (True, None)