@bismawy/pi-arnative 0.3.4 → 0.3.6

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.
@@ -6,7 +6,7 @@
6
6
  * - No double counting: the active session is already in its own file, so it is
7
7
  * NOT re-added from sessionManager (that inflated the ↑↓ totals).
8
8
  */
9
- import { existsSync, mkdirSync, readFileSync, readdirSync, statSync, writeFileSync } from "node:fs";
9
+ import { closeSync, existsSync, fstatSync, mkdirSync, openSync, readFileSync, readSync, readdirSync, statSync, writeFileSync } from "node:fs";
10
10
  import { dirname, join } from "node:path";
11
11
  import { getAgentDir } from "@earendil-works/pi-coding-agent";
12
12
 
@@ -23,7 +23,59 @@ interface FileCacheItem {
23
23
  mtimeMs: number;
24
24
  size: number;
25
25
  v?: number;
26
- entries: UsageEntry[];
26
+ /** Byte offset of the last complete line parsed. Session files are append-only, so a
27
+ * grown file only needs its tail read — re-parsing the whole 1MB+ active session on the
28
+ * 5s render path cost ~15ms and grew with chat length. */
29
+ consumed?: number;
30
+ /** Per-file rollup, not the raw entries: the summary is rebuilt from these every scan, and
31
+ * a long session holds tens of thousands of entries. Storing the rolled-up numbers keeps
32
+ * the cache small and makes a refresh cost the size of the *growth*, not the session. */
33
+ rollup: FileRollup;
34
+ }
35
+
36
+ /** Usage totals for one session file, per provider and per provider+model. */
37
+ interface FileRollup {
38
+ providers: Record<string, Rollup>;
39
+ }
40
+
41
+ interface Rollup {
42
+ sessions: number;
43
+ msgs: number;
44
+ input: number;
45
+ output: number;
46
+ cacheRead: number;
47
+ cost: number;
48
+ }
49
+
50
+ const emptyRollup = (): Rollup => ({ sessions: 0, msgs: 0, input: 0, output: 0, cacheRead: 0, cost: 0 });
51
+
52
+ function addToRollup(r: Rollup, e: UsageEntry): void {
53
+ r.msgs++;
54
+ r.input += e.input;
55
+ r.output += e.output;
56
+ r.cacheRead += e.cacheRead;
57
+ r.cost += e.cost;
58
+ }
59
+
60
+ /** Fold entries into an existing file rollup, updating provider and provider+model keys together. */
61
+ function foldInto(rollup: FileRollup, entries: UsageEntry[]): void {
62
+ if (entries.length === 0) return;
63
+ for (const e of entries) {
64
+ const key = `${e.provider}\u0000${e.model}`;
65
+ (rollup.providers[e.provider] ??= emptyRollup());
66
+ addToRollup(rollup.providers[e.provider]!, e);
67
+ (rollup.providers[key] ??= emptyRollup());
68
+ addToRollup(rollup.providers[key]!, e);
69
+ }
70
+ // `sessions` counts the file, not the messages, so it is 1 once the file has any use.
71
+ for (const key of Object.keys(rollup.providers)) rollup.providers[key]!.sessions = 1;
72
+ }
73
+
74
+ /** The rollup of a freshly parsed file (a foldInto that starts from nothing). */
75
+ function rollupOfFile(entries: UsageEntry[]): FileRollup {
76
+ const rollup: FileRollup = { providers: {} };
77
+ foldInto(rollup, entries);
78
+ return rollup;
27
79
  }
28
80
 
29
81
  interface UsageModelStats {
@@ -77,8 +129,9 @@ export function formatTokens(n: number): string {
77
129
  return fmtUnit(n / 1_000_000_000, "B", "T");
78
130
  }
79
131
 
80
- const CACHE_FILE_PATH = join(getAgentDir(), "arnative-usage-cache.json");
81
- const CACHE_VERSION = 2; // v2: per-entry cost
132
+ // Resolved per call, not at import: the self-check points getAgentDir() at a fixture dir.
133
+ const cacheFilePath = (): string => join(getAgentDir(), "arnative-usage-cache.json");
134
+ const CACHE_VERSION = 4; // v4: per-file rollup (was raw entries in v3)
82
135
 
83
136
  // Disk cache is memoized; save writes memory + disk together.
84
137
  let diskMemo: Map<string, FileCacheItem> | null = null;
@@ -87,8 +140,8 @@ function loadDiskCache(): Map<string, FileCacheItem> {
87
140
  if (diskMemo) return diskMemo;
88
141
  diskMemo = new Map<string, FileCacheItem>();
89
142
  try {
90
- if (existsSync(CACHE_FILE_PATH)) {
91
- const raw = JSON.parse(readFileSync(CACHE_FILE_PATH, "utf8")) as Record<string, FileCacheItem>;
143
+ if (existsSync(cacheFilePath())) {
144
+ const raw = JSON.parse(readFileSync(cacheFilePath(), "utf8")) as Record<string, FileCacheItem>;
92
145
  for (const [k, v] of Object.entries(raw)) {
93
146
  diskMemo.set(k, v);
94
147
  }
@@ -105,9 +158,9 @@ function saveDiskCache(map: Map<string, FileCacheItem>): void {
105
158
  for (const [k, v] of map.entries()) {
106
159
  obj[k] = v;
107
160
  }
108
- const dir = dirname(CACHE_FILE_PATH);
161
+ const dir = dirname(cacheFilePath());
109
162
  if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
110
- writeFileSync(CACHE_FILE_PATH, JSON.stringify(obj), "utf8");
163
+ writeFileSync(cacheFilePath(), JSON.stringify(obj), "utf8");
111
164
  } catch {
112
165
  // ignore
113
166
  }
@@ -132,39 +185,88 @@ function scanSessionFiles(dir: string): string[] {
132
185
  return files;
133
186
  }
134
187
 
135
- function parseSessionFile(filePath: string): UsageEntry[] {
188
+ /** Extract a usage entry from one line, or null when it is not an assistant message. */
189
+ function entryOfLine(line: string): UsageEntry | null {
190
+ // Loose prefilter (fast path only): exact key would break on JSON formatting changes
191
+ // and silently drop usage. The role is re-checked on the parsed object below.
192
+ if (!line || !line.includes('"role"')) return null;
193
+ try {
194
+ const m = JSON.parse(line)?.message;
195
+ if (m?.role !== "assistant") return null;
196
+ const u = m.usage || {};
197
+ return {
198
+ provider: String(m.provider || "unknown"),
199
+ model: String(m.model || "unknown"),
200
+ input: Number(u.input) || 0,
201
+ output: Number(u.output) || 0,
202
+ cacheRead: Number(u.cacheRead) || 0,
203
+ cost: Number(u.cost?.total) || 0,
204
+ };
205
+ } catch {
206
+ return null;
207
+ }
208
+ }
209
+
210
+ /**
211
+ * Parse newline-terminated lines from a buffer. Byte granularity, so the returned offset can be
212
+ * fed back to `readSync`. A trailing segment with no newline is parsed (the last record of a file
213
+ * not ending in `\n`) but only consumed when it is valid JSON — a write caught mid-flight is left
214
+ * for the next scan instead of being dropped or truncated into a bad entry.
215
+ */
216
+ function parseBuffer(buf: Buffer): { entries: UsageEntry[]; consumed: number } {
136
217
  const entries: UsageEntry[] = [];
218
+ let start = 0;
219
+ let consumed = 0;
220
+ for (;;) {
221
+ const nl = buf.indexOf(10, start);
222
+ if (nl === -1) break;
223
+ const entry = entryOfLine(buf.toString("utf8", start, nl));
224
+ if (entry) entries.push(entry);
225
+ start = nl + 1;
226
+ consumed = start;
227
+ }
228
+ if (start < buf.length) {
229
+ const tail = buf.toString("utf8", start);
230
+ const entry = entryOfLine(tail);
231
+ if (entry) {
232
+ entries.push(entry);
233
+ consumed = buf.length;
234
+ } else if (isJsonParseable(tail)) {
235
+ // A complete record that is not an assistant message: consume it and move on.
236
+ consumed = buf.length;
237
+ }
238
+ // Anything else is a half-written record; leave it for the next scan.
239
+ }
240
+ return { entries, consumed };
241
+ }
242
+
243
+ /** True when the text parses as JSON. Used to tell a whole record from a half-written one. */
244
+ function isJsonParseable(text: string): boolean {
137
245
  try {
138
- const content = readFileSync(filePath, "utf8");
139
- const lines = content.split("\n");
140
- for (const line of lines) {
141
- // Loose prefilter (fast path only): exact key would break on JSON formatting changes
142
- // and silently drop usage. The role is re-checked on the parsed object below.
143
- if (!line || !line.includes('"role"')) continue;
144
- try {
145
- const obj = JSON.parse(line);
146
- const m = obj?.message;
147
- if (m?.role === "assistant") {
148
- const provider = String(m.provider || "unknown");
149
- const model = String(m.model || "unknown");
150
- const u = m.usage || {};
151
- entries.push({
152
- provider,
153
- model,
154
- input: Number(u.input) || 0,
155
- output: Number(u.output) || 0,
156
- cacheRead: Number(u.cacheRead) || 0,
157
- cost: Number(u.cost?.total) || 0,
158
- });
159
- }
160
- } catch {
161
- // unparsable line
162
- }
246
+ JSON.parse(text);
247
+ return true;
248
+ } catch {
249
+ return false;
250
+ }
251
+ }
252
+
253
+ /** Read the bytes written since `fromByte`. Null when the file cannot be opened (deleted mid-scan). */
254
+ function readTail(filePath: string, fromByte: number): Buffer | null {
255
+ try {
256
+ const fd = openSync(filePath, "r");
257
+ try {
258
+ const size = fstatSync(fd).size;
259
+ if (size <= fromByte) return Buffer.alloc(0);
260
+ const len = size - fromByte;
261
+ const buf = Buffer.allocUnsafe(len);
262
+ const read = readSync(fd, buf, 0, len, fromByte);
263
+ return buf.subarray(0, read);
264
+ } finally {
265
+ closeSync(fd);
163
266
  }
164
267
  } catch {
165
- // unreadable file
268
+ return null;
166
269
  }
167
- return entries;
168
270
  }
169
271
 
170
272
  let cachedSummary: UsageSummary | null = null;
@@ -202,58 +304,66 @@ export function collectUsageSummary(forceScan = false): UsageSummary {
202
304
  continue;
203
305
  }
204
306
 
205
- let fileEntries: UsageEntry[];
206
- const cached = diskCache.get(file);
207
- if (cached && cached.v === CACHE_VERSION && cached.mtimeMs === stat.mtimeMs && cached.size === stat.size) {
208
- fileEntries = cached.entries;
307
+ let cached = diskCache.get(file);
308
+ if (!(cached && cached.v === CACHE_VERSION && cached.rollup)) cached = undefined;
309
+
310
+ if (cached && cached.mtimeMs === stat.mtimeMs && cached.size === stat.size) {
311
+ // Unchanged since the last scan: its rollup already holds everything.
312
+ } else if (cached && (cached.consumed ?? 0) > 0 && stat.size > (cached.consumed ?? 0)) {
313
+ // Grew, and a prefix was already parsed: read only the appended tail and fold it into
314
+ // the existing rollup. (A shrunk file, or one whose size is below the offset,
315
+ // re-parses whole — see the final branch.)
316
+ const tail = readTail(file, cached.consumed!);
317
+ const parsed = tail ? parseBuffer(tail) : null;
318
+ if (parsed) {
319
+ foldInto(cached.rollup, parsed.entries);
320
+ cached.consumed = cached.consumed! + parsed.consumed;
321
+ cached.mtimeMs = stat.mtimeMs;
322
+ cached.size = stat.size;
323
+ } else {
324
+ // Unreadable: fall back to whole-file.
325
+ const full = parseBuffer(readFileSync(file));
326
+ cached = { mtimeMs: stat.mtimeMs, size: stat.size, v: CACHE_VERSION, consumed: full.consumed, rollup: rollupOfFile(full.entries) };
327
+ diskCache.set(file, cached);
328
+ }
329
+ cacheDirty = true;
209
330
  } else {
210
- fileEntries = parseSessionFile(file);
211
- diskCache.set(file, {
212
- mtimeMs: stat.mtimeMs,
213
- size: stat.size,
214
- v: CACHE_VERSION,
215
- entries: fileEntries,
216
- });
331
+ const full = parseBuffer(readFileSync(file));
332
+ cached = { mtimeMs: stat.mtimeMs, size: stat.size, v: CACHE_VERSION, consumed: full.consumed, rollup: rollupOfFile(full.entries) };
333
+ diskCache.set(file, cached);
217
334
  cacheDirty = true;
218
335
  }
219
336
 
220
- for (const ent of fileEntries) {
221
- if (!providerMap.has(ent.provider)) {
222
- providerMap.set(ent.provider, {
223
- sessions: new Set(),
224
- msgs: 0,
225
- input: 0,
226
- output: 0,
227
- cacheRead: 0,
228
- cost: 0,
229
- models: new Map(),
230
- });
337
+ for (const [key, r] of Object.entries(cached.rollup.providers)) {
338
+ const sep = key.indexOf("\u0000");
339
+ const pname = sep === -1 ? key : key.slice(0, sep);
340
+ if (!providerMap.has(pname)) {
341
+ providerMap.set(pname, { sessions: new Set(), msgs: 0, input: 0, output: 0, cacheRead: 0, cost: 0, models: new Map() });
231
342
  }
232
- const p = providerMap.get(ent.provider)!;
343
+ const p = providerMap.get(pname)!;
233
344
  p.sessions.add(file);
234
- p.msgs++;
235
- p.input += ent.input;
236
- p.output += ent.output;
237
- p.cacheRead += ent.cacheRead;
238
- p.cost += ent.cost;
239
-
240
- if (!p.models.has(ent.model)) {
241
- p.models.set(ent.model, {
242
- sessions: new Set(),
243
- msgs: 0,
244
- input: 0,
245
- output: 0,
246
- cacheRead: 0,
247
- cost: 0,
248
- });
345
+
346
+ if (sep === -1) {
347
+ // Provider-level key: the message totals belong here only.
348
+ p.msgs += r.msgs;
349
+ p.input += r.input;
350
+ p.output += r.output;
351
+ p.cacheRead += r.cacheRead;
352
+ p.cost += r.cost;
353
+ continue;
354
+ }
355
+
356
+ const mname = key.slice(sep + 1);
357
+ if (!p.models.has(mname)) {
358
+ p.models.set(mname, { sessions: new Set(), msgs: 0, input: 0, output: 0, cacheRead: 0, cost: 0 });
249
359
  }
250
- const m = p.models.get(ent.model)!;
360
+ const m = p.models.get(mname)!;
251
361
  m.sessions.add(file);
252
- m.msgs++;
253
- m.input += ent.input;
254
- m.output += ent.output;
255
- m.cacheRead += ent.cacheRead;
256
- m.cost += ent.cost;
362
+ m.msgs += r.msgs;
363
+ m.input += r.input;
364
+ m.output += r.output;
365
+ m.cacheRead += r.cacheRead;
366
+ m.cost += r.cost;
257
367
  }
258
368
  }
259
369
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bismawy/pi-arnative",
3
- "version": "0.3.4",
3
+ "version": "0.3.6",
4
4
  "description": "Refined aesthetics. Cohesive tools. Built for Pi. Warm themes, rounded tool boxes, tabbed header, compact footer and a /usage dashboard.",
5
5
  "keywords": [
6
6
  "pi-package",
@@ -41,9 +41,9 @@
41
41
  },
42
42
  "scripts": {
43
43
  "dev": "pi --extension ./extensions/tools.ts --extension ./extensions/section-headers.ts --extension ./extensions/footer.ts --extension ./extensions/timestamps.ts --extension ./extensions/ui-render-tweaks.ts --extension ./extensions/usage.ts --extension ./extensions/arnative.ts",
44
- "test": "node lib/box.ts && node lib/ansi.ts && node lib/shortcuts.ts && node themes/gen-themes.mjs --check && node extensions/tools.ts && node extensions/footer.ts && node extensions/timestamps.ts && node extensions/section-headers.ts && node extensions/ui-render-tweaks.ts && node extensions/usage.ts && node extensions/arnative.ts",
44
+ "test": "node lib/box.ts && node lib/ansi.ts && node lib/color.ts && node lib/shortcuts.ts && node lib/preset-store.ts && node themes/gen-themes.mjs --check && node extensions/tools.ts && node extensions/footer.ts && node extensions/timestamps.ts && node extensions/section-headers.ts && node extensions/ui-render-tweaks.ts && node extensions/usage.ts && node extensions/arnative.ts",
45
45
  "themes": "node themes/gen-themes.mjs",
46
46
  "release:check": "node scripts/release-check.mjs pre-tag",
47
- "prepublishOnly": "node scripts/release-check.mjs pre-publish"
47
+ "prepublishOnly": "npm test && node scripts/release-check.mjs pre-publish"
48
48
  }
49
49
  }