@krtclcdy/tersio 2.5.0 → 2.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -33,10 +33,10 @@ omp plugin features @krtclcdy/tersio --disable rtk
33
33
  Then restart OMP and enable a preset:
34
34
 
35
35
  ```text
36
- /combo medium
36
+ /tersio combo medium
37
37
  ```
38
38
 
39
- Individual toggles: `/caveman full` · `/rtk on` · `/ponytail full`. Everything starts off until you enable it.
39
+ Individual toggles: `/tersio caveman full` · `/tersio rtk on` · `/tersio ponytail full`. Everything starts off until you enable it.
40
40
 
41
41
  Session-start defaults (prompted during `install`, or flags):
42
42
 
@@ -44,7 +44,7 @@ Session-start defaults (prompted during `install`, or flags):
44
44
  tersio install --combo-default balanced --caveman-default lite --rtk-default on
45
45
  ```
46
46
 
47
- Defaults apply to fresh sessions only — anything persisted with `/combo`, `/caveman`, or `/rtk` wins. Stored as plugin settings (`omp plugin config get @krtclcdy/tersio comboDefault`).
47
+ Defaults apply to fresh sessions only — anything persisted with `/tersio combo`, `/tersio caveman`, or `/tersio rtk` wins. Stored as plugin settings (`omp plugin config get @krtclcdy/tersio comboDefault`).
48
48
 
49
49
  ## What it installs
50
50
 
@@ -54,7 +54,7 @@ Defaults apply to fresh sessions only — anything persisted with `/combo`, `/ca
54
54
  | **RTK** | Routes noisy shell commands through the RTK binary for compact output |
55
55
  | **Ponytail** | Minimal, YAGNI-oriented code decisions |
56
56
  | **Combo** | Toggles all three at once. Presets: `off`, `medium`, `balanced`, `max` |
57
- | **Updater** | `/ai-addons` checks and updates Ponytail, RTK, and Caveman in-session, with dry-run and backups |
57
+ | **Updater** | `/tersio check` and `/tersio update` cover Ponytail, RTK, and Caveman in-session, with dry-run and backups |
58
58
 
59
59
  User-level installs also register the package in `~/.omp/plugins` (visible in OMP **Settings → Plugins**), directly through the plugin manifest. Active modes reassert after Ponytail's prompt block on every top-level turn, including after history compaction.
60
60
 
@@ -81,6 +81,8 @@ Break-even math, balanced preset (caveman full + rtk + ponytail): `⌈overhead
81
81
  | `tersio update` | Refresh the CLI, extensions, and add-ons (RTK binary, Caveman rule, Ponytail) |
82
82
  | `tersio reinstall` | Fresh install, preserving the Ponytail package |
83
83
  | `tersio doctor` | Check OMP, extension, Ponytail, and RTK health |
84
+ | `tersio usage` | Ledger-backed usage + savings report |
85
+ | `tersio dashboard` | Serve the gain dashboard on localhost (`--open`, `--export <file>`, `--port <n>`) |
84
86
  | `tersio uninstall` | Remove extensions, registration, and the Ponytail plugin (`--keep-ponytail` keeps Ponytail; `--remove-rtk` also removes the RTK binary) |
85
87
  | `tersio version` | Print version |
86
88
 
@@ -88,63 +90,21 @@ Flags: `--dry-run`, `--yes`/`-y`, `--verbose`, `--scope`, `--combo-default`/`--c
88
90
 
89
91
  ## Commands reference
90
92
 
91
- ### Caveman — terse replies
93
+ Everything runs under one root. Bare `/tersio` prints status.
92
94
 
93
- ```text
94
- /caveman lite concise, drops pleasantries
95
- /caveman full terse caveman style
96
- /caveman ultra maximum terse, fragments only
97
- /caveman wenyan classical-Chinese-style where clear
98
- /caveman off normal mode
99
- /caveman status show current mode
100
- ```
101
-
102
- `caveman off`, `stop caveman`, and `normal mode` also work.
103
-
104
- ### RTK — compact shell output
105
-
106
- ```text
107
- /rtk on enable compact RTK output
108
- /rtk off disable
109
- /rtk status show current state
110
- ```
111
-
112
- Covers noisy commands (`git status`, `git diff`, `read`, `grep`, test, `tsc`, lint).
113
-
114
- ### Ponytail — minimal code
115
-
116
- ```text
117
- /ponytail lite light guidance
118
- /ponytail full full YAGNI enforcement
119
- /ponytail ultra aggressive simplification
120
- /ponytail off disable
121
- /ponytail status show current state
122
- ```
123
-
124
- ### Updater — check and update add-ons
125
-
126
- ```text
127
- /ai-addons check check all add-on versions
128
- /ai-addons status same as check
129
- /ai-addons update ponytail update Ponytail
130
- /ai-addons update rtk update the RTK binary
131
- /ai-addons update caveman update the Caveman rule
132
- /ai-addons update all update all three
133
- /ai-addons update all --dry-run preview without changes
134
- ```
135
-
136
- ### Combo — toggle all three
137
-
138
- ```text
139
- /combo off all three off (default)
140
- /combo medium caveman=lite, rtk=on, ponytail=lite
141
- /combo balanced caveman=full, rtk=on, ponytail=full
142
- /combo max caveman=ultra, rtk=on, ponytail=ultra
143
- /combo status show the level and underlying modes
144
- /combo help show available levels
145
- ```
146
-
147
- `/combo` persists state and reloads OMP without emitting separate command messages. Presets propagate to task subagents and light the Combo footer indicator; individual commands leave Combo inactive (`/combo status` still reports the mixed state).
95
+ | Command | Purpose |
96
+ |---|---|
97
+ | `/tersio combo off\|medium\|balanced\|max\|status` | One preset for all three (start here): medium = lite/lite/on, balanced = full/full/on, max = ultra/ultra/on; per-mode tweaks drop to `custom`. |
98
+ | `/tersio caveman lite\|full\|ultra\|wenyan\|off\|status` | Terse replies (substance stays); `ultra` uses fragments only, `wenyan` a compressed classical-Chinese-inspired register. |
99
+ | `/tersio rtk on\|off\|status` | Compact shell output via the real `rtk` binary; exact bytes (checksums, patches) bypass it by policy. |
100
+ | `/tersio ponytail off\|lite\|full\|ultra\|review\|status` | Minimal code (YAGNI): `lite` nudges, `full` enforces, `ultra` challenges the requirement, `review` audits for over-engineering. |
101
+ | `/tersio status` | Active modes + combo level; state persists, propagates to subagents, and lights the Combo footer indicator. |
102
+ | `/tersio check` | Add-on version check |
103
+ | `/tersio update <ponytail\|rtk\|caveman\|all> [--dry-run]` | Update add-ons, preview with dry-run |
104
+ | `/tersio gain` | Savings summary; the gain dashboard charts `~/.omp/plugins/tersio-usage.jsonl` (`tersio dashboard` serves on 127.0.0.1, `--export` writes one file). |
105
+ | `/tersio usage` | Ledger report for this machine (rows in `~/.omp/plugins/tersio-usage.jsonl`, local only) |
106
+ | `/tersio help` | This table |
107
+ | `/caveman`, `/rtk`, `/combo`, `/ai-addons` | Legacy aliases, still work; prefer `/tersio`. |
148
108
 
149
109
  ## Files and backups
150
110
 
@@ -176,7 +136,7 @@ npm exec --yes --prefer-online --package=@krtclcdy/tersio@latest -- tersio insta
176
136
 
177
137
  **RTK missing or not executable:** run `tersio reinstall`, then `tersio doctor`. On Linux/macOS: `chmod +x ~/.bun/bin/rtk`.
178
138
 
179
- **Checksum warning or failure:** the installer aborts on RTK checksum mismatch but warns and continues when checksum metadata is unavailable; `/ai-addons update rtk` aborts when metadata is missing.
139
+ **Checksum warning or failure:** the installer aborts on RTK checksum mismatch but warns and continues when checksum metadata is unavailable; `/tersio update rtk` aborts when metadata is missing.
180
140
 
181
141
  ## License
182
142
 
package/cli/brand.webp ADDED
Binary file
package/cli/common.js CHANGED
@@ -46,7 +46,7 @@ function flagValue(name) {
46
46
  return args.find((arg) => arg.startsWith(prefix))?.slice(prefix.length);
47
47
  }
48
48
  // --- CLI flags ---
49
- const COMMANDS = new Set(['install', 'update', 'reinstall', 'doctor', 'uninstall', 'version', 'help']);
49
+ const COMMANDS = new Set(['install', 'update', 'reinstall', 'doctor', 'uninstall', 'usage', 'dashboard', 'version', 'help']);
50
50
  const args = process.argv.slice(2);
51
51
  const commandArg = args.find((arg, index) => !arg.startsWith('-') && args[index - 1] !== '--scope');
52
52
  const command = commandArg?.toLowerCase() || null;
@@ -61,6 +61,11 @@ const dryRun = args.includes('--dry-run');
61
61
  const yes = args.includes('--yes') || args.includes('-y') || install || update || reinstall || applyUpdate;
62
62
  const verbose = args.includes('--verbose');
63
63
  const doctor = command === 'doctor' || args.includes('--doctor');
64
+ const usage = command === 'usage' || args.includes('--usage');
65
+ const dashboard = command === 'dashboard' || args.includes('--dashboard');
66
+ const dashboardPort = Number.parseInt(flagValue('--port') ?? '', 10) || 0;
67
+ const dashboardOpen = args.includes('--open');
68
+ const dashboardExport = flagValue('--export') ?? null;
64
69
  const uninstall = command === 'uninstall' || args.includes('--uninstall');
65
70
  const removePonytail = args.includes('--remove-ponytail');
66
71
  const keepPonytail = args.includes('--keep-ponytail');
@@ -249,4 +254,4 @@ function relTime(ageMs) {
249
254
  const weeks = Math.floor(days / 7);
250
255
  return `${weeks} week${weeks === 1 ? '' : 's'} ago`;
251
256
  }
252
- export { IS_WINDOWS, RTK_BINARY_NAME, HOME, OMP_AGENT_DIR, OMP_PLUGINS_DIR, BUN_BIN_DIR, OMP_BIN, PACKAGE_NAME, PACKAGE_BIN, PACKAGE_VERSION, CAVEMAN_DEFAULTS, PONYTAIL_DEFAULTS, RTK_DEFAULTS, COMBO_PRESET_MODES, COMBO_DEFAULTS, parseEnum, flagValue, COMMANDS, commandArg, command, unknownCommand, install, update, reinstall, showVersion, showHelp, applyUpdate, dryRun, yes, verbose, doctor, uninstall, removePonytail, keepPonytail, removeRtk, scopeFlag, comboDefaultFlag, cavemanDefaultFlag, ponytailDefaultFlag, rtkDefaultFlag, profileFlagsGiven, debug, execFileP, execP, writeIfChanged, normalizeExtensionsKey, EXTENSIONS_KEY_RE, writeConfigLines, ensureExtensionInConfig, ensureExtensionAfterConfigEntry, readPonytailConfig, parseJsonObject, parsePonytailConfig, patchPonytailConfig, ensurePonytailConfigValue, readPluginsPackage, SCOPE_MAP, relTime, };
257
+ export { IS_WINDOWS, RTK_BINARY_NAME, HOME, OMP_AGENT_DIR, OMP_PLUGINS_DIR, BUN_BIN_DIR, OMP_BIN, PACKAGE_NAME, PACKAGE_BIN, PACKAGE_VERSION, CAVEMAN_DEFAULTS, PONYTAIL_DEFAULTS, RTK_DEFAULTS, COMBO_PRESET_MODES, COMBO_DEFAULTS, parseEnum, flagValue, COMMANDS, commandArg, command, unknownCommand, install, update, reinstall, showVersion, showHelp, applyUpdate, dryRun, yes, verbose, doctor, uninstall, usage, dashboard, dashboardPort, dashboardOpen, dashboardExport, removePonytail, keepPonytail, removeRtk, scopeFlag, comboDefaultFlag, cavemanDefaultFlag, ponytailDefaultFlag, rtkDefaultFlag, profileFlagsGiven, debug, execFileP, execP, writeIfChanged, normalizeExtensionsKey, EXTENSIONS_KEY_RE, writeConfigLines, ensureExtensionInConfig, ensureExtensionAfterConfigEntry, readPonytailConfig, parseJsonObject, parsePonytailConfig, patchPonytailConfig, ensurePonytailConfigValue, readPluginsPackage, SCOPE_MAP, relTime, };
@@ -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 };