pi-sdk-web 0.4.11 → 0.5.1

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.
File without changes
package/dist/server.js CHANGED
@@ -359,6 +359,13 @@ export class PiWebServer {
359
359
  for (const s of this.session.resourceLoader.getSkills().skills) {
360
360
  commands.push({ name: `skill:${s.name}`, description: s.description, source: "skill", sourceInfo: s.sourceInfo });
361
361
  }
362
+ // pi-sdk-web built-ins: commands provided by this package itself
363
+ // (not tied to any extension), e.g. /usage which collects Pi session
364
+ // usage natively (usage-render.ts) instead of running the extension's
365
+ // command. Source "maxdai" = the package author's npm identity.
366
+ if (!commands.some((c) => c.name === "usage")) {
367
+ commands.push({ name: "usage", description: "Show usage statistics (tokens/cost, all sessions)", source: "maxdai", sourceInfo: { path: "pi-sdk-web", source: "maxdai", scope: "global", origin: "cli" } });
368
+ }
362
369
  return commands;
363
370
  }
364
371
  catch {
@@ -622,6 +629,13 @@ export class PiWebServer {
622
629
  await this.executeCommand(name, args);
623
630
  break;
624
631
  }
632
+ case "usage": {
633
+ // /usage panel scope switch (session | all) - re-render with the
634
+ // chosen data range.
635
+ const scope = data.scope === "all" ? "all" : "session";
636
+ await this.handleUsageCommand(scope);
637
+ break;
638
+ }
625
639
  case "session":
626
640
  // Read-only session info (TUI /session equivalent)
627
641
  this.broadcastSessionInfo();
@@ -752,6 +766,13 @@ export class PiWebServer {
752
766
  * modal (mirroring the TUI dialog behavior).
753
767
  */
754
768
  async executeCommand(name, args) {
769
+ // /usage is now a pi-web-native feature: collect usage directly from
770
+ // Pi's session files (usage-render.ts) and show the panel - the
771
+ // pi-usage-extension is no longer required (it reads the same files).
772
+ if (name === "usage") {
773
+ await this.handleUsageCommand("session");
774
+ return;
775
+ }
755
776
  const cmd = this.session.extensionRunner.getCommand(name);
756
777
  if (!cmd)
757
778
  throw new Error(`Unknown command: ${name}`);
@@ -817,6 +838,39 @@ export class PiWebServer {
817
838
  notifyType: "info",
818
839
  });
819
840
  }
841
+ // --- /usage (pi-web-native) handled early in executeCommand ---
842
+ }
843
+ /** Collect usage from Pi session files and broadcast the panel payload.
844
+ * scope "session" = current session only; "all" = every session. */
845
+ async handleUsageCommand(scope = "session") {
846
+ try {
847
+ const { buildUsageData } = await import("./usage-render.js");
848
+ const sessionId = scope === "session" ? String(this.session.sessionId ?? "") : undefined;
849
+ const data = buildUsageData(sessionId);
850
+ if (data) {
851
+ this.broadcast({ type: "usage_data", data });
852
+ }
853
+ else {
854
+ this.broadcast({
855
+ type: "extension_ui_request",
856
+ id: crypto.randomUUID(),
857
+ method: "notify",
858
+ title: "/usage",
859
+ message: scope === "session" ? "No usage data for this session yet." : "No usage data found yet (no Pi session files with usage).",
860
+ notifyType: "info",
861
+ });
862
+ }
863
+ }
864
+ catch (e) {
865
+ this.broadcast({
866
+ type: "extension_ui_request",
867
+ id: crypto.randomUUID(),
868
+ method: "notify",
869
+ title: "/usage",
870
+ message: `Usage collection failed: ${e instanceof Error ? e.message : String(e)}`,
871
+ notifyType: "error",
872
+ });
873
+ }
820
874
  }
821
875
  buildCommandContext(hasUI) {
822
876
  const sm = this.session.sessionManager;
@@ -355,6 +355,9 @@ class PiWebClient {
355
355
  case 'scoped_models':
356
356
  this.handleScopedModels(data.data);
357
357
  break;
358
+ case 'usage_data':
359
+ this.renderUsagePanel(data.data);
360
+ break;
358
361
  case 'sessions':
359
362
  this.handleSessions(data.data);
360
363
  break;
@@ -1705,7 +1708,19 @@ class PiWebClient {
1705
1708
  if (e.key === 'Escape' && this.modalOverlay && this.modalOverlay.style.display !== 'none') {
1706
1709
  this.closeModal();
1707
1710
  }
1711
+ const usageOverlay = document.getElementById('usage-overlay');
1712
+ if (e.key === 'Escape' && usageOverlay && usageOverlay.style.display !== 'none') {
1713
+ this.closeUsagePanel();
1714
+ }
1708
1715
  });
1716
+ const usageClose = document.getElementById('usage-close');
1717
+ if (usageClose) usageClose.addEventListener('click', () => this.closeUsagePanel());
1718
+ const usageOverlayEl = document.getElementById('usage-overlay');
1719
+ if (usageOverlayEl) {
1720
+ usageOverlayEl.addEventListener('click', (e) => {
1721
+ if (e.target === usageOverlayEl) this.closeUsagePanel();
1722
+ });
1723
+ }
1709
1724
  }
1710
1725
 
1711
1726
  // ------------------------------------------------------------------
@@ -1950,6 +1965,303 @@ class PiWebClient {
1950
1965
  }, type === 'error' ? 8000 : 5000);
1951
1966
  }
1952
1967
 
1968
+ // ------------------------------------------------------------------
1969
+ // Usage panel (dedicated big panel for /usage, rendered from the
1970
+ // server-aggregated usage_data payload)
1971
+ // ------------------------------------------------------------------
1972
+
1973
+ renderUsagePanel(data) {
1974
+ if (!data || !data.tabs) return;
1975
+ // Preserve the user's current choices (tab/view/scope/expanded) across
1976
+ // data refresh (e.g. scope switch Session|All must not reset them);
1977
+ // defaults are set only on first open. usageExpanded must exist for the
1978
+ // tab/provider click handlers (created once, kept afterwards).
1979
+ if (!this.usageTab) this.usageTab = 'thisWeek';
1980
+ if (!this.usageView) this.usageView = 'table';
1981
+ if (!this.usageScope) this.usageScope = 'session';
1982
+ if (!this.usageExpanded) this.usageExpanded = new Set();
1983
+ this.usageData = data;
1984
+ const overlay = document.getElementById('usage-overlay');
1985
+ if (!overlay) return;
1986
+ overlay.style.display = 'flex';
1987
+ this.renderUsageTabs();
1988
+ this.renderUsageBody();
1989
+ }
1990
+
1991
+ // Switch the usage scope (session | all) - re-fetch from the server.
1992
+ setUsageScope(scope) {
1993
+ if (this.usageScope === scope) return;
1994
+ this.usageScope = scope;
1995
+ this.renderUsageTabs();
1996
+ this.send({ type: 'usage', scope });
1997
+ }
1998
+
1999
+ closeUsagePanel() {
2000
+ const overlay = document.getElementById('usage-overlay');
2001
+ if (overlay) overlay.style.display = 'none';
2002
+ }
2003
+
2004
+ renderUsageTabs() {
2005
+ const tabsEl = document.getElementById('usage-tabs');
2006
+ if (!tabsEl) return;
2007
+ const labels = {
2008
+ today: 'Today',
2009
+ thisWeek: 'This Week',
2010
+ lastWeek: 'Last Week',
2011
+ last30Days: 'Last 30 Days',
2012
+ allTime: 'All Time',
2013
+ };
2014
+ tabsEl.innerHTML = '';
2015
+ for (const key of Object.keys(labels)) {
2016
+ const tab = document.createElement('span');
2017
+ tab.className = 'usage-tab' + (key === this.usageTab ? ' active' : '');
2018
+ tab.textContent = labels[key];
2019
+ tab.addEventListener('click', () => {
2020
+ this.usageTab = key;
2021
+ this.usageExpanded.clear();
2022
+ this.renderUsageTabs();
2023
+ this.renderUsageBody();
2024
+ });
2025
+ tabsEl.appendChild(tab);
2026
+ }
2027
+ // View switcher: Table / Insights / Graph + scope (Session | All)
2028
+ const viewsEl = document.getElementById('usage-views');
2029
+ if (viewsEl) {
2030
+ viewsEl.innerHTML = '';
2031
+ for (const [key, label] of [['table', 'Table'], ['insights', 'Insights'], ['graph', 'Graph']]) {
2032
+ const v = document.createElement('span');
2033
+ v.className = 'usage-view' + (key === this.usageView ? ' active' : '');
2034
+ v.textContent = label;
2035
+ v.addEventListener('click', () => {
2036
+ this.usageView = key;
2037
+ this.renderUsageTabs();
2038
+ this.renderUsageBody();
2039
+ });
2040
+ viewsEl.appendChild(v);
2041
+ }
2042
+ const scopeSep = document.createElement('span');
2043
+ scopeSep.className = 'usage-scope-sep';
2044
+ scopeSep.textContent = '|';
2045
+ viewsEl.appendChild(scopeSep);
2046
+ for (const [key, label] of [['session', 'Session'], ['all', 'All']]) {
2047
+ const s = document.createElement('span');
2048
+ s.className = 'usage-view' + (key === this.usageScope ? ' active' : '');
2049
+ s.textContent = label;
2050
+ s.addEventListener('click', () => this.setUsageScope(key));
2051
+ viewsEl.appendChild(s);
2052
+ }
2053
+ }
2054
+ }
2055
+
2056
+ fmtTokens(n) {
2057
+ if (n >= 1e9) return (n / 1e9).toFixed(2) + 'B';
2058
+ if (n >= 1e6) return (n / 1e6).toFixed(2) + 'M';
2059
+ if (n >= 1e3) return (n / 1e3).toFixed(1) + 'K';
2060
+ return String(Math.round(n));
2061
+ }
2062
+
2063
+ fmtCost(c) {
2064
+ if (!c || c <= 0) return '-';
2065
+ if (c < 0.01) return '$' + c.toFixed(4);
2066
+ if (c < 10) return '$' + c.toFixed(2);
2067
+ return '$' + c.toFixed(1);
2068
+ }
2069
+
2070
+ usageRow(stat) {
2071
+ const t = stat.tokens || {};
2072
+ return [
2073
+ this.fmtTokens(t.input),
2074
+ this.fmtTokens(t.output),
2075
+ this.fmtTokens((t.cacheRead || 0) + (t.cacheWrite || 0)),
2076
+ this.fmtTokens((t.total === undefined ? (t.input || 0) + (t.output || 0) + (t.cacheRead || 0) + (t.cacheWrite || 0) : t.total)),
2077
+ this.fmtCost(stat.cost),
2078
+ String(stat.messages),
2079
+ String(stat.sessions),
2080
+ ];
2081
+ }
2082
+
2083
+ renderUsageBody() {
2084
+ const body = document.getElementById('usage-body');
2085
+ if (!body) return;
2086
+ const period = this.usageData.tabs[this.usageTab];
2087
+ if (!period) return;
2088
+ body.innerHTML = '';
2089
+ body.scrollTop = 0;
2090
+
2091
+ if (this.usageView === 'table') {
2092
+ const table = document.createElement('table');
2093
+ table.className = 'usage-table';
2094
+ const head = document.createElement('thead');
2095
+ head.innerHTML = '<tr><th>Provider / Model</th><th>Msgs</th><th>Cost</th><th>Tokens</th><th>↑In</th><th>↓Out</th><th>Cache</th><th>Sessions</th></tr>';
2096
+ table.appendChild(head);
2097
+ const tbody = document.createElement('tbody');
2098
+
2099
+ const rowFor = (name, stat, isModel, isTotals) => {
2100
+ const tr = document.createElement('tr');
2101
+ if (isTotals) tr.className = 'usage-totals';
2102
+ else if (isModel) tr.className = 'usage-model-row';
2103
+ else tr.className = 'usage-provider-row';
2104
+ const nameTd = document.createElement('td');
2105
+ nameTd.className = 'usage-name';
2106
+ nameTd.textContent = name;
2107
+ const [, cost, tokens, input, output, cache, msgs, sessions] = [null, this.fmtCost(stat.cost), this.fmtTokens(stat.tokens ? stat.tokens.total : 0), this.fmtTokens(stat.tokens ? stat.tokens.input : 0), this.fmtTokens(stat.tokens ? stat.tokens.output : 0), this.fmtTokens(stat.tokens ? (stat.tokens.cacheRead + stat.tokens.cacheWrite) : 0), String(stat.messages), String(stat.sessions)];
2108
+ tr.appendChild(nameTd);
2109
+ for (const cell of [msgs, cost, tokens, input, output, cache, sessions]) {
2110
+ const td = document.createElement('td');
2111
+ td.textContent = cell;
2112
+ td.className = 'usage-num';
2113
+ tr.appendChild(td);
2114
+ }
2115
+ return tr;
2116
+ };
2117
+
2118
+ for (const p of period.providers) {
2119
+ const key = 'p:' + p.name;
2120
+ const expanded = this.usageExpanded.has(key);
2121
+ const tr = rowFor(p.name, p, false, false);
2122
+ tr.classList.add('usage-clickable');
2123
+ tr.addEventListener('click', () => {
2124
+ if (this.usageExpanded.has(key)) this.usageExpanded.delete(key);
2125
+ else this.usageExpanded.add(key);
2126
+ this.renderUsageBody();
2127
+ });
2128
+ tbody.appendChild(tr);
2129
+ if (expanded) {
2130
+ for (const m of p.models || []) {
2131
+ tbody.appendChild(rowFor(' └ ' + m.name, m, true, false));
2132
+ }
2133
+ }
2134
+ }
2135
+ const totals = period.totals;
2136
+ tbody.appendChild(rowFor('Total', totals, false, true));
2137
+ table.appendChild(tbody);
2138
+ body.appendChild(table);
2139
+ } else if (this.usageView === 'insights') {
2140
+ const list = document.createElement('div');
2141
+ list.className = 'usage-insights';
2142
+ const ins = period.insights || [];
2143
+ if (ins.length === 0) {
2144
+ list.innerHTML = '<div class="usage-insight">No insights for this period.</div>';
2145
+ }
2146
+ for (const i of ins) {
2147
+ const div = document.createElement('div');
2148
+ div.className = 'usage-insight ' + (i.kind === 'alarm' ? 'alarm' : 'structure');
2149
+ const stat = document.createElement('span');
2150
+ stat.className = 'usage-insight-stat';
2151
+ stat.textContent = i.stat;
2152
+ const text = document.createElement('span');
2153
+ text.textContent = i.headline;
2154
+ div.appendChild(stat);
2155
+ div.appendChild(text);
2156
+ if (i.advice) {
2157
+ const advice = document.createElement('div');
2158
+ advice.className = 'usage-insight-advice';
2159
+ advice.textContent = i.advice;
2160
+ div.appendChild(advice);
2161
+ }
2162
+ list.appendChild(div);
2163
+ }
2164
+ body.appendChild(list);
2165
+ } else if (this.usageView === 'graph') {
2166
+ // Simple CSS bar chart: cost per provider (this period) + hourly tokens trend
2167
+ const container = document.createElement('div');
2168
+ container.className = 'usage-graph';
2169
+ if (period.providers.length === 0) {
2170
+ container.innerHTML = '<div class="usage-insight">No data for this period.</div>';
2171
+ body.appendChild(container);
2172
+ return;
2173
+ }
2174
+ const maxCost = Math.max(...period.providers.map((p) => p.cost), 1e-9);
2175
+ let html = '<div class="usage-graph-title">Cost by provider</div><div class="usage-bars">';
2176
+ for (const p of period.providers) {
2177
+ const w = ((p.cost / maxCost) * 100).toFixed(1);
2178
+ html += `<div class="usage-bar-row"><span class="usage-bar-label">${this.escapeHtml(p.name)}</span><div class="usage-bar-track"><div class="usage-bar" style="width:${w}%"></div></div><span class="usage-bar-val">${this.fmtCost(p.cost)}</span></div>`;
2179
+ }
2180
+ html += '</div>';
2181
+ // Hourly tokens trend for the current tab. The server provides one
2182
+ // global hourly series + per-tab windows; filtering (per tab) and
2183
+ // gap-filling are render-side concerns.
2184
+ const hourly = this.usageData.hourly ? this.usageData.hourly : null;
2185
+ const window = (this.usageData.tabWindow || {})[this.usageTab] || null;
2186
+ if (hourly && hourly.length > 0) {
2187
+ // Filter to the tab's window, then fill the x-axis with a
2188
+ // continuous hour sequence so idle hours appear as empty columns.
2189
+ // Axis start rule: if the earliest data predates the period start,
2190
+ // show the whole period (zeros included); if data only exists
2191
+ // inside the period, start from the first hour with data (e.g.
2192
+ // allTime never starts at epoch - it starts at first usage).
2193
+ let seq = hourly;
2194
+ if (window && window[1] > window[0]) {
2195
+ const H = 3600_000;
2196
+ const inWindow = hourly.filter((b) => b.hour >= window[0] && b.hour < window[1]);
2197
+ const periodStartHour = Math.floor(window[0] / H) * H;
2198
+ // Rule: compare the GLOBAL earliest data hour with the period
2199
+ // start. If data predates the period (global first < period
2200
+ // start), show the whole period (zeros included). Only when the
2201
+ // very first usage ever is inside this period do we start from
2202
+ // the first data hour (e.g. allTime starts at first usage, not
2203
+ // epoch).
2204
+ const globalFirstHour = hourly[0].hour; // hourly is time-sorted
2205
+ const startHour = globalFirstHour > periodStartHour ? globalFirstHour : periodStartHour;
2206
+ const endHour = Math.floor(window[1] / H) * H;
2207
+ const byHour = new Map(inWindow.map((b) => [b.hour, b]));
2208
+ seq = [];
2209
+ for (let h = startHour; h <= endHour; h += H) {
2210
+ const b = byHour.get(h);
2211
+ seq.push(b ? { ...b } : { hour: h, cost: 0, tokens: 0, messages: 0 });
2212
+ }
2213
+ }
2214
+ // Cap the column count (~120) so very long windows (allTime) stay
2215
+ // manageable; the hourly chart scrolls horizontally to show idle
2216
+ // hours, so a generous count is fine.
2217
+ let buckets = seq;
2218
+ const maxCols = 120;
2219
+ if (buckets.length > maxCols) {
2220
+ const step = Math.ceil(buckets.length / maxCols);
2221
+ const agg = [];
2222
+ for (let i = 0; i < buckets.length; i += step) {
2223
+ const slice = buckets.slice(i, Math.min(i + step, buckets.length));
2224
+ const start = slice[0].hour;
2225
+ const end = slice[slice.length - 1].hour + 3600_000;
2226
+ agg.push({
2227
+ hour: start,
2228
+ tokens: slice.reduce((s, b) => s + b.tokens, 0),
2229
+ cost: slice.reduce((s, b) => s + b.cost, 0),
2230
+ messages: slice.reduce((s, b) => s + b.messages, 0),
2231
+ spanEnd: end,
2232
+ spanCount: slice.length,
2233
+ });
2234
+ }
2235
+ buckets = agg;
2236
+ }
2237
+ const maxT = Math.max(...buckets.map((b) => b.tokens), 1e-9);
2238
+ // Fixed-height bars (px) - avoids percentage-height resolution
2239
+ // issues in nested flex containers.
2240
+ const BAR_MAX_PX = 96;
2241
+ // Label every ~6th column with its time (sparse to avoid crowding).
2242
+ const labelStep = Math.ceil(buckets.length / 8);
2243
+ html += '<div class="usage-graph-title" style="margin-top:14px">Hourly tokens (recent)</div><div class="usage-hourly">';
2244
+ for (let i = 0; i < buckets.length; i++) {
2245
+ const b = buckets[i];
2246
+ const hPx = b.tokens > 0 ? Math.max(2, Math.round((b.tokens / maxT) * BAR_MAX_PX)) : 0;
2247
+ const d = new Date(b.hour);
2248
+ const spanText = b.spanCount ? ` (${b.spanCount}h)` : '';
2249
+ const showLabel = i % labelStep === 0 || i === buckets.length - 1;
2250
+ const label = showLabel
2251
+ ? d.toLocaleDateString(undefined, { month: 'numeric', day: 'numeric' }) + '\n' + d.toLocaleTimeString(undefined, { hour: '2-digit' })
2252
+ : '';
2253
+ // Day/night banding: hour 6..17 (local) = day, else night
2254
+ const hourOfDay = d.getHours();
2255
+ const band = hourOfDay >= 6 && hourOfDay < 18 ? 'day' : 'night';
2256
+ html += `<div class="usage-hour-col ${band}" title="${d.toLocaleString()}${spanText} — ${this.fmtTokens(b.tokens)}"><div class="usage-hour-bar-wrap"><div class="usage-hour-bar" style="height:${hPx}px"></div></div><div class="usage-hour-label">${this.escapeHtml(label).replace(/\n/g, '<br>')}</div></div>`;
2257
+ }
2258
+ html += '</div>';
2259
+ }
2260
+ container.innerHTML = html;
2261
+ body.appendChild(container);
2262
+ }
2263
+ }
2264
+
1953
2265
  handleModels(models) {
1954
2266
  if (this.modalMode !== 'model') return;
1955
2267
  const items = (models || []).map((m) => ({
@@ -55,6 +55,15 @@
55
55
  </div>
56
56
  </div>
57
57
  <div id="toast-container"></div>
58
+ <div id="usage-overlay" style="display:none">
59
+ <div id="usage-panel">
60
+ <div id="usage-title">Usage</div>
61
+ <div id="usage-tabs"></div>
62
+ <div id="usage-views"></div>
63
+ <div id="usage-body"></div>
64
+ <div id="usage-close">Close (Esc)</div>
65
+ </div>
66
+ </div>
58
67
  <script src="vendor/marked.min.js"></script>
59
68
  <script src="app.js"></script>
60
69
  </body>
@@ -785,8 +785,8 @@ body {
785
785
  background: var(--code-bg);
786
786
  border: 1px solid var(--border);
787
787
  border-radius: 8px;
788
- width: 480px;
789
- max-width: 90vw;
788
+ width: min(720px, 92vw);
789
+ max-width: none;
790
790
  max-height: 70vh;
791
791
  display: flex;
792
792
  flex-direction: column;
@@ -1181,3 +1181,297 @@ body {
1181
1181
  #abort-btn:active {
1182
1182
  opacity: 0.8;
1183
1183
  }
1184
+
1185
+ /* ======================================================================
1186
+ Usage panel (dedicated large panel for /usage)
1187
+ ====================================================================== */
1188
+ #usage-overlay {
1189
+ position: fixed;
1190
+ inset: 0;
1191
+ background: rgba(0, 0, 0, 0.6);
1192
+ display: none;
1193
+ align-items: center;
1194
+ justify-content: center;
1195
+ z-index: 150;
1196
+ }
1197
+
1198
+ #usage-panel {
1199
+ background: var(--modal-bg);
1200
+ border: 1px solid var(--border);
1201
+ border-radius: 10px;
1202
+ width: min(1100px, 94vw);
1203
+ height: min(85vh, 800px);
1204
+ display: flex;
1205
+ flex-direction: column;
1206
+ overflow: hidden;
1207
+ }
1208
+
1209
+ #usage-title {
1210
+ padding: 10px 16px;
1211
+ font-weight: bold;
1212
+ font-size: 15px;
1213
+ border-bottom: 1px solid var(--border);
1214
+ display: flex;
1215
+ justify-content: space-between;
1216
+ align-items: center;
1217
+ }
1218
+
1219
+ #usage-tabs {
1220
+ display: flex;
1221
+ gap: 2px;
1222
+ padding: 8px 12px 4px;
1223
+ border-bottom: 1px solid var(--border);
1224
+ flex-wrap: wrap;
1225
+ }
1226
+
1227
+ .usage-tab, .usage-view {
1228
+ padding: 4px 12px;
1229
+ font-size: 12px;
1230
+ border-radius: 4px;
1231
+ cursor: pointer;
1232
+ color: var(--muted);
1233
+ }
1234
+
1235
+ .usage-tab.active, .usage-view.active {
1236
+ background: var(--accent);
1237
+ color: var(--bg);
1238
+ font-weight: 600;
1239
+ }
1240
+
1241
+ #usage-views {
1242
+ display: flex;
1243
+ gap: 2px;
1244
+ padding: 4px 12px 8px;
1245
+ border-bottom: 1px solid var(--border);
1246
+ }
1247
+
1248
+ #usage-body {
1249
+ flex: 1;
1250
+ overflow-y: auto;
1251
+ padding: 8px 12px;
1252
+ }
1253
+
1254
+ .usage-table {
1255
+ width: 100%;
1256
+ border-collapse: collapse;
1257
+ font-size: 12px;
1258
+ }
1259
+
1260
+ .usage-table th {
1261
+ text-align: right;
1262
+ padding: 4px 8px;
1263
+ color: var(--dim);
1264
+ font-weight: 600;
1265
+ border-bottom: 1px solid var(--border);
1266
+ font-size: 11px;
1267
+ }
1268
+
1269
+ .usage-table th:first-child {
1270
+ text-align: left;
1271
+ }
1272
+
1273
+ .usage-table td {
1274
+ padding: 3px 8px;
1275
+ border-bottom: 1px solid var(--border);
1276
+ white-space: nowrap;
1277
+ }
1278
+
1279
+ .usage-name {
1280
+ text-align: left;
1281
+ overflow: hidden;
1282
+ text-overflow: ellipsis;
1283
+ max-width: 340px;
1284
+ }
1285
+
1286
+ .usage-num {
1287
+ text-align: right;
1288
+ font-family: monospace;
1289
+ }
1290
+
1291
+ .usage-provider-row {
1292
+ cursor: pointer;
1293
+ font-weight: 600;
1294
+ }
1295
+
1296
+ .usage-provider-row:hover {
1297
+ background: var(--tool-pending-bg);
1298
+ }
1299
+
1300
+ .usage-model-row {
1301
+ color: var(--muted);
1302
+ font-size: 11px;
1303
+ }
1304
+
1305
+ .usage-totals {
1306
+ font-weight: 700;
1307
+ border-top: 2px solid var(--border);
1308
+ }
1309
+
1310
+ .usage-insights {
1311
+ display: flex;
1312
+ flex-direction: column;
1313
+ gap: 8px;
1314
+ }
1315
+
1316
+ .usage-insight {
1317
+ border: 1px solid var(--border);
1318
+ border-radius: 6px;
1319
+ padding: 8px 12px;
1320
+ font-size: 13px;
1321
+ display: flex;
1322
+ align-items: baseline;
1323
+ gap: 10px;
1324
+ flex-wrap: wrap;
1325
+ }
1326
+
1327
+ .usage-insight.alarm {
1328
+ border-left: 3px solid var(--error);
1329
+ }
1330
+
1331
+ .usage-insight-stat {
1332
+ font-weight: 700;
1333
+ color: var(--accent);
1334
+ font-family: monospace;
1335
+ white-space: nowrap;
1336
+ }
1337
+
1338
+ .usage-insight-advice {
1339
+ width: 100%;
1340
+ color: var(--dim);
1341
+ font-size: 12px;
1342
+ }
1343
+
1344
+ .usage-graph-title {
1345
+ font-size: 13px;
1346
+ font-weight: 600;
1347
+ margin-bottom: 6px;
1348
+ color: var(--text);
1349
+ }
1350
+
1351
+ .usage-bars {
1352
+ display: flex;
1353
+ flex-direction: column;
1354
+ gap: 4px;
1355
+ }
1356
+
1357
+ .usage-bar-row {
1358
+ display: flex;
1359
+ align-items: center;
1360
+ gap: 8px;
1361
+ font-size: 12px;
1362
+ }
1363
+
1364
+ .usage-bar-label {
1365
+ width: 200px;
1366
+ overflow: hidden;
1367
+ text-overflow: ellipsis;
1368
+ white-space: nowrap;
1369
+ text-align: right;
1370
+ }
1371
+
1372
+ .usage-bar-track {
1373
+ flex: 1;
1374
+ height: 14px;
1375
+ background: var(--tool-pending-bg);
1376
+ border-radius: 3px;
1377
+ overflow: hidden;
1378
+ }
1379
+
1380
+ .usage-bar {
1381
+ height: 100%;
1382
+ background: var(--accent);
1383
+ border-radius: 3px;
1384
+ }
1385
+
1386
+ .usage-bar-val {
1387
+ width: 70px;
1388
+ text-align: right;
1389
+ font-family: monospace;
1390
+ color: var(--muted);
1391
+ }
1392
+
1393
+ .usage-hourly {
1394
+ position: relative;
1395
+ display: flex;
1396
+ align-items: flex-end;
1397
+ gap: 2px;
1398
+ height: 120px;
1399
+ /* fixed-width columns; long windows scroll horizontally - the chart is
1400
+ the one place inside the panel where x-scrolling is acceptable */
1401
+ overflow-x: auto;
1402
+ }
1403
+
1404
+ /* Zero baseline spanning the whole chart, just above the time labels. */
1405
+ .usage-hourly::after {
1406
+ content: '';
1407
+ position: absolute;
1408
+ left: 0;
1409
+ right: 0;
1410
+ bottom: 24px;
1411
+ border-bottom: 1px solid var(--border);
1412
+ pointer-events: none;
1413
+ }
1414
+
1415
+ .usage-hour-bar-wrap {
1416
+ flex: 1;
1417
+ display: flex;
1418
+ align-items: flex-end;
1419
+ min-height: 0;
1420
+ }
1421
+
1422
+ .usage-hour-col {
1423
+ flex: 0 0 20px;
1424
+ width: 20px;
1425
+ height: 100%;
1426
+ display: flex;
1427
+ flex-direction: column;
1428
+ align-items: stretch;
1429
+ }
1430
+
1431
+ /* Day/night banding: 6:00-17:59 = day (warm tint), rest = night (cool tint) */
1432
+ .usage-hour-col.day {
1433
+ background: color-mix(in srgb, var(--accent) 5%, transparent);
1434
+ }
1435
+
1436
+ .usage-hour-col.night {
1437
+ background: color-mix(in srgb, var(--muted) 12%, transparent);
1438
+ }
1439
+
1440
+ .usage-hour-bar-wrap {
1441
+ flex: 1;
1442
+ display: flex;
1443
+ align-items: flex-end;
1444
+ min-height: 0;
1445
+ }
1446
+
1447
+ .usage-hour-label {
1448
+ font-size: 9px;
1449
+ color: var(--dim);
1450
+ text-align: center;
1451
+ line-height: 1.15;
1452
+ white-space: nowrap;
1453
+ overflow: hidden;
1454
+ text-overflow: ellipsis;
1455
+ height: 22px;
1456
+ margin-top: 2px;
1457
+ }
1458
+
1459
+ .usage-hour-bar {
1460
+ width: 100%;
1461
+ background: var(--accent);
1462
+ opacity: 0.75;
1463
+ border-radius: 2px 2px 0 0;
1464
+ }
1465
+
1466
+ #usage-close {
1467
+ padding: 8px 16px;
1468
+ text-align: right;
1469
+ font-size: 12px;
1470
+ color: var(--dim);
1471
+ cursor: pointer;
1472
+ border-top: 1px solid var(--border);
1473
+ }
1474
+
1475
+ #usage-close:hover {
1476
+ color: var(--text);
1477
+ }
@@ -200,12 +200,54 @@ export class WebUIContext {
200
200
  setHeader() { }
201
201
  custom() {
202
202
  // Pi's custom() shows an extension-drawn TUI component. Web has no TUI
203
- // renderer, so a full component can't be displayed. Same headless stub
204
- // as Pi's RPC mode (rpc-mode.ts: "Custom UI not supported in RPC mode"):
205
- // settle immediately so commands awaiting the panel don't hang.
206
- // Commands whose extensions branch on hasUI (magic-context ctx-status)
207
- // are routed to their text fallback by executeCommand (hasUI:false);
208
- // any other custom() caller just gets a no-op close.
203
+ // renderer, so the component can't be *displayed* - but the factory is
204
+ // still invoked with no-op stubs so the extension's own logic runs
205
+ // (e.g. /usage starts its data collection inside the factory), then we
206
+ // settle immediately: display-only panels (user-driven close) would
207
+ // otherwise hang the command forever, and data-producing panels
208
+ // (loader -> done(value)) have their result read from the extension's
209
+ // own cache file afterwards when needed (see usage-render.ts). So
210
+ // custom() never blocks the command.
211
+ // Extensions that branch on hasUI before custom (magic-context
212
+ // ctx-status) are routed to their text fallback by executeCommand
213
+ // (hasUI:false) and never reach this.
214
+ try {
215
+ // Arguments are (factory, options) at runtime; the declared
216
+ // signature stays interface-compatible, read them dynamically.
217
+ const args = arguments;
218
+ const factory = args[0];
219
+ const options = args[1];
220
+ if (typeof factory === "function") {
221
+ // No-op TUI stub: enough surface for factories that need a render
222
+ // handle; rendering itself is never performed on Web.
223
+ const stubTui = {
224
+ requestRender: () => { },
225
+ invalidate: () => { },
226
+ setFocus: () => { },
227
+ getWidth: () => 100,
228
+ };
229
+ // Theme stub: color helpers degrade to plain text.
230
+ const stubTheme = {
231
+ fg: (_k, s) => s,
232
+ bold: (s) => s,
233
+ dim: (s) => s,
234
+ get theme() {
235
+ return undefined;
236
+ },
237
+ };
238
+ const component = factory(stubTui, stubTheme, {}, () => { });
239
+ // Component built and its logic ran (data collection started);
240
+ // NOT disposed - async work inside may still be running and own
241
+ // its resources. settle immediately.
242
+ void component;
243
+ }
244
+ if (options && typeof options.onHandle === "function") {
245
+ options.onHandle({ setHidden: () => { }, focus: () => { } });
246
+ }
247
+ }
248
+ catch {
249
+ // factory threw - ignore, still settle
250
+ }
209
251
  return Promise.resolve(undefined);
210
252
  }
211
253
  pasteToEditor() { }
@@ -0,0 +1,529 @@
1
+ /**
2
+ * Usage statistics for the pi-web panel (standalone module).
3
+ *
4
+ * Data is collected directly from Pi's own session files
5
+ * (~/.pi/agent/sessions/**\/*.jsonl): each assistant message carries
6
+ * provider/model/timestamp/usage{input,output,cacheRead,cacheWrite,
7
+ * reasoning,cost}, and auxiliary entries (compaction/branch_summary)
8
+ * carry usage too. The collection mirrors the semantics used by the
9
+ * pi-usage-extension (which reads the same session files and normalizes
10
+ * them to a cache) - but this module is independent: it parses the
11
+ * session files itself, so /usage works without the extension installed.
12
+ *
13
+ * The module aggregates the messages into the structured UsageDataPayload
14
+ * (5 time tabs x provider/model x metrics + insights + global hourly
15
+ * series + tab windows); the frontend renders.
16
+ */
17
+ import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
18
+ import { join } from "node:path";
19
+ import { homedir } from "node:os";
20
+ const SESSIONS_DIRS = [
21
+ join(process.env.PI_HOME?.replace(/^~/, homedir()) ?? homedir(), ".pi", "agent", "sessions"),
22
+ join(homedir(), ".pi", "agent", "sessions"),
23
+ ];
24
+ function sessionsDir() {
25
+ for (const dir of SESSIONS_DIRS) {
26
+ if (existsSync(dir))
27
+ return dir;
28
+ }
29
+ return null;
30
+ }
31
+ /** Recursively collect all .jsonl session files under dir (sorted). */
32
+ function collectSessionFiles(dir, out) {
33
+ let entries;
34
+ try {
35
+ entries = readdirSync(dir, { withFileTypes: true });
36
+ }
37
+ catch {
38
+ return;
39
+ }
40
+ for (const e of entries) {
41
+ const p = join(dir, e.name);
42
+ if (e.isDirectory())
43
+ collectSessionFiles(p, out);
44
+ else if (e.isFile() && e.name.endsWith(".jsonl"))
45
+ out.push(p);
46
+ }
47
+ }
48
+ function parseUsageAmount(value) {
49
+ const u = value;
50
+ if (!u || typeof u !== "object")
51
+ return null;
52
+ return {
53
+ cost: Number(u.cost?.total) || 0,
54
+ input: Number(u.input) || 0,
55
+ output: Number(u.output) || 0,
56
+ cacheRead: Number(u.cacheRead) || 0,
57
+ cacheWrite: Number(u.cacheWrite) || 0,
58
+ reasoning: Number(u.reasoning) || 0,
59
+ };
60
+ }
61
+ function tsOf(messageTimestamp, entryTimestamp) {
62
+ if (typeof messageTimestamp === "number")
63
+ return messageTimestamp;
64
+ if (typeof messageTimestamp === "string") {
65
+ const n = new Date(messageTimestamp).getTime();
66
+ if (!Number.isNaN(n))
67
+ return n;
68
+ }
69
+ if (typeof entryTimestamp === "string") {
70
+ const n = new Date(entryTimestamp).getTime();
71
+ if (!Number.isNaN(n))
72
+ return n;
73
+ }
74
+ return 0;
75
+ }
76
+ function auxMessage(usage, ts, sourceId) {
77
+ return {
78
+ provider: "aux",
79
+ model: "auxiliary",
80
+ thinkingLevel: undefined,
81
+ cost: usage.cost,
82
+ input: usage.input,
83
+ output: usage.output,
84
+ cacheRead: usage.cacheRead,
85
+ cacheWrite: usage.cacheWrite,
86
+ timestamp: ts,
87
+ reasoning: usage.reasoning,
88
+ afterCompaction: false,
89
+ source: "auxiliary",
90
+ };
91
+ }
92
+ /**
93
+ * Parse one session jsonl into its usage messages (the same semantics as
94
+ * the pi-usage-extension: assistant messages contribute their own usage,
95
+ * compaction/branch_summary entries contribute auxiliary usage).
96
+ */
97
+ function parseSessionFile(path) {
98
+ let sessionId = "";
99
+ let cwd = "";
100
+ let parentSession = "";
101
+ let thinkingLevel;
102
+ let compactionPending = false;
103
+ const messages = [];
104
+ let content;
105
+ try {
106
+ content = readFileSync(path, "utf8");
107
+ }
108
+ catch {
109
+ return { sessionId, cwd, parentSession, messages };
110
+ }
111
+ for (const line of content.split("\n")) {
112
+ if (!line.trim())
113
+ continue;
114
+ let entry;
115
+ try {
116
+ entry = JSON.parse(line);
117
+ }
118
+ catch {
119
+ continue; // malformed line
120
+ }
121
+ switch (entry.type) {
122
+ case "session": {
123
+ if (typeof entry.id === "string")
124
+ sessionId = entry.id;
125
+ if (typeof entry.cwd === "string")
126
+ cwd = entry.cwd;
127
+ if (typeof entry.parentSession === "string")
128
+ parentSession = entry.parentSession;
129
+ break;
130
+ }
131
+ case "thinking_level_change": {
132
+ if (typeof entry.thinkingLevel === "string")
133
+ thinkingLevel = entry.thinkingLevel;
134
+ break;
135
+ }
136
+ case "compaction": {
137
+ const usage = parseUsageAmount(entry.usage);
138
+ if (usage)
139
+ messages.push(auxMessage(usage, tsOf(undefined, entry.timestamp), typeof entry.id === "string" ? entry.id : ""));
140
+ compactionPending = true;
141
+ break;
142
+ }
143
+ case "branch_summary": {
144
+ const usage = parseUsageAmount(entry.usage);
145
+ if (usage)
146
+ messages.push(auxMessage(usage, tsOf(undefined, entry.timestamp), typeof entry.id === "string" ? entry.id : ""));
147
+ break;
148
+ }
149
+ case "message": {
150
+ const msg = entry.message;
151
+ if (!msg)
152
+ break;
153
+ if (msg.role === "assistant" && msg.usage && msg.provider && msg.model) {
154
+ const usage = parseUsageAmount(msg.usage);
155
+ if (usage) {
156
+ messages.push({
157
+ provider: msg.provider,
158
+ model: msg.model,
159
+ thinkingLevel,
160
+ cost: usage.cost,
161
+ input: usage.input,
162
+ output: usage.output,
163
+ cacheRead: usage.cacheRead,
164
+ cacheWrite: usage.cacheWrite,
165
+ timestamp: tsOf(msg.timestamp, entry.timestamp),
166
+ reasoning: usage.reasoning,
167
+ afterCompaction: compactionPending,
168
+ source: "assistant",
169
+ });
170
+ compactionPending = false;
171
+ }
172
+ }
173
+ break;
174
+ }
175
+ default:
176
+ break;
177
+ }
178
+ }
179
+ return { sessionId, cwd, parentSession, messages };
180
+ }
181
+ /** Collect usage across all session files. Empty array when none found. */
182
+ export function collectUsage() {
183
+ const root = sessionsDir();
184
+ if (!root)
185
+ return [];
186
+ const files = [];
187
+ collectSessionFiles(root, files);
188
+ const out = [];
189
+ for (const f of files) {
190
+ const parsed = parseSessionFile(f);
191
+ if (parsed.messages.length > 0 || parsed.sessionId)
192
+ out.push(parsed);
193
+ }
194
+ return out;
195
+ }
196
+ /** A coarse freshness stamp: sum of session-file mtimes, for cheap invalidation. */
197
+ export function sessionsStamp() {
198
+ const root = sessionsDir();
199
+ if (!root)
200
+ return 0;
201
+ const files = [];
202
+ collectSessionFiles(root, files);
203
+ let sum = 0;
204
+ for (const f of files) {
205
+ try {
206
+ sum += statSync(f).mtimeMs;
207
+ }
208
+ catch {
209
+ // ignore
210
+ }
211
+ }
212
+ return sum;
213
+ }
214
+ function startOfDay(ts) {
215
+ const d = new Date(ts);
216
+ d.setHours(0, 0, 0, 0);
217
+ return d.getTime();
218
+ }
219
+ function startOfWeek(ts) {
220
+ const d = new Date(ts);
221
+ const day = d.getDay() === 0 ? 6 : d.getDay() - 1;
222
+ d.setHours(0, 0, 0, 0);
223
+ d.setDate(d.getDate() - day);
224
+ return d.getTime();
225
+ }
226
+ function emptyTotals() {
227
+ return { cost: 0, input: 0, output: 0, cacheRead: 0, cacheWrite: 0, messages: 0, sessions: new Set() };
228
+ }
229
+ function fmt(n) {
230
+ return n >= 1e9
231
+ ? `${(n / 1e9).toFixed(2)}B`
232
+ : n >= 1e6
233
+ ? `${(n / 1e6).toFixed(2)}M`
234
+ : n >= 1e3
235
+ ? `${(n / 1e3).toFixed(1)}K`
236
+ : `${Math.round(n)}`;
237
+ }
238
+ function fmtCost(n) {
239
+ return `$${n.toFixed(n >= 1 ? 2 : 4)}`;
240
+ }
241
+ /** Render a Markdown usage summary (today / this week / all time). */
242
+ export function renderUsageSummary() {
243
+ const files = collectUsage();
244
+ if (files.length === 0) {
245
+ return "## Usage\n\nNo usage data found yet.";
246
+ }
247
+ const now = Date.now();
248
+ const todayStart = startOfDay(now);
249
+ const weekStart = startOfWeek(now);
250
+ const today = emptyTotals();
251
+ const week = emptyTotals();
252
+ const all = emptyTotals();
253
+ const byModel = new Map();
254
+ for (const file of files) {
255
+ for (const m of file.messages) {
256
+ const t = m.timestamp;
257
+ const targets = [all];
258
+ if (t >= todayStart)
259
+ targets.push(today);
260
+ if (t >= weekStart)
261
+ targets.push(week);
262
+ for (const target of targets) {
263
+ target.cost += m.cost;
264
+ target.input += m.input;
265
+ target.output += m.output;
266
+ target.cacheRead += m.cacheRead;
267
+ target.cacheWrite += m.cacheWrite;
268
+ target.messages += 1;
269
+ target.sessions.add(file.sessionId);
270
+ }
271
+ const key = `${m.provider}/${m.model}`;
272
+ const byModelEntry = byModel.get(key) ?? emptyTotals();
273
+ byModelEntry.cost += m.cost;
274
+ byModelEntry.input += m.input;
275
+ byModelEntry.output += m.output;
276
+ byModelEntry.cacheRead += m.cacheRead;
277
+ byModelEntry.cacheWrite += m.cacheWrite;
278
+ byModelEntry.messages += 1;
279
+ byModel.set(key, byModelEntry);
280
+ }
281
+ }
282
+ const row = (label, t) => `| ${label} | ${fmt(t.input)} | ${fmt(t.output)} | ${fmt(t.cacheRead)} | ${fmt(t.cacheWrite)} | $${t.cost.toFixed(4)} | ${t.messages} | ${t.sessions.size} |`;
283
+ const lines = [
284
+ "## Usage",
285
+ "",
286
+ "| Period | Input | Output | Cache R | Cache W | Cost | Msgs | Sessions |",
287
+ "| --- | --- | --- | --- | --- | --- | --- | --- |",
288
+ row("Today", today),
289
+ row("This week", week),
290
+ row("All time", all),
291
+ ];
292
+ if (byModel.size > 0) {
293
+ const sorted = [...byModel.entries()].sort((a, b) => b[1].cost - a[1].cost).slice(0, 10);
294
+ lines.push("", "### By model (all time)", "", "| Model | Input | Output | Cost | Msgs |", "| --- | --- | --- | --- | --- |");
295
+ for (const [key, t] of sorted) {
296
+ lines.push(`| ${key} | ${fmt(t.input)} | ${fmt(t.output)} | $${t.cost.toFixed(4)} | ${t.messages} |`);
297
+ }
298
+ }
299
+ return lines.join("\n");
300
+ }
301
+ const TAB_KEYS = ["today", "thisWeek", "lastWeek", "last30Days", "allTime"];
302
+ function emptyTokens() {
303
+ return { total: 0, input: 0, output: 0, cacheRead: 0, cacheWrite: 0 };
304
+ }
305
+ function addTokens(a, b) {
306
+ a.total += b.total;
307
+ a.input += b.input;
308
+ a.output += b.output;
309
+ a.cacheRead += b.cacheRead;
310
+ a.cacheWrite += b.cacheWrite;
311
+ }
312
+ /** Format a cost value the way the extension does (0 -> "-"). */
313
+ export function formatUsageCost(cost) {
314
+ if (cost === 0)
315
+ return "-";
316
+ if (cost < 0.01)
317
+ return `$${cost.toFixed(4)}`;
318
+ if (cost < 1)
319
+ return `$${cost.toFixed(2)}`;
320
+ if (cost < 10)
321
+ return `$${cost.toFixed(2)}`;
322
+ if (cost < 100)
323
+ return `$${cost.toFixed(1)}`;
324
+ return `$${Math.round(cost)}`;
325
+ }
326
+ /** Format a token count compactly (12.3K, 4.5M, ...). */
327
+ export function formatUsageTokens(n) {
328
+ if (n >= 1e9)
329
+ return `${(n / 1e9).toFixed(2)}B`;
330
+ if (n >= 1e6)
331
+ return `${(n / 1e6).toFixed(2)}M`;
332
+ if (n >= 1e3)
333
+ return `${(n / 1e3).toFixed(1)}K`;
334
+ return `${Math.round(n)}`;
335
+ }
336
+ function periodStart(key, now) {
337
+ const d = new Date(now);
338
+ d.setHours(0, 0, 0, 0);
339
+ if (key === "today")
340
+ return d.getTime();
341
+ const day = d.getDay() === 0 ? 6 : d.getDay() - 1; // Monday start
342
+ if (key === "thisWeek") {
343
+ d.setDate(d.getDate() - day);
344
+ return d.getTime();
345
+ }
346
+ if (key === "lastWeek") {
347
+ d.setDate(d.getDate() - day - 7);
348
+ return d.getTime();
349
+ }
350
+ if (key === "last30Days") {
351
+ return d.getTime() - 29 * 24 * 3600 * 1000;
352
+ }
353
+ return 0; // allTime
354
+ }
355
+ /**
356
+ * Aggregate collected session usage into the structured payload the web
357
+ * panel renders. Returns null when no usage data exists. When sessionId
358
+ * is given, only that session's usage is aggregated (scope: current
359
+ * session); otherwise all sessions.
360
+ */
361
+ export function buildUsageData(sessionId) {
362
+ const files = collectUsage();
363
+ const scoped = sessionId ? files.filter((f) => f.sessionId === sessionId) : files;
364
+ if (scoped.length === 0)
365
+ return null;
366
+ const now = Date.now();
367
+ // per-tab accumulators
368
+ const tabs = {};
369
+ for (const key of TAB_KEYS) {
370
+ tabs[key] = { providers: new Map(), totals: { messages: 0, cost: 0, tokens: emptyTokens(), sessions: new Set() } };
371
+ }
372
+ // hourly buckets (all providers, global) - the render side filters per tab
373
+ const hourly = new Map();
374
+ const hourStart = (ts) => Math.floor(ts / 3600_000) * 3600_000;
375
+ for (const file of scoped) {
376
+ for (const m of file.messages) {
377
+ const ts = m.timestamp;
378
+ {
379
+ const bucket = hourly.get(hourStart(ts));
380
+ if (bucket) {
381
+ bucket.cost += m.cost;
382
+ bucket.tokens += m.input + m.output + m.cacheRead + m.cacheWrite;
383
+ bucket.messages += 1;
384
+ }
385
+ else {
386
+ hourly.set(hourStart(ts), { cost: m.cost, tokens: m.input + m.output + m.cacheRead + m.cacheWrite, messages: 1 });
387
+ }
388
+ }
389
+ for (const key of TAB_KEYS) {
390
+ const start = periodStart(key, now);
391
+ if (ts < start)
392
+ continue;
393
+ const tab = tabs[key];
394
+ tab.totals.messages += 1;
395
+ tab.totals.cost += m.cost;
396
+ addTokens(tab.totals.tokens, {
397
+ total: m.input + m.output + m.cacheRead + m.cacheWrite,
398
+ input: m.input,
399
+ output: m.output,
400
+ cacheRead: m.cacheRead,
401
+ cacheWrite: m.cacheWrite,
402
+ });
403
+ tab.totals.sessions.add(file.sessionId);
404
+ let prov = tab.providers.get(m.provider);
405
+ if (!prov) {
406
+ prov = {
407
+ messages: 0, cost: 0, tokens: emptyTokens(), sessions: new Set(), models: new Map(),
408
+ };
409
+ tab.providers.set(m.provider, prov);
410
+ }
411
+ prov.messages += 1;
412
+ prov.cost += m.cost;
413
+ addTokens(prov.tokens, {
414
+ total: m.input + m.output + m.cacheRead + m.cacheWrite,
415
+ input: m.input,
416
+ output: m.output,
417
+ cacheRead: m.cacheRead,
418
+ cacheWrite: m.cacheWrite,
419
+ });
420
+ prov.sessions.add(file.sessionId);
421
+ let model = prov.models.get(m.model);
422
+ if (!model) {
423
+ model = { messages: 0, cost: 0, tokens: emptyTokens(), sessions: new Set() };
424
+ prov.models.set(m.model, model);
425
+ }
426
+ model.messages += 1;
427
+ model.cost += m.cost;
428
+ addTokens(model.tokens, {
429
+ total: m.input + m.output + m.cacheRead + m.cacheWrite,
430
+ input: m.input,
431
+ output: m.output,
432
+ cacheRead: m.cacheRead,
433
+ cacheWrite: m.cacheWrite,
434
+ });
435
+ model.sessions.add(file.sessionId);
436
+ }
437
+ }
438
+ }
439
+ const payload = { tabs: {}, hourly: [], tabWindow: {}, collectedAt: sessionsStamp() };
440
+ // Graph x-axis windows per tab: [start, end]. today: midnight->now;
441
+ // thisWeek: monday->now; lastWeek: monday->next Monday; last30Days:
442
+ // start->now; allTime: 0 -> now (frontend clips to first data).
443
+ for (const key of TAB_KEYS) {
444
+ const start = periodStart(key, now);
445
+ let end = now;
446
+ if (key === "lastWeek") {
447
+ const d = new Date(now);
448
+ d.setHours(0, 0, 0, 0);
449
+ const day = d.getDay() === 0 ? 6 : d.getDay() - 1;
450
+ d.setDate(d.getDate() - day);
451
+ end = d.getTime();
452
+ }
453
+ payload.tabWindow[key] = [start, end];
454
+ }
455
+ for (const key of TAB_KEYS) {
456
+ const tab = tabs[key];
457
+ const providers = [...tab.providers.entries()]
458
+ .sort((a, b) => b[1].cost - a[1].cost)
459
+ .map(([name, p]) => ({
460
+ name,
461
+ messages: p.messages,
462
+ cost: p.cost,
463
+ tokens: p.tokens,
464
+ sessions: p.sessions.size,
465
+ models: [...p.models.entries()]
466
+ .sort((a, b) => b[1].cost - a[1].cost)
467
+ .map(([mname, m]) => ({
468
+ name: mname,
469
+ messages: m.messages,
470
+ cost: m.cost,
471
+ tokens: m.tokens,
472
+ sessions: m.sessions.size,
473
+ })),
474
+ }));
475
+ // Simple insights: top provider share + largest model cost (alarms
476
+ // when material), plus a structure line for overall usage.
477
+ const insights = [];
478
+ const totalCost = tab.totals.cost;
479
+ const totalTokens = tab.totals.tokens.total;
480
+ if (totalCost > 0 && providers.length > 0) {
481
+ const top = providers[0];
482
+ const share = (top.cost / totalCost) * 100;
483
+ if (share >= 20) {
484
+ insights.push({
485
+ kind: "alarm",
486
+ stat: `${share.toFixed(0)}%`,
487
+ headline: `${top.name} drives ${share.toFixed(0)}% of cost ($${top.cost.toFixed(2)})`,
488
+ advice: "Consider switching cheaper models for routine work.",
489
+ });
490
+ }
491
+ if (top.models.length > 0) {
492
+ const topModel = [...top.models].sort((a, b) => b.cost - a.cost)[0];
493
+ if (topModel.cost / totalCost >= 0.3) {
494
+ insights.push({
495
+ kind: "alarm",
496
+ stat: formatUsageCost(topModel.cost),
497
+ headline: `${topModel.name} is the costliest model (${((topModel.cost / totalCost) * 100).toFixed(0)}% of total)`,
498
+ advice: "Check whether its output quality justifies the price.",
499
+ });
500
+ }
501
+ }
502
+ }
503
+ else if (tab.totals.messages === 0) {
504
+ insights.push({ kind: "structure", stat: "-", headline: "No usage recorded for this period.", advice: "" });
505
+ }
506
+ if (tab.totals.messages > 0) {
507
+ insights.push({
508
+ kind: "structure",
509
+ stat: formatUsageTokens(totalTokens),
510
+ headline: `${tab.totals.messages.toLocaleString()} messages, ${formatUsageTokens(totalTokens)} tokens total`,
511
+ advice: totalCost > 0 ? `Spent ${formatUsageCost(totalCost)} across ${tab.totals.sessions.size} session(s).` : "",
512
+ });
513
+ }
514
+ payload.tabs[key] = {
515
+ providers,
516
+ totals: {
517
+ messages: tab.totals.messages,
518
+ cost: tab.totals.cost,
519
+ tokens: tab.totals.tokens,
520
+ sessions: tab.totals.sessions.size,
521
+ },
522
+ insights,
523
+ };
524
+ }
525
+ payload.hourly = [...hourly.entries()]
526
+ .sort((a, b) => a[0] - b[0])
527
+ .map(([hour, v]) => ({ hour, cost: v.cost, tokens: v.tokens, messages: v.messages }));
528
+ return payload;
529
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-sdk-web",
3
- "version": "0.4.11",
3
+ "version": "0.5.1",
4
4
  "description": "Browser Web access for Pi (AI coding agent) via the Pi SDK - standalone module, zero modification to Pi itself",
5
5
  "type": "module",
6
6
  "bin": {
@@ -11,7 +11,7 @@
11
11
  "dist"
12
12
  ],
13
13
  "scripts": {
14
- "build": "tsc -p tsconfig.json && node -e \"require('node:fs').cpSync('../static', 'dist/static', { recursive: true }); require('node:fs').mkdirSync('dist/pi-bin', { recursive: true }); require('node:fs').renameSync('dist/pii-cli.js', 'dist/pi-bin/pii-cli.js'); require('node:fs').cpSync('../pii/pii', 'dist/pi-bin/pii'); require('node:fs').cpSync('../server', 'dist/pi-bin/server', { recursive: true, filter: (s) => !s.includes('__pycache__') })\"",
14
+ "build": "tsc -p tsconfig.json && node -e \"require('node:fs').cpSync('../static', 'dist/static', { recursive: true }); require('node:fs').mkdirSync('dist/pi-bin', { recursive: true }); require('node:fs').chmodSync('dist/cli.js', 0o755); require('node:fs').renameSync('dist/pii-cli.js', 'dist/pi-bin/pii-cli.js'); require('node:fs').chmodSync('dist/pi-bin/pii-cli.js', 0o755); require('node:fs').cpSync('../pii/pii', 'dist/pi-bin/pii'); require('node:fs').cpSync('../server', 'dist/pi-bin/server', { recursive: true, filter: (s) => !s.includes('__pycache__') })\"",
15
15
  "dev": "tsx src/cli.ts",
16
16
  "verify": "tsx src/verify-sdk.ts",
17
17
  "prepublishOnly": "npm run build"