@pify/usage 0.10.0 → 0.12.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +8 -0
- package/extensions/usage.ts +54 -6
- package/package.json +1 -1
- package/src/cache-health.ts +69 -0
- package/src/export.ts +40 -0
- package/src/ratelimit.ts +40 -0
- package/src/sessions.ts +62 -2
package/README.md
CHANGED
|
@@ -20,6 +20,8 @@ Folded from each message's `usage.cost`, which pi already computes. It survives
|
|
|
20
20
|
|
|
21
21
|
When [`@pify/subagent`](https://github.com/pifydev/subagent), [`@pify/swarm`](https://github.com/pifydev/swarm) or [`@pify/workflow`](https://github.com/pifydev/workflow) run child agents, those children are separate in-memory sessions whose spend never reaches this branch — a session that delegates used to look cheaper than it was. Those packages now add every child message's cost to a process-wide tally, and the footer shows it as a separate ` · agents $0.12` beside the session's own figure (the `/usage` session block has the same line, with tokens). It is kept apart on purpose: the session numbers stay what the branch says, and the agents figure is what the delegation cost on top. The tally survives `/reload` and starts over with a new session.
|
|
22
22
|
|
|
23
|
+
On a Claude subscription (OAuth Pro/Max) the footer also carries the wall you are approaching: the headers `/usage quota` reads were already captured on every response, but only that command showed them, so the weekly or 5-hour limit used to arrive as a stalled turn. Now a `⚠ 7d 8% · resets 14:05` segment appears once the binding window is below 15%, or whenever Anthropic marks the subscription as anything but allowed, and stays out of the way otherwise. Other providers carry no such headers, so they never show it.
|
|
24
|
+
|
|
23
25
|
The gauge on the right is how full the context window is — the third mid-session question, next to tokens and cost. It was computed for the `/usage` dashboard but shown only there; now it is live. It appears once there is a window to measure against (so not under `-p`) and turns to `⚠` past 90%, the point where "how full" stops being trivia and becomes a decision. For the breakdown of what filled it, run `/context`.
|
|
24
26
|
|
|
25
27
|
## `/usage`
|
|
@@ -42,6 +44,12 @@ By project (all time)
|
|
|
42
44
|
D--project-shop-api $6.60 · 3.6M tok
|
|
43
45
|
```
|
|
44
46
|
|
|
47
|
+
`/usage export` writes the history out — `by-model.csv`, `by-day.csv`, `by-project.csv` and `totals.json`, full precision — into a timestamped folder under the OS temp directory (or `PIFY_USAGE_EXPORT_DIR`), never into the repository or your home, and prints the path.
|
|
48
|
+
|
|
49
|
+
The per-file scan cache is also kept on disk (`usage-scan-cache.json` next to pi's other state, keyed by size and mtime, versioned), so a fresh `pi` no longer pays the whole cold scan on its first `/usage`: it is read once before the first scan and rewritten after each completed one; a corrupt or older file is ignored and rebuilt, and only files the latest scan saw are kept.
|
|
50
|
+
|
|
51
|
+
Once a session has shown a healthy prompt-cache hit rate on a large prompt, five large prompts in a row at ~0% hit raise one warning naming the likely causes — a model or thinking-level switch, a provider dropping its cache, something rewriting the start of the context each turn — because from then on every turn re-bills the whole prefix and the footer alone only shows the spend climbing.
|
|
52
|
+
|
|
45
53
|
History counts every usage-bearing entry in pi's session JSONL — assistant turns plus the tool-result usage pi persists, and the summariser's own call, which pi records on the compaction and branch-summary entries themselves rather than on a message. Those summary calls are among the most expensive in a session, so counting them is what makes a session's total match what you were billed rather than what the visible messages add up to; they have no model of their own, so they group under a `compaction` / `branch_summary` label in the by-model view. The live footer and session totals fold the same summariser usage in as it happens (on compaction and tree navigation), so the number does not jump only after a reload. pi 0.87's cache warmer spends real money outside any message too, as standalone `usage` entries; those are counted under their own model on reload and in history (pi raises no extension event for them, so the live footer picks them up at the next session start). Negative and NaN fields clamp to zero, days are your **local** calendar days, and a per-file mtime cache keeps repeat scans instant.
|
|
46
54
|
|
|
47
55
|
Per-project totals come for free: pi stores sessions one directory per project, so the dashboard can show where the money actually went.
|
package/extensions/usage.ts
CHANGED
|
@@ -32,10 +32,14 @@ import { addRecord, aggregate, recordFromEntry, windowTotals } from "../src/aggr
|
|
|
32
32
|
import { buildBreakdown, contextTokensFromUsage, formatBreakdown, resolveUsedTokens } from "../src/context.ts";
|
|
33
33
|
import { contextGauge, footerText, formatCost, formatTokens, historyBlock, sessionBlock } from "../src/format.ts";
|
|
34
34
|
import { childSpendTotal, onChildSpend, resetChildSpend } from "../src/child-cost.ts";
|
|
35
|
+
import { buildByDayCsv, buildByModelCsv, buildByProjectCsv, buildTotalsJson } from "../src/export.ts";
|
|
36
|
+
import { emptyCacheHealth, observeCacheHealth } from "../src/cache-health.ts";
|
|
37
|
+
import { mkdirSync, writeFileSync } from "node:fs";
|
|
38
|
+
import { tmpdir } from "node:os";
|
|
35
39
|
import { QUOTA_PROVIDERS, fetchQuota, quotaReport, type QuotaResult } from "../src/quota.ts";
|
|
36
40
|
import { redact } from "../src/redact.ts";
|
|
37
|
-
import { scanSessions } from "../src/sessions.ts";
|
|
38
|
-
import { parseRateLimit, formatRateLimit, type RateLimitSnapshot } from "../src/ratelimit.ts";
|
|
41
|
+
import { scanSessions, persistScanCache, restoreScanCache } from "../src/sessions.ts";
|
|
42
|
+
import { parseRateLimit, formatRateLimit, type RateLimitSnapshot, footerQuotaSegment } from "../src/ratelimit.ts";
|
|
39
43
|
import { emptyTotals, isRecord, type UsageTotals } from "../src/types.ts";
|
|
40
44
|
|
|
41
45
|
type UiContext = ExtensionContext;
|
|
@@ -83,7 +87,10 @@ export default function usage(pi: ExtensionAPI) {
|
|
|
83
87
|
return;
|
|
84
88
|
}
|
|
85
89
|
const gauge = contextGauge(contextInfo(ctx).pct);
|
|
86
|
-
|
|
90
|
+
// The subscription wall, from the headers already captured for this
|
|
91
|
+
// provider: quiet while healthy, a short warning once it is close.
|
|
92
|
+
const quota = footerQuotaSegment(rateLimits.get((ctx.model as { provider?: string } | null)?.provider ?? ""));
|
|
93
|
+
ctx.ui.setStatus("usage", [base, gauge, quota].filter(Boolean).join(" · "));
|
|
87
94
|
}
|
|
88
95
|
|
|
89
96
|
/**
|
|
@@ -109,9 +116,25 @@ export default function usage(pi: ExtensionAPI) {
|
|
|
109
116
|
return { pct: Math.min(100, (used / window) * 100), window };
|
|
110
117
|
}
|
|
111
118
|
|
|
119
|
+
/** The on-disk twin of the scan cache: read once before the first scan, written after each completed one. */
|
|
120
|
+
const scanCacheFile = () => join(getAgentDir(), "usage-scan-cache.json");
|
|
121
|
+
let scanCacheRestored = false;
|
|
122
|
+
function scanHistory() {
|
|
123
|
+
if (!scanCacheRestored) {
|
|
124
|
+
scanCacheRestored = true;
|
|
125
|
+
restoreScanCache(scanCacheFile());
|
|
126
|
+
}
|
|
127
|
+
const scan = scanSessions(join(getAgentDir(), "sessions"));
|
|
128
|
+
persistScanCache(scanCacheFile());
|
|
129
|
+
return scan;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/** Prompt-cache health for this session: warns once when a warm cache goes cold. */
|
|
133
|
+
let cacheHealth = emptyCacheHealth();
|
|
134
|
+
|
|
112
135
|
function dashboard(ctx: UiContext): string {
|
|
113
136
|
const history = aggregate(...(() => {
|
|
114
|
-
const scan =
|
|
137
|
+
const scan = scanHistory();
|
|
115
138
|
return [scan.records, scan.files] as const;
|
|
116
139
|
})());
|
|
117
140
|
return [sessionBlock(session, contextInfo(ctx).pct, childSpendTotal()), historyBlock(history, Date.now())].join("\n\n");
|
|
@@ -125,6 +148,8 @@ export default function usage(pi: ExtensionAPI) {
|
|
|
125
148
|
addRecord(session, record);
|
|
126
149
|
const message = (event as { message?: { role?: string; usage?: unknown } }).message;
|
|
127
150
|
if (message?.role === "assistant" && isRecord(message.usage)) {
|
|
151
|
+
const warning = observeCacheHealth(cacheHealth, message.usage as { input?: number; cacheRead?: number; cacheWrite?: number });
|
|
152
|
+
if (warning && ctx.hasUI) ctx.ui.notify(warning, "warning");
|
|
128
153
|
// Use pi's own formula so the gauge cannot drift below pi's reading —
|
|
129
154
|
// input+cacheRead alone dropped cacheWrite and output, a real slice of
|
|
130
155
|
// every Anthropic prompt-cached turn.
|
|
@@ -140,6 +165,7 @@ export default function usage(pi: ExtensionAPI) {
|
|
|
140
165
|
// Child spend is not in the branch, so a /reload must keep the tally; a
|
|
141
166
|
// new, resumed or forked session starts its own.
|
|
142
167
|
if ((event as { reason?: string }).reason !== "reload") resetChildSpend();
|
|
168
|
+
cacheHealth = emptyCacheHealth();
|
|
143
169
|
footerCtx = ctx;
|
|
144
170
|
stopChildSpend?.();
|
|
145
171
|
stopChildSpend = onChildSpend(() => {
|
|
@@ -294,9 +320,31 @@ export default function usage(pi: ExtensionAPI) {
|
|
|
294
320
|
}
|
|
295
321
|
|
|
296
322
|
pi.registerCommand("usage", {
|
|
297
|
-
description: "Token and cost dashboard: /usage [context | quota]",
|
|
323
|
+
description: "Token and cost dashboard: /usage [context | quota | export]",
|
|
298
324
|
handler: async (args, ctx) => {
|
|
299
325
|
if (!ctx.hasUI) return;
|
|
326
|
+
if ((args ?? "").trim().toLowerCase() === "export") {
|
|
327
|
+
// Hand the history off: CSV per model/day/project and a totals JSON,
|
|
328
|
+
// in the OS temp dir by default (never the repo or home), or
|
|
329
|
+
// PIFY_USAGE_EXPORT_DIR. Full precision; the reader rounds.
|
|
330
|
+
const scan = scanHistory();
|
|
331
|
+
const history = aggregate(scan.records, scan.files);
|
|
332
|
+
const now = Date.now();
|
|
333
|
+
const stamp = new Date(now).toISOString().replace(/[:.]/g, "-");
|
|
334
|
+
const dir = join(process.env.PIFY_USAGE_EXPORT_DIR?.trim() || join(tmpdir(), "pify-usage"), stamp);
|
|
335
|
+
try {
|
|
336
|
+
mkdirSync(dir, { recursive: true });
|
|
337
|
+
writeFileSync(join(dir, "by-model.csv"), buildByModelCsv(history.byModel));
|
|
338
|
+
writeFileSync(join(dir, "by-day.csv"), buildByDayCsv(history.byDay));
|
|
339
|
+
writeFileSync(join(dir, "by-project.csv"), buildByProjectCsv(history.byProject));
|
|
340
|
+
writeFileSync(join(dir, "totals.json"), buildTotalsJson(history.total, history.files, now));
|
|
341
|
+
} catch (err) {
|
|
342
|
+
ctx.ui.notify(`Export failed: ${err instanceof Error ? err.message : String(err)}`, "error");
|
|
343
|
+
return;
|
|
344
|
+
}
|
|
345
|
+
ctx.ui.notify(`✓ Exported by-model.csv, by-day.csv, by-project.csv and totals.json to ${dir}`, "info");
|
|
346
|
+
return;
|
|
347
|
+
}
|
|
300
348
|
if ((args ?? "").trim().toLowerCase() === "context") {
|
|
301
349
|
// Documented since the breakdown landed, but never wired — the handler
|
|
302
350
|
// only knew "quota", so `/usage context` quietly showed the dashboard.
|
|
@@ -358,7 +406,7 @@ export default function usage(pi: ExtensionAPI) {
|
|
|
358
406
|
"is proportionate, or whether to /compact first.",
|
|
359
407
|
parameters: Type.Object({}),
|
|
360
408
|
async execute(_id, _params, _signal, _onUpdate, ctx) {
|
|
361
|
-
const scan =
|
|
409
|
+
const scan = scanHistory();
|
|
362
410
|
const history = aggregate(scan.records, scan.files);
|
|
363
411
|
const today = windowTotals(history.byDay, 1, Date.now());
|
|
364
412
|
const lines = [
|
package/package.json
CHANGED
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Noticing when the prompt cache stops working.
|
|
3
|
+
*
|
|
4
|
+
* A session that enjoyed a warm prefix cache can lose it mid-way — a model
|
|
5
|
+
* switch, a provider dropping its cached KV, a context edit that rewrote
|
|
6
|
+
* the prefix — and from then on every turn re-bills the whole prompt cold.
|
|
7
|
+
* The footer shows the spend climbing; nothing names the cause. This is a
|
|
8
|
+
* small state machine fed with each assistant message's usage: once the
|
|
9
|
+
* session has shown a healthy hit rate on a large prompt, a run of large
|
|
10
|
+
* prompts at ~0% hit trips one warning, and only one. Pure; the extension
|
|
11
|
+
* feeds it and resets it at session start. (billion-context's cache-warn,
|
|
12
|
+
* reduced to the in-process case — a restart is a new session here.)
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
export const MIN_PROMPT_TOKENS = 8_000;
|
|
16
|
+
export const HEALTHY_HIT = 0.5;
|
|
17
|
+
export const COLLAPSED_HIT = 0.05;
|
|
18
|
+
export const LOW_RUN = 5;
|
|
19
|
+
|
|
20
|
+
export interface CacheHealthState {
|
|
21
|
+
sawHealthy: boolean;
|
|
22
|
+
lowRun: number;
|
|
23
|
+
warned: boolean;
|
|
24
|
+
lastHealthyHit: number;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function emptyCacheHealth(): CacheHealthState {
|
|
28
|
+
return { sawHealthy: false, lowRun: 0, warned: false, lastHealthyHit: 0 };
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export interface PromptUsageLike {
|
|
32
|
+
input?: number;
|
|
33
|
+
cacheRead?: number;
|
|
34
|
+
cacheWrite?: number;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** The share of the prompt that came from the cache; null when the prompt is too small to judge. */
|
|
38
|
+
export function cacheHit(usage: PromptUsageLike): number | null {
|
|
39
|
+
const input = Math.max(0, usage.input ?? 0);
|
|
40
|
+
const read = Math.max(0, usage.cacheRead ?? 0);
|
|
41
|
+
const write = Math.max(0, usage.cacheWrite ?? 0);
|
|
42
|
+
const prompt = input + read + write;
|
|
43
|
+
if (prompt < MIN_PROMPT_TOKENS) return null;
|
|
44
|
+
return read / prompt;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Feed one assistant message; returns the warning text exactly once, when the collapse is confirmed. */
|
|
48
|
+
export function observeCacheHealth(state: CacheHealthState, usage: PromptUsageLike): string | null {
|
|
49
|
+
const hit = cacheHit(usage);
|
|
50
|
+
if (hit === null) return null;
|
|
51
|
+
if (hit >= HEALTHY_HIT) {
|
|
52
|
+
state.sawHealthy = true;
|
|
53
|
+
state.lastHealthyHit = hit;
|
|
54
|
+
state.lowRun = 0;
|
|
55
|
+
return null;
|
|
56
|
+
}
|
|
57
|
+
if (!state.sawHealthy || hit > COLLAPSED_HIT) {
|
|
58
|
+
state.lowRun = 0;
|
|
59
|
+
return null;
|
|
60
|
+
}
|
|
61
|
+
state.lowRun++;
|
|
62
|
+
if (state.lowRun < LOW_RUN || state.warned) return null;
|
|
63
|
+
state.warned = true;
|
|
64
|
+
return (
|
|
65
|
+
`Prompt cache collapsed: ${LOW_RUN} large prompts in a row at ~${Math.round(hit * 100)}% cache hit after a healthy ` +
|
|
66
|
+
`${Math.round(state.lastHealthyHit * 100)}%. Every turn is now re-billing the whole prefix. Likely causes: a model ` +
|
|
67
|
+
`or thinking-level switch, a provider dropping its cache, or something rewriting the start of the context each turn.`
|
|
68
|
+
);
|
|
69
|
+
}
|
package/src/export.ts
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Handing the history off: CSV for a spreadsheet, JSON for a script.
|
|
3
|
+
* Pure builders over the aggregate the dashboard already computes; the
|
|
4
|
+
* extension owns the disk write. Full precision, no rounding — rounding is
|
|
5
|
+
* the reader's call.
|
|
6
|
+
*/
|
|
7
|
+
import type { UsageTotals } from "./types.ts";
|
|
8
|
+
|
|
9
|
+
function csvCell(value: string | number): string {
|
|
10
|
+
const text = String(value);
|
|
11
|
+
return /[",\n]/.test(text) ? `"${text.replace(/"/g, '""')}"` : text;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function totalsRow(key: string, t: UsageTotals): string {
|
|
15
|
+
return [key, t.messages, t.input, t.output, t.cacheRead, t.cacheWrite, t.totalTokens, t.cost].map(csvCell).join(",");
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const HEADER = "input,output,cacheRead,cacheWrite,totalTokens,cost";
|
|
19
|
+
|
|
20
|
+
/** One row per model, costliest first. */
|
|
21
|
+
export function buildByModelCsv(byModel: ReadonlyMap<string, UsageTotals>): string {
|
|
22
|
+
const rows = [...byModel.entries()].sort((a, b) => b[1].cost - a[1].cost).map(([model, t]) => totalsRow(model, t));
|
|
23
|
+
return [`model,messages,${HEADER}`, ...rows].join("\n") + "\n";
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** One row per local calendar day, oldest first. */
|
|
27
|
+
export function buildByDayCsv(byDay: ReadonlyMap<string, UsageTotals>): string {
|
|
28
|
+
const rows = [...byDay.entries()].sort((a, b) => a[0].localeCompare(b[0])).map(([day, t]) => totalsRow(day, t));
|
|
29
|
+
return [`day,messages,${HEADER}`, ...rows].join("\n") + "\n";
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** One row per project, costliest first. */
|
|
33
|
+
export function buildByProjectCsv(byProject: ReadonlyMap<string, UsageTotals>): string {
|
|
34
|
+
const rows = [...byProject.entries()].sort((a, b) => b[1].cost - a[1].cost).map(([p, t]) => totalsRow(p || "(no project)", t));
|
|
35
|
+
return [`project,messages,${HEADER}`, ...rows].join("\n") + "\n";
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function buildTotalsJson(total: UsageTotals, files: number, generatedAt: number): string {
|
|
39
|
+
return `${JSON.stringify({ generatedAt: new Date(generatedAt).toISOString(), files, total }, null, 2)}\n`;
|
|
40
|
+
}
|
package/src/ratelimit.ts
CHANGED
|
@@ -166,6 +166,46 @@ function formatReset(snap: RateLimitSnapshot): string {
|
|
|
166
166
|
return raw;
|
|
167
167
|
}
|
|
168
168
|
|
|
169
|
+
/** A reset time short enough for the footer: HH:MM today, otherwise MM-DD HH:MM; "" when unknown or unwieldy. */
|
|
170
|
+
export function shortReset(snap: RateLimitSnapshot, now: number = Date.now()): string {
|
|
171
|
+
const raw = snap.resets;
|
|
172
|
+
if (!raw) return "";
|
|
173
|
+
if (snap.source === "anthropic" && /^\d{9,}$/.test(raw)) {
|
|
174
|
+
const d = new Date(Number.parseInt(raw, 10) * 1000);
|
|
175
|
+
if (!Number.isFinite(d.getTime())) return "";
|
|
176
|
+
const hm = `${String(d.getHours()).padStart(2, "0")}:${String(d.getMinutes()).padStart(2, "0")}`;
|
|
177
|
+
const sameDay = new Date(now).toDateString() === d.toDateString();
|
|
178
|
+
return sameDay ? hm : `${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")} ${hm}`;
|
|
179
|
+
}
|
|
180
|
+
return raw.length <= 12 ? raw : "";
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/** Below this share of the binding window left, the footer starts warning. */
|
|
184
|
+
export const FOOTER_WARN_FRACTION = 0.15;
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* The footer's quota warning, or "" while the session is healthy. A Claude
|
|
188
|
+
* subscription user used to learn about the 5h/7d wall only when a turn
|
|
189
|
+
* stalled, or by opening `/usage quota` — the headers were captured either
|
|
190
|
+
* way. This shows the binding window once it is nearly gone, or whenever
|
|
191
|
+
* Anthropic marks the subscription as anything but "allowed". Non-Anthropic
|
|
192
|
+
* providers carry none of these fields, so it stays empty for them.
|
|
193
|
+
*/
|
|
194
|
+
export function footerQuotaSegment(snap: RateLimitSnapshot | null | undefined, now: number = Date.now()): string {
|
|
195
|
+
if (!snap) return "";
|
|
196
|
+
const binding =
|
|
197
|
+
snap.unifiedBinding ?? (snap.unified7dRemaining !== null ? "7d" : snap.unified5hRemaining !== null ? "5h" : null);
|
|
198
|
+
const remaining = binding === "7d" ? snap.unified7dRemaining : binding === "5h" ? snap.unified5hRemaining : null;
|
|
199
|
+
const walled = Boolean(snap.unifiedStatus && snap.unifiedStatus !== "allowed");
|
|
200
|
+
const low = remaining !== null && remaining < FOOTER_WARN_FRACTION;
|
|
201
|
+
if (!walled && !low) return "";
|
|
202
|
+
const parts = [`⚠ ${binding ?? "subscription"}`];
|
|
203
|
+
if (remaining !== null) parts.push(`${Math.max(0, Math.round(remaining * 100))}%`);
|
|
204
|
+
if (walled) parts.push(snap.unifiedStatus!.replace(/_/g, " "));
|
|
205
|
+
const reset = shortReset(snap, now);
|
|
206
|
+
return reset ? `${parts.join(" ")} · resets ${reset}` : parts.join(" ");
|
|
207
|
+
}
|
|
208
|
+
|
|
169
209
|
/** One human line for the /usage quota report; null when nothing was captured. */
|
|
170
210
|
export function formatRateLimit(snap: RateLimitSnapshot | null): string | null {
|
|
171
211
|
if (!snap) return null;
|
package/src/sessions.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import { readFileSync, readdirSync, statSync } from "node:fs";
|
|
2
|
-
import { join } from "node:path";
|
|
1
|
+
import { mkdirSync, readFileSync, readdirSync, renameSync, statSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { dirname, join } from "node:path";
|
|
3
3
|
import { recordFromLine } from "./aggregate.ts";
|
|
4
4
|
import type { UsageRecord } from "./types.ts";
|
|
5
5
|
|
|
@@ -12,9 +12,66 @@ import type { UsageRecord } from "./types.ts";
|
|
|
12
12
|
const MAX_FILE_BYTES = 64 * 1024 * 1024;
|
|
13
13
|
|
|
14
14
|
const cache = new Map<string, { mtimeMs: number; size: number; records: UsageRecord[] }>();
|
|
15
|
+
/** Files the latest scan saw; only these are persisted, so a moved store does not grow the cache forever. */
|
|
16
|
+
let lastSeen = new Set<string>();
|
|
15
17
|
|
|
16
18
|
export function clearScanCache(): void {
|
|
17
19
|
cache.clear();
|
|
20
|
+
lastSeen = new Set();
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** Bump when the cached record shape changes; an older file is ignored and rebuilt. */
|
|
24
|
+
export const SCAN_CACHE_VERSION = 1;
|
|
25
|
+
/** Entries kept on disk; beyond it the oldest-modified files are dropped. */
|
|
26
|
+
export const SCAN_CACHE_MAX_ENTRIES = 5000;
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* The in-process cache only ever helped within one pi process, and the
|
|
30
|
+
* dashboard is opened about once per session — so every fresh pi paid the
|
|
31
|
+
* whole cold scan (seconds, for a large store) on the first /usage. The
|
|
32
|
+
* same Map, written once after a completed scan and read back before the
|
|
33
|
+
* first, makes a warm open cheap. Keyed by size+mtime exactly like the
|
|
34
|
+
* in-memory check, so a stale entry is simply re-read; corrupt, missing or
|
|
35
|
+
* older-version files start empty. Last writer wins — no lock: the cost of
|
|
36
|
+
* a lost write is one cold scan. (pi-usage-extension's cache, simplified.)
|
|
37
|
+
*/
|
|
38
|
+
export function restoreScanCache(file: string): number {
|
|
39
|
+
let raw: unknown;
|
|
40
|
+
try {
|
|
41
|
+
raw = JSON.parse(readFileSync(file, "utf8"));
|
|
42
|
+
} catch {
|
|
43
|
+
return 0;
|
|
44
|
+
}
|
|
45
|
+
if (!raw || typeof raw !== "object" || (raw as { version?: unknown }).version !== SCAN_CACHE_VERSION) return 0;
|
|
46
|
+
const entries = (raw as { entries?: unknown }).entries;
|
|
47
|
+
if (!entries || typeof entries !== "object") return 0;
|
|
48
|
+
let loaded = 0;
|
|
49
|
+
for (const [path, value] of Object.entries(entries as Record<string, unknown>)) {
|
|
50
|
+
const v = value as { mtimeMs?: unknown; size?: unknown; records?: unknown };
|
|
51
|
+
if (typeof v?.mtimeMs !== "number" || typeof v.size !== "number" || !Array.isArray(v.records)) continue;
|
|
52
|
+
cache.set(path, { mtimeMs: v.mtimeMs, size: v.size, records: v.records as UsageRecord[] });
|
|
53
|
+
loaded++;
|
|
54
|
+
}
|
|
55
|
+
return loaded;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function persistScanCache(file: string): number {
|
|
59
|
+
const kept = [...cache.entries()]
|
|
60
|
+
.filter(([path]) => lastSeen.has(path))
|
|
61
|
+
.sort((a, b) => b[1].mtimeMs - a[1].mtimeMs)
|
|
62
|
+
.slice(0, SCAN_CACHE_MAX_ENTRIES);
|
|
63
|
+
const entries: Record<string, { mtimeMs: number; size: number; records: UsageRecord[] }> = {};
|
|
64
|
+
for (const [path, value] of kept) entries[path] = value;
|
|
65
|
+
try {
|
|
66
|
+
mkdirSync(dirname(file), { recursive: true });
|
|
67
|
+
const tmp = `${file}.${process.pid}.tmp`;
|
|
68
|
+
writeFileSync(tmp, JSON.stringify({ version: SCAN_CACHE_VERSION, entries }));
|
|
69
|
+
renameSync(tmp, file);
|
|
70
|
+
} catch {
|
|
71
|
+
// An unwritable cache costs the next process one cold scan, nothing else.
|
|
72
|
+
return 0;
|
|
73
|
+
}
|
|
74
|
+
return kept.length;
|
|
18
75
|
}
|
|
19
76
|
|
|
20
77
|
function listJsonlFiles(dir: string, depth: number): string[] {
|
|
@@ -87,9 +144,11 @@ function groupByProject(sessionsDir: string): Array<{ project: string; files: st
|
|
|
87
144
|
export function scanSessions(sessionsDir: string): ScanResult {
|
|
88
145
|
const records: UsageRecord[] = [];
|
|
89
146
|
let fileCount = 0;
|
|
147
|
+
const seen = new Set<string>();
|
|
90
148
|
for (const { project, files } of groupByProject(sessionsDir)) {
|
|
91
149
|
fileCount += files.length;
|
|
92
150
|
for (const file of files) {
|
|
151
|
+
seen.add(file);
|
|
93
152
|
try {
|
|
94
153
|
const stat = statSync(file);
|
|
95
154
|
const cached = cache.get(file);
|
|
@@ -109,5 +168,6 @@ export function scanSessions(sessionsDir: string): ScanResult {
|
|
|
109
168
|
}
|
|
110
169
|
}
|
|
111
170
|
}
|
|
171
|
+
lastSeen = seen;
|
|
112
172
|
return { records, files: fileCount };
|
|
113
173
|
}
|