@krtclcdy/tersio 2.4.1 → 2.6.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.
@@ -0,0 +1,404 @@
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1">
6
+ <meta name="color-scheme" content="dark light">
7
+ <title>Tersio Gain Dashboard</title>
8
+ <link rel="icon" type="image/png" href="brand.webp">
9
+ <script src="https://cdn.jsdelivr.net/npm/@tailwindcss/browser@4"></script>
10
+ <script src="https://unpkg.com/lucide@latest"></script>
11
+ <style>
12
+ :root {
13
+ --bg: #f4f4f5; --panel: #ffffff; --ink: #18181b; --dim: #52525b;
14
+ --line: rgba(24, 24, 27, .12); --accent: #047857; --accent-soft: rgba(4, 120, 87, .12);
15
+ --track: rgba(24, 24, 27, .08); --grid: rgba(4, 120, 87, .07);
16
+ --cell-0: rgba(24, 24, 27, .06);
17
+ --cell-1: rgba(4, 120, 87, .25); --cell-2: rgba(4, 120, 87, .5);
18
+ --cell-3: rgba(4, 120, 87, .75); --cell-4: #047857;
19
+ }
20
+ @media (prefers-color-scheme: dark) {
21
+ :root {
22
+ --bg: #09090b; --panel: #101014; --ink: #f4f4f5; --dim: #a1a1aa;
23
+ --line: rgba(255, 255, 255, .09); --accent: #34d399; --accent-soft: rgba(52, 211, 153, .1);
24
+ --track: rgba(255, 255, 255, .07); --grid: rgba(52, 211, 153, .05);
25
+ --cell-0: rgba(255, 255, 255, .06);
26
+ --cell-1: rgba(52, 211, 153, .25); --cell-2: rgba(52, 211, 153, .5);
27
+ --cell-3: rgba(52, 211, 153, .75); --cell-4: #34d399;
28
+ }
29
+ }
30
+ body { background: var(--bg); color: var(--ink); }
31
+ .bg-blueprint {
32
+ background-image: linear-gradient(var(--grid) 1px, transparent 1px),
33
+ linear-gradient(90deg, var(--grid) 1px, transparent 1px);
34
+ background-size: 44px 44px;
35
+ mask-image: radial-gradient(ellipse 90% 70% at 50% 0%, black 30%, transparent 75%);
36
+ }
37
+ .mono { font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; font-variant-numeric: tabular-nums; }
38
+ .rowline { border-bottom: 1px solid var(--line); }
39
+ .bar-fill { background: var(--accent); border-radius: 999px; transform-origin: left; }
40
+ .bar-track { background: var(--track); border-radius: 999px; }
41
+ .bar-dim { background: var(--dim); opacity: .55; border-radius: 999px; transform-origin: bottom; }
42
+ .live-dot { background: var(--accent); box-shadow: 0 0 0 0 var(--accent-soft); animation: ping 2s cubic-bezier(0, 0, .2, 1) infinite; }
43
+ @keyframes ping { 75%, 100% { box-shadow: 0 0 0 9px transparent; } }
44
+ .rise { opacity: 0; transform: translateY(26px); }
45
+ .rise.in { opacity: 1; transform: none; transition: opacity .7s cubic-bezier(.16, 1, .3, 1), transform .7s cubic-bezier(.16, 1, .3, 1); }
46
+ .grow { transform: scaleX(0); transition: transform 1.1s cubic-bezier(.16, 1, .3, 1); }
47
+ .grow.in { transform: scaleX(1); }
48
+ .growY { transform: scaleY(0); transition: transform .9s cubic-bezier(.16, 1, .3, 1); }
49
+ .growY.in { transform: scaleY(1); }
50
+ .tilt { transition: transform .25s cubic-bezier(.16, 1, .3, 1), background .25s; }
51
+ .tilt:hover { transform: translateX(6px); background: var(--accent-soft); }
52
+ .btn-push { transition: transform .12s, background .2s; }
53
+ .btn-push:hover { background: var(--accent-soft); }
54
+ .btn-push:active { transform: scale(.96); }
55
+ a.social { color: var(--ink); }
56
+ a.social:hover { color: var(--accent); }
57
+ .cell { width: 11px; height: 11px; border-radius: 2.5px; background: var(--cell-0); }
58
+ .cell.l1 { background: var(--cell-1); } .cell.l2 { background: var(--cell-2); }
59
+ .cell.l3 { background: var(--cell-3); } .cell.l4 { background: var(--cell-4); }
60
+ .dock {
61
+ position: sticky; top: 12px; z-index: 50;
62
+ background: color-mix(in srgb, var(--panel) 84%, transparent);
63
+ -webkit-backdrop-filter: blur(18px) saturate(160%); backdrop-filter: blur(18px) saturate(160%);
64
+ border: 1px solid var(--line); border-radius: 20px;
65
+ box-shadow: 0 12px 40px rgba(0, 0, 0, .25);
66
+ }
67
+ .dock a.docklink { transition: transform .22s cubic-bezier(.34, 1.56, .64, 1), background .2s; border-radius: 12px; }
68
+ .dock a.docklink:hover { transform: scale(1.28) translateY(-3px); background: var(--accent-soft); }
69
+ .dock a.docklink:active { transform: scale(1.05); }
70
+ .dock img.brand { border-radius: 10px; }
71
+ @media (prefers-reduced-motion: reduce) {
72
+ .dock a.docklink { transition: none; }
73
+ .dock a.docklink:hover { transform: none; }
74
+ }
75
+ @media (prefers-reduced-motion: reduce) {
76
+ .rise { opacity: 1; transform: none; }
77
+ .grow, .growY { transform: none; transition: none; }
78
+ .live-dot { animation: none; }
79
+ .tilt, .btn-push { transition: none; }
80
+ }
81
+ </style>
82
+ </head>
83
+ <body class="min-h-[100dvh] antialiased">
84
+ <div class="bg-blueprint pointer-events-none fixed inset-0" aria-hidden="true"></div>
85
+
86
+ <div class="relative mx-auto max-w-7xl px-4 sm:px-6 pb-16">
87
+
88
+ <!-- Floating dock navbar -->
89
+ <header class="dock mt-4 flex w-full items-center gap-2 px-3 py-2">
90
+ <a href="#" class="flex shrink-0 items-center gap-2.5 pl-1 pr-2" aria-label="Tersio gain home">
91
+ <img src="brand.webp" alt="Tersio" width="32" height="32" class="brand size-8 rounded-full">
92
+ <span class="leading-tight text-left">
93
+ <span class="block font-bold tracking-tight text-sm">TERSIO GAIN</span>
94
+ <span class="mono block text-[11px]" style="color: var(--dim)" id="meta">connecting to ledger</span>
95
+ </span>
96
+ </a>
97
+ <span class="mono hidden shrink-0 sm:flex items-center gap-2 text-xs px-2 ml-auto" style="color: var(--dim)">
98
+ <span class="live-dot inline-block size-2 rounded-full"></span>
99
+ <span id="clock">--:--</span>
100
+ </span>
101
+ <button id="reload" class="btn-push mono flex shrink-0 items-center gap-2 text-xs pl-2.5 pr-3 py-2 rounded-xl" style="border: 1px solid var(--line)" aria-label="Reload data">
102
+ <i data-lucide="refresh-cw" class="size-3.5"></i><span class="hidden md:inline">Reload</span>
103
+ </button>
104
+ </header>
105
+
106
+ <!-- Hero: asymmetric 7/5 -->
107
+ <section class="grid grid-cols-1 lg:grid-cols-12 gap-8 pt-10 pb-8 items-end">
108
+ <div class="lg:col-span-7 rise">
109
+ <p class="mono text-[11px] uppercase tracking-[0.22em] mb-3" style="color: var(--accent)">Live token feed</p>
110
+ <p class="mono font-bold tracking-tighter leading-none text-7xl md:text-8xl" id="total">0</p>
111
+ <p class="mt-3 text-base max-w-[52ch]" style="color: var(--dim)">session tokens parsed from OMP transcripts. Input, output, cache read, cache write, USD cost, CO2.</p>
112
+ </div>
113
+ <div class="lg:col-span-5 rise" style="transition-delay: .12s">
114
+ <dl class="mono text-sm">
115
+ <div class="flex justify-between gap-4 py-2 rowline"><dt style="color: var(--dim)">usd cost</dt><dd id="usd">-</dd></div>
116
+ <div class="flex justify-between gap-4 py-2 rowline"><dt style="color: var(--dim)">co2</dt><dd id="co2">-</dd></div>
117
+ <div class="flex justify-between gap-4 py-2"><dt style="color: var(--dim)">assistant msgs</dt><dd id="msgs">0</dd></div>
118
+ </dl>
119
+ </div>
120
+ </section>
121
+
122
+ <!-- Token strip: plain stats, dividers, no cards -->
123
+ <section class="rise grid grid-cols-2 sm:grid-cols-4 rounded-xl overflow-hidden" style="border: 1px solid var(--line); background: var(--panel)" id="tokens" aria-label="Tokens by bucket"></section>
124
+
125
+ <!-- Contribution graph, GitHub style -->
126
+ <section id="activity" class="rise mt-8 rounded-xl p-5 overflow-hidden" style="border: 1px solid var(--line); background: var(--panel); scroll-margin-top: 90px" aria-label="Activity graph">
127
+ <div class="flex items-center gap-2 mb-1">
128
+ <i data-lucide="calendar-days" class="size-4" style="color: var(--accent)"></i>
129
+ <h2 class="font-bold tracking-tight text-xl">Activity</h2>
130
+ <span class="mono text-xs ml-auto" style="color: var(--dim)" id="graphRange"></span>
131
+ </div>
132
+ <p class="mono text-xs mb-4" style="color: var(--dim)">daily session tokens / last 26 weeks / darker means more</p>
133
+ <div class="overflow-x-auto pb-1">
134
+ <div class="flex gap-[3px] w-max" id="graph"></div>
135
+ </div>
136
+ <div class="mono text-[11px] mt-3 flex items-center gap-1.5" style="color: var(--dim)">
137
+ <span>less</span>
138
+ <span class="cell"></span><span class="cell l1"></span><span class="cell l2"></span><span class="cell l3"></span><span class="cell l4"></span>
139
+ <span>more</span>
140
+ </div>
141
+ </section>
142
+
143
+ <!-- Main: 8/4 split -->
144
+ <main class="grid grid-cols-1 lg:grid-cols-12 gap-8 mt-8">
145
+
146
+ <div class="lg:col-span-8 flex flex-col gap-8">
147
+ <section id="daily" class="rise" style="scroll-margin-top: 90px" aria-label="Daily chart">
148
+ <div class="flex items-center gap-2 mb-1">
149
+ <i data-lucide="chart-column" class="size-4" style="color: var(--accent)"></i>
150
+ <h2 class="font-bold tracking-tight text-xl">Daily input vs output</h2>
151
+ </div>
152
+ <p class="mono text-xs mb-4" style="color: var(--dim)">last 14 days / tall bar input, short bar output</p>
153
+ <div class="rounded-xl p-5" style="border: 1px solid var(--line); background: var(--panel)">
154
+ <div class="flex items-end gap-2 h-36" id="chart"></div>
155
+ <div class="flex gap-2 mt-2 mono text-[10px]" style="color: var(--dim)" id="chartDays"></div>
156
+ </div>
157
+ </section>
158
+
159
+ <section id="commands" class="rise" style="scroll-margin-top: 90px" aria-label="Top commands">
160
+ <div class="flex items-center gap-2 mb-1">
161
+ <i data-lucide="terminal" class="size-4" style="color: var(--accent)"></i>
162
+ <h2 class="font-bold tracking-tight text-xl">Top commands</h2>
163
+ </div>
164
+ <p class="mono text-xs mb-4" style="color: var(--dim)">ranked by ledger count / top 10</p>
165
+ <ol id="top" class="rounded-xl overflow-hidden" style="border: 1px solid var(--line); background: var(--panel)"></ol>
166
+ <p id="emptyTop" class="mono text-sm py-8 text-center hidden" style="color: var(--dim)">No ledger rows yet. Run tersio commands to record activity.</p>
167
+ </section>
168
+ </div>
169
+
170
+ <div class="lg:col-span-4 flex flex-col gap-8">
171
+ <section id="savings" class="rise rounded-xl p-5" style="border: 1px solid var(--line); background: var(--panel); transition-delay: .1s; scroll-margin-top: 90px" aria-label="Savings">
172
+ <div class="flex items-center gap-2 mb-3">
173
+ <i data-lucide="gauge" class="size-4" style="color: var(--accent)"></i>
174
+ <h2 class="font-bold tracking-tight text-xl">Savings</h2>
175
+ </div>
176
+ <ul class="mono text-[13px] leading-relaxed space-y-2">
177
+ <li class="flex gap-2"><i data-lucide="circle-dollar-sign" class="size-4 shrink-0 mt-0.5" style="color: var(--accent)"></i><span id="usdLine">USD from per-model price table.</span></li>
178
+ <li class="flex gap-2"><i data-lucide="leaf" class="size-4 shrink-0 mt-0.5" style="color: var(--accent)"></i><span id="co2Line">CO2 ~est. from output tokens.</span></li>
179
+ <li class="flex gap-2"><i data-lucide="database" class="size-4 shrink-0 mt-0.5" style="color: var(--accent)"></i><span id="cacheLine">Cache share loads with session data.</span></li>
180
+ </ul>
181
+ </section>
182
+
183
+ <section class="rise rounded-xl p-5" style="border: 1px solid var(--line); background: var(--panel); transition-delay: .18s" aria-label="Models">
184
+ <div class="flex items-center gap-2 mb-3">
185
+ <i data-lucide="cpu" class="size-4" style="color: var(--accent)"></i>
186
+ <h2 class="font-bold tracking-tight text-xl">Models</h2>
187
+ </div>
188
+ <ol id="models" class="mono text-[13px] space-y-2" style="scroll-margin-top: 90px"></ol>
189
+ </section>
190
+ </div>
191
+ </main>
192
+
193
+ <footer class="mono text-xs mt-12 pt-4 flex items-center gap-4" style="color: var(--dim); border-top: 1px solid var(--line)">
194
+ <span>tersio gain dashboard / single file</span>
195
+ <span class="ml-auto flex items-center gap-1" style="color: var(--ink)">
196
+ <a href="https://github.com/KurutoDenzeru" target="_blank" rel="noopener" aria-label="GitHub" class="btn-push social grid size-8 place-items-center rounded-lg"><svg width="16" height="16" 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-.3-5.466-1.332-5.466-5.93 0-1.31.465-2.38 1.235-3.22-.135-.303-.54-1.523.105-3.176 0 0 1.005-.322 3.3 1.23.96-.267 1.98-.399 3-.405 1.02.006 2.04.138 3 .405 2.28-1.552 3.285-1.23 3.285-1.23.645 1.653.24 2.873.12 3.176.765.84 1.23 1.91 1.23 3.22 0 4.61-2.805 5.625-5.475 5.92.42.36.81 1.096.81 2.22 0 1.606-.015 2.896-.015 3.286 0 .315.21.69.825.57C20.565 22.092 24 17.592 24 12.297c0-6.627-5.373-12-12-12"/></svg></a>
197
+ <a href="https://linkedin.com/in/kurtcalacday/" target="_blank" rel="noopener" aria-label="LinkedIn" class="btn-push social grid size-8 place-items-center rounded-lg"><svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><path d="M20.447 20.452h-3.554v-5.569c0-1.328-.027-3.037-1.852-3.037-1.853 0-2.136 1.445-2.136 2.939v5.667H9.351V9h3.414v1.561h.046c.477-.9 1.637-1.85 3.37-1.85 3.601 0 4.267 2.37 4.267 5.455v6.286zM5.337 7.433c-1.144 0-2.063-.926-2.063-2.065 0-1.138.92-2.063 2.063-2.063 1.14 0 2.064.925 2.064 2.063 0 1.139-.925 2.065-2.064 2.065zm1.782 13.019H3.555V9h3.564v11.452zM22.225 0H1.771C.792 0 0 .774 0 1.729v20.542C0 23.227.792 24 1.771 24h20.451C23.2 24 24 23.227 24 22.271V1.729C24 .774 23.2 0 22.222 0h.003z"/></svg></a>
198
+ <a href="https://instagram.com/krtclcdy/" target="_blank" rel="noopener" aria-label="Instagram" class="btn-push social grid size-8 place-items-center rounded-lg"><svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><path d="M7.0301.084c-1.2768.0602-2.1487.264-2.911.5634-.7888.3075-1.4575.72-2.1228 1.3877-.6652.6677-1.075 1.3368-1.3802 2.127-.2954.7638-.4956 1.6365-.552 2.914-.0564 1.2775-.0689 1.6882-.0626 4.947.0062 3.2586.0206 3.6671.0825 4.9473.061 1.2765.264 2.1482.5635 2.9107.308.7889.72 1.4573 1.388 2.1228.6679.6655 1.3365 1.0743 2.1285 1.38.7632.295 1.6361.4961 2.9134.552 1.2773.056 1.6884.069 4.9462.0627 3.2578-.0062 3.668-.0207 4.9478-.0814 1.28-.0607 2.147-.2652 2.9098-.5633.7889-.3086 1.4578-.72 2.1228-1.3881.665-.6682 1.0745-1.3378 1.3795-2.1284.2957-.7632.4966-1.636.552-2.9124.056-1.2809.0692-1.6898.063-4.948-.0063-3.2583-.021-3.6668-.0817-4.9465-.0607-1.2797-.264-2.1487-.5633-2.9117-.3084-.7889-.72-1.4568-1.3876-2.1228C21.2982 1.33 20.628.9208 19.8378.6165 19.074.321 18.2017.1197 16.9244.0645 15.6471.0093 15.236-.005 11.977.0014 8.718.0076 8.31.0215 7.0301.0839m.1402 21.6932c-1.17-.0509-1.8053-.2453-2.2287-.408-.5606-.216-.96-.4771-1.3819-.895-.422-.4178-.6811-.8186-.9-1.378-.1644-.4234-.3624-1.058-.4171-2.228-.0595-1.2645-.072-1.6442-.079-4.848-.007-3.2037.0053-3.583.0607-4.848.05-1.169.2456-1.805.408-2.2282.216-.5613.4762-.96.895-1.3816.4188-.4217.8184-.6814 1.3783-.9003.423-.1651 1.0575-.3614 2.227-.4171 1.2655-.06 1.6447-.072 4.848-.079 3.2033-.007 3.5835.005 4.8495.0608 1.169.0508 1.8053.2445 2.228.408.5608.216.96.4754 1.3816.895.4217.4194.6816.8176.9005 1.3787.1653.4217.3617 1.056.4169 2.2263.0602 1.2655.0739 1.645.0796 4.848.0058 3.203-.0055 3.5834-.061 4.848-.051 1.17-.245 1.8055-.408 2.2294-.216.5604-.4763.96-.8954 1.3814-.419.4215-.8181.6811-1.3783.9-.4224.1649-1.0577.3617-2.2262.4174-1.2656.0595-1.6448.072-4.8493.079-3.2045.007-3.5825-.006-4.848-.0608M16.953 5.5864A1.44 1.44 0 1 0 18.39 4.144a1.44 1.44 0 0 0-1.437 1.4424M5.8385 12.012c.0067 3.4032 2.7706 6.1557 6.173 6.1493 3.4026-.0065 6.157-2.7701 6.1506-6.1733-.0065-3.4032-2.771-6.1565-6.174-6.1498-3.403.0067-6.156 2.771-6.1496 6.1738M8 12.0077a4 4 0 1 1 4.008 3.9921A3.9996 3.9996 0 0 1 8 12.0077"/></svg></a>
199
+ </span>
200
+ <a id="rawlink" href="data.json" class="underline underline-offset-2">raw data.json</a>
201
+ </footer>
202
+ </div>
203
+
204
+ <script>
205
+ (function () {
206
+ var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
207
+
208
+ function fmt(n) { return Number(n).toLocaleString('en-US'); }
209
+
210
+ function countUp(el, target, money) {
211
+ function text(v) { return money ? '$' + v.toFixed(2) : fmt(Math.round(v)); }
212
+ if (reduce || target <= 0) { el.textContent = text(target); return; }
213
+ var t0 = null, dur = 900;
214
+ function step(t) {
215
+ if (!t0) t0 = t;
216
+ var p = Math.min((t - t0) / dur, 1), e = 1 - Math.pow(1 - p, 3);
217
+ el.textContent = text(target * e);
218
+ if (p < 1) requestAnimationFrame(step);
219
+ }
220
+ requestAnimationFrame(step);
221
+ }
222
+
223
+ function tick() {
224
+ var c = document.getElementById('clock');
225
+ if (c) c.textContent = new Date().toLocaleString('en-US', { month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit' });
226
+ }
227
+ tick(); setInterval(tick, 1000);
228
+
229
+ function dayKey(d) {
230
+ return d.getFullYear() + '-' + String(d.getMonth() + 1).padStart(2, '0') + '-' + String(d.getDate()).padStart(2, '0');
231
+ }
232
+
233
+ function renderGraph(byDay) {
234
+ var g = document.getElementById('graph');
235
+ g.innerHTML = '';
236
+ var today = new Date(); today.setHours(0, 0, 0, 0);
237
+ var start = new Date(today); start.setDate(start.getDate() - (26 * 7 - 1) - today.getDay());
238
+ var max = 1, sums = {};
239
+ Object.keys(byDay).forEach(function (day) {
240
+ var b = byDay[day];
241
+ sums[day] = b.input + b.output + b.cacheRead + b.cacheWrite;
242
+ if (sums[day] > max) max = sums[day];
243
+ });
244
+ var weeks = 0;
245
+ for (var w = 0; w < 26; w++) {
246
+ var col = document.createElement('div');
247
+ col.className = 'flex flex-col gap-[3px]';
248
+ for (var i = 0; i < 7; i++) {
249
+ var dt = new Date(start); dt.setDate(dt.getDate() + weeks * 7 + i);
250
+ var key = dayKey(dt), v = sums[key] || 0, lvl = 0;
251
+ if (v > 0) lvl = Math.min(4, 1 + Math.floor((v / max) * 3.99));
252
+ var cell = document.createElement('span');
253
+ cell.className = 'cell' + (lvl ? ' l' + lvl : '');
254
+ cell.title = key + ': ' + fmt(v) + ' tokens';
255
+ col.appendChild(cell);
256
+ }
257
+ weeks++;
258
+ g.appendChild(col);
259
+ }
260
+ document.getElementById('graphRange').textContent =
261
+ dayKey(start) + ' to ' + dayKey(today);
262
+ }
263
+
264
+ function renderChart(byDay) {
265
+ var chart = document.getElementById('chart'), days = document.getElementById('chartDays');
266
+ chart.innerHTML = ''; days.innerHTML = '';
267
+ var list = [], today = new Date(); today.setHours(0, 0, 0, 0);
268
+ for (var i = 13; i >= 0; i--) {
269
+ var dt = new Date(today); dt.setDate(dt.getDate() - i);
270
+ list.push({ key: dayKey(dt), label: String(dt.getDate()), b: byDay[dayKey(dt)] || { input: 0, output: 0 } });
271
+ }
272
+ var max = 1;
273
+ list.forEach(function (d) { max = Math.max(max, d.b.input, d.b.output); });
274
+ list.forEach(function (d, i) {
275
+ var wrap = document.createElement('div');
276
+ wrap.className = 'flex-1 flex items-end justify-center gap-1 h-full';
277
+ wrap.title = d.key + ': in ' + fmt(d.b.input) + ' / out ' + fmt(d.b.output);
278
+ var bi = document.createElement('div');
279
+ bi.className = 'bar-fill growY w-3';
280
+ bi.style.height = Math.max(2, Math.round(d.b.input / max * 100)) + '%';
281
+ bi.style.transitionDelay = reduce ? '' : (i * 40) + 'ms';
282
+ var bo = document.createElement('div');
283
+ bo.className = 'bar-dim growY w-3';
284
+ bo.style.height = Math.max(2, Math.round(d.b.output / max * 100)) + '%';
285
+ bo.style.transitionDelay = reduce ? '' : (i * 40 + 60) + 'ms';
286
+ wrap.appendChild(bi); wrap.appendChild(bo); chart.appendChild(wrap);
287
+ var lb = document.createElement('span');
288
+ lb.className = 'flex-1 text-center'; lb.textContent = d.label;
289
+ days.appendChild(lb);
290
+ });
291
+ }
292
+
293
+ function render(d) {
294
+ var t = d.tokens || { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 };
295
+ var total = t.input + t.output + t.cacheRead + t.cacheWrite;
296
+ document.getElementById('meta').textContent =
297
+ fmt(total) + ' session tokens / ' + d.total + ' ledger rows';
298
+ countUp(document.getElementById('total'), total);
299
+ countUp(document.getElementById('usd'), d.usd || 0, true);
300
+ document.getElementById('co2').textContent = '~' + (d.co2g || 0).toFixed(1) + 'g (est.)';
301
+ document.getElementById('msgs').textContent = fmt(d.messages || 0);
302
+
303
+ var buckets = [['input', t.input, 'arrow-down-to-line'], ['output', t.output, 'arrow-up-from-line'], ['cache read', t.cacheRead, 'hard-drive-download'], ['cache write', t.cacheWrite, 'hard-drive-upload']];
304
+ var strip = document.getElementById('tokens');
305
+ strip.innerHTML = '';
306
+ buckets.forEach(function (b, i) {
307
+ var cell = document.createElement('div');
308
+ cell.className = 'tilt px-4 py-4' + (i > 0 ? ' sm:border-l' : '');
309
+ cell.style.borderColor = 'var(--line)';
310
+ cell.innerHTML = '<div class="flex items-center gap-2 mono text-[11px] uppercase tracking-[0.14em]" style="color: var(--dim)">' +
311
+ '<i data-lucide="' + b[2] + '" class="size-3.5"></i><span></span></div>' +
312
+ '<p class="mono font-bold text-3xl mt-1"></p>';
313
+ cell.querySelector('span').textContent = b[0];
314
+ cell.querySelector('p').textContent = fmt(b[1]);
315
+ strip.appendChild(cell);
316
+ });
317
+
318
+ renderGraph(d.byDay || {});
319
+ renderChart(d.byDay || {});
320
+
321
+ var cacheShare = total ? Math.round((t.cacheRead + t.cacheWrite) / total * 100) : 0;
322
+ document.getElementById('usdLine').textContent =
323
+ '$' + (d.usd || 0).toFixed(2) + (d.priced ? ' measured (price table).' : ' incl. default pricing.');
324
+ document.getElementById('co2Line').textContent =
325
+ '~' + (d.co2g || 0).toFixed(1) + 'g CO2 est. from output tokens.';
326
+ document.getElementById('cacheLine').textContent =
327
+ cacheShare + '% of tokens served from cache.';
328
+
329
+ var models = document.getElementById('models');
330
+ models.innerHTML = '';
331
+ Object.entries(d.byModel || {}).sort(function (a, b) {
332
+ return (b[1].input + b[1].output) - (a[1].input + a[1].output);
333
+ }).slice(0, 5).forEach(function (row) {
334
+ var li = document.createElement('li');
335
+ li.className = 'flex justify-between gap-2';
336
+ var name = document.createElement('span'); name.className = 'truncate'; name.textContent = row[0];
337
+ var n = document.createElement('span'); n.textContent = fmt(row[1].input + row[1].output);
338
+ li.appendChild(name); li.appendChild(n); models.appendChild(li);
339
+ });
340
+ if (!models.children.length) {
341
+ models.innerHTML = '<li style="color: var(--dim)">no session tokens yet</li>';
342
+ }
343
+
344
+ var ol = document.getElementById('top');
345
+ ol.innerHTML = '';
346
+ var max = 1;
347
+ (d.byDetail || []).forEach(function (row) { if (row[1] > max) max = row[1]; });
348
+ (d.byDetail || []).forEach(function (row, i) {
349
+ var li = document.createElement('li');
350
+ li.className = 'tilt flex items-center gap-3 px-4 py-3' + (i < d.byDetail.length - 1 ? ' rowline' : '');
351
+ var rank = document.createElement('span');
352
+ rank.className = 'mono text-xs w-6 shrink-0'; rank.style.color = 'var(--dim)';
353
+ rank.textContent = String(i + 1).padStart(2, '0');
354
+ var mid = document.createElement('div'); mid.className = 'min-w-0 flex-1';
355
+ var name = document.createElement('p'); name.className = 'mono text-sm truncate'; name.textContent = row[0];
356
+ var track = document.createElement('div'); track.className = 'bar-track mt-1.5 h-1.5 overflow-hidden';
357
+ var fill = document.createElement('div'); fill.className = 'bar-fill grow h-full';
358
+ fill.style.width = Math.max(2, Math.round(row[1] / max * 100)) + '%';
359
+ track.appendChild(fill); mid.appendChild(name); mid.appendChild(track);
360
+ var n = document.createElement('span'); n.className = 'mono font-bold text-lg shrink-0'; n.textContent = String(row[1]);
361
+ li.appendChild(rank); li.appendChild(mid); li.appendChild(n);
362
+ ol.appendChild(li);
363
+ });
364
+ document.getElementById('emptyTop').classList.toggle('hidden', (d.total || 0) > 0);
365
+ ol.classList.toggle('hidden', (d.total || 0) === 0);
366
+
367
+ if (window.lucide) lucide.createIcons();
368
+ observe();
369
+ }
370
+
371
+ var seen = new WeakSet();
372
+ function observe() {
373
+ var els = document.querySelectorAll('.rise, .grow, .growY');
374
+ if (!('IntersectionObserver' in window) || reduce) {
375
+ els.forEach(function (el) { el.classList.add('in'); });
376
+ return;
377
+ }
378
+ var io = new IntersectionObserver(function (entries) {
379
+ entries.forEach(function (e) {
380
+ if (e.isIntersecting && !seen.has(e.target)) {
381
+ seen.add(e.target); e.target.classList.add('in'); io.unobserve(e.target);
382
+ }
383
+ });
384
+ }, { threshold: 0.12 });
385
+ els.forEach(function (el) { if (!seen.has(el)) io.observe(el); });
386
+ }
387
+
388
+ function load() {
389
+ document.getElementById('meta').textContent = 'connecting to ledger';
390
+ fetch('data.json').then(function (r) { return r.json(); }).then(render).catch(function () {
391
+ document.getElementById('meta').textContent = 'No live data. Serve with: tersio dashboard';
392
+ document.getElementById('rawlink').style.display = 'none';
393
+ document.getElementById('emptyTop').classList.remove('hidden');
394
+ if (window.lucide) lucide.createIcons();
395
+ observe();
396
+ });
397
+ }
398
+ document.getElementById('reload').addEventListener('click', load);
399
+ load();
400
+ observe();
401
+ })();
402
+ </script>
403
+ </body>
404
+ </html>
@@ -0,0 +1,72 @@
1
+ // cli/dashboard.ts — localhost gain dashboard. Serves the single plain-HTML
2
+ // template (no deps, no build) plus a /data.json endpoint from the ledger.
3
+ // Binds 127.0.0.1 only; --export writes a file://-ready file instead.
4
+ // Note: no Promise.withResolvers here — engines still allow Node 20.12,
5
+ // which lacks it; the listening server itself keeps the process alive.
6
+ import { spawn } from 'node:child_process';
7
+ import { promises as fs } from 'node:fs';
8
+ import http from 'node:http';
9
+ import path from 'node:path';
10
+ import { fileURLToPath } from 'node:url';
11
+ import { readUsage } from '../extensions/shared/usage-ledger.js';
12
+ import { summarizeUsage } from './usage.js';
13
+ import { withInteractiveSpinner } from './interactive.js';
14
+ const TEMPLATE = path.join(path.dirname(fileURLToPath(import.meta.url)), 'dashboard.html');
15
+ const BRAND = path.join(path.dirname(fileURLToPath(import.meta.url)), 'brand.webp');
16
+ async function templateHtml() {
17
+ return fs.readFile(TEMPLATE, 'utf8');
18
+ }
19
+ async function faviconDataUri() {
20
+ const png = await fs.readFile(BRAND);
21
+ return `data:image/webp;base64,${png.toString('base64')}`;
22
+ }
23
+ function dataJson() {
24
+ return JSON.stringify(summarizeUsage(readUsage()));
25
+ }
26
+ function openBrowser(url) {
27
+ const cmd = process.platform === 'darwin' ? 'open' : process.platform === 'win32' ? 'start' : 'xdg-open';
28
+ spawn(cmd, [url], { detached: true, stdio: 'ignore' }).unref();
29
+ }
30
+ async function runDashboard(options) {
31
+ if (options.exportFile) {
32
+ const html = await templateHtml();
33
+ const inline = html
34
+ .replace("fetch('data.json')", `Promise.resolve({ json: function () { return ${dataJson()}; } }).then(function (r) { return r.json(); }).then`)
35
+ .replace('href="brand.webp"', `href="${await faviconDataUri()}"`)
36
+ .replace('src="brand.webp"', `src="${await faviconDataUri()}"`);
37
+ await fs.writeFile(options.exportFile, inline, 'utf8');
38
+ console.log(`[ok] dashboard exported → ${options.exportFile}`);
39
+ return;
40
+ }
41
+ const html = await withInteractiveSpinner('Loading dashboard template', templateHtml);
42
+ const server = http.createServer(async (req, res) => {
43
+ if (req.url === '/data.json') {
44
+ res.writeHead(200, { 'Content-Type': 'application/json' });
45
+ res.end(dataJson());
46
+ return;
47
+ }
48
+ if (req.url === '/brand.webp') {
49
+ try {
50
+ const png = await fs.readFile(BRAND);
51
+ res.writeHead(200, { 'Content-Type': 'image/webp' });
52
+ res.end(png);
53
+ }
54
+ catch {
55
+ res.writeHead(404);
56
+ res.end();
57
+ }
58
+ return;
59
+ }
60
+ res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
61
+ res.end(html);
62
+ });
63
+ server.listen(options.port, '127.0.0.1', () => {
64
+ const address = server.address();
65
+ const port = typeof address === 'object' && address ? address.port : options.port;
66
+ const url = `http://127.0.0.1:${port}`;
67
+ console.log(`[ok] dashboard live → ${url} (Ctrl-C to stop)`);
68
+ if (options.open)
69
+ openBrowser(url);
70
+ });
71
+ }
72
+ export { runDashboard };
package/cli/doctor.js ADDED
@@ -0,0 +1,150 @@
1
+ // cli/doctor.ts — installation health checks.
2
+ import { promises as fs } from 'node:fs';
3
+ import path from 'node:path';
4
+ import { BUN_BIN_DIR, HOME, OMP_AGENT_DIR, OMP_PLUGINS_DIR, OMP_BIN, PACKAGE_NAME, PACKAGE_VERSION, RTK_BINARY_NAME, execP, parseJsonObject, relTime, } from './common.js';
5
+ import { runInteractivePhase } from './interactive.js';
6
+ import { ledgerPath, readUsage } from '../extensions/shared/usage-ledger.js';
7
+ import { checkForUpdate } from './update.js';
8
+ import { readTextIfExists } from '../extensions/lib/utils.js';
9
+ async function runDoctor() {
10
+ console.log('\n=== Tersio Doctor ===');
11
+ // Directories
12
+ const agentDir = OMP_AGENT_DIR;
13
+ const extDir = path.join(agentDir, 'extensions');
14
+ const configPath = path.join(agentDir, 'config.yml');
15
+ const pluginsDir = OMP_PLUGINS_DIR;
16
+ const rtkBin = path.join(BUN_BIN_DIR, RTK_BINARY_NAME);
17
+ // Ponytail
18
+ const ponytailPkg = path.join(pluginsDir, 'node_modules', '@dietrichgebert', 'ponytail', 'package.json');
19
+ const ponytailExt = path.join(pluginsDir, 'node_modules', '@dietrichgebert', 'ponytail', 'pi-extension', 'index.js');
20
+ const cavemanIndex = path.join(extDir, 'caveman-session', 'index.js');
21
+ const cavemanRule = path.join(extDir, 'caveman-session', 'rule.md');
22
+ const rtkIndex = path.join(extDir, 'rtk-session', 'index.js');
23
+ const updaterIndex = path.join(extDir, 'ai-addons-updater', 'index.js');
24
+ const comboIndex = path.join(extDir, 'combo-toggle', 'index.js');
25
+ const modeReinforcement = path.join(extDir, 'shared', 'mode-reinforcement.js');
26
+ const selfPkg = path.join(pluginsDir, 'node_modules', PACKAGE_NAME, 'package.json');
27
+ // Independent probes start concurrently; sections report in fixed order as
28
+ // their data settles. Every probe resolves instead of rejecting.
29
+ const probes = {
30
+ ompVersion: execP(OMP_BIN, ['--version']).then((r) => r.stdout.trim(), () => null),
31
+ agentEntries: fs.readdir(agentDir).catch(() => null),
32
+ extEntries: fs.readdir(extDir).catch(() => null),
33
+ sharedStateText: readTextIfExists(path.join(extDir, 'shared', 'session-state.js')),
34
+ configText: readTextIfExists(configPath),
35
+ ponytailPkgText: readTextIfExists(ponytailPkg),
36
+ ponytailExtText: readTextIfExists(ponytailExt),
37
+ pluginsPkgRaw: readTextIfExists(path.join(pluginsDir, 'package.json')),
38
+ selfPkgText: readTextIfExists(selfPkg),
39
+ rtkBinText: readTextIfExists(rtkBin),
40
+ cavemanIndexText: readTextIfExists(cavemanIndex),
41
+ cavemanRuleText: readTextIfExists(cavemanRule),
42
+ rtkIndexText: readTextIfExists(rtkIndex),
43
+ updaterIndexText: readTextIfExists(updaterIndex),
44
+ comboIndexText: readTextIfExists(comboIndex),
45
+ modeReinforcementText: readTextIfExists(modeReinforcement),
46
+ ruleMtime: fs.stat(cavemanRule).catch(() => null),
47
+ rtkMtime: fs.stat(rtkBin).catch(() => null),
48
+ ponytailMtime: fs.stat(ponytailPkg).catch(() => null),
49
+ updateVersion: checkForUpdate(),
50
+ };
51
+ const rtkVersionProbe = probes.rtkBinText.then((text) => text === null ? null : execP(rtkBin, ['--version'], { timeout: 5000 }).then((r) => r.stdout.trim() || r.stderr.trim() || null, (e) => {
52
+ const err = e;
53
+ return err.stdout?.trim() || err.stderr?.trim() || null;
54
+ }));
55
+ const [ompVersion, agentEntries, extEntries, sharedStateText, configText, updateVersion] = await runInteractivePhase('Checking environment and installation', () => Promise.all([
56
+ probes.ompVersion,
57
+ probes.agentEntries,
58
+ probes.extEntries,
59
+ probes.sharedStateText,
60
+ probes.configText,
61
+ probes.updateVersion,
62
+ ]));
63
+ const [cavemanIndexText, rtkIndexText, updaterIndexText, comboIndexText, modeReinforcementText, ponytailPkgText, ponytailExtText, pluginsPkgRaw, selfPkgText] = await runInteractivePhase('Checking extensions and plugins', () => Promise.all([
64
+ probes.cavemanIndexText,
65
+ probes.rtkIndexText,
66
+ probes.updaterIndexText,
67
+ probes.comboIndexText,
68
+ probes.modeReinforcementText,
69
+ probes.ponytailPkgText,
70
+ probes.ponytailExtText,
71
+ probes.pluginsPkgRaw,
72
+ probes.selfPkgText,
73
+ ]));
74
+ const [cavemanRuleText, ruleMtime, rtkBinText, rtkMtime, rtkVersion, ponytailMtime] = await runInteractivePhase('Checking add-ons', () => Promise.all([
75
+ probes.cavemanRuleText,
76
+ probes.ruleMtime,
77
+ probes.rtkBinText,
78
+ probes.rtkMtime,
79
+ rtkVersionProbe,
80
+ probes.ponytailMtime,
81
+ ]));
82
+ // Categorized output with a tally; section headers group related probes.
83
+ const tally = { ok: 0, missing: 0, warn: 0 };
84
+ function check(label, ok, detail = '') {
85
+ if (ok) {
86
+ tally.ok++;
87
+ console.log(` ${label}: ok${detail ? ` ${detail}` : ''}`);
88
+ }
89
+ else {
90
+ tally.missing++;
91
+ console.log(` ${label}: MISSING${detail ? ` ${detail}` : ''}`);
92
+ }
93
+ }
94
+ function warnLine(label, detail) {
95
+ tally.warn++;
96
+ console.log(` ${label}: warn ${detail}`);
97
+ }
98
+ section('Environment');
99
+ check('Node', true, process.version);
100
+ check('OMP CLI', ompVersion !== null, ompVersion ?? '');
101
+ if (updateVersion)
102
+ warnLine('Tersio CLI', `${updateVersion} available — run tersio update`);
103
+ else
104
+ check('Tersio CLI', true, PACKAGE_VERSION);
105
+ console.log(` Home: ${HOME}`);
106
+ section('Installation');
107
+ check('OMP agent dir', agentEntries !== null, agentDir);
108
+ check('OMP extensions dir', extEntries !== null, extDir);
109
+ check('OMP config.yml', configText !== null, configPath);
110
+ check('Shared session bridge', sharedStateText !== null);
111
+ section('Extensions');
112
+ check('Caveman extension', cavemanIndexText !== null);
113
+ check('RTK extension', rtkIndexText !== null);
114
+ check('Updater extension', updaterIndexText !== null);
115
+ check('Combo extension', comboIndexText !== null);
116
+ check('Mode reinforcement extension', modeReinforcementText !== null);
117
+ section('Plugins');
118
+ check('Ponytail package', ponytailPkgText !== null);
119
+ check('Ponytail extension', ponytailExtText !== null);
120
+ if (configText) {
121
+ check('Ponytail in config.yml', configText.includes('ponytail') && configText.includes('pi-extension'));
122
+ check('Combo in config.yml', configText.includes('combo-toggle'));
123
+ }
124
+ check('Self plugin package', selfPkgText !== null, parseJsonObject(selfPkgText)?.version ?? '');
125
+ const selfDep = PACKAGE_NAME in (parseJsonObject(pluginsPkgRaw)?.dependencies || {});
126
+ check('Self plugin in plugins/package.json', selfDep);
127
+ section('Usage');
128
+ const usageRows = readUsage();
129
+ check('Usage ledger', usageRows.length > 0, usageRows.length ? `${usageRows.length} rows · ${ledgerPath()}` : ledgerPath());
130
+ section('Add-ons');
131
+ const ruleAge = ruleMtime ? `updated ${relTime(Date.now() - ruleMtime.mtimeMs)}` : '';
132
+ check('Caveman rule', cavemanRuleText !== null, ruleAge);
133
+ const rtkAge = rtkMtime ? `updated ${relTime(Date.now() - rtkMtime.mtimeMs)}` : '';
134
+ if (rtkBinText === null)
135
+ check('RTK binary', false, rtkBin);
136
+ else {
137
+ const bits = [rtkVersion, rtkAge].filter(Boolean).join(', ');
138
+ check('RTK binary', true, bits);
139
+ if (!rtkVersion)
140
+ warnLine('RTK version', 'unavailable — binary may not be executable');
141
+ }
142
+ const ponytailAge = ponytailMtime ? `updated ${relTime(Date.now() - ponytailMtime.mtimeMs)}` : '';
143
+ check('Ponytail', ponytailPkgText !== null, [parseJsonObject(ponytailPkgText)?.version ?? '', ponytailAge].filter(Boolean).join(', '));
144
+ const total = tally.ok + tally.missing + tally.warn;
145
+ console.log(`\n Summary: ${total} checks — ${tally.ok} ok, ${tally.warn} warn, ${tally.missing} missing`);
146
+ }
147
+ function section(name) {
148
+ console.log(`\n${name}`);
149
+ }
150
+ export { runDoctor };