@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 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.
@@ -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
- ctx.ui.setStatus("usage", gauge ? `${base} · ${gauge}` : base);
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 = scanSessions(join(getAgentDir(), "sessions"));
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 = scanSessions(join(getAgentDir(), "sessions"));
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pify/usage",
3
- "version": "0.10.0",
3
+ "version": "0.12.0",
4
4
  "description": "Token and cost reporting for pi sessions: live footer, local-history dashboard with per-project costs, opt-in provider quota",
5
5
  "keywords": [
6
6
  "pi-package",
@@ -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
  }