enigma-cli 1.15.7 → 1.15.9

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/README.md CHANGED
@@ -8,18 +8,29 @@ committed.
8
8
 
9
9
  ## Install
10
10
 
11
- One command, no global install, no prompts - deploy the skills to every supported
12
- agent at user level:
11
+ Recommended - install the `enigma` command globally, then run the interactive hub:
13
12
 
14
13
  ```bash
15
- npx enigma-cli@latest install --all --yes
14
+ npm install -g enigma-cli@latest # provides the `enigma` command
15
+ enigma # interactive hub: pick what to set up
16
+ ```
17
+
18
+ Or via the install script (clears the npm cache, installs the latest version, then
19
+ runs `enigma install` for you - handy where npm `postinstall` scripts are disabled):
20
+
21
+ ```bash
22
+ # macOS / Linux
23
+ curl -fsSL https://raw.githubusercontent.com/FJRG2007/enigma/main/install.sh | sh
24
+
25
+ # Windows (PowerShell)
26
+ irm https://raw.githubusercontent.com/FJRG2007/enigma/main/install.ps1 | iex
16
27
  ```
17
28
 
18
- Or install the command and pick interactively:
29
+ Or one-shot, no global install, no prompts - deploy the skills to every supported
30
+ agent at user level:
19
31
 
20
32
  ```bash
21
- npm install -g enigma-cli # provides the `enigma` command
22
- enigma # interactive hub: pick what to set up
33
+ npx enigma-cli@latest install --all --yes
23
34
  ```
24
35
 
25
36
  That first install is the only one you ever need to run by hand: afterwards,
@@ -72,8 +83,11 @@ enigma account ... Manage per-tool accounts (list/add/use/login/remove)
72
83
  enigma profile ... Group one account per tool (list/add/use/set/unset/remove)
73
84
  enigma skills ... List skills and manage discards (list/discard/restore)
74
85
  enigma compress [file] Compress JSON/logs/text to fewer tokens (reversible via CCR);
75
- --retrieve <hash> restores, --stats shows total savings
86
+ --retrieve <hash> restores, --stats shows total savings,
87
+ --clear wipes all dashboard data (stats/history/cache)
76
88
  enigma mcp Run the context-compression MCP server over stdio
89
+ enigma dashboard|dash Open the local savings dashboard in your browser (http://enigma,
90
+ or http://localhost:24282 if :80/hosts is unavailable)
77
91
  enigma seal Maintenance: (re)compute skill content hashes
78
92
  enigma check Integrity gate: verify skills are well-formed and sealed
79
93
  enigma help | version
@@ -462,8 +476,24 @@ cat tool-output.json | enigma compress # compressed to stdout, savings to st
462
476
  enigma compress big.log # compress a file
463
477
  enigma compress --retrieve <hash> # restore a cached original
464
478
  enigma compress --stats # cumulative token savings
479
+ enigma compress --clear # wipe all dashboard data (stats/history/cache)
465
480
  ```
466
481
 
482
+ ### Savings dashboard
483
+
484
+ `enigma dashboard` (alias `dash`) serves a local, loopback-only browser dashboard
485
+ of the token savings - totals, **estimated money saved**, average/best per call, a
486
+ per-day graph (toggle tokens/$ and daily/cumulative), breakdowns by the app that
487
+ requested each compression (Claude Code, OpenCode, Codex, or the CLI) and by content
488
+ type (JSON/logs/text...), a daily/weekly/monthly savings history, a recent-compressions
489
+ table, and reversible-cache (CCR) stats. It runs only while open; `enigma config
490
+ dashboard always` keeps a lightweight background daemon, and `enigma compress --clear`
491
+ resets the data.
492
+
493
+ The money figure is an estimate: enigma isn't a proxy, so it can't see the model -
494
+ it prices saved tokens per source with sensible defaults. Override the rate with
495
+ `enigma config token-price <usd-per-1M-input-tokens>` (0 restores the defaults).
496
+
467
497
  ### As an MCP server
468
498
 
469
499
  `enigma mcp` runs a stdio MCP server exposing three tools - `enigma_compress`,
@@ -3,7 +3,7 @@
3
3
  <head>
4
4
  <meta charset="utf-8">
5
5
  <meta name="viewport" content="width=device-width, initial-scale=1">
6
- <title>enigma - context savings</title>
6
+ <title>Enigma - Context Savings</title>
7
7
  <style>
8
8
  :root {
9
9
  --bg: #0b0e14; --surface: #151a23; --surface2: #1b2230; --border: #232a36;
@@ -20,10 +20,10 @@
20
20
  .wordmark { font-size: 20px; font-weight: 700; letter-spacing: 0.5px; }
21
21
  .wordmark span { color: var(--accent); }
22
22
  .sub { color: var(--muted); font-size: 13px; }
23
- .live { margin-left: auto; display: flex; align-items: center; gap: 8px; color: var(--muted); font-size: 12px; }
23
+ .live { display: flex; align-items: center; gap: 8px; color: var(--muted); font-size: 12px; }
24
24
  .dot { width: 8px; height: 8px; border-radius: 50%; background: var(--good); box-shadow: 0 0 8px var(--good); }
25
25
  .dot.stale { background: var(--muted); box-shadow: none; }
26
- .grid { display: grid; grid-template-columns: repeat(4, 1fr); gap: 12px; margin-bottom: 20px; }
26
+ .grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); gap: 12px; margin-bottom: 20px; }
27
27
  .card { background: var(--surface); border: 1px solid var(--border); border-radius: 12px; padding: 16px; }
28
28
  .card .label { color: var(--muted); font-size: 12px; text-transform: uppercase; letter-spacing: 0.6px; }
29
29
  .card .value { font-size: 28px; font-weight: 700; margin-top: 6px; }
@@ -31,15 +31,49 @@
31
31
  .card .value.good { color: var(--good); }
32
32
  .card .foot { color: var(--muted); font-size: 12px; margin-top: 4px; }
33
33
  .panel { background: var(--surface); border: 1px solid var(--border); border-radius: 12px; padding: 18px; margin-bottom: 20px; }
34
- .panel h2 { font-size: 14px; font-weight: 600; margin: 0 0 14px; color: var(--text); }
34
+ .panel-head { display: flex; align-items: center; gap: 12px; margin-bottom: 14px; }
35
+ .panel h2 { font-size: 14px; font-weight: 600; margin: 0; color: var(--text); }
35
36
  .panel h2 small { color: var(--muted); font-weight: 400; margin-left: 8px; }
36
- .chart { width: 100%; height: 200px; display: block; }
37
+ .ctrls { margin-left: auto; display: flex; gap: 10px; flex-wrap: wrap; align-items: center; }
38
+ .ranges { display: flex; gap: 4px; }
39
+ .ranges button {
40
+ background: var(--surface2); color: var(--muted); border: 1px solid var(--border);
41
+ border-radius: 7px; padding: 4px 10px; font-size: 12px; cursor: pointer; font-family: inherit;
42
+ }
43
+ .ranges button:hover { color: var(--text); }
44
+ .ranges button.active { background: var(--accent); color: #1a1206; border-color: var(--accent); font-weight: 600; }
45
+ .chartwrap { position: relative; width: 100%; height: 280px; }
46
+ /* Suppress the charting library's attribution logo. */
47
+ #tv-attr-logo { display: none !important; }
48
+ .nav { margin-left: auto; display: flex; align-items: center; gap: 18px; }
49
+ .links { display: flex; gap: 14px; align-items: center; }
50
+ .links a { color: var(--muted); display: inline-flex; transition: color 0.15s; }
51
+ .links a:hover { color: var(--accent); }
52
+ .links svg { width: 19px; height: 19px; display: block; }
53
+ .tip {
54
+ position: absolute; pointer-events: none; z-index: 5; background: var(--surface2);
55
+ border: 1px solid var(--border); border-radius: 8px; padding: 6px 10px; font-size: 12px;
56
+ box-shadow: 0 4px 16px rgba(0,0,0,0.4); white-space: nowrap;
57
+ }
58
+ .tip .tip-d { color: var(--muted); }
59
+ .tip .tip-v { color: var(--accent); font-weight: 600; margin-top: 2px; }
37
60
  .bar-row { display: flex; align-items: center; gap: 12px; margin: 10px 0; font-size: 13px; }
38
61
  .bar-row .name { width: 70px; color: var(--muted); }
39
62
  .bar-track { flex: 1; height: 14px; background: var(--surface2); border-radius: 7px; overflow: hidden; }
40
63
  .bar-fill { height: 100%; border-radius: 7px; }
41
64
  .bar-row .num { width: 90px; text-align: right; font-variant-numeric: tabular-nums; }
65
+ .bar-row .money { width: 78px; text-align: right; color: var(--good); font-variant-numeric: tabular-nums; }
42
66
  .empty { color: var(--muted); font-size: 14px; text-align: center; padding: 40px 0; }
67
+ .tbl { width: 100%; border-collapse: collapse; font-size: 13px; }
68
+ .tbl th { text-align: left; color: var(--muted); font-weight: 500; font-size: 11px; text-transform: uppercase; letter-spacing: 0.5px; padding: 6px 10px; border-bottom: 1px solid var(--border); }
69
+ .tbl td { padding: 7px 10px; border-bottom: 1px solid var(--surface2); font-variant-numeric: tabular-nums; }
70
+ .tbl tr:last-child td { border-bottom: none; }
71
+ .tbl .r { text-align: right; }
72
+ .tbl .good { color: var(--good); }
73
+ .tbl-wrap { max-height: 320px; overflow-y: auto; }
74
+ .stat-line { display: flex; gap: 24px; margin-bottom: 12px; flex-wrap: wrap; }
75
+ .stat-line div { font-size: 13px; color: var(--muted); }
76
+ .stat-line b { color: var(--text); font-weight: 600; font-variant-numeric: tabular-nums; }
43
77
  footer { color: var(--muted); font-size: 12px; line-height: 1.6; margin-top: 8px; }
44
78
  code { background: var(--surface2); padding: 1px 6px; border-radius: 5px; color: var(--accent2); }
45
79
  @media (max-width: 720px) { .grid { grid-template-columns: repeat(2, 1fr); } }
@@ -48,38 +82,108 @@
48
82
  <body>
49
83
  <header>
50
84
  <div>
51
- <div class="wordmark">enigma<span>.</span></div>
52
- <div class="sub">context compression savings</div>
85
+ <div class="wordmark">Enigma<span>.</span></div>
86
+ <div class="sub">Context Compression Savings</div>
87
+ </div>
88
+ <div class="nav">
89
+ <span class="live"><span id="dot" class="dot stale"></span><span id="updated">Connecting...</span></span>
90
+ <nav class="links">
91
+ <a href="https://github.com/FJRG2007/enigma" target="_blank" rel="noopener noreferrer" title="GitHub repository" aria-label="GitHub repository">
92
+ <svg viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><path d="M12 .297c-6.63 0-12 5.373-12 12 0 5.303 3.438 9.8 8.205 11.385.6.113.82-.258.82-.577 0-.285-.01-1.04-.015-2.04-3.338.724-4.042-1.61-4.042-1.61C4.422 18.07 3.633 17.7 3.633 17.7c-1.087-.744.084-.729.084-.729 1.205.084 1.838 1.236 1.838 1.236 1.07 1.835 2.809 1.305 3.495.998.108-.776.417-1.305.76-1.605-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23A11.52 11.52 0 0 1 12 5.803c1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.84 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222 0 1.606-.014 2.898-.014 3.293 0 .322.216.694.825.576C20.565 22.092 24 17.592 24 12.297c0-6.627-5.373-12-12-12"/></svg>
93
+ </a>
94
+ <a href="https://tpe.li/dsc" target="_blank" rel="noopener noreferrer" title="Discord" aria-label="Discord">
95
+ <svg viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><path d="M20.317 4.3698a19.7913 19.7913 0 0 0-4.8851-1.5152.0741.0741 0 0 0-.0785.0371c-.211.3753-.4447.8648-.6083 1.2495-1.8447-.2762-3.68-.2762-5.4868 0-.1636-.3933-.4058-.8742-.6177-1.2495a.077.077 0 0 0-.0785-.037 19.7363 19.7363 0 0 0-4.8852 1.515.0699.0699 0 0 0-.0321.0277C.5334 9.0458-.319 13.5799.0992 18.0578a.0824.0824 0 0 0 .0312.0561c2.0528 1.5076 4.0413 2.4228 5.9929 3.0294a.0777.0777 0 0 0 .0842-.0276c.4616-.6304.8731-1.2952 1.226-1.9942a.076.076 0 0 0-.0416-.1057c-.6528-.2476-1.2743-.5495-1.8722-.8923a.077.077 0 0 1-.0076-.1277c.1258-.0943.2517-.1923.3718-.2914a.0743.0743 0 0 1 .0776-.0105c3.9278 1.7933 8.18 1.7933 12.0614 0a.0739.0739 0 0 1 .0785.0095c.1202.099.246.1981.3728.2924a.077.077 0 0 1-.0066.1276 12.2986 12.2986 0 0 1-1.873.8914.0766.0766 0 0 0-.0407.1067c.3604.698.7719 1.3628 1.225 1.9932a.076.076 0 0 0 .0842.0286c1.961-.6067 3.9495-1.5219 6.0023-3.0294a.077.077 0 0 0 .0313-.0552c.5004-5.177-.8382-9.6739-3.5485-13.6604a.061.061 0 0 0-.0312-.0286ZM8.02 15.3312c-1.1825 0-2.1569-1.0857-2.1569-2.419 0-1.3332.9555-2.4189 2.157-2.4189 1.2108 0 2.1757 1.0952 2.1568 2.419 0 1.3332-.9555 2.4189-2.1569 2.4189Zm7.9748 0c-1.1825 0-2.1569-1.0857-2.1569-2.419 0-1.3332.9554-2.4189 2.1569-2.4189 1.2108 0 2.1757 1.0952 2.1568 2.419 0 1.3332-.9461 2.4189-2.1568 2.4189Z"/></svg>
96
+ </a>
97
+ <a href="https://ko-fi.com/fjrg2007" target="_blank" rel="noopener noreferrer" title="Support on Ko-fi" aria-label="Support on Ko-fi">
98
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M17 8h1a4 4 0 1 1 0 8h-1"/><path d="M3 8h14v9a4 4 0 0 1-4 4H7a4 4 0 0 1-4-4Z"/><line x1="6" y1="2" x2="6" y2="4"/><line x1="10" y1="2" x2="10" y2="4"/><line x1="14" y1="2" x2="14" y2="4"/></svg>
99
+ </a>
100
+ </nav>
53
101
  </div>
54
- <div class="live"><span id="dot" class="dot stale"></span><span id="updated">connecting...</span></div>
55
102
  </header>
56
103
 
57
104
  <div class="grid">
58
- <div class="card"><div class="label">Tokens saved</div><div id="saved" class="value good">-</div><div id="savedFoot" class="foot">across all compressions</div></div>
59
- <div class="card"><div class="label">Savings rate</div><div id="rate" class="value accent">-</div><div class="foot">of input tokens removed</div></div>
60
- <div class="card"><div class="label">Compressions</div><div id="calls" class="value">-</div><div class="foot">reversible via CCR</div></div>
61
- <div class="card"><div class="label">In &rarr; Out</div><div id="inout" class="value" style="font-size:20px">-</div><div class="foot">total tokens</div></div>
105
+ <div class="card"><div class="label">Money Saved</div><div id="money" class="value good">-</div><div id="moneyFoot" class="foot">Estimated, by source</div></div>
106
+ <div class="card"><div class="label">Tokens Saved</div><div id="saved" class="value">-</div><div id="savedFoot" class="foot">Across all compressions</div></div>
107
+ <div class="card"><div class="label">Savings Rate</div><div id="rate" class="value accent">-</div><div class="foot">Of input tokens removed</div></div>
108
+ <div class="card"><div class="label">Compressions</div><div id="calls" class="value">-</div><div class="foot">Reversible via CCR</div></div>
109
+ <div class="card"><div class="label">Avg / Compression</div><div id="avg" class="value">-</div><div class="foot">Tokens saved per call</div></div>
110
+ <div class="card"><div class="label">Best Compression</div><div id="best" class="value">-</div><div class="foot">Most saved in one call</div></div>
62
111
  </div>
63
112
 
64
113
  <div class="panel">
65
- <h2>Tokens saved per day <small id="rangeLabel"></small></h2>
66
- <svg id="chart" class="chart" viewBox="0 0 600 200" preserveAspectRatio="none" role="img" aria-label="Daily tokens saved"></svg>
114
+ <div class="panel-head">
115
+ <h2>Savings Per Day <small id="rangeLabel"></small></h2>
116
+ <div class="ctrls">
117
+ <div class="ranges" id="metricToggle">
118
+ <button type="button" data-m="tokens" class="active">Tokens</button>
119
+ <button type="button" data-m="usd">$</button>
120
+ </div>
121
+ <div class="ranges" id="modeToggle">
122
+ <button type="button" data-o="daily" class="active">Daily</button>
123
+ <button type="button" data-o="cumulative">Cumulative</button>
124
+ </div>
125
+ <div class="ranges" id="ranges">
126
+ <button type="button" data-d="7">7D</button>
127
+ <button type="button" data-d="30" class="active">30D</button>
128
+ <button type="button" data-d="90">90D</button>
129
+ <button type="button" data-d="0">All</button>
130
+ </div>
131
+ </div>
132
+ </div>
133
+ <div id="chartWrap" class="chartwrap"></div>
67
134
  <div id="chartEmpty" class="empty" style="display:none">No compression activity recorded yet. Run <code>enigma compress &lt;file&gt;</code> or enable the compression MCP (<code>enigma config compress on</code>).</div>
68
135
  </div>
69
136
 
70
137
  <div class="panel">
71
- <h2>Before vs after <small>cumulative tokens</small></h2>
138
+ <h2 style="margin-bottom:14px">Savings By Source <small>which app/CLI compressed</small></h2>
139
+ <div id="sources"></div>
140
+ </div>
141
+
142
+ <div class="panel">
143
+ <h2 style="margin-bottom:14px">Savings By Content Type <small>what enigma compressed</small></h2>
144
+ <div id="types"></div>
145
+ </div>
146
+
147
+ <div class="panel">
148
+ <h2 style="margin-bottom:14px">Before vs. After <small id="ratioLabel">cumulative tokens</small></h2>
72
149
  <div class="bar-row"><span class="name">Before</span><div class="bar-track"><div id="barBefore" class="bar-fill" style="background:var(--accent2)"></div></div><span id="numBefore" class="num">-</span></div>
73
150
  <div class="bar-row"><span class="name">After</span><div class="bar-track"><div id="barAfter" class="bar-fill" style="background:var(--good)"></div></div><span id="numAfter" class="num">-</span></div>
151
+ <div class="stat-line" style="margin:14px 0 0"><div>Removed <b id="removed">-</b></div><div>Reduction <b id="reduction">-</b></div><div>Compression ratio <b id="ratio">-</b></div></div>
152
+ </div>
153
+
154
+ <div class="panel">
155
+ <div class="panel-head">
156
+ <h2>Savings History <small id="histStats"></small></h2>
157
+ <div class="ctrls">
158
+ <div class="ranges" id="histToggle">
159
+ <button type="button" data-p="day" class="active">Daily</button>
160
+ <button type="button" data-p="week">Weekly</button>
161
+ <button type="button" data-p="month">Monthly</button>
162
+ </div>
163
+ </div>
164
+ </div>
165
+ <div class="tbl-wrap"><div id="histBody"></div></div>
166
+ </div>
167
+
168
+ <div class="panel">
169
+ <h2 style="margin-bottom:14px">Recent Compressions <small>newest first</small></h2>
170
+ <div class="tbl-wrap"><div id="recent"></div></div>
171
+ </div>
172
+
173
+ <div class="panel">
174
+ <h2 style="margin-bottom:14px">Reversible Cache (CCR) <small>recoverable originals on disk</small></h2>
175
+ <div id="cache"></div>
74
176
  </div>
75
177
 
76
178
  <footer>
77
179
  Polling pauses automatically when this tab is hidden, and the server caches each snapshot - so an open dashboard costs almost nothing.
78
- Data source: <code>~/.enigma/ccr/stats.json</code> + <code>history.jsonl</code>. enigma <span id="ver"></span>
180
+ Data source: <code>~/.enigma/ccr/stats.json</code> + <code>history.jsonl</code> - reset it with <code>enigma compress --clear</code>. Enigma <span id="ver"></span>
79
181
  </footer>
80
182
 
183
+ <script src="/lib/chart.min.js"></script>
81
184
  <script>
82
185
  const POLL_MS = 5000;
186
+ const DAY = 86400000;
83
187
  const $ = (id) => document.getElementById(id);
84
188
 
85
189
  function fmt(n) {
@@ -89,60 +193,278 @@
89
193
  if (n >= 1e3) return (n / 1e3).toFixed(1) + "k";
90
194
  return String(Math.round(n));
91
195
  }
196
+ function fmtUsd(n) {
197
+ n = Number(n) || 0;
198
+ if (n >= 1e3) return "$" + (n / 1e3).toFixed(2) + "k";
199
+ if (n >= 1) return "$" + n.toFixed(2);
200
+ return "$" + n.toFixed(n >= 0.1 ? 2 : 3);
201
+ }
202
+ function fmtBytes(n) {
203
+ n = Number(n) || 0;
204
+ if (n >= 1048576) return (n / 1048576).toFixed(1) + " MB";
205
+ if (n >= 1024) return (n / 1024).toFixed(1) + " KB";
206
+ return n + " B";
207
+ }
208
+
209
+ // Estimated price per 1M INPUT tokens, by source. enigma is not a proxy, so it
210
+ // can't know the real model - these are sensible defaults, overridable globally
211
+ // with `enigma config token-price <usd>` (priceOverride from the payload).
212
+ const PRICE_1M = {
213
+ "claude-code": 3, "claude": 3, "claude-desktop": 3,
214
+ "opencode": 3, "codex": 2.5, "codex-cli": 2.5, "cli": 3, "unknown": 3,
215
+ };
216
+ const DEFAULT_PRICE_1M = 3;
217
+ let priceOverride = 0;
218
+ function priceFor(source) { return priceOverride > 0 ? priceOverride : (PRICE_1M[source] ?? DEFAULT_PRICE_1M); }
219
+ function moneyOf(source, savedTokens) { return savedTokens * priceFor(source) / 1e6; }
220
+
221
+ // Friendly names for known MCP clients / content types; unknown -> title-cased raw.
222
+ const SOURCE_LABELS = {
223
+ "claude-code": "Claude Code", "claude": "Claude Code", "claude-desktop": "Claude Desktop",
224
+ "opencode": "OpenCode", "codex": "Codex", "codex-cli": "Codex",
225
+ "cli": "CLI (enigma compress)", "unknown": "Unattributed",
226
+ };
227
+ function prettySource(k) { return SOURCE_LABELS[k] || String(k).replace(/[-_]/g, " ").replace(/\b\w/g, (c) => c.toUpperCase()); }
228
+ const TYPE_LABELS = { json: "JSON", log: "Logs", text: "Text", code: "Code", diff: "Diffs", markdown: "Markdown", unknown: "Other" };
229
+ function prettyType(k) { return TYPE_LABELS[k] || String(k).replace(/\b\w/g, (c) => c.toUpperCase()); }
230
+
231
+ // One bar per key (source or type), sorted by tokens saved, with an optional $ column.
232
+ function renderBreakdown(el, map, label, money) {
233
+ const entries = Object.entries(map || {}).sort((a, b) => (b[1].tokensSaved || 0) - (a[1].tokensSaved || 0));
234
+ if (!entries.length) {
235
+ el.innerHTML = '<div class="empty" style="padding:20px 0">No data yet - this fills in as compressions are recorded.</div>';
236
+ return;
237
+ }
238
+ const max = Math.max(1, ...entries.map(([, v]) => v.tokensSaved || 0));
239
+ el.innerHTML = entries.map(([k, v]) => {
240
+ const saved = v.tokensSaved || 0, pct = Math.round(saved / max * 100);
241
+ const m = money ? '<span class="money">' + fmtUsd(moneyOf(k, saved)) + '</span>' : '';
242
+ return '<div class="bar-row"><span class="name" style="width:140px">' + label(k)
243
+ + '</span><div class="bar-track"><div class="bar-fill" style="width:' + pct + '%;background:var(--accent)"></div></div>'
244
+ + '<span class="num">' + fmt(saved) + '</span>' + m
245
+ + '<span class="foot" style="width:64px;text-align:right;margin:0">' + fmt(v.calls || 0) + ' calls</span></div>';
246
+ }).join("");
247
+ }
92
248
 
93
- // Bucket history points into the last 30 calendar days of saved tokens.
94
- function dailySaved(history) {
95
- const DAY = 86400000;
96
- const today = Math.floor(Date.now() / DAY);
97
- const start = today - 29;
98
- const buckets = new Array(30).fill(0);
249
+ // A millisecond timestamp -> the UTC calendar day as the YYYY-MM-DD string the
250
+ // chart's time scale expects (matches the server's UTC-day history buckets).
251
+ function dayKey(ms) { return new Date(ms).toISOString().slice(0, 10); }
252
+
253
+ const savedOf = (p) => Math.max(0, (p.b || 0) - (p.a || 0));
254
+
255
+ // Recent compressions table (newest first), one row per history point.
256
+ function renderRecent(history) {
257
+ const rows = (history || []).slice(-20).reverse();
258
+ const el = $("recent");
259
+ if (!rows.length) { el.innerHTML = '<div class="empty" style="padding:24px 0">No compressions recorded yet.</div>'; return; }
260
+ el.innerHTML = '<table class="tbl"><thead><tr><th>Time</th><th>Type</th><th>Source</th>'
261
+ + '<th class="r">Before</th><th class="r">After</th><th class="r">Saved</th></tr></thead><tbody>'
262
+ + rows.map((p) => {
263
+ const saved = savedOf(p), pct = p.b ? Math.round(saved / p.b * 100) : 0;
264
+ return '<tr><td>' + new Date(p.t).toLocaleString() + '</td><td>' + prettyType(p.c || "unknown")
265
+ + '</td><td>' + prettySource(p.s || "unknown") + '</td><td class="r">' + fmt(p.b || 0)
266
+ + '</td><td class="r">' + fmt(p.a || 0) + '</td><td class="r good">' + fmt(saved) + " (" + pct + "%)</td></tr>";
267
+ }).join("") + "</tbody></table>";
268
+ }
269
+
270
+ // ISO-ish period key for grouping (day = YYYY-MM-DD, week = YYYY-Www, month = YYYY-MM).
271
+ function periodKey(t, period) {
272
+ const d = new Date(t);
273
+ if (period === "month") return d.toISOString().slice(0, 7);
274
+ if (period === "week") {
275
+ const onejan = Date.UTC(d.getUTCFullYear(), 0, 1);
276
+ const week = Math.ceil(((d.getTime() - onejan) / DAY + 1) / 7);
277
+ return d.getUTCFullYear() + "-W" + String(week).padStart(2, "0");
278
+ }
279
+ return dayKey(t);
280
+ }
281
+
282
+ let histPeriod = "day";
283
+ function renderHistory(history) {
284
+ const hist = history || [];
285
+ const days = new Set(hist.map((p) => dayKey(p.t))).size;
286
+ const total = hist.reduce((s, p) => s + savedOf(p), 0);
287
+ $("histStats").textContent = days ? days + " active day" + (days === 1 ? "" : "s") + " · avg " + fmt(Math.round(total / days)) + "/day" : "";
288
+ const byP = new Map();
289
+ for (const p of hist) {
290
+ const k = periodKey(p.t, histPeriod);
291
+ const cur = byP.get(k) || { tokens: 0, usd: 0, calls: 0 };
292
+ cur.tokens += savedOf(p);
293
+ cur.usd += moneyOf(p.s || "unknown", savedOf(p));
294
+ cur.calls++;
295
+ byP.set(k, cur);
296
+ }
297
+ const rows = [...byP.entries()].sort((a, b) => a[0] < b[0] ? 1 : -1);
298
+ const el = $("histBody");
299
+ if (!rows.length) { el.innerHTML = '<div class="empty" style="padding:24px 0">No history yet.</div>'; return; }
300
+ el.innerHTML = '<table class="tbl"><thead><tr><th>Period</th><th class="r">Compressions</th>'
301
+ + '<th class="r">Tokens Saved</th><th class="r">$ Saved</th></tr></thead><tbody>'
302
+ + rows.map(([k, v]) => '<tr><td>' + k + '</td><td class="r">' + v.calls + '</td><td class="r good">'
303
+ + fmt(v.tokens) + '</td><td class="r">' + fmtUsd(v.usd) + "</td></tr>").join("") + "</tbody></table>";
304
+ }
305
+
306
+ function renderCache(c) {
307
+ const el = $("cache");
308
+ if (!c) { el.innerHTML = '<div class="empty" style="padding:20px 0">No cache data.</div>'; return; }
309
+ const pct = c.cap ? Math.round((c.count || 0) / c.cap * 100) : 0;
310
+ el.innerHTML = '<div class="bar-row"><span class="name" style="width:170px">Cached originals</span>'
311
+ + '<div class="bar-track"><div class="bar-fill" style="width:' + pct + '%;background:var(--accent)"></div></div>'
312
+ + '<span class="num">' + fmt(c.count || 0) + " / " + fmt(c.cap || 0) + '</span></div>'
313
+ + '<div class="stat-line" style="margin-top:6px"><div>On disk <b>' + fmtBytes(c.bytes || 0) + '</b></div>'
314
+ + '<div>Retention <b>newest ' + fmt(c.cap || 0) + '</b></div>'
315
+ + '<div>Restore <b>enigma compress --retrieve &lt;hash&gt;</b></div></div>';
316
+ }
317
+
318
+ // Aggregate raw history points into one {time, tokens, usd} per day (ascending,
319
+ // unique times). $ per point uses that point's recorded source price.
320
+ function dailySeries(history) {
321
+ const byDay = new Map();
99
322
  for (const p of history || []) {
100
- const day = Math.floor(p.t / DAY);
101
- if (day < start || day > today) continue;
102
- buckets[day - start] += Math.max(0, (p.b || 0) - (p.a || 0));
323
+ const saved = Math.max(0, (p.b || 0) - (p.a || 0));
324
+ const key = dayKey(p.t);
325
+ const cur = byDay.get(key) || { tokens: 0, usd: 0 };
326
+ cur.tokens += saved;
327
+ cur.usd += moneyOf(p.s || "unknown", saved);
328
+ byDay.set(key, cur);
103
329
  }
104
- return buckets;
105
- }
106
-
107
- function renderChart(buckets) {
108
- const svg = $("chart"), W = 600, H = 200, pad = 6;
109
- const max = Math.max(1, ...buckets);
110
- const n = buckets.length;
111
- const x = (i) => pad + (i * (W - pad * 2)) / (n - 1);
112
- const y = (v) => H - pad - (v / max) * (H - pad * 2);
113
- const pts = buckets.map((v, i) => x(i) + "," + y(v));
114
- const line = "M" + pts.join(" L");
115
- const area = line + " L" + x(n - 1) + "," + (H - pad) + " L" + x(0) + "," + (H - pad) + " Z";
116
- const hasData = buckets.some((v) => v > 0);
117
- $("chartEmpty").style.display = hasData ? "none" : "block";
118
- svg.style.display = hasData ? "block" : "none";
119
- svg.innerHTML =
120
- '<defs><linearGradient id="g" x1="0" y1="0" x2="0" y2="1">' +
121
- '<stop offset="0%" stop-color="#e0a458" stop-opacity="0.35"/>' +
122
- '<stop offset="100%" stop-color="#e0a458" stop-opacity="0"/></linearGradient></defs>' +
123
- '<path d="' + area + '" fill="url(#g)"/>' +
124
- '<path d="' + line + '" fill="none" stroke="#e0a458" stroke-width="2" vector-effect="non-scaling-stroke"/>';
330
+ return [...byDay.entries()].sort((a, b) => a[0] < b[0] ? -1 : 1)
331
+ .map(([time, v]) => ({ time, tokens: v.tokens, usd: v.usd }));
332
+ }
333
+
334
+ let chart = null, series = null, dailyData = [], lastHistory = [], currentRange = 30, metric = "tokens", mode = "daily";
335
+ const metricFmt = (v) => metric === "usd" ? fmtUsd(v) : fmt(v);
336
+
337
+ // Map the per-day data to LC {time, value} for the active metric and mode.
338
+ function seriesValues() {
339
+ let cum = 0;
340
+ return dailyData.map((d) => {
341
+ const base = metric === "usd" ? d.usd : d.tokens;
342
+ if (mode === "cumulative") { cum += base; return { time: d.time, value: cum }; }
343
+ return { time: d.time, value: base };
344
+ });
345
+ }
346
+
347
+ function initChart() {
348
+ if (!window.LightweightCharts) return false;
349
+ const LC = window.LightweightCharts;
350
+ const el = $("chartWrap");
351
+ chart = LC.createChart(el, {
352
+ autoSize: true,
353
+ layout: { background: { type: "solid", color: "transparent" }, textColor: "#8a93a3", fontSize: 12 },
354
+ grid: { vertLines: { color: "rgba(35,42,54,0.5)" }, horzLines: { color: "rgba(35,42,54,0.5)" } },
355
+ rightPriceScale: { borderColor: "#232a36" },
356
+ timeScale: { borderColor: "#232a36", fixLeftEdge: true, fixRightEdge: true },
357
+ crosshair: {
358
+ mode: LC.CrosshairMode.Magnet,
359
+ vertLine: { color: "#3a4150", labelBackgroundColor: "#e0a458" },
360
+ horzLine: { color: "#3a4150", labelBackgroundColor: "#e0a458" },
361
+ },
362
+ });
363
+ series = chart.addSeries(LC.AreaSeries, {
364
+ lineColor: "#e0a458", topColor: "rgba(224,164,88,0.35)", bottomColor: "rgba(224,164,88,0.02)",
365
+ lineWidth: 2, priceLineVisible: false, lastValueVisible: false,
366
+ priceFormat: { type: "custom", minMove: 1, formatter: metricFmt },
367
+ });
368
+ setupTooltip(el);
369
+ // Button groups: select one, restyle siblings, run the picker.
370
+ const group = (id, onPick) => $(id).addEventListener("click", (e) => {
371
+ const b = e.target.closest("button");
372
+ if (!b) return;
373
+ for (const x of $(id).children) x.classList.toggle("active", x === b);
374
+ onPick(b);
375
+ });
376
+ group("ranges", (b) => { currentRange = Number(b.dataset.d); applyRange(); });
377
+ group("metricToggle", (b) => { metric = b.dataset.m; redraw(); });
378
+ group("modeToggle", (b) => { mode = b.dataset.o; redraw(); });
379
+ group("histToggle", (b) => { histPeriod = b.dataset.p; renderHistory(lastHistory); });
380
+ return true;
381
+ }
382
+
383
+ // Floating crosshair tooltip: hovered date + that point's value (tokens or $).
384
+ function setupTooltip(container) {
385
+ const tip = document.createElement("div");
386
+ tip.className = "tip";
387
+ tip.style.display = "none";
388
+ container.appendChild(tip);
389
+ chart.subscribeCrosshairMove((param) => {
390
+ if (!param.time || !param.point || param.point.x < 0 || param.point.y < 0) { tip.style.display = "none"; return; }
391
+ const d = param.seriesData.get(series);
392
+ if (!d) { tip.style.display = "none"; return; }
393
+ const suffix = (metric === "usd" ? " saved" : " tokens saved") + (mode === "cumulative" ? " (total)" : "");
394
+ tip.innerHTML = '<div class="tip-d">' + param.time + '</div><div class="tip-v">' + metricFmt(d.value) + suffix + "</div>";
395
+ tip.style.display = "block";
396
+ const left = Math.max(0, Math.min(param.point.x + 14, container.clientWidth - 160));
397
+ tip.style.left = left + "px";
398
+ tip.style.top = Math.max(0, param.point.y - 10) + "px";
399
+ });
400
+ }
401
+
402
+ // Push the active metric/mode data into the series and reformat the axis.
403
+ function redraw() {
404
+ if (!series) return;
405
+ series.applyOptions({ priceFormat: { type: "custom", minMove: metric === "usd" ? 0.0001 : 1, formatter: metricFmt } });
406
+ const has = dailyData.length > 0;
407
+ $("chartEmpty").style.display = has ? "none" : "block";
408
+ $("chartWrap").style.display = has ? "block" : "none";
409
+ if (has) { series.setData(seriesValues()); applyRange(); }
410
+ }
411
+
412
+ // Constrain the visible window to the selected range (All = fit everything).
413
+ function applyRange() {
414
+ if (!chart || !dailyData.length) return;
415
+ const ts = chart.timeScale();
416
+ if (currentRange === 0) { ts.fitContent(); $("rangeLabel").textContent = "all time"; return; }
417
+ const to = dailyData[dailyData.length - 1].time;
418
+ const from = dayKey(Date.now() - currentRange * DAY);
419
+ try { ts.setVisibleRange({ from, to }); }
420
+ catch { ts.fitContent(); }
421
+ $("rangeLabel").textContent = "last " + currentRange + " days";
422
+ }
423
+
424
+ // Total estimated $ saved: sum each source's saved tokens at its price (legacy
425
+ // data with no attribution falls back to the default price).
426
+ function totalMoney(s) {
427
+ const bs = s.bySource || {};
428
+ const keys = Object.keys(bs);
429
+ if (keys.length) return keys.reduce((sum, k) => sum + moneyOf(k, bs[k].tokensSaved || 0), 0);
430
+ return moneyOf("unknown", s.tokensSaved || 0);
125
431
  }
126
432
 
127
433
  function render(data) {
434
+ priceOverride = Number(data.priceOverride) || 0;
128
435
  const s = data.stats || {};
129
- const before = s.tokensBefore || 0, after = s.tokensAfter || 0, saved = s.tokensSaved || 0;
436
+ const before = s.tokensBefore || 0, after = s.tokensAfter || 0, saved = s.tokensSaved || 0, calls = s.calls || 0;
130
437
  const rate = before ? Math.round((saved / before) * 100) : 0;
438
+ $("money").textContent = fmtUsd(totalMoney(s));
439
+ $("moneyFoot").textContent = priceOverride > 0 ? "~$" + priceOverride + "/1M tokens" : "Estimated, by source";
131
440
  $("saved").textContent = fmt(saved);
132
- $("savedFoot").textContent = saved ? "~" + saved.toLocaleString() + " tokens" : "across all compressions";
441
+ $("savedFoot").textContent = saved ? "~" + saved.toLocaleString() + " tokens" : "Across all compressions";
133
442
  $("rate").textContent = rate + "%";
134
- $("calls").textContent = fmt(s.calls || 0);
135
- $("inout").textContent = fmt(before) + " " + fmt(after);
443
+ $("calls").textContent = fmt(calls);
444
+ $("avg").textContent = calls ? fmt(Math.round(saved / calls)) : "0";
445
+ $("best").textContent = fmt(s.best || 0);
136
446
  $("numBefore").textContent = fmt(before);
137
447
  $("numAfter").textContent = fmt(after);
138
448
  const max = Math.max(1, before);
139
449
  $("barBefore").style.width = (before / max * 100) + "%";
140
450
  $("barAfter").style.width = (after / max * 100) + "%";
141
- renderChart(dailySaved(data.history));
142
- $("rangeLabel").textContent = "last 30 days";
451
+ $("removed").textContent = fmt(saved) + " (" + rate + "%)";
452
+ $("reduction").textContent = before ? (100 - rate) + "% of input sent" : "-";
453
+ $("ratio").textContent = after ? "~" + (before / after).toFixed(1) + ":1" : "-";
454
+ renderBreakdown($("sources"), s.bySource, prettySource, true);
455
+ renderBreakdown($("types"), s.byType, prettyType, false);
456
+
457
+ lastHistory = data.history || [];
458
+ renderRecent(lastHistory);
459
+ renderHistory(lastHistory);
460
+ renderCache(data.cache);
461
+
462
+ dailyData = dailySeries(data.history);
463
+ redraw();
464
+
143
465
  $("ver").textContent = data.version ? "v" + data.version : "";
144
466
  $("dot").classList.remove("stale");
145
- $("updated").textContent = "updated " + new Date(data.generatedAt || Date.now()).toLocaleTimeString();
467
+ $("updated").textContent = "Updated " + new Date(data.generatedAt || Date.now()).toLocaleTimeString();
146
468
  }
147
469
 
148
470
  async function poll() {
@@ -153,10 +475,11 @@
153
475
  render(await res.json());
154
476
  } catch {
155
477
  $("dot").classList.add("stale");
156
- $("updated").textContent = "disconnected";
478
+ $("updated").textContent = "Disconnected";
157
479
  }
158
480
  }
159
481
 
482
+ initChart();
160
483
  poll();
161
484
  setInterval(poll, POLL_MS);
162
485
  document.addEventListener("visibilitychange", () => { if (!document.hidden) poll(); });