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.
- package/dist/pi-bin/pii-cli.js +0 -0
- package/dist/server.js +54 -0
- package/dist/static/app.js +312 -0
- package/dist/static/index.html +9 -0
- package/dist/static/style.css +296 -2
- package/dist/ui-context.js +48 -6
- package/dist/usage-render.js +529 -0
- package/package.json +2 -2
package/dist/pi-bin/pii-cli.js
CHANGED
|
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;
|
package/dist/static/app.js
CHANGED
|
@@ -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) => ({
|
package/dist/static/index.html
CHANGED
|
@@ -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>
|
package/dist/static/style.css
CHANGED
|
@@ -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:
|
|
789
|
-
max-width:
|
|
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
|
+
}
|
package/dist/ui-context.js
CHANGED
|
@@ -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
|
|
204
|
-
//
|
|
205
|
-
//
|
|
206
|
-
//
|
|
207
|
-
//
|
|
208
|
-
//
|
|
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.
|
|
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"
|