pi-sdk-web 0.5.0 → 0.5.2

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/server.js CHANGED
@@ -359,6 +359,13 @@ export class PiWebServer {
359
359
  for (const s of this.session.resourceLoader.getSkills().skills) {
360
360
  commands.push({ name: `skill:${s.name}`, description: s.description, source: "skill", sourceInfo: s.sourceInfo });
361
361
  }
362
+ // pi-sdk-web built-ins: commands provided by this package itself
363
+ // (not tied to any extension), e.g. /usage which collects Pi session
364
+ // usage natively (usage-render.ts) instead of running the extension's
365
+ // command. Source "maxdai" = the package author's npm identity.
366
+ if (!commands.some((c) => c.name === "usage")) {
367
+ commands.push({ name: "usage", description: "Show usage statistics (tokens/cost, all sessions)", source: "maxdai", sourceInfo: { path: "pi-sdk-web", source: "maxdai", scope: "global", origin: "cli" } });
368
+ }
362
369
  return commands;
363
370
  }
364
371
  catch {
@@ -622,6 +629,13 @@ export class PiWebServer {
622
629
  await this.executeCommand(name, args);
623
630
  break;
624
631
  }
632
+ case "usage": {
633
+ // /usage panel scope switch (session | all) - re-render with the
634
+ // chosen data range.
635
+ const scope = data.scope === "all" ? "all" : "session";
636
+ await this.handleUsageCommand(scope);
637
+ break;
638
+ }
625
639
  case "session":
626
640
  // Read-only session info (TUI /session equivalent)
627
641
  this.broadcastSessionInfo();
@@ -752,6 +766,13 @@ export class PiWebServer {
752
766
  * modal (mirroring the TUI dialog behavior).
753
767
  */
754
768
  async executeCommand(name, args) {
769
+ // /usage is now a pi-web-native feature: collect usage directly from
770
+ // Pi's session files (usage-render.ts) and show the panel - the
771
+ // pi-usage-extension is no longer required (it reads the same files).
772
+ if (name === "usage") {
773
+ await this.handleUsageCommand("session");
774
+ return;
775
+ }
755
776
  const cmd = this.session.extensionRunner.getCommand(name);
756
777
  if (!cmd)
757
778
  throw new Error(`Unknown command: ${name}`);
@@ -817,34 +838,39 @@ export class PiWebServer {
817
838
  notifyType: "info",
818
839
  });
819
840
  }
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
- }
841
+ // --- /usage (pi-web-native) handled early in executeCommand ---
842
+ }
843
+ /** Collect usage from Pi session files and broadcast the panel payload.
844
+ * scope "session" = current session only; "all" = every session. */
845
+ async handleUsageCommand(scope = "session") {
846
+ try {
847
+ const { buildUsageData } = await import("./usage-render.js");
848
+ const sessionId = scope === "session" ? String(this.session.sessionId ?? "") : undefined;
849
+ const data = buildUsageData(sessionId);
850
+ if (data) {
851
+ this.broadcast({ type: "usage_data", data });
842
852
  }
843
- catch {
844
- // integration failed silently - command output (if any) already
845
- // broadcast above
853
+ else {
854
+ this.broadcast({
855
+ type: "extension_ui_request",
856
+ id: crypto.randomUUID(),
857
+ method: "notify",
858
+ title: "/usage",
859
+ message: scope === "session" ? "No usage data for this session yet." : "No usage data found yet (no Pi session files with usage).",
860
+ notifyType: "info",
861
+ });
846
862
  }
847
863
  }
864
+ catch (e) {
865
+ this.broadcast({
866
+ type: "extension_ui_request",
867
+ id: crypto.randomUUID(),
868
+ method: "notify",
869
+ title: "/usage",
870
+ message: `Usage collection failed: ${e instanceof Error ? e.message : String(e)}`,
871
+ notifyType: "error",
872
+ });
873
+ }
848
874
  }
849
875
  buildCommandContext(hasUI) {
850
876
  const sm = this.session.sessionManager;
@@ -1972,10 +1972,15 @@ class PiWebClient {
1972
1972
 
1973
1973
  renderUsagePanel(data) {
1974
1974
  if (!data || !data.tabs) return;
1975
+ // Preserve the user's current choices (tab/view/scope/expanded) across
1976
+ // data refresh (e.g. scope switch Session|All must not reset them);
1977
+ // defaults are set only on first open. usageExpanded must exist for the
1978
+ // tab/provider click handlers (created once, kept afterwards).
1979
+ if (!this.usageTab) this.usageTab = 'thisWeek';
1980
+ if (!this.usageView) this.usageView = 'table';
1981
+ if (!this.usageScope) this.usageScope = 'session';
1982
+ if (!this.usageExpanded) this.usageExpanded = new Set();
1975
1983
  this.usageData = data;
1976
- this.usageTab = 'thisWeek';
1977
- this.usageView = 'table';
1978
- this.usageExpanded = new Set();
1979
1984
  const overlay = document.getElementById('usage-overlay');
1980
1985
  if (!overlay) return;
1981
1986
  overlay.style.display = 'flex';
@@ -1983,6 +1988,14 @@ class PiWebClient {
1983
1988
  this.renderUsageBody();
1984
1989
  }
1985
1990
 
1991
+ // Switch the usage scope (session | all) - re-fetch from the server.
1992
+ setUsageScope(scope) {
1993
+ if (this.usageScope === scope) return;
1994
+ this.usageScope = scope;
1995
+ this.renderUsageTabs();
1996
+ this.send({ type: 'usage', scope });
1997
+ }
1998
+
1986
1999
  closeUsagePanel() {
1987
2000
  const overlay = document.getElementById('usage-overlay');
1988
2001
  if (overlay) overlay.style.display = 'none';
@@ -2011,7 +2024,7 @@ class PiWebClient {
2011
2024
  });
2012
2025
  tabsEl.appendChild(tab);
2013
2026
  }
2014
- // View switcher: Table / Insights / Graph
2027
+ // View switcher: Table / Insights / Graph + scope (Session | All)
2015
2028
  const viewsEl = document.getElementById('usage-views');
2016
2029
  if (viewsEl) {
2017
2030
  viewsEl.innerHTML = '';
@@ -2026,6 +2039,17 @@ class PiWebClient {
2026
2039
  });
2027
2040
  viewsEl.appendChild(v);
2028
2041
  }
2042
+ const scopeSep = document.createElement('span');
2043
+ scopeSep.className = 'usage-scope-sep';
2044
+ scopeSep.textContent = '|';
2045
+ viewsEl.appendChild(scopeSep);
2046
+ for (const [key, label] of [['session', 'Session'], ['all', 'All']]) {
2047
+ const s = document.createElement('span');
2048
+ s.className = 'usage-view' + (key === this.usageScope ? ' active' : '');
2049
+ s.textContent = label;
2050
+ s.addEventListener('click', () => this.setUsageScope(key));
2051
+ viewsEl.appendChild(s);
2052
+ }
2029
2053
  }
2030
2054
  }
2031
2055
 
@@ -200,54 +200,11 @@ 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 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
- }
203
+ // renderer, so the component can't be displayed - same headless stub as
204
+ // 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. This is
206
+ // now purely internal: /usage (which used to collect data inside the
207
+ // factory) collects natively from Pi session files instead.
251
208
  return Promise.resolve(undefined);
252
209
  }
253
210
  pasteToEditor() { }
@@ -1,186 +1,197 @@
1
1
  /**
2
- * Usage extension integration (independent module).
2
+ * Usage statistics for the pi-web panel (standalone module).
3
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.
4
+ * Data is collected directly from Pi's own session files
5
+ * (~/.pi/agent/sessions/**\/*.jsonl): each assistant message carries
6
+ * provider/model/timestamp/usage{input,output,cacheRead,cacheWrite,
7
+ * reasoning,cost}, and auxiliary entries (compaction/branch_summary)
8
+ * carry usage too. The collection mirrors the semantics used by the
9
+ * pi-usage-extension (which reads the same session files and normalizes
10
+ * them to a cache) - but this module is independent: it parses the
11
+ * session files itself, so /usage works without the extension installed.
11
12
  *
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]
13
+ * The module aggregates the messages into the structured UsageDataPayload
14
+ * (5 time tabs x provider/model x metrics + insights + global hourly
15
+ * series + tab windows); the frontend renders.
19
16
  */
20
- import { existsSync, readFileSync, statSync } from "node:fs";
17
+ import { existsSync, readdirSync, readFileSync } from "node:fs";
21
18
  import { join } from "node:path";
22
19
  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"),
20
+ const SESSIONS_DIRS = [
21
+ join(process.env.PI_HOME?.replace(/^~/, homedir()) ?? homedir(), ".pi", "agent", "sessions"),
22
+ join(homedir(), ".pi", "agent", "sessions"),
27
23
  ];
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;
24
+ function sessionsDir() {
25
+ for (const dir of SESSIONS_DIRS) {
26
+ if (existsSync(dir))
27
+ return dir;
33
28
  }
34
29
  return null;
35
30
  }
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;
31
+ /** Recursively collect all .jsonl session files under dir (sorted). */
32
+ function collectSessionFiles(dir, out) {
33
+ let entries;
41
34
  try {
42
- return statSync(path).mtimeMs;
35
+ entries = readdirSync(dir, { withFileTypes: true });
43
36
  }
44
37
  catch {
45
- return null;
38
+ return;
39
+ }
40
+ for (const e of entries) {
41
+ const p = join(dir, e.name);
42
+ if (e.isDirectory())
43
+ collectSessionFiles(p, out);
44
+ else if (e.isFile() && e.name.endsWith(".jsonl"))
45
+ out.push(p);
46
46
  }
47
47
  }
48
- /** Read and normalize the usage cache; null when absent/unreadable. */
49
- export function loadUsageCache() {
50
- const path = findCachePath();
51
- if (!path)
48
+ function parseUsageAmount(value) {
49
+ const u = value;
50
+ if (!u || typeof u !== "object")
52
51
  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 };
52
+ return {
53
+ cost: Number(u.cost?.total) || 0,
54
+ input: Number(u.input) || 0,
55
+ output: Number(u.output) || 0,
56
+ cacheRead: Number(u.cacheRead) || 0,
57
+ cacheWrite: Number(u.cacheWrite) || 0,
58
+ reasoning: Number(u.reasoning) || 0,
59
+ };
60
+ }
61
+ function tsOf(messageTimestamp, entryTimestamp) {
62
+ if (typeof messageTimestamp === "number")
63
+ return messageTimestamp;
64
+ if (typeof messageTimestamp === "string") {
65
+ const n = new Date(messageTimestamp).getTime();
66
+ if (!Number.isNaN(n))
67
+ return n;
93
68
  }
94
- catch {
95
- return null;
69
+ if (typeof entryTimestamp === "string") {
70
+ const n = new Date(entryTimestamp).getTime();
71
+ if (!Number.isNaN(n))
72
+ return n;
96
73
  }
74
+ return 0;
97
75
  }
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)}`;
76
+ function auxMessage(usage, ts, sourceId) {
77
+ return {
78
+ provider: "aux",
79
+ model: "auxiliary",
80
+ thinkingLevel: undefined,
81
+ cost: usage.cost,
82
+ input: usage.input,
83
+ output: usage.output,
84
+ cacheRead: usage.cacheRead,
85
+ cacheWrite: usage.cacheWrite,
86
+ timestamp: ts,
87
+ reasoning: usage.reasoning,
88
+ afterCompaction: false,
89
+ source: "auxiliary",
90
+ };
121
91
  }
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.";
92
+ /**
93
+ * Parse one session jsonl into its usage messages (the same semantics as
94
+ * the pi-usage-extension: assistant messages contribute their own usage,
95
+ * compaction/branch_summary entries contribute auxiliary usage).
96
+ */
97
+ function parseSessionFile(path) {
98
+ let sessionId = "";
99
+ let cwd = "";
100
+ let parentSession = "";
101
+ let thinkingLevel;
102
+ let compactionPending = false;
103
+ const messages = [];
104
+ let content;
105
+ try {
106
+ content = readFileSync(path, "utf8");
130
107
  }
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);
108
+ catch {
109
+ return { sessionId, cwd, parentSession, messages };
110
+ }
111
+ for (const line of content.split("\n")) {
112
+ if (!line.trim())
113
+ continue;
114
+ let entry;
115
+ try {
116
+ entry = JSON.parse(line);
117
+ }
118
+ catch {
119
+ continue; // malformed line
120
+ }
121
+ switch (entry.type) {
122
+ case "session": {
123
+ if (typeof entry.id === "string")
124
+ sessionId = entry.id;
125
+ if (typeof entry.cwd === "string")
126
+ cwd = entry.cwd;
127
+ if (typeof entry.parentSession === "string")
128
+ parentSession = entry.parentSession;
129
+ break;
130
+ }
131
+ case "thinking_level_change": {
132
+ if (typeof entry.thinkingLevel === "string")
133
+ thinkingLevel = entry.thinkingLevel;
134
+ break;
135
+ }
136
+ case "compaction": {
137
+ const usage = parseUsageAmount(entry.usage);
138
+ if (usage)
139
+ messages.push(auxMessage(usage, tsOf(undefined, entry.timestamp), typeof entry.id === "string" ? entry.id : ""));
140
+ compactionPending = true;
141
+ break;
142
+ }
143
+ case "branch_summary": {
144
+ const usage = parseUsageAmount(entry.usage);
145
+ if (usage)
146
+ messages.push(auxMessage(usage, tsOf(undefined, entry.timestamp), typeof entry.id === "string" ? entry.id : ""));
147
+ break;
154
148
  }
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);
149
+ case "message": {
150
+ const msg = entry.message;
151
+ if (!msg)
152
+ break;
153
+ if (msg.role === "assistant" && msg.usage && msg.provider && msg.model) {
154
+ const usage = parseUsageAmount(msg.usage);
155
+ if (usage) {
156
+ messages.push({
157
+ provider: msg.provider,
158
+ model: msg.model,
159
+ thinkingLevel,
160
+ cost: usage.cost,
161
+ input: usage.input,
162
+ output: usage.output,
163
+ cacheRead: usage.cacheRead,
164
+ cacheWrite: usage.cacheWrite,
165
+ timestamp: tsOf(msg.timestamp, entry.timestamp),
166
+ reasoning: usage.reasoning,
167
+ afterCompaction: compactionPending,
168
+ source: "assistant",
169
+ });
170
+ compactionPending = false;
171
+ }
172
+ }
173
+ break;
174
+ }
175
+ default:
176
+ break;
164
177
  }
165
178
  }
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
- }
179
+ return { sessionId, cwd, parentSession, messages };
180
+ }
181
+ /** Collect usage across all session files. Empty array when none found. */
182
+ export function collectUsage() {
183
+ const root = sessionsDir();
184
+ if (!root)
185
+ return [];
186
+ const files = [];
187
+ collectSessionFiles(root, files);
188
+ const out = [];
189
+ for (const f of files) {
190
+ const parsed = parseSessionFile(f);
191
+ if (parsed.messages.length > 0 || parsed.sessionId)
192
+ out.push(parsed);
182
193
  }
183
- return lines.join("\n");
194
+ return out;
184
195
  }
185
196
  const TAB_KEYS = ["today", "thisWeek", "lastWeek", "last30Days", "allTime"];
186
197
  function emptyTokens() {
@@ -237,12 +248,15 @@ function periodStart(key, now) {
237
248
  return 0; // allTime
238
249
  }
239
250
  /**
240
- * Aggregate the cache into the structured payload the web panel renders.
241
- * Returns null when no cache exists yet.
251
+ * Aggregate collected session usage into the structured payload the web
252
+ * panel renders. Returns null when no usage data exists. When sessionId
253
+ * is given, only that session's usage is aggregated (scope: current
254
+ * session); otherwise all sessions.
242
255
  */
243
- export function buildUsageData() {
244
- const cache = loadUsageCache();
245
- if (!cache)
256
+ export function buildUsageData(sessionId) {
257
+ const files = collectUsage();
258
+ const scoped = sessionId ? files.filter((f) => f.sessionId === sessionId) : files;
259
+ if (scoped.length === 0)
246
260
  return null;
247
261
  const now = Date.now();
248
262
  // per-tab accumulators
@@ -253,7 +267,7 @@ export function buildUsageData() {
253
267
  // hourly buckets (all providers, global) - the render side filters per tab
254
268
  const hourly = new Map();
255
269
  const hourStart = (ts) => Math.floor(ts / 3600_000) * 3600_000;
256
- for (const file of cache.files) {
270
+ for (const file of scoped) {
257
271
  for (const m of file.messages) {
258
272
  const ts = m.timestamp;
259
273
  {
@@ -317,7 +331,7 @@ export function buildUsageData() {
317
331
  }
318
332
  }
319
333
  }
320
- const payload = { tabs: {}, hourly: [], tabWindow: {}, collectedAt: usageCacheMtime() };
334
+ const payload = { tabs: {}, hourly: [], tabWindow: {}, collectedAt: Date.now() };
321
335
  // Graph x-axis windows per tab: [start, end]. today: midnight->now;
322
336
  // thisWeek: monday->now; lastWeek: monday->next Monday; last30Days:
323
337
  // start->now; allTime: 0 -> now (frontend clips to first data).
@@ -375,7 +389,9 @@ export function buildUsageData() {
375
389
  insights.push({
376
390
  kind: "alarm",
377
391
  stat: formatUsageCost(topModel.cost),
378
- headline: `${topModel.name} is the costliest model (${((topModel.cost / totalCost) * 100).toFixed(0)}% of total)`,
392
+ // Model names can repeat across providers (e.g. deepseek-v4-
393
+ // flash on several providers) - show provider/model.
394
+ headline: `${top.name}/${topModel.name} is the costliest model (${((topModel.cost / totalCost) * 100).toFixed(0)}% of total)`,
379
395
  advice: "Check whether its output quality justifies the price.",
380
396
  });
381
397
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-sdk-web",
3
- "version": "0.5.0",
3
+ "version": "0.5.2",
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').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__') })\"",
14
+ "build": "tsc -p tsconfig.json && node -e \"require('node:fs').cpSync('../static', 'dist/static', { recursive: true }); require('node:fs').mkdirSync('dist/pi-bin', { recursive: true }); require('node:fs').chmodSync('dist/cli.js', 0o755); require('node:fs').renameSync('dist/pii-cli.js', 'dist/pi-bin/pii-cli.js'); require('node:fs').chmodSync('dist/pi-bin/pii-cli.js', 0o755); require('node:fs').cpSync('../pii/pii', 'dist/pi-bin/pii'); require('node:fs').cpSync('../server', 'dist/pi-bin/server', { recursive: true, filter: (s) => !s.includes('__pycache__') })\"",
15
15
  "dev": "tsx src/cli.ts",
16
16
  "verify": "tsx src/verify-sdk.ts",
17
17
  "prepublishOnly": "npm run build"