pi-sdk-web 0.5.0 → 0.5.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/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
 
@@ -1,99 +1,215 @@
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, statSync } 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;
52
+ return {
53
+ cost: Number(u.cost?.total) || 0,
54
+ input: Number(u.input) || 0,
55
+ output: Number(u.output) || 0,
56
+ cacheRead: Number(u.cacheRead) || 0,
57
+ cacheWrite: Number(u.cacheWrite) || 0,
58
+ reasoning: Number(u.reasoning) || 0,
59
+ };
60
+ }
61
+ function tsOf(messageTimestamp, entryTimestamp) {
62
+ if (typeof messageTimestamp === "number")
63
+ return messageTimestamp;
64
+ if (typeof messageTimestamp === "string") {
65
+ const n = new Date(messageTimestamp).getTime();
66
+ if (!Number.isNaN(n))
67
+ return n;
68
+ }
69
+ if (typeof entryTimestamp === "string") {
70
+ const n = new Date(entryTimestamp).getTime();
71
+ if (!Number.isNaN(n))
72
+ return n;
73
+ }
74
+ return 0;
75
+ }
76
+ function auxMessage(usage, ts, sourceId) {
77
+ return {
78
+ provider: "aux",
79
+ model: "auxiliary",
80
+ thinkingLevel: undefined,
81
+ cost: usage.cost,
82
+ input: usage.input,
83
+ output: usage.output,
84
+ cacheRead: usage.cacheRead,
85
+ cacheWrite: usage.cacheWrite,
86
+ timestamp: ts,
87
+ reasoning: usage.reasoning,
88
+ afterCompaction: false,
89
+ source: "auxiliary",
90
+ };
91
+ }
92
+ /**
93
+ * Parse one session jsonl into its usage messages (the same semantics as
94
+ * the pi-usage-extension: assistant messages contribute their own usage,
95
+ * compaction/branch_summary entries contribute auxiliary usage).
96
+ */
97
+ function parseSessionFile(path) {
98
+ let sessionId = "";
99
+ let cwd = "";
100
+ let parentSession = "";
101
+ let thinkingLevel;
102
+ let compactionPending = false;
103
+ const messages = [];
104
+ let content;
53
105
  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
- });
106
+ content = readFileSync(path, "utf8");
107
+ }
108
+ catch {
109
+ return { sessionId, cwd, parentSession, messages };
110
+ }
111
+ for (const line of content.split("\n")) {
112
+ if (!line.trim())
113
+ continue;
114
+ let entry;
115
+ try {
116
+ entry = JSON.parse(line);
117
+ }
118
+ catch {
119
+ continue; // malformed line
120
+ }
121
+ switch (entry.type) {
122
+ case "session": {
123
+ if (typeof entry.id === "string")
124
+ sessionId = entry.id;
125
+ if (typeof entry.cwd === "string")
126
+ cwd = entry.cwd;
127
+ if (typeof entry.parentSession === "string")
128
+ parentSession = entry.parentSession;
129
+ break;
130
+ }
131
+ case "thinking_level_change": {
132
+ if (typeof entry.thinkingLevel === "string")
133
+ thinkingLevel = entry.thinkingLevel;
134
+ break;
135
+ }
136
+ case "compaction": {
137
+ const usage = parseUsageAmount(entry.usage);
138
+ if (usage)
139
+ messages.push(auxMessage(usage, tsOf(undefined, entry.timestamp), typeof entry.id === "string" ? entry.id : ""));
140
+ compactionPending = true;
141
+ break;
142
+ }
143
+ case "branch_summary": {
144
+ const usage = parseUsageAmount(entry.usage);
145
+ if (usage)
146
+ messages.push(auxMessage(usage, tsOf(undefined, entry.timestamp), typeof entry.id === "string" ? entry.id : ""));
147
+ break;
148
+ }
149
+ case "message": {
150
+ const msg = entry.message;
151
+ if (!msg)
152
+ break;
153
+ if (msg.role === "assistant" && msg.usage && msg.provider && msg.model) {
154
+ const usage = parseUsageAmount(msg.usage);
155
+ if (usage) {
156
+ messages.push({
157
+ provider: msg.provider,
158
+ model: msg.model,
159
+ thinkingLevel,
160
+ cost: usage.cost,
161
+ input: usage.input,
162
+ output: usage.output,
163
+ cacheRead: usage.cacheRead,
164
+ cacheWrite: usage.cacheWrite,
165
+ timestamp: tsOf(msg.timestamp, entry.timestamp),
166
+ reasoning: usage.reasoning,
167
+ afterCompaction: compactionPending,
168
+ source: "assistant",
169
+ });
170
+ compactionPending = false;
171
+ }
83
172
  }
173
+ break;
84
174
  }
85
- files.push({
86
- messages,
87
- sessionId: f.sessionId ?? "",
88
- cwd: f.cwd ?? "",
89
- parentSession: f.parentSession ?? "",
90
- });
175
+ default:
176
+ break;
91
177
  }
92
- return { files, names };
93
178
  }
94
- catch {
95
- return null;
179
+ return { sessionId, cwd, parentSession, messages };
180
+ }
181
+ /** Collect usage across all session files. Empty array when none found. */
182
+ export function collectUsage() {
183
+ const root = sessionsDir();
184
+ if (!root)
185
+ return [];
186
+ const files = [];
187
+ collectSessionFiles(root, files);
188
+ const out = [];
189
+ for (const f of files) {
190
+ const parsed = parseSessionFile(f);
191
+ if (parsed.messages.length > 0 || parsed.sessionId)
192
+ out.push(parsed);
193
+ }
194
+ return out;
195
+ }
196
+ /** A coarse freshness stamp: sum of session-file mtimes, for cheap invalidation. */
197
+ export function sessionsStamp() {
198
+ const root = sessionsDir();
199
+ if (!root)
200
+ return 0;
201
+ const files = [];
202
+ collectSessionFiles(root, files);
203
+ let sum = 0;
204
+ for (const f of files) {
205
+ try {
206
+ sum += statSync(f).mtimeMs;
207
+ }
208
+ catch {
209
+ // ignore
210
+ }
96
211
  }
212
+ return sum;
97
213
  }
98
214
  function startOfDay(ts) {
99
215
  const d = new Date(ts);
@@ -124,9 +240,9 @@ function fmtCost(n) {
124
240
  }
125
241
  /** Render a Markdown usage summary (today / this week / all time). */
126
242
  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.";
243
+ const files = collectUsage();
244
+ if (files.length === 0) {
245
+ return "## Usage\n\nNo usage data found yet.";
130
246
  }
131
247
  const now = Date.now();
132
248
  const todayStart = startOfDay(now);
@@ -135,7 +251,7 @@ export function renderUsageSummary() {
135
251
  const week = emptyTotals();
136
252
  const all = emptyTotals();
137
253
  const byModel = new Map();
138
- for (const file of cache.files) {
254
+ for (const file of files) {
139
255
  for (const m of file.messages) {
140
256
  const t = m.timestamp;
141
257
  const targets = [all];
@@ -237,12 +353,15 @@ function periodStart(key, now) {
237
353
  return 0; // allTime
238
354
  }
239
355
  /**
240
- * Aggregate the cache into the structured payload the web panel renders.
241
- * Returns null when no cache exists yet.
356
+ * Aggregate collected session usage into the structured payload the web
357
+ * panel renders. Returns null when no usage data exists. When sessionId
358
+ * is given, only that session's usage is aggregated (scope: current
359
+ * session); otherwise all sessions.
242
360
  */
243
- export function buildUsageData() {
244
- const cache = loadUsageCache();
245
- if (!cache)
361
+ export function buildUsageData(sessionId) {
362
+ const files = collectUsage();
363
+ const scoped = sessionId ? files.filter((f) => f.sessionId === sessionId) : files;
364
+ if (scoped.length === 0)
246
365
  return null;
247
366
  const now = Date.now();
248
367
  // per-tab accumulators
@@ -253,7 +372,7 @@ export function buildUsageData() {
253
372
  // hourly buckets (all providers, global) - the render side filters per tab
254
373
  const hourly = new Map();
255
374
  const hourStart = (ts) => Math.floor(ts / 3600_000) * 3600_000;
256
- for (const file of cache.files) {
375
+ for (const file of scoped) {
257
376
  for (const m of file.messages) {
258
377
  const ts = m.timestamp;
259
378
  {
@@ -317,7 +436,7 @@ export function buildUsageData() {
317
436
  }
318
437
  }
319
438
  }
320
- const payload = { tabs: {}, hourly: [], tabWindow: {}, collectedAt: usageCacheMtime() };
439
+ const payload = { tabs: {}, hourly: [], tabWindow: {}, collectedAt: sessionsStamp() };
321
440
  // Graph x-axis windows per tab: [start, end]. today: midnight->now;
322
441
  // thisWeek: monday->now; lastWeek: monday->next Monday; last30Days:
323
442
  // start->now; allTime: 0 -> now (frontend clips to first data).
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.1",
4
4
  "description": "Browser Web access for Pi (AI coding agent) via the Pi SDK - standalone module, zero modification to Pi itself",
5
5
  "type": "module",
6
6
  "bin": {
@@ -11,7 +11,7 @@
11
11
  "dist"
12
12
  ],
13
13
  "scripts": {
14
- "build": "tsc -p tsconfig.json && node -e \"require('node:fs').cpSync('../static', 'dist/static', { recursive: true }); require('node:fs').mkdirSync('dist/pi-bin', { recursive: true }); require('node:fs').renameSync('dist/pii-cli.js', 'dist/pi-bin/pii-cli.js'); require('node:fs').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"