pi-sdk-web 0.4.10 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/pi-bin/pii-cli.js +0 -0
- package/dist/server.js +62 -4
- package/dist/static/app.js +300 -3
- package/dist/static/index.html +9 -0
- package/dist/static/style.css +318 -2
- package/dist/ui-context.js +49 -0
- package/dist/usage-render.js +410 -0
- package/package.json +2 -2
package/dist/pi-bin/pii-cli.js
CHANGED
|
File without changes
|
package/dist/server.js
CHANGED
|
@@ -764,10 +764,38 @@ export class PiWebServer {
|
|
|
764
764
|
}
|
|
765
765
|
};
|
|
766
766
|
const unsubscribe = this.session.subscribe(listener);
|
|
767
|
+
// hasUI by extension: most extensions degrade gracefully to notify()
|
|
768
|
+
// (our notify -> browser dialog/toast), but a few (magic-context) use
|
|
769
|
+
// ctx.ui.custom() - an extension-drawn TUI panel we can't render. For
|
|
770
|
+
// those, hasUI:false routes them to their own text fallback
|
|
771
|
+
// (appendEntry -> modal), same as before.
|
|
772
|
+
const cmdPath = (cmd.sourceInfo && cmd.sourceInfo.path) || "";
|
|
773
|
+
const customOnly = /pi-magic-context|magic-context/.test(cmdPath);
|
|
774
|
+
const ui = this.uiContext;
|
|
775
|
+
const originalSink = ui.sink;
|
|
767
776
|
try {
|
|
768
|
-
|
|
777
|
+
// While the command runs, notify() broadcasts are tagged with the
|
|
778
|
+
// command name as title, so the browser shows them as a titled modal
|
|
779
|
+
// (colored, Markdown-rendered) instead of a plain stream line.
|
|
780
|
+
ui.sink = (obj) => {
|
|
781
|
+
const req = obj;
|
|
782
|
+
if (req && req.method === "notify") {
|
|
783
|
+
// Tag command-time notifies with the command name as title so the
|
|
784
|
+
// browser renders them as a titled modal (colored, Markdown).
|
|
785
|
+
originalSink({ ...obj, title: `/${name}` });
|
|
786
|
+
}
|
|
787
|
+
else {
|
|
788
|
+
originalSink(obj);
|
|
789
|
+
}
|
|
790
|
+
};
|
|
791
|
+
await cmd.handler(args, this.buildCommandContext(customOnly ? false : true));
|
|
792
|
+
}
|
|
793
|
+
catch (e) {
|
|
794
|
+
ui.sink = originalSink;
|
|
795
|
+
throw e;
|
|
769
796
|
}
|
|
770
797
|
finally {
|
|
798
|
+
ui.sink = originalSink;
|
|
771
799
|
unsubscribe();
|
|
772
800
|
}
|
|
773
801
|
// Show command output as a modal (TUI-like). The session entry remains
|
|
@@ -789,14 +817,44 @@ export class PiWebServer {
|
|
|
789
817
|
notifyType: "info",
|
|
790
818
|
});
|
|
791
819
|
}
|
|
820
|
+
// Usage extension: the /usage command collects data (via custom(), whose
|
|
821
|
+
// factory runs the collection) and keeps its cache file up to date.
|
|
822
|
+
// Aggregate the cache into structured data and send it as a dedicated
|
|
823
|
+
// message; the frontend renders its own usage panel (independent
|
|
824
|
+
// integration in usage-render.ts - no effect on other commands).
|
|
825
|
+
if (name === "usage") {
|
|
826
|
+
try {
|
|
827
|
+
const { buildUsageData } = await import("./usage-render.js");
|
|
828
|
+
const data = buildUsageData();
|
|
829
|
+
if (data) {
|
|
830
|
+
this.broadcast({ type: "usage_data", data });
|
|
831
|
+
}
|
|
832
|
+
else {
|
|
833
|
+
this.broadcast({
|
|
834
|
+
type: "extension_ui_request",
|
|
835
|
+
id: crypto.randomUUID(),
|
|
836
|
+
method: "notify",
|
|
837
|
+
title: "/usage",
|
|
838
|
+
message: "No usage data found. Run `/usage` in the TUI first to build the usage cache.",
|
|
839
|
+
notifyType: "info",
|
|
840
|
+
});
|
|
841
|
+
}
|
|
842
|
+
}
|
|
843
|
+
catch {
|
|
844
|
+
// integration failed silently - command output (if any) already
|
|
845
|
+
// broadcast above
|
|
846
|
+
}
|
|
847
|
+
}
|
|
792
848
|
}
|
|
793
|
-
buildCommandContext() {
|
|
849
|
+
buildCommandContext(hasUI) {
|
|
794
850
|
const sm = this.session.sessionManager;
|
|
795
851
|
return {
|
|
796
852
|
ui: this.uiContext,
|
|
797
|
-
//
|
|
853
|
+
// hasUI is decided per command (see executeCommand): true for
|
|
854
|
+
// extensions that degrade to notify(), false for custom-panel-only
|
|
855
|
+
// extensions (magic-context) so they use their text fallback.
|
|
798
856
|
mode: "print",
|
|
799
|
-
hasUI
|
|
857
|
+
hasUI,
|
|
800
858
|
cwd: sm.getCwd(),
|
|
801
859
|
sessionManager: sm,
|
|
802
860
|
modelRegistry: new ModelRegistry(this.session.modelRuntime),
|
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
|
// ------------------------------------------------------------------
|
|
@@ -1915,8 +1930,9 @@ class PiWebClient {
|
|
|
1915
1930
|
|
|
1916
1931
|
openExtensionNotify(req) {
|
|
1917
1932
|
// Command output notifications (title starts with "/", e.g. /ctx-status) keep
|
|
1918
|
-
// the modal. Other extension notifies are
|
|
1919
|
-
//
|
|
1933
|
+
// the modal. Other extension notifies are appended to the chat stream as
|
|
1934
|
+
// persistent status lines, matching TUI's notify -> showStatus (a dim text
|
|
1935
|
+
// appended to the chat container; NOT a transient toast).
|
|
1920
1936
|
if (req.title && String(req.title).startsWith('/')) {
|
|
1921
1937
|
this.openModal(req.title || 'Notification', 'extension-notify');
|
|
1922
1938
|
this.currentExtRequest = req;
|
|
@@ -1924,7 +1940,15 @@ class PiWebClient {
|
|
|
1924
1940
|
this.modalList.innerHTML = `<div class="modal-message body-text">${this.renderMarkdown(req.message || '')}</div>`;
|
|
1925
1941
|
return;
|
|
1926
1942
|
}
|
|
1927
|
-
this.
|
|
1943
|
+
this.appendNotifyLine(req.message || '', req.notifyType);
|
|
1944
|
+
}
|
|
1945
|
+
|
|
1946
|
+
appendNotifyLine(message, type) {
|
|
1947
|
+
const div = document.createElement('div');
|
|
1948
|
+
div.className = 'notify-line' + (type ? ` notify-${type}` : '');
|
|
1949
|
+
div.innerHTML = ansiToHtml(String(message));
|
|
1950
|
+
this.contentEl.appendChild(div);
|
|
1951
|
+
this.scrollToBottom();
|
|
1928
1952
|
}
|
|
1929
1953
|
|
|
1930
1954
|
showToast(message, type) {
|
|
@@ -1941,6 +1965,279 @@ class PiWebClient {
|
|
|
1941
1965
|
}, type === 'error' ? 8000 : 5000);
|
|
1942
1966
|
}
|
|
1943
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
|
+
this.usageData = data;
|
|
1976
|
+
this.usageTab = 'thisWeek';
|
|
1977
|
+
this.usageView = 'table';
|
|
1978
|
+
this.usageExpanded = new Set();
|
|
1979
|
+
const overlay = document.getElementById('usage-overlay');
|
|
1980
|
+
if (!overlay) return;
|
|
1981
|
+
overlay.style.display = 'flex';
|
|
1982
|
+
this.renderUsageTabs();
|
|
1983
|
+
this.renderUsageBody();
|
|
1984
|
+
}
|
|
1985
|
+
|
|
1986
|
+
closeUsagePanel() {
|
|
1987
|
+
const overlay = document.getElementById('usage-overlay');
|
|
1988
|
+
if (overlay) overlay.style.display = 'none';
|
|
1989
|
+
}
|
|
1990
|
+
|
|
1991
|
+
renderUsageTabs() {
|
|
1992
|
+
const tabsEl = document.getElementById('usage-tabs');
|
|
1993
|
+
if (!tabsEl) return;
|
|
1994
|
+
const labels = {
|
|
1995
|
+
today: 'Today',
|
|
1996
|
+
thisWeek: 'This Week',
|
|
1997
|
+
lastWeek: 'Last Week',
|
|
1998
|
+
last30Days: 'Last 30 Days',
|
|
1999
|
+
allTime: 'All Time',
|
|
2000
|
+
};
|
|
2001
|
+
tabsEl.innerHTML = '';
|
|
2002
|
+
for (const key of Object.keys(labels)) {
|
|
2003
|
+
const tab = document.createElement('span');
|
|
2004
|
+
tab.className = 'usage-tab' + (key === this.usageTab ? ' active' : '');
|
|
2005
|
+
tab.textContent = labels[key];
|
|
2006
|
+
tab.addEventListener('click', () => {
|
|
2007
|
+
this.usageTab = key;
|
|
2008
|
+
this.usageExpanded.clear();
|
|
2009
|
+
this.renderUsageTabs();
|
|
2010
|
+
this.renderUsageBody();
|
|
2011
|
+
});
|
|
2012
|
+
tabsEl.appendChild(tab);
|
|
2013
|
+
}
|
|
2014
|
+
// View switcher: Table / Insights / Graph
|
|
2015
|
+
const viewsEl = document.getElementById('usage-views');
|
|
2016
|
+
if (viewsEl) {
|
|
2017
|
+
viewsEl.innerHTML = '';
|
|
2018
|
+
for (const [key, label] of [['table', 'Table'], ['insights', 'Insights'], ['graph', 'Graph']]) {
|
|
2019
|
+
const v = document.createElement('span');
|
|
2020
|
+
v.className = 'usage-view' + (key === this.usageView ? ' active' : '');
|
|
2021
|
+
v.textContent = label;
|
|
2022
|
+
v.addEventListener('click', () => {
|
|
2023
|
+
this.usageView = key;
|
|
2024
|
+
this.renderUsageTabs();
|
|
2025
|
+
this.renderUsageBody();
|
|
2026
|
+
});
|
|
2027
|
+
viewsEl.appendChild(v);
|
|
2028
|
+
}
|
|
2029
|
+
}
|
|
2030
|
+
}
|
|
2031
|
+
|
|
2032
|
+
fmtTokens(n) {
|
|
2033
|
+
if (n >= 1e9) return (n / 1e9).toFixed(2) + 'B';
|
|
2034
|
+
if (n >= 1e6) return (n / 1e6).toFixed(2) + 'M';
|
|
2035
|
+
if (n >= 1e3) return (n / 1e3).toFixed(1) + 'K';
|
|
2036
|
+
return String(Math.round(n));
|
|
2037
|
+
}
|
|
2038
|
+
|
|
2039
|
+
fmtCost(c) {
|
|
2040
|
+
if (!c || c <= 0) return '-';
|
|
2041
|
+
if (c < 0.01) return '$' + c.toFixed(4);
|
|
2042
|
+
if (c < 10) return '$' + c.toFixed(2);
|
|
2043
|
+
return '$' + c.toFixed(1);
|
|
2044
|
+
}
|
|
2045
|
+
|
|
2046
|
+
usageRow(stat) {
|
|
2047
|
+
const t = stat.tokens || {};
|
|
2048
|
+
return [
|
|
2049
|
+
this.fmtTokens(t.input),
|
|
2050
|
+
this.fmtTokens(t.output),
|
|
2051
|
+
this.fmtTokens((t.cacheRead || 0) + (t.cacheWrite || 0)),
|
|
2052
|
+
this.fmtTokens((t.total === undefined ? (t.input || 0) + (t.output || 0) + (t.cacheRead || 0) + (t.cacheWrite || 0) : t.total)),
|
|
2053
|
+
this.fmtCost(stat.cost),
|
|
2054
|
+
String(stat.messages),
|
|
2055
|
+
String(stat.sessions),
|
|
2056
|
+
];
|
|
2057
|
+
}
|
|
2058
|
+
|
|
2059
|
+
renderUsageBody() {
|
|
2060
|
+
const body = document.getElementById('usage-body');
|
|
2061
|
+
if (!body) return;
|
|
2062
|
+
const period = this.usageData.tabs[this.usageTab];
|
|
2063
|
+
if (!period) return;
|
|
2064
|
+
body.innerHTML = '';
|
|
2065
|
+
body.scrollTop = 0;
|
|
2066
|
+
|
|
2067
|
+
if (this.usageView === 'table') {
|
|
2068
|
+
const table = document.createElement('table');
|
|
2069
|
+
table.className = 'usage-table';
|
|
2070
|
+
const head = document.createElement('thead');
|
|
2071
|
+
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>';
|
|
2072
|
+
table.appendChild(head);
|
|
2073
|
+
const tbody = document.createElement('tbody');
|
|
2074
|
+
|
|
2075
|
+
const rowFor = (name, stat, isModel, isTotals) => {
|
|
2076
|
+
const tr = document.createElement('tr');
|
|
2077
|
+
if (isTotals) tr.className = 'usage-totals';
|
|
2078
|
+
else if (isModel) tr.className = 'usage-model-row';
|
|
2079
|
+
else tr.className = 'usage-provider-row';
|
|
2080
|
+
const nameTd = document.createElement('td');
|
|
2081
|
+
nameTd.className = 'usage-name';
|
|
2082
|
+
nameTd.textContent = name;
|
|
2083
|
+
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)];
|
|
2084
|
+
tr.appendChild(nameTd);
|
|
2085
|
+
for (const cell of [msgs, cost, tokens, input, output, cache, sessions]) {
|
|
2086
|
+
const td = document.createElement('td');
|
|
2087
|
+
td.textContent = cell;
|
|
2088
|
+
td.className = 'usage-num';
|
|
2089
|
+
tr.appendChild(td);
|
|
2090
|
+
}
|
|
2091
|
+
return tr;
|
|
2092
|
+
};
|
|
2093
|
+
|
|
2094
|
+
for (const p of period.providers) {
|
|
2095
|
+
const key = 'p:' + p.name;
|
|
2096
|
+
const expanded = this.usageExpanded.has(key);
|
|
2097
|
+
const tr = rowFor(p.name, p, false, false);
|
|
2098
|
+
tr.classList.add('usage-clickable');
|
|
2099
|
+
tr.addEventListener('click', () => {
|
|
2100
|
+
if (this.usageExpanded.has(key)) this.usageExpanded.delete(key);
|
|
2101
|
+
else this.usageExpanded.add(key);
|
|
2102
|
+
this.renderUsageBody();
|
|
2103
|
+
});
|
|
2104
|
+
tbody.appendChild(tr);
|
|
2105
|
+
if (expanded) {
|
|
2106
|
+
for (const m of p.models || []) {
|
|
2107
|
+
tbody.appendChild(rowFor(' └ ' + m.name, m, true, false));
|
|
2108
|
+
}
|
|
2109
|
+
}
|
|
2110
|
+
}
|
|
2111
|
+
const totals = period.totals;
|
|
2112
|
+
tbody.appendChild(rowFor('Total', totals, false, true));
|
|
2113
|
+
table.appendChild(tbody);
|
|
2114
|
+
body.appendChild(table);
|
|
2115
|
+
} else if (this.usageView === 'insights') {
|
|
2116
|
+
const list = document.createElement('div');
|
|
2117
|
+
list.className = 'usage-insights';
|
|
2118
|
+
const ins = period.insights || [];
|
|
2119
|
+
if (ins.length === 0) {
|
|
2120
|
+
list.innerHTML = '<div class="usage-insight">No insights for this period.</div>';
|
|
2121
|
+
}
|
|
2122
|
+
for (const i of ins) {
|
|
2123
|
+
const div = document.createElement('div');
|
|
2124
|
+
div.className = 'usage-insight ' + (i.kind === 'alarm' ? 'alarm' : 'structure');
|
|
2125
|
+
const stat = document.createElement('span');
|
|
2126
|
+
stat.className = 'usage-insight-stat';
|
|
2127
|
+
stat.textContent = i.stat;
|
|
2128
|
+
const text = document.createElement('span');
|
|
2129
|
+
text.textContent = i.headline;
|
|
2130
|
+
div.appendChild(stat);
|
|
2131
|
+
div.appendChild(text);
|
|
2132
|
+
if (i.advice) {
|
|
2133
|
+
const advice = document.createElement('div');
|
|
2134
|
+
advice.className = 'usage-insight-advice';
|
|
2135
|
+
advice.textContent = i.advice;
|
|
2136
|
+
div.appendChild(advice);
|
|
2137
|
+
}
|
|
2138
|
+
list.appendChild(div);
|
|
2139
|
+
}
|
|
2140
|
+
body.appendChild(list);
|
|
2141
|
+
} else if (this.usageView === 'graph') {
|
|
2142
|
+
// Simple CSS bar chart: cost per provider (this period) + hourly tokens trend
|
|
2143
|
+
const container = document.createElement('div');
|
|
2144
|
+
container.className = 'usage-graph';
|
|
2145
|
+
if (period.providers.length === 0) {
|
|
2146
|
+
container.innerHTML = '<div class="usage-insight">No data for this period.</div>';
|
|
2147
|
+
body.appendChild(container);
|
|
2148
|
+
return;
|
|
2149
|
+
}
|
|
2150
|
+
const maxCost = Math.max(...period.providers.map((p) => p.cost), 1e-9);
|
|
2151
|
+
let html = '<div class="usage-graph-title">Cost by provider</div><div class="usage-bars">';
|
|
2152
|
+
for (const p of period.providers) {
|
|
2153
|
+
const w = ((p.cost / maxCost) * 100).toFixed(1);
|
|
2154
|
+
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>`;
|
|
2155
|
+
}
|
|
2156
|
+
html += '</div>';
|
|
2157
|
+
// Hourly tokens trend for the current tab. The server provides one
|
|
2158
|
+
// global hourly series + per-tab windows; filtering (per tab) and
|
|
2159
|
+
// gap-filling are render-side concerns.
|
|
2160
|
+
const hourly = this.usageData.hourly ? this.usageData.hourly : null;
|
|
2161
|
+
const window = (this.usageData.tabWindow || {})[this.usageTab] || null;
|
|
2162
|
+
if (hourly && hourly.length > 0) {
|
|
2163
|
+
// Filter to the tab's window, then fill the x-axis with a
|
|
2164
|
+
// continuous hour sequence so idle hours appear as empty columns.
|
|
2165
|
+
// Axis start rule: if the earliest data predates the period start,
|
|
2166
|
+
// show the whole period (zeros included); if data only exists
|
|
2167
|
+
// inside the period, start from the first hour with data (e.g.
|
|
2168
|
+
// allTime never starts at epoch - it starts at first usage).
|
|
2169
|
+
let seq = hourly;
|
|
2170
|
+
if (window && window[1] > window[0]) {
|
|
2171
|
+
const H = 3600_000;
|
|
2172
|
+
const inWindow = hourly.filter((b) => b.hour >= window[0] && b.hour < window[1]);
|
|
2173
|
+
const periodStartHour = Math.floor(window[0] / H) * H;
|
|
2174
|
+
// Rule: compare the GLOBAL earliest data hour with the period
|
|
2175
|
+
// start. If data predates the period (global first < period
|
|
2176
|
+
// start), show the whole period (zeros included). Only when the
|
|
2177
|
+
// very first usage ever is inside this period do we start from
|
|
2178
|
+
// the first data hour (e.g. allTime starts at first usage, not
|
|
2179
|
+
// epoch).
|
|
2180
|
+
const globalFirstHour = hourly[0].hour; // hourly is time-sorted
|
|
2181
|
+
const startHour = globalFirstHour > periodStartHour ? globalFirstHour : periodStartHour;
|
|
2182
|
+
const endHour = Math.floor(window[1] / H) * H;
|
|
2183
|
+
const byHour = new Map(inWindow.map((b) => [b.hour, b]));
|
|
2184
|
+
seq = [];
|
|
2185
|
+
for (let h = startHour; h <= endHour; h += H) {
|
|
2186
|
+
const b = byHour.get(h);
|
|
2187
|
+
seq.push(b ? { ...b } : { hour: h, cost: 0, tokens: 0, messages: 0 });
|
|
2188
|
+
}
|
|
2189
|
+
}
|
|
2190
|
+
// Cap the column count (~120) so very long windows (allTime) stay
|
|
2191
|
+
// manageable; the hourly chart scrolls horizontally to show idle
|
|
2192
|
+
// hours, so a generous count is fine.
|
|
2193
|
+
let buckets = seq;
|
|
2194
|
+
const maxCols = 120;
|
|
2195
|
+
if (buckets.length > maxCols) {
|
|
2196
|
+
const step = Math.ceil(buckets.length / maxCols);
|
|
2197
|
+
const agg = [];
|
|
2198
|
+
for (let i = 0; i < buckets.length; i += step) {
|
|
2199
|
+
const slice = buckets.slice(i, Math.min(i + step, buckets.length));
|
|
2200
|
+
const start = slice[0].hour;
|
|
2201
|
+
const end = slice[slice.length - 1].hour + 3600_000;
|
|
2202
|
+
agg.push({
|
|
2203
|
+
hour: start,
|
|
2204
|
+
tokens: slice.reduce((s, b) => s + b.tokens, 0),
|
|
2205
|
+
cost: slice.reduce((s, b) => s + b.cost, 0),
|
|
2206
|
+
messages: slice.reduce((s, b) => s + b.messages, 0),
|
|
2207
|
+
spanEnd: end,
|
|
2208
|
+
spanCount: slice.length,
|
|
2209
|
+
});
|
|
2210
|
+
}
|
|
2211
|
+
buckets = agg;
|
|
2212
|
+
}
|
|
2213
|
+
const maxT = Math.max(...buckets.map((b) => b.tokens), 1e-9);
|
|
2214
|
+
// Fixed-height bars (px) - avoids percentage-height resolution
|
|
2215
|
+
// issues in nested flex containers.
|
|
2216
|
+
const BAR_MAX_PX = 96;
|
|
2217
|
+
// Label every ~6th column with its time (sparse to avoid crowding).
|
|
2218
|
+
const labelStep = Math.ceil(buckets.length / 8);
|
|
2219
|
+
html += '<div class="usage-graph-title" style="margin-top:14px">Hourly tokens (recent)</div><div class="usage-hourly">';
|
|
2220
|
+
for (let i = 0; i < buckets.length; i++) {
|
|
2221
|
+
const b = buckets[i];
|
|
2222
|
+
const hPx = b.tokens > 0 ? Math.max(2, Math.round((b.tokens / maxT) * BAR_MAX_PX)) : 0;
|
|
2223
|
+
const d = new Date(b.hour);
|
|
2224
|
+
const spanText = b.spanCount ? ` (${b.spanCount}h)` : '';
|
|
2225
|
+
const showLabel = i % labelStep === 0 || i === buckets.length - 1;
|
|
2226
|
+
const label = showLabel
|
|
2227
|
+
? d.toLocaleDateString(undefined, { month: 'numeric', day: 'numeric' }) + '\n' + d.toLocaleTimeString(undefined, { hour: '2-digit' })
|
|
2228
|
+
: '';
|
|
2229
|
+
// Day/night banding: hour 6..17 (local) = day, else night
|
|
2230
|
+
const hourOfDay = d.getHours();
|
|
2231
|
+
const band = hourOfDay >= 6 && hourOfDay < 18 ? 'day' : 'night';
|
|
2232
|
+
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>`;
|
|
2233
|
+
}
|
|
2234
|
+
html += '</div>';
|
|
2235
|
+
}
|
|
2236
|
+
container.innerHTML = html;
|
|
2237
|
+
body.appendChild(container);
|
|
2238
|
+
}
|
|
2239
|
+
}
|
|
2240
|
+
|
|
1944
2241
|
handleModels(models) {
|
|
1945
2242
|
if (this.modalMode !== 'model') return;
|
|
1946
2243
|
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;
|
|
@@ -907,6 +907,7 @@ body {
|
|
|
907
907
|
color: var(--text);
|
|
908
908
|
font-size: 13px;
|
|
909
909
|
line-height: 1.5;
|
|
910
|
+
white-space: pre-wrap;
|
|
910
911
|
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
|
|
911
912
|
opacity: 1;
|
|
912
913
|
transition: opacity 0.3s;
|
|
@@ -920,6 +921,27 @@ body {
|
|
|
920
921
|
border-left-color: var(--error);
|
|
921
922
|
}
|
|
922
923
|
|
|
924
|
+
/* Extension notify lines appended to the chat stream (TUI showStatus
|
|
925
|
+
equivalent): persistent dim status text, colored by type. */
|
|
926
|
+
.notify-line {
|
|
927
|
+
color: var(--dim);
|
|
928
|
+
font-size: 12px;
|
|
929
|
+
padding: 2px 12px;
|
|
930
|
+
white-space: pre-wrap;
|
|
931
|
+
overflow-wrap: anywhere;
|
|
932
|
+
border-left: 3px solid transparent;
|
|
933
|
+
}
|
|
934
|
+
|
|
935
|
+
.notify-warning {
|
|
936
|
+
color: var(--warning);
|
|
937
|
+
border-left-color: var(--warning);
|
|
938
|
+
}
|
|
939
|
+
|
|
940
|
+
.notify-error {
|
|
941
|
+
color: var(--error);
|
|
942
|
+
border-left-color: var(--error);
|
|
943
|
+
}
|
|
944
|
+
|
|
923
945
|
.toast-hide {
|
|
924
946
|
opacity: 0;
|
|
925
947
|
}
|
|
@@ -1159,3 +1181,297 @@ body {
|
|
|
1159
1181
|
#abort-btn:active {
|
|
1160
1182
|
opacity: 0.8;
|
|
1161
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
|
@@ -199,6 +199,55 @@ export class WebUIContext {
|
|
|
199
199
|
setFooter() { }
|
|
200
200
|
setHeader() { }
|
|
201
201
|
custom() {
|
|
202
|
+
// Pi's custom() shows an extension-drawn TUI component. Web has no TUI
|
|
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
|
+
}
|
|
202
251
|
return Promise.resolve(undefined);
|
|
203
252
|
}
|
|
204
253
|
pasteToEditor() { }
|
|
@@ -0,0 +1,410 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Usage extension integration (independent module).
|
|
3
|
+
*
|
|
4
|
+
* pi-usage-extension's `/usage` command draws a TUI dashboard via
|
|
5
|
+
* ctx.ui.custom() which Web cannot render. However, running the command
|
|
6
|
+
* (through WebUIContext.custom() which now executes the factory) makes the
|
|
7
|
+
* extension collect usage data and write its cache file
|
|
8
|
+
* (`~/.pi/agent/usage-extension-cache.json`). This module reads that cache
|
|
9
|
+
* after the command runs and renders an equivalent summary as Markdown,
|
|
10
|
+
* shown as a titled modal - without coupling into the main server flow.
|
|
11
|
+
*
|
|
12
|
+
* Cache format (v7): { version, names: string[], files: { [sessionFile]: {
|
|
13
|
+
* size, mtimeMs, sessionId, cwd, parentSession,
|
|
14
|
+
* messages: 13-tuple[][], toolUsages: 5-tuple[][] } } }
|
|
15
|
+
* message tuple: [provider, model, cost, inputTokens, outputTokens,
|
|
16
|
+
* cacheReadTokens, cacheWriteTokens, timestampMs, thinkingLevel,
|
|
17
|
+
* reasoningTokens, afterCompaction(0|1), source(0 assistant|1 auxiliary),
|
|
18
|
+
* sourceId]
|
|
19
|
+
*/
|
|
20
|
+
import { existsSync, readFileSync, statSync } from "node:fs";
|
|
21
|
+
import { join } from "node:path";
|
|
22
|
+
import { homedir } from "node:os";
|
|
23
|
+
export const USAGE_CACHE_FILE = "usage-extension-cache.json";
|
|
24
|
+
const USAGE_CACHE_DIRS = [
|
|
25
|
+
join(process.env.PI_HOME?.replace(/^~/, homedir()) ?? homedir(), ".pi", "agent"),
|
|
26
|
+
join(homedir(), ".pi", "agent"),
|
|
27
|
+
];
|
|
28
|
+
function findCachePath() {
|
|
29
|
+
for (const dir of USAGE_CACHE_DIRS) {
|
|
30
|
+
const p = join(dir, USAGE_CACHE_FILE);
|
|
31
|
+
if (existsSync(p))
|
|
32
|
+
return p;
|
|
33
|
+
}
|
|
34
|
+
return null;
|
|
35
|
+
}
|
|
36
|
+
/** mtime of the usage cache file, or null when absent. */
|
|
37
|
+
export function usageCacheMtime() {
|
|
38
|
+
const path = findCachePath();
|
|
39
|
+
if (!path)
|
|
40
|
+
return null;
|
|
41
|
+
try {
|
|
42
|
+
return statSync(path).mtimeMs;
|
|
43
|
+
}
|
|
44
|
+
catch {
|
|
45
|
+
return null;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
/** Read and normalize the usage cache; null when absent/unreadable. */
|
|
49
|
+
export function loadUsageCache() {
|
|
50
|
+
const path = findCachePath();
|
|
51
|
+
if (!path)
|
|
52
|
+
return null;
|
|
53
|
+
try {
|
|
54
|
+
const raw = JSON.parse(readFileSync(path, "utf8"));
|
|
55
|
+
const names = raw.names ?? [];
|
|
56
|
+
const files = [];
|
|
57
|
+
for (const file of Object.values(raw.files ?? {})) {
|
|
58
|
+
const f = file;
|
|
59
|
+
const messages = [];
|
|
60
|
+
if (Array.isArray(f.messages)) {
|
|
61
|
+
for (const tuple of f.messages) {
|
|
62
|
+
if (!Array.isArray(tuple) || tuple.length !== 13)
|
|
63
|
+
continue;
|
|
64
|
+
const provider = names[tuple[0]];
|
|
65
|
+
const model = names[tuple[1]];
|
|
66
|
+
const thinkingLevel = names[tuple[8]];
|
|
67
|
+
if (typeof provider !== "string" || typeof model !== "string")
|
|
68
|
+
continue;
|
|
69
|
+
messages.push({
|
|
70
|
+
provider,
|
|
71
|
+
model,
|
|
72
|
+
thinkingLevel,
|
|
73
|
+
cost: Number(tuple[2]) || 0,
|
|
74
|
+
input: Number(tuple[3]) || 0,
|
|
75
|
+
output: Number(tuple[4]) || 0,
|
|
76
|
+
cacheRead: Number(tuple[5]) || 0,
|
|
77
|
+
cacheWrite: Number(tuple[6]) || 0,
|
|
78
|
+
timestamp: Number(tuple[7]) || 0,
|
|
79
|
+
reasoning: Number(tuple[9]) || 0,
|
|
80
|
+
afterCompaction: tuple[10] === 1,
|
|
81
|
+
source: tuple[11] === 1 ? "auxiliary" : "assistant",
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
files.push({
|
|
86
|
+
messages,
|
|
87
|
+
sessionId: f.sessionId ?? "",
|
|
88
|
+
cwd: f.cwd ?? "",
|
|
89
|
+
parentSession: f.parentSession ?? "",
|
|
90
|
+
});
|
|
91
|
+
}
|
|
92
|
+
return { files, names };
|
|
93
|
+
}
|
|
94
|
+
catch {
|
|
95
|
+
return null;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
function startOfDay(ts) {
|
|
99
|
+
const d = new Date(ts);
|
|
100
|
+
d.setHours(0, 0, 0, 0);
|
|
101
|
+
return d.getTime();
|
|
102
|
+
}
|
|
103
|
+
function startOfWeek(ts) {
|
|
104
|
+
const d = new Date(ts);
|
|
105
|
+
const day = d.getDay() === 0 ? 6 : d.getDay() - 1;
|
|
106
|
+
d.setHours(0, 0, 0, 0);
|
|
107
|
+
d.setDate(d.getDate() - day);
|
|
108
|
+
return d.getTime();
|
|
109
|
+
}
|
|
110
|
+
function emptyTotals() {
|
|
111
|
+
return { cost: 0, input: 0, output: 0, cacheRead: 0, cacheWrite: 0, messages: 0, sessions: new Set() };
|
|
112
|
+
}
|
|
113
|
+
function fmt(n) {
|
|
114
|
+
return n >= 1e9
|
|
115
|
+
? `${(n / 1e9).toFixed(2)}B`
|
|
116
|
+
: n >= 1e6
|
|
117
|
+
? `${(n / 1e6).toFixed(2)}M`
|
|
118
|
+
: n >= 1e3
|
|
119
|
+
? `${(n / 1e3).toFixed(1)}K`
|
|
120
|
+
: `${Math.round(n)}`;
|
|
121
|
+
}
|
|
122
|
+
function fmtCost(n) {
|
|
123
|
+
return `$${n.toFixed(n >= 1 ? 2 : 4)}`;
|
|
124
|
+
}
|
|
125
|
+
/** Render a Markdown usage summary (today / this week / all time). */
|
|
126
|
+
export function renderUsageSummary() {
|
|
127
|
+
const cache = loadUsageCache();
|
|
128
|
+
if (!cache) {
|
|
129
|
+
return "## Usage\n\nNo usage data found. Run `/usage` in the TUI first to build the usage cache.";
|
|
130
|
+
}
|
|
131
|
+
const now = Date.now();
|
|
132
|
+
const todayStart = startOfDay(now);
|
|
133
|
+
const weekStart = startOfWeek(now);
|
|
134
|
+
const today = emptyTotals();
|
|
135
|
+
const week = emptyTotals();
|
|
136
|
+
const all = emptyTotals();
|
|
137
|
+
const byModel = new Map();
|
|
138
|
+
for (const file of cache.files) {
|
|
139
|
+
for (const m of file.messages) {
|
|
140
|
+
const t = m.timestamp;
|
|
141
|
+
const targets = [all];
|
|
142
|
+
if (t >= todayStart)
|
|
143
|
+
targets.push(today);
|
|
144
|
+
if (t >= weekStart)
|
|
145
|
+
targets.push(week);
|
|
146
|
+
for (const target of targets) {
|
|
147
|
+
target.cost += m.cost;
|
|
148
|
+
target.input += m.input;
|
|
149
|
+
target.output += m.output;
|
|
150
|
+
target.cacheRead += m.cacheRead;
|
|
151
|
+
target.cacheWrite += m.cacheWrite;
|
|
152
|
+
target.messages += 1;
|
|
153
|
+
target.sessions.add(file.sessionId);
|
|
154
|
+
}
|
|
155
|
+
const key = `${m.provider}/${m.model}`;
|
|
156
|
+
const byModelEntry = byModel.get(key) ?? emptyTotals();
|
|
157
|
+
byModelEntry.cost += m.cost;
|
|
158
|
+
byModelEntry.input += m.input;
|
|
159
|
+
byModelEntry.output += m.output;
|
|
160
|
+
byModelEntry.cacheRead += m.cacheRead;
|
|
161
|
+
byModelEntry.cacheWrite += m.cacheWrite;
|
|
162
|
+
byModelEntry.messages += 1;
|
|
163
|
+
byModel.set(key, byModelEntry);
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
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} |`;
|
|
167
|
+
const lines = [
|
|
168
|
+
"## Usage",
|
|
169
|
+
"",
|
|
170
|
+
"| Period | Input | Output | Cache R | Cache W | Cost | Msgs | Sessions |",
|
|
171
|
+
"| --- | --- | --- | --- | --- | --- | --- | --- |",
|
|
172
|
+
row("Today", today),
|
|
173
|
+
row("This week", week),
|
|
174
|
+
row("All time", all),
|
|
175
|
+
];
|
|
176
|
+
if (byModel.size > 0) {
|
|
177
|
+
const sorted = [...byModel.entries()].sort((a, b) => b[1].cost - a[1].cost).slice(0, 10);
|
|
178
|
+
lines.push("", "### By model (all time)", "", "| Model | Input | Output | Cost | Msgs |", "| --- | --- | --- | --- | --- |");
|
|
179
|
+
for (const [key, t] of sorted) {
|
|
180
|
+
lines.push(`| ${key} | ${fmt(t.input)} | ${fmt(t.output)} | $${t.cost.toFixed(4)} | ${t.messages} |`);
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
return lines.join("\n");
|
|
184
|
+
}
|
|
185
|
+
const TAB_KEYS = ["today", "thisWeek", "lastWeek", "last30Days", "allTime"];
|
|
186
|
+
function emptyTokens() {
|
|
187
|
+
return { total: 0, input: 0, output: 0, cacheRead: 0, cacheWrite: 0 };
|
|
188
|
+
}
|
|
189
|
+
function addTokens(a, b) {
|
|
190
|
+
a.total += b.total;
|
|
191
|
+
a.input += b.input;
|
|
192
|
+
a.output += b.output;
|
|
193
|
+
a.cacheRead += b.cacheRead;
|
|
194
|
+
a.cacheWrite += b.cacheWrite;
|
|
195
|
+
}
|
|
196
|
+
/** Format a cost value the way the extension does (0 -> "-"). */
|
|
197
|
+
export function formatUsageCost(cost) {
|
|
198
|
+
if (cost === 0)
|
|
199
|
+
return "-";
|
|
200
|
+
if (cost < 0.01)
|
|
201
|
+
return `$${cost.toFixed(4)}`;
|
|
202
|
+
if (cost < 1)
|
|
203
|
+
return `$${cost.toFixed(2)}`;
|
|
204
|
+
if (cost < 10)
|
|
205
|
+
return `$${cost.toFixed(2)}`;
|
|
206
|
+
if (cost < 100)
|
|
207
|
+
return `$${cost.toFixed(1)}`;
|
|
208
|
+
return `$${Math.round(cost)}`;
|
|
209
|
+
}
|
|
210
|
+
/** Format a token count compactly (12.3K, 4.5M, ...). */
|
|
211
|
+
export function formatUsageTokens(n) {
|
|
212
|
+
if (n >= 1e9)
|
|
213
|
+
return `${(n / 1e9).toFixed(2)}B`;
|
|
214
|
+
if (n >= 1e6)
|
|
215
|
+
return `${(n / 1e6).toFixed(2)}M`;
|
|
216
|
+
if (n >= 1e3)
|
|
217
|
+
return `${(n / 1e3).toFixed(1)}K`;
|
|
218
|
+
return `${Math.round(n)}`;
|
|
219
|
+
}
|
|
220
|
+
function periodStart(key, now) {
|
|
221
|
+
const d = new Date(now);
|
|
222
|
+
d.setHours(0, 0, 0, 0);
|
|
223
|
+
if (key === "today")
|
|
224
|
+
return d.getTime();
|
|
225
|
+
const day = d.getDay() === 0 ? 6 : d.getDay() - 1; // Monday start
|
|
226
|
+
if (key === "thisWeek") {
|
|
227
|
+
d.setDate(d.getDate() - day);
|
|
228
|
+
return d.getTime();
|
|
229
|
+
}
|
|
230
|
+
if (key === "lastWeek") {
|
|
231
|
+
d.setDate(d.getDate() - day - 7);
|
|
232
|
+
return d.getTime();
|
|
233
|
+
}
|
|
234
|
+
if (key === "last30Days") {
|
|
235
|
+
return d.getTime() - 29 * 24 * 3600 * 1000;
|
|
236
|
+
}
|
|
237
|
+
return 0; // allTime
|
|
238
|
+
}
|
|
239
|
+
/**
|
|
240
|
+
* Aggregate the cache into the structured payload the web panel renders.
|
|
241
|
+
* Returns null when no cache exists yet.
|
|
242
|
+
*/
|
|
243
|
+
export function buildUsageData() {
|
|
244
|
+
const cache = loadUsageCache();
|
|
245
|
+
if (!cache)
|
|
246
|
+
return null;
|
|
247
|
+
const now = Date.now();
|
|
248
|
+
// per-tab accumulators
|
|
249
|
+
const tabs = {};
|
|
250
|
+
for (const key of TAB_KEYS) {
|
|
251
|
+
tabs[key] = { providers: new Map(), totals: { messages: 0, cost: 0, tokens: emptyTokens(), sessions: new Set() } };
|
|
252
|
+
}
|
|
253
|
+
// hourly buckets (all providers, global) - the render side filters per tab
|
|
254
|
+
const hourly = new Map();
|
|
255
|
+
const hourStart = (ts) => Math.floor(ts / 3600_000) * 3600_000;
|
|
256
|
+
for (const file of cache.files) {
|
|
257
|
+
for (const m of file.messages) {
|
|
258
|
+
const ts = m.timestamp;
|
|
259
|
+
{
|
|
260
|
+
const bucket = hourly.get(hourStart(ts));
|
|
261
|
+
if (bucket) {
|
|
262
|
+
bucket.cost += m.cost;
|
|
263
|
+
bucket.tokens += m.input + m.output + m.cacheRead + m.cacheWrite;
|
|
264
|
+
bucket.messages += 1;
|
|
265
|
+
}
|
|
266
|
+
else {
|
|
267
|
+
hourly.set(hourStart(ts), { cost: m.cost, tokens: m.input + m.output + m.cacheRead + m.cacheWrite, messages: 1 });
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
for (const key of TAB_KEYS) {
|
|
271
|
+
const start = periodStart(key, now);
|
|
272
|
+
if (ts < start)
|
|
273
|
+
continue;
|
|
274
|
+
const tab = tabs[key];
|
|
275
|
+
tab.totals.messages += 1;
|
|
276
|
+
tab.totals.cost += m.cost;
|
|
277
|
+
addTokens(tab.totals.tokens, {
|
|
278
|
+
total: m.input + m.output + m.cacheRead + m.cacheWrite,
|
|
279
|
+
input: m.input,
|
|
280
|
+
output: m.output,
|
|
281
|
+
cacheRead: m.cacheRead,
|
|
282
|
+
cacheWrite: m.cacheWrite,
|
|
283
|
+
});
|
|
284
|
+
tab.totals.sessions.add(file.sessionId);
|
|
285
|
+
let prov = tab.providers.get(m.provider);
|
|
286
|
+
if (!prov) {
|
|
287
|
+
prov = {
|
|
288
|
+
messages: 0, cost: 0, tokens: emptyTokens(), sessions: new Set(), models: new Map(),
|
|
289
|
+
};
|
|
290
|
+
tab.providers.set(m.provider, prov);
|
|
291
|
+
}
|
|
292
|
+
prov.messages += 1;
|
|
293
|
+
prov.cost += m.cost;
|
|
294
|
+
addTokens(prov.tokens, {
|
|
295
|
+
total: m.input + m.output + m.cacheRead + m.cacheWrite,
|
|
296
|
+
input: m.input,
|
|
297
|
+
output: m.output,
|
|
298
|
+
cacheRead: m.cacheRead,
|
|
299
|
+
cacheWrite: m.cacheWrite,
|
|
300
|
+
});
|
|
301
|
+
prov.sessions.add(file.sessionId);
|
|
302
|
+
let model = prov.models.get(m.model);
|
|
303
|
+
if (!model) {
|
|
304
|
+
model = { messages: 0, cost: 0, tokens: emptyTokens(), sessions: new Set() };
|
|
305
|
+
prov.models.set(m.model, model);
|
|
306
|
+
}
|
|
307
|
+
model.messages += 1;
|
|
308
|
+
model.cost += m.cost;
|
|
309
|
+
addTokens(model.tokens, {
|
|
310
|
+
total: m.input + m.output + m.cacheRead + m.cacheWrite,
|
|
311
|
+
input: m.input,
|
|
312
|
+
output: m.output,
|
|
313
|
+
cacheRead: m.cacheRead,
|
|
314
|
+
cacheWrite: m.cacheWrite,
|
|
315
|
+
});
|
|
316
|
+
model.sessions.add(file.sessionId);
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
const payload = { tabs: {}, hourly: [], tabWindow: {}, collectedAt: usageCacheMtime() };
|
|
321
|
+
// Graph x-axis windows per tab: [start, end]. today: midnight->now;
|
|
322
|
+
// thisWeek: monday->now; lastWeek: monday->next Monday; last30Days:
|
|
323
|
+
// start->now; allTime: 0 -> now (frontend clips to first data).
|
|
324
|
+
for (const key of TAB_KEYS) {
|
|
325
|
+
const start = periodStart(key, now);
|
|
326
|
+
let end = now;
|
|
327
|
+
if (key === "lastWeek") {
|
|
328
|
+
const d = new Date(now);
|
|
329
|
+
d.setHours(0, 0, 0, 0);
|
|
330
|
+
const day = d.getDay() === 0 ? 6 : d.getDay() - 1;
|
|
331
|
+
d.setDate(d.getDate() - day);
|
|
332
|
+
end = d.getTime();
|
|
333
|
+
}
|
|
334
|
+
payload.tabWindow[key] = [start, end];
|
|
335
|
+
}
|
|
336
|
+
for (const key of TAB_KEYS) {
|
|
337
|
+
const tab = tabs[key];
|
|
338
|
+
const providers = [...tab.providers.entries()]
|
|
339
|
+
.sort((a, b) => b[1].cost - a[1].cost)
|
|
340
|
+
.map(([name, p]) => ({
|
|
341
|
+
name,
|
|
342
|
+
messages: p.messages,
|
|
343
|
+
cost: p.cost,
|
|
344
|
+
tokens: p.tokens,
|
|
345
|
+
sessions: p.sessions.size,
|
|
346
|
+
models: [...p.models.entries()]
|
|
347
|
+
.sort((a, b) => b[1].cost - a[1].cost)
|
|
348
|
+
.map(([mname, m]) => ({
|
|
349
|
+
name: mname,
|
|
350
|
+
messages: m.messages,
|
|
351
|
+
cost: m.cost,
|
|
352
|
+
tokens: m.tokens,
|
|
353
|
+
sessions: m.sessions.size,
|
|
354
|
+
})),
|
|
355
|
+
}));
|
|
356
|
+
// Simple insights: top provider share + largest model cost (alarms
|
|
357
|
+
// when material), plus a structure line for overall usage.
|
|
358
|
+
const insights = [];
|
|
359
|
+
const totalCost = tab.totals.cost;
|
|
360
|
+
const totalTokens = tab.totals.tokens.total;
|
|
361
|
+
if (totalCost > 0 && providers.length > 0) {
|
|
362
|
+
const top = providers[0];
|
|
363
|
+
const share = (top.cost / totalCost) * 100;
|
|
364
|
+
if (share >= 20) {
|
|
365
|
+
insights.push({
|
|
366
|
+
kind: "alarm",
|
|
367
|
+
stat: `${share.toFixed(0)}%`,
|
|
368
|
+
headline: `${top.name} drives ${share.toFixed(0)}% of cost ($${top.cost.toFixed(2)})`,
|
|
369
|
+
advice: "Consider switching cheaper models for routine work.",
|
|
370
|
+
});
|
|
371
|
+
}
|
|
372
|
+
if (top.models.length > 0) {
|
|
373
|
+
const topModel = [...top.models].sort((a, b) => b.cost - a.cost)[0];
|
|
374
|
+
if (topModel.cost / totalCost >= 0.3) {
|
|
375
|
+
insights.push({
|
|
376
|
+
kind: "alarm",
|
|
377
|
+
stat: formatUsageCost(topModel.cost),
|
|
378
|
+
headline: `${topModel.name} is the costliest model (${((topModel.cost / totalCost) * 100).toFixed(0)}% of total)`,
|
|
379
|
+
advice: "Check whether its output quality justifies the price.",
|
|
380
|
+
});
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
else if (tab.totals.messages === 0) {
|
|
385
|
+
insights.push({ kind: "structure", stat: "-", headline: "No usage recorded for this period.", advice: "" });
|
|
386
|
+
}
|
|
387
|
+
if (tab.totals.messages > 0) {
|
|
388
|
+
insights.push({
|
|
389
|
+
kind: "structure",
|
|
390
|
+
stat: formatUsageTokens(totalTokens),
|
|
391
|
+
headline: `${tab.totals.messages.toLocaleString()} messages, ${formatUsageTokens(totalTokens)} tokens total`,
|
|
392
|
+
advice: totalCost > 0 ? `Spent ${formatUsageCost(totalCost)} across ${tab.totals.sessions.size} session(s).` : "",
|
|
393
|
+
});
|
|
394
|
+
}
|
|
395
|
+
payload.tabs[key] = {
|
|
396
|
+
providers,
|
|
397
|
+
totals: {
|
|
398
|
+
messages: tab.totals.messages,
|
|
399
|
+
cost: tab.totals.cost,
|
|
400
|
+
tokens: tab.totals.tokens,
|
|
401
|
+
sessions: tab.totals.sessions.size,
|
|
402
|
+
},
|
|
403
|
+
insights,
|
|
404
|
+
};
|
|
405
|
+
}
|
|
406
|
+
payload.hourly = [...hourly.entries()]
|
|
407
|
+
.sort((a, b) => a[0] - b[0])
|
|
408
|
+
.map(([hour, v]) => ({ hour, cost: v.cost, tokens: v.tokens, messages: v.messages }));
|
|
409
|
+
return payload;
|
|
410
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-sdk-web",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.0",
|
|
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').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"
|