pi-sdk-web 0.4.11 → 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.
File without changes
package/dist/server.js CHANGED
@@ -817,6 +817,34 @@ export class PiWebServer {
817
817
  notifyType: "info",
818
818
  });
819
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
+ }
820
848
  }
821
849
  buildCommandContext(hasUI) {
822
850
  const sm = this.session.sessionManager;
@@ -355,6 +355,9 @@ class PiWebClient {
355
355
  case 'scoped_models':
356
356
  this.handleScopedModels(data.data);
357
357
  break;
358
+ case 'usage_data':
359
+ this.renderUsagePanel(data.data);
360
+ break;
358
361
  case 'sessions':
359
362
  this.handleSessions(data.data);
360
363
  break;
@@ -1705,7 +1708,19 @@ class PiWebClient {
1705
1708
  if (e.key === 'Escape' && this.modalOverlay && this.modalOverlay.style.display !== 'none') {
1706
1709
  this.closeModal();
1707
1710
  }
1711
+ const usageOverlay = document.getElementById('usage-overlay');
1712
+ if (e.key === 'Escape' && usageOverlay && usageOverlay.style.display !== 'none') {
1713
+ this.closeUsagePanel();
1714
+ }
1708
1715
  });
1716
+ const usageClose = document.getElementById('usage-close');
1717
+ if (usageClose) usageClose.addEventListener('click', () => this.closeUsagePanel());
1718
+ const usageOverlayEl = document.getElementById('usage-overlay');
1719
+ if (usageOverlayEl) {
1720
+ usageOverlayEl.addEventListener('click', (e) => {
1721
+ if (e.target === usageOverlayEl) this.closeUsagePanel();
1722
+ });
1723
+ }
1709
1724
  }
1710
1725
 
1711
1726
  // ------------------------------------------------------------------
@@ -1950,6 +1965,279 @@ 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
+ 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
+
1953
2241
  handleModels(models) {
1954
2242
  if (this.modalMode !== 'model') return;
1955
2243
  const items = (models || []).map((m) => ({
@@ -55,6 +55,15 @@
55
55
  </div>
56
56
  </div>
57
57
  <div id="toast-container"></div>
58
+ <div id="usage-overlay" style="display:none">
59
+ <div id="usage-panel">
60
+ <div id="usage-title">Usage</div>
61
+ <div id="usage-tabs"></div>
62
+ <div id="usage-views"></div>
63
+ <div id="usage-body"></div>
64
+ <div id="usage-close">Close (Esc)</div>
65
+ </div>
66
+ </div>
58
67
  <script src="vendor/marked.min.js"></script>
59
68
  <script src="app.js"></script>
60
69
  </body>
@@ -785,8 +785,8 @@ body {
785
785
  background: var(--code-bg);
786
786
  border: 1px solid var(--border);
787
787
  border-radius: 8px;
788
- width: 480px;
789
- max-width: 90vw;
788
+ width: min(720px, 92vw);
789
+ max-width: none;
790
790
  max-height: 70vh;
791
791
  display: flex;
792
792
  flex-direction: column;
@@ -1181,3 +1181,297 @@ body {
1181
1181
  #abort-btn:active {
1182
1182
  opacity: 0.8;
1183
1183
  }
1184
+
1185
+ /* ======================================================================
1186
+ Usage panel (dedicated large panel for /usage)
1187
+ ====================================================================== */
1188
+ #usage-overlay {
1189
+ position: fixed;
1190
+ inset: 0;
1191
+ background: rgba(0, 0, 0, 0.6);
1192
+ display: none;
1193
+ align-items: center;
1194
+ justify-content: center;
1195
+ z-index: 150;
1196
+ }
1197
+
1198
+ #usage-panel {
1199
+ background: var(--modal-bg);
1200
+ border: 1px solid var(--border);
1201
+ border-radius: 10px;
1202
+ width: min(1100px, 94vw);
1203
+ height: min(85vh, 800px);
1204
+ display: flex;
1205
+ flex-direction: column;
1206
+ overflow: hidden;
1207
+ }
1208
+
1209
+ #usage-title {
1210
+ padding: 10px 16px;
1211
+ font-weight: bold;
1212
+ font-size: 15px;
1213
+ border-bottom: 1px solid var(--border);
1214
+ display: flex;
1215
+ justify-content: space-between;
1216
+ align-items: center;
1217
+ }
1218
+
1219
+ #usage-tabs {
1220
+ display: flex;
1221
+ gap: 2px;
1222
+ padding: 8px 12px 4px;
1223
+ border-bottom: 1px solid var(--border);
1224
+ flex-wrap: wrap;
1225
+ }
1226
+
1227
+ .usage-tab, .usage-view {
1228
+ padding: 4px 12px;
1229
+ font-size: 12px;
1230
+ border-radius: 4px;
1231
+ cursor: pointer;
1232
+ color: var(--muted);
1233
+ }
1234
+
1235
+ .usage-tab.active, .usage-view.active {
1236
+ background: var(--accent);
1237
+ color: var(--bg);
1238
+ font-weight: 600;
1239
+ }
1240
+
1241
+ #usage-views {
1242
+ display: flex;
1243
+ gap: 2px;
1244
+ padding: 4px 12px 8px;
1245
+ border-bottom: 1px solid var(--border);
1246
+ }
1247
+
1248
+ #usage-body {
1249
+ flex: 1;
1250
+ overflow-y: auto;
1251
+ padding: 8px 12px;
1252
+ }
1253
+
1254
+ .usage-table {
1255
+ width: 100%;
1256
+ border-collapse: collapse;
1257
+ font-size: 12px;
1258
+ }
1259
+
1260
+ .usage-table th {
1261
+ text-align: right;
1262
+ padding: 4px 8px;
1263
+ color: var(--dim);
1264
+ font-weight: 600;
1265
+ border-bottom: 1px solid var(--border);
1266
+ font-size: 11px;
1267
+ }
1268
+
1269
+ .usage-table th:first-child {
1270
+ text-align: left;
1271
+ }
1272
+
1273
+ .usage-table td {
1274
+ padding: 3px 8px;
1275
+ border-bottom: 1px solid var(--border);
1276
+ white-space: nowrap;
1277
+ }
1278
+
1279
+ .usage-name {
1280
+ text-align: left;
1281
+ overflow: hidden;
1282
+ text-overflow: ellipsis;
1283
+ max-width: 340px;
1284
+ }
1285
+
1286
+ .usage-num {
1287
+ text-align: right;
1288
+ font-family: monospace;
1289
+ }
1290
+
1291
+ .usage-provider-row {
1292
+ cursor: pointer;
1293
+ font-weight: 600;
1294
+ }
1295
+
1296
+ .usage-provider-row:hover {
1297
+ background: var(--tool-pending-bg);
1298
+ }
1299
+
1300
+ .usage-model-row {
1301
+ color: var(--muted);
1302
+ font-size: 11px;
1303
+ }
1304
+
1305
+ .usage-totals {
1306
+ font-weight: 700;
1307
+ border-top: 2px solid var(--border);
1308
+ }
1309
+
1310
+ .usage-insights {
1311
+ display: flex;
1312
+ flex-direction: column;
1313
+ gap: 8px;
1314
+ }
1315
+
1316
+ .usage-insight {
1317
+ border: 1px solid var(--border);
1318
+ border-radius: 6px;
1319
+ padding: 8px 12px;
1320
+ font-size: 13px;
1321
+ display: flex;
1322
+ align-items: baseline;
1323
+ gap: 10px;
1324
+ flex-wrap: wrap;
1325
+ }
1326
+
1327
+ .usage-insight.alarm {
1328
+ border-left: 3px solid var(--error);
1329
+ }
1330
+
1331
+ .usage-insight-stat {
1332
+ font-weight: 700;
1333
+ color: var(--accent);
1334
+ font-family: monospace;
1335
+ white-space: nowrap;
1336
+ }
1337
+
1338
+ .usage-insight-advice {
1339
+ width: 100%;
1340
+ color: var(--dim);
1341
+ font-size: 12px;
1342
+ }
1343
+
1344
+ .usage-graph-title {
1345
+ font-size: 13px;
1346
+ font-weight: 600;
1347
+ margin-bottom: 6px;
1348
+ color: var(--text);
1349
+ }
1350
+
1351
+ .usage-bars {
1352
+ display: flex;
1353
+ flex-direction: column;
1354
+ gap: 4px;
1355
+ }
1356
+
1357
+ .usage-bar-row {
1358
+ display: flex;
1359
+ align-items: center;
1360
+ gap: 8px;
1361
+ font-size: 12px;
1362
+ }
1363
+
1364
+ .usage-bar-label {
1365
+ width: 200px;
1366
+ overflow: hidden;
1367
+ text-overflow: ellipsis;
1368
+ white-space: nowrap;
1369
+ text-align: right;
1370
+ }
1371
+
1372
+ .usage-bar-track {
1373
+ flex: 1;
1374
+ height: 14px;
1375
+ background: var(--tool-pending-bg);
1376
+ border-radius: 3px;
1377
+ overflow: hidden;
1378
+ }
1379
+
1380
+ .usage-bar {
1381
+ height: 100%;
1382
+ background: var(--accent);
1383
+ border-radius: 3px;
1384
+ }
1385
+
1386
+ .usage-bar-val {
1387
+ width: 70px;
1388
+ text-align: right;
1389
+ font-family: monospace;
1390
+ color: var(--muted);
1391
+ }
1392
+
1393
+ .usage-hourly {
1394
+ position: relative;
1395
+ display: flex;
1396
+ align-items: flex-end;
1397
+ gap: 2px;
1398
+ height: 120px;
1399
+ /* fixed-width columns; long windows scroll horizontally - the chart is
1400
+ the one place inside the panel where x-scrolling is acceptable */
1401
+ overflow-x: auto;
1402
+ }
1403
+
1404
+ /* Zero baseline spanning the whole chart, just above the time labels. */
1405
+ .usage-hourly::after {
1406
+ content: '';
1407
+ position: absolute;
1408
+ left: 0;
1409
+ right: 0;
1410
+ bottom: 24px;
1411
+ border-bottom: 1px solid var(--border);
1412
+ pointer-events: none;
1413
+ }
1414
+
1415
+ .usage-hour-bar-wrap {
1416
+ flex: 1;
1417
+ display: flex;
1418
+ align-items: flex-end;
1419
+ min-height: 0;
1420
+ }
1421
+
1422
+ .usage-hour-col {
1423
+ flex: 0 0 20px;
1424
+ width: 20px;
1425
+ height: 100%;
1426
+ display: flex;
1427
+ flex-direction: column;
1428
+ align-items: stretch;
1429
+ }
1430
+
1431
+ /* Day/night banding: 6:00-17:59 = day (warm tint), rest = night (cool tint) */
1432
+ .usage-hour-col.day {
1433
+ background: color-mix(in srgb, var(--accent) 5%, transparent);
1434
+ }
1435
+
1436
+ .usage-hour-col.night {
1437
+ background: color-mix(in srgb, var(--muted) 12%, transparent);
1438
+ }
1439
+
1440
+ .usage-hour-bar-wrap {
1441
+ flex: 1;
1442
+ display: flex;
1443
+ align-items: flex-end;
1444
+ min-height: 0;
1445
+ }
1446
+
1447
+ .usage-hour-label {
1448
+ font-size: 9px;
1449
+ color: var(--dim);
1450
+ text-align: center;
1451
+ line-height: 1.15;
1452
+ white-space: nowrap;
1453
+ overflow: hidden;
1454
+ text-overflow: ellipsis;
1455
+ height: 22px;
1456
+ margin-top: 2px;
1457
+ }
1458
+
1459
+ .usage-hour-bar {
1460
+ width: 100%;
1461
+ background: var(--accent);
1462
+ opacity: 0.75;
1463
+ border-radius: 2px 2px 0 0;
1464
+ }
1465
+
1466
+ #usage-close {
1467
+ padding: 8px 16px;
1468
+ text-align: right;
1469
+ font-size: 12px;
1470
+ color: var(--dim);
1471
+ cursor: pointer;
1472
+ border-top: 1px solid var(--border);
1473
+ }
1474
+
1475
+ #usage-close:hover {
1476
+ color: var(--text);
1477
+ }
@@ -200,12 +200,54 @@ export class WebUIContext {
200
200
  setHeader() { }
201
201
  custom() {
202
202
  // Pi's custom() shows an extension-drawn TUI component. Web has no TUI
203
- // renderer, so a full component can't be displayed. Same headless stub
204
- // as Pi's RPC mode (rpc-mode.ts: "Custom UI not supported in RPC mode"):
205
- // settle immediately so commands awaiting the panel don't hang.
206
- // Commands whose extensions branch on hasUI (magic-context ctx-status)
207
- // are routed to their text fallback by executeCommand (hasUI:false);
208
- // any other custom() caller just gets a no-op close.
203
+ // renderer, so the component can't be *displayed* - but the factory is
204
+ // still invoked with no-op stubs so the extension's own logic runs
205
+ // (e.g. /usage starts its data collection inside the factory), then we
206
+ // settle immediately: display-only panels (user-driven close) would
207
+ // otherwise hang the command forever, and data-producing panels
208
+ // (loader -> done(value)) have their result read from the extension's
209
+ // own cache file afterwards when needed (see usage-render.ts). So
210
+ // custom() never blocks the command.
211
+ // Extensions that branch on hasUI before custom (magic-context
212
+ // ctx-status) are routed to their text fallback by executeCommand
213
+ // (hasUI:false) and never reach this.
214
+ try {
215
+ // Arguments are (factory, options) at runtime; the declared
216
+ // signature stays interface-compatible, read them dynamically.
217
+ const args = arguments;
218
+ const factory = args[0];
219
+ const options = args[1];
220
+ if (typeof factory === "function") {
221
+ // No-op TUI stub: enough surface for factories that need a render
222
+ // handle; rendering itself is never performed on Web.
223
+ const stubTui = {
224
+ requestRender: () => { },
225
+ invalidate: () => { },
226
+ setFocus: () => { },
227
+ getWidth: () => 100,
228
+ };
229
+ // Theme stub: color helpers degrade to plain text.
230
+ const stubTheme = {
231
+ fg: (_k, s) => s,
232
+ bold: (s) => s,
233
+ dim: (s) => s,
234
+ get theme() {
235
+ return undefined;
236
+ },
237
+ };
238
+ const component = factory(stubTui, stubTheme, {}, () => { });
239
+ // Component built and its logic ran (data collection started);
240
+ // NOT disposed - async work inside may still be running and own
241
+ // its resources. settle immediately.
242
+ void component;
243
+ }
244
+ if (options && typeof options.onHandle === "function") {
245
+ options.onHandle({ setHidden: () => { }, focus: () => { } });
246
+ }
247
+ }
248
+ catch {
249
+ // factory threw - ignore, still settle
250
+ }
209
251
  return Promise.resolve(undefined);
210
252
  }
211
253
  pasteToEditor() { }
@@ -0,0 +1,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.4.11",
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"