@drakulavich/zapara 0.7.4 → 0.8.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/CHANGELOG.md CHANGED
@@ -5,6 +5,26 @@ All notable changes to this project are documented here. The format follows
5
5
 
6
6
  ## [Unreleased]
7
7
 
8
+ ## [0.8.0] - 2026-09-26
9
+
10
+ ### Added
11
+ - zapara caches each transcript's parsed events between runs, in
12
+ `~/.claude/zapara/cache.db`, and reparses a file only when it has changed.
13
+ A row is a hit when its size, modification time and the SHA-256 of its
14
+ last 4 KiB all match the cached one; the row is keyed by the transcript's
15
+ device and inode, never by its path. On one machine, with nothing changed
16
+ since the run before, `zapara card --json --verbose` went from 4362 ms
17
+ (816 files read, 0 cache hits) to 246 ms (0 files read, 816 hits).
18
+ `--no-cache` runs without reading or writing the cache, and `--verbose`
19
+ gains a `cache` line with the hit and miss counts, or `off`.
20
+
21
+ ### Changed
22
+ - A PNG card renders in about 0.7 s instead of 1.1 s on macOS. zapara opens
23
+ one browser view instead of two, and writes WebKit's screenshot as it is
24
+ when it already has the card's size, instead of encoding the same PNG a
25
+ second time. The file is about 2.6 MB instead of 2.1 MB: WebKit's PNG
26
+ encoder compresses less, and the pixels are the same.
27
+
8
28
  ## [0.7.4] - 2026-09-26
9
29
 
10
30
  ### Changed
package/README.md CHANGED
@@ -135,7 +135,8 @@ If your card told you something about your week, star [the repository](https://g
135
135
  | `--projects <dir>` | Read this directory instead of `~/.claude/projects`. |
136
136
  | `--out <path>` | Where `card` writes instead of `~/Downloads`; the extension picks the format. |
137
137
  | `--no-color` | Plain glyphs and peaks with no ANSI codes. `NO_COLOR` in the environment does the same. |
138
- | `--verbose` | After the output, prints to stderr where the time went: files scanned and read, megabytes, and milliseconds for scanning, reading, analysis and the card's render, plus the zapara and Bun versions, platform and CPU count. Numbers only, no path, so the lines are safe to paste into an issue when zapara is slow on your machine. |
138
+ | `--no-cache` | Read and parse every transcript again instead of using `~/.claude/zapara/cache.db`. |
139
+ | `--verbose` | After the output, prints to stderr where the time went: files scanned and read, megabytes, cache hits and misses (or `off` with `--no-cache`, or when the cache could not be opened, including an empty `HOME`), and milliseconds for scanning, reading, analysis and the card's render, plus the zapara and Bun versions, platform and CPU count. Numbers only, no path, so the lines are safe to paste into an issue when zapara is slow on your machine. |
139
140
  | `-h`, `--help` | Usage, exit 0. |
140
141
  | `-V`, `--version` | The version from `package.json`, exit 0. |
141
142
 
@@ -171,7 +172,9 @@ Refreshing is the reader's job, and zapara adds no hook, no timer and no daemon.
171
172
 
172
173
  zapara reads `~/.claude/projects/**/*.jsonl`, skipping subagent transcripts under `subagents/`. It picks files by modification time first, and opens one whose modification time is older than the window only to read the last timestamp in its final 64 KB; nothing from that tail is kept or printed. It compares message text against a few fixed markers, for interrupts, tool rejections and inbound agent messages, then discards it. What survives into an event is a timestamp, a session id, an event kind and a token count.
173
174
 
174
- zapara keeps, writes and prints no message text, prompt length, file path or session title. The CLI never prints a path it derived or read, not even the projects root when it cannot open it. It sends nothing anywhere, writes no file except the card or the status file you ask for, and installs nothing into Claude Code.
175
+ zapara keeps, writes and prints no message text, prompt length, file path or session title. The CLI never prints a path it derived or read, not even the projects root when it cannot open it. It sends nothing anywhere, writes no file except the card, the status file and the cache below, and installs nothing into Claude Code.
176
+
177
+ Between runs, zapara caches each transcript's parsed events in `~/.claude/zapara/cache.db`. A hit still reads the last 4 KiB of the file to confirm it matches the cached row, then skips reading and parsing the rest. A row is keyed by the transcript's device and inode, never by its path or a hash of it. Besides the device and inode, a row stores a fingerprint of the parser that wrote it, the file's size and modification time, a hash of its last 4 KiB, the cutoff its events were parsed with, when the row was last used, and the parsed events themselves. No message text, prompt length, path or title is stored. `--no-cache` runs without reading or writing it.
175
178
 
176
179
  ## Limits
177
180
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@drakulavich/zapara",
3
- "version": "0.7.4",
3
+ "version": "0.8.0",
4
4
  "description": "Cognitive load index for people driving Claude Code, computed locally from transcripts",
5
5
  "license": "MIT",
6
6
  "author": "Anton Yakutovich",
package/src/analyze.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  import { compareStrings, derive, windowBounds } from "./derive.ts";
2
2
  import { parseTranscript } from "./parse.ts";
3
- import type { Day, Transcript, Window } from "./types.ts";
3
+ import type { Day, Event, Transcript, Window } from "./types.ts";
4
4
 
5
5
  // Sorted by path so file discovery order cannot change a result.
6
6
  export function analyze(transcripts: Transcript[], w: Window): Day[] {
@@ -8,5 +8,10 @@ export function analyze(transcripts: Transcript[], w: Window): Day[] {
8
8
  const events = [...transcripts]
9
9
  .sort((a, b) => compareStrings(a.path, b.path))
10
10
  .flatMap((t) => parseTranscript(t.text, cutoffMs));
11
+ return analyzeEvents(events, w);
12
+ }
13
+
14
+ // Events already parsed, concatenated in path order as `analyze` does.
15
+ export function analyzeEvents(events: Event[], w: Window): Day[] {
11
16
  return derive(events, w);
12
17
  }
package/src/cache.ts ADDED
@@ -0,0 +1,207 @@
1
+ // The only module that uses bun:sqlite or writes the cache. The transcripts are
2
+ // the system of record: every doubt here resolves to a miss, and no error leaves.
3
+ import { Database } from "bun:sqlite";
4
+ import { createHash } from "node:crypto";
5
+ import { chmodSync, mkdirSync, readFileSync, rmSync } from "node:fs";
6
+ import { open } from "node:fs/promises";
7
+ import { join } from "node:path";
8
+ import type { ScanEntry } from "./scan.ts";
9
+ import type { Event, EventKind } from "./types.ts";
10
+
11
+ export type Fresh = { entry: ScanEntry; tail: Uint8Array; fromMs: number; events: Event[] };
12
+ export type TranscriptCache = {
13
+ hits(entries: ScanEntry[], cutoffMs: number): Promise<Map<string, Event[]>>;
14
+ save(hit: ScanEntry[], fresh: Fresh[], nowMs: number): void;
15
+ close(): void;
16
+ };
17
+
18
+ const USER_VERSION = 1;
19
+ const TAIL_BYTES = 4096;
20
+ const READERS = 16;
21
+ const KEEP_MS = 90 * 86_400_000;
22
+ const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
23
+ const KINDS: Record<EventKind, true> = { prompt: true, report: true, output: true, interrupt: true, reject: true, answer: true, question: true, plan_review: true, mode_change: true, activity: true };
24
+
25
+ export function tailHash(bytes: Uint8Array): Uint8Array {
26
+ return createHash("sha256").update(bytes.subarray(Math.max(0, bytes.length - TAIL_BYTES))).digest();
27
+ }
28
+
29
+ function fingerprint(): Uint8Array {
30
+ const h = createHash("sha256");
31
+ h.update(readFileSync(new URL("./parse.ts", import.meta.url)));
32
+ h.update(readFileSync(new URL("./types.ts", import.meta.url)));
33
+ h.update(String((JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8")) as { version?: unknown }).version));
34
+ return h.digest();
35
+ }
36
+
37
+ function encode(events: Event[]): Uint8Array {
38
+ const sessions: string[] = [];
39
+ const index = new Map<string, number>();
40
+ const rows = events.map((e) => {
41
+ let i = index.get(e.sessionId);
42
+ if (i === undefined) { i = sessions.length; index.set(e.sessionId, i); sessions.push(e.sessionId); }
43
+ return e.tokens === undefined ? [e.ts, e.kind, i] : [e.ts, e.kind, i, e.tokens];
44
+ });
45
+ return new TextEncoder().encode(JSON.stringify({ sessions, events: rows }));
46
+ }
47
+
48
+ function decode(blob: Uint8Array): Event[] | null {
49
+ try {
50
+ const d: unknown = JSON.parse(new TextDecoder().decode(blob));
51
+ if (typeof d !== "object" || d === null) return null;
52
+ const { sessions, events } = d as { sessions?: unknown; events?: unknown };
53
+ if (!Array.isArray(sessions) || !sessions.every((s) => typeof s === "string") || !Array.isArray(events)) return null;
54
+ const out: Event[] = [];
55
+ for (const r of events) {
56
+ if (!Array.isArray(r) || r.length < 3 || r.length > 4) return null;
57
+ const [ts, kind, i, tokens] = r as unknown[];
58
+ if (typeof ts !== "number" || typeof kind !== "string" || !Object.hasOwn(KINDS, kind) || typeof i !== "number" || sessions[i] === undefined) return null;
59
+ const e: Event = { ts, sessionId: sessions[i] as string, kind: kind as EventKind };
60
+ if (r.length === 4) {
61
+ if (kind !== "output" || typeof tokens !== "number") return null;
62
+ e.tokens = tokens;
63
+ }
64
+ out.push(e);
65
+ }
66
+ return out;
67
+ } catch {
68
+ return null;
69
+ }
70
+ }
71
+
72
+ // The bytes just before the stored offset, like a log consumer's check; the
73
+ // size comparison is what notices a file that grew.
74
+ async function readTail(path: string, size: number): Promise<Uint8Array | null> {
75
+ try {
76
+ const fh = await open(path, "r");
77
+ try {
78
+ const n = Math.min(TAIL_BYTES, size);
79
+ const buf = new Uint8Array(n);
80
+ const { bytesRead } = await fh.read(buf, 0, n, size - n);
81
+ return bytesRead === n ? buf : null;
82
+ } finally {
83
+ await fh.close();
84
+ }
85
+ } catch {
86
+ return null;
87
+ }
88
+ }
89
+
90
+ const equal = (a: Uint8Array, b: Uint8Array): boolean => a.length === b.length && a.every((x, i) => x === b[i]);
91
+
92
+ type Row = { dev: number; ino: number; size: number; mtime_ms: number; tail: Uint8Array; from_ms: number; events: Uint8Array };
93
+
94
+ class Foreign extends Error {}
95
+
96
+ // Only a file that is not ours is replaced; a locked or unopenable one is left alone.
97
+ const replaceable = (e: unknown): boolean =>
98
+ e instanceof Foreign || /^SQLITE_(NOTADB|CORRUPT)/.test(String((e as { code?: unknown } | null)?.code));
99
+
100
+ function connect(path: string): Database {
101
+ const db = new Database(path, { create: true, strict: true });
102
+ try {
103
+ chmodSync(path, 0o600);
104
+ db.run("PRAGMA busy_timeout = 2000");
105
+ db.run("PRAGMA journal_mode = WAL");
106
+ db.run("PRAGMA synchronous = NORMAL");
107
+ db.transaction(() => {
108
+ const { user_version } = db.query<{ user_version: number }, []>("PRAGMA user_version").get()!;
109
+ if (user_version === USER_VERSION) return;
110
+ if (user_version !== 0) throw new Foreign();
111
+ db.run(`CREATE TABLE transcript (
112
+ dev INTEGER NOT NULL, ino INTEGER NOT NULL, parser BLOB NOT NULL, size INTEGER NOT NULL, mtime_ms REAL NOT NULL,
113
+ tail BLOB NOT NULL, from_ms REAL NOT NULL, used_at INTEGER NOT NULL, events BLOB NOT NULL, PRIMARY KEY (dev, ino))`);
114
+ db.run(`PRAGMA user_version = ${USER_VERSION}`);
115
+ }).immediate();
116
+ return db;
117
+ } catch (e) {
118
+ try { db.close(); } catch {}
119
+ throw e;
120
+ }
121
+ }
122
+
123
+ export function openCache(env: NodeJS.ProcessEnv): TranscriptCache | null {
124
+ const home = env.HOME;
125
+ if (!home) return null;
126
+ try {
127
+ const parser = fingerprint();
128
+ process.umask(0o077);
129
+ const dir = join(home, ".claude", "zapara");
130
+ mkdirSync(dir, { recursive: true, mode: 0o700 });
131
+ chmodSync(dir, 0o700);
132
+ const path = join(dir, "cache.db");
133
+ try {
134
+ return cacheOn(connect(path), parser);
135
+ } catch (e) {
136
+ if (!replaceable(e)) return null;
137
+ }
138
+ for (const f of [path, `${path}-wal`, `${path}-shm`]) rmSync(f, { force: true });
139
+ return cacheOn(connect(path), parser);
140
+ } catch {
141
+ return null;
142
+ }
143
+ }
144
+
145
+ function cacheOn(db: Database, parser: Uint8Array): TranscriptCache {
146
+ let failed = false;
147
+ return {
148
+ async hits(entries, cutoffMs) {
149
+ const found = new Map<string, Event[]>();
150
+ try {
151
+ const keyed = entries.filter((e) => e.ino !== 0);
152
+ const rows = db.query<Row, [Uint8Array, string]>(
153
+ `SELECT dev, ino, size, mtime_ms, tail, from_ms, events FROM transcript
154
+ WHERE parser = ? AND (dev, ino) IN (SELECT json_extract(value, '$[0]'), json_extract(value, '$[1]') FROM json_each(?))`,
155
+ ).all(parser, JSON.stringify(keyed.map((e) => [e.dev, e.ino])));
156
+ const byKey = new Map(rows.map((r) => [`${r.dev}:${r.ino}`, r]));
157
+ const candidates = keyed.flatMap((entry) => {
158
+ const row = byKey.get(`${entry.dev}:${entry.ino}`);
159
+ return row && row.size === entry.size && row.mtime_ms === entry.mtimeMs && row.from_ms <= cutoffMs ? [{ entry, row }] : [];
160
+ });
161
+ let next = 0;
162
+ const reader = async (): Promise<void> => {
163
+ while (next < candidates.length) {
164
+ const { entry, row } = candidates[next++]!;
165
+ const tail = await readTail(entry.path, row.size);
166
+ if (tail === null || !equal(tailHash(tail), row.tail)) continue;
167
+ const events = decode(row.events);
168
+ if (events !== null) found.set(entry.path, events);
169
+ }
170
+ };
171
+ await Promise.all(Array.from({ length: Math.min(READERS, candidates.length) }, reader));
172
+ } catch {
173
+ failed = true;
174
+ found.clear();
175
+ }
176
+ return found;
177
+ },
178
+
179
+ save(hit, fresh, nowMs) {
180
+ if (failed) return;
181
+ try {
182
+ const upsert = db.query(
183
+ `INSERT INTO transcript (dev, ino, parser, size, mtime_ms, tail, from_ms, used_at, events)
184
+ VALUES ($dev, $ino, $parser, $size, $mtime, $tail, $from, $now, $events)
185
+ ON CONFLICT(dev, ino) DO UPDATE SET parser = excluded.parser, size = excluded.size, mtime_ms = excluded.mtime_ms,
186
+ tail = excluded.tail, from_ms = excluded.from_ms, used_at = excluded.used_at, events = excluded.events
187
+ WHERE NOT (transcript.parser = excluded.parser AND transcript.size = excluded.size
188
+ AND transcript.mtime_ms = excluded.mtime_ms AND transcript.tail = excluded.tail
189
+ AND transcript.from_ms <= excluded.from_ms)`,
190
+ );
191
+ const touch = db.query("UPDATE transcript SET used_at = $now WHERE dev = $dev AND ino = $ino");
192
+ db.transaction(() => {
193
+ for (const { entry: e, tail, fromMs, events } of fresh) {
194
+ if (e.ino === 0 || !events.every((ev) => UUID.test(ev.sessionId))) continue;
195
+ upsert.run({ dev: e.dev, ino: e.ino, parser, size: e.size, mtime: e.mtimeMs, tail, from: fromMs, now: nowMs, events: encode(events) });
196
+ }
197
+ for (const e of hit) if (e.ino !== 0) touch.run({ now: nowMs, dev: e.dev, ino: e.ino });
198
+ db.run("DELETE FROM transcript WHERE used_at < ?", [nowMs - KEEP_MS]);
199
+ }).immediate();
200
+ } catch {}
201
+ },
202
+
203
+ close() {
204
+ try { db.close(); } catch {}
205
+ },
206
+ };
207
+ }
package/src/image.ts CHANGED
@@ -45,20 +45,19 @@ export async function renderCard(html: string, out: string, timeoutMs = 15_000):
45
45
  // 4800-wide shot, 2.2 s of a 3 s render. The viewport and the page's zoom
46
46
  // (2 in cardHtml) shrink by the density, so the shot is 2400 wide already.
47
47
  // Headless Chrome (or Edge) shoots at 1x and cannot evaluate before a navigate.
48
- let dpr = 1;
49
- if (BACKEND === "webkit") {
50
- const probe = new Bun.WebView({ width: 1, height: 1, backend: BACKEND });
51
- try { dpr = await probe.evaluate<number>("devicePixelRatio"); } finally { probe.close(); }
52
- }
53
- const view = new Bun.WebView({ width: Math.round(WIDTH / dpr), height: Math.round(HEIGHT / dpr), backend: BACKEND });
48
+ const view = new Bun.WebView({ width: WIDTH, height: HEIGHT, backend: BACKEND });
54
49
  try {
50
+ const dpr = BACKEND === "webkit" ? await view.evaluate<number>("devicePixelRatio") : 1;
51
+ if (dpr !== 1) await view.resize(Math.round(WIDTH / dpr), Math.round(HEIGHT / dpr));
55
52
  await view.navigate("data:text/html;charset=utf-8," + encodeURIComponent(html));
56
53
  await view.evaluate(`document.documentElement.style.zoom = "${2 / dpr}"`);
57
54
  while (!(await view.evaluate<boolean>(READY))) await Bun.sleep(50);
58
55
  const shot = await view.screenshot({ encoding: "buffer", format: "png" });
59
56
  const image = new Bun.Image(shot);
60
57
  const meta = await image.metadata();
61
- if (meta.width !== WIDTH || meta.height !== HEIGHT) image.resize(WIDTH, HEIGHT, { fit: "fill" });
58
+ const sized = meta.width === WIDTH && meta.height === HEIGHT;
59
+ if (sized && !lower.endsWith(".webp")) return shot;
60
+ if (!sized) image.resize(WIDTH, HEIGHT, { fit: "fill" });
62
61
  return lower.endsWith(".webp") ? await image.webp({ quality: 90 }).bytes() : await image.png().bytes();
63
62
  } finally {
64
63
  view.close();
package/src/index.ts CHANGED
@@ -3,6 +3,7 @@
3
3
  import { readFileSync, statSync } from "node:fs";
4
4
  import { cpus, homedir } from "node:os";
5
5
  import { join } from "node:path";
6
+ import { openCache, type TranscriptCache } from "./cache.ts";
6
7
  import { cardData, sentenceText } from "./card.ts";
7
8
  import { cardHtml } from "./cardhtml.ts";
8
9
  import { localDate } from "./derive.ts";
@@ -40,6 +41,7 @@ options:
40
41
  --json the same data as JSON; a pipe gets JSON without asking
41
42
  --projects <dir> read this directory instead of ~/.claude/projects
42
43
  --no-color no ANSI colors; NO_COLOR does the same
44
+ --no-cache parse every transcript again
43
45
  --verbose timings and counts on stderr, for a slow run
44
46
  -h, --help -V, --version
45
47
 
@@ -48,7 +50,7 @@ levels: calm 0-29 warming 30-59 heating 60-84 fried 85-100
48
50
  bugs, ideas and a star: github.com/drakulavich/zapara`;
49
51
  const HINT = "run 'zapara --help' for usage";
50
52
 
51
- type Args = { command: "grid" | "day" | "card" | "status"; to: string; days: number; explain: boolean; json: boolean; out: string | null; projects: string; color: boolean; verbose: boolean };
53
+ type Args = { command: "grid" | "day" | "card" | "status"; to: string; days: number; explain: boolean; json: boolean; out: string | null; projects: string; color: boolean; verbose: boolean; cache: boolean };
52
54
 
53
55
  class UsageError extends Error {}
54
56
  // Thrown only at a flag position, never for a token consumed as another flag's
@@ -99,7 +101,7 @@ function windowOf(days: string | null, from: string | null, to: string | null, d
99
101
  }
100
102
 
101
103
  function parseArgs(argv: string[], now: Date, env: NodeJS.ProcessEnv, isTTY: boolean): Args {
102
- const a: Args = { command: "grid", to: localDate(now), days: 7, explain: false, json: false, out: null, projects: join(homedir(), ".claude", "projects"), color: isTTY && !env.NO_COLOR, verbose: false };
104
+ const a: Args = { command: "grid", to: localDate(now), days: 7, explain: false, json: false, out: null, projects: join(homedir(), ".claude", "projects"), color: isTTY && !env.NO_COLOR, verbose: false, cache: true };
103
105
  let days: string | null = null;
104
106
  let from: string | null = null;
105
107
  let to: string | null = null;
@@ -136,6 +138,7 @@ function parseArgs(argv: string[], now: Date, env: NodeJS.ProcessEnv, isTTY: boo
136
138
  case "--explain": bare(); a.explain = true; break;
137
139
  case "--no-color": bare(); a.color = false; break;
138
140
  case "--verbose": bare(); a.verbose = true; break;
141
+ case "--no-cache": bare(); a.cache = false; break;
139
142
  case "--projects": a.projects = value(); break;
140
143
  case "--from": from = value(); break;
141
144
  case "--to": to = value(); break;
@@ -176,13 +179,18 @@ async function main(): Promise<number> {
176
179
  const now = new Date();
177
180
  const a = parseArgs(argv, now, process.env, process.stdout.isTTY === true);
178
181
  const timing = a.verbose ? ({} as Timing) : undefined;
179
- const code = a.command === "card" ? await card(a, timing) : a.command === "status" ? await status(a, now, timing) : await table(a, now, timing);
180
- if (timing) process.stderr.write(timingLines(timing));
181
- return code;
182
+ const cache = a.cache ? openCache(process.env) : null;
183
+ try {
184
+ const code = a.command === "card" ? await card(a, cache, timing) : a.command === "status" ? await status(a, now, cache, timing) : await table(a, now, cache, timing);
185
+ if (timing) process.stderr.write(timingLines(timing));
186
+ return code;
187
+ } finally {
188
+ cache?.close();
189
+ }
182
190
  }
183
191
 
184
- async function table(a: Args, now: Date, timing?: Timing): Promise<number> {
185
- const days: Day[] = await report({ projects: a.projects, to: a.to, days: a.days, now }, timing);
192
+ async function table(a: Args, now: Date, cache: TranscriptCache | null, timing?: Timing): Promise<number> {
193
+ const days: Day[] = await report({ projects: a.projects, to: a.to, days: a.days, now }, timing, cache);
186
194
  const data = a.command === "day" ? days[0] : days;
187
195
  if (a.json) console.log(renderJson(data!));
188
196
  else if (a.command === "day") console.log(renderDay(days[0]!, { explain: a.explain, color: a.color }));
@@ -191,8 +199,8 @@ async function table(a: Args, now: Date, timing?: Timing): Promise<number> {
191
199
  }
192
200
 
193
201
  // The write comes first: a caller never reads a line that was not saved.
194
- async function status(a: Args, now: Date, timing?: Timing): Promise<number> {
195
- const days: Day[] = await report({ projects: a.projects, to: a.to, days: a.days, now }, timing);
202
+ async function status(a: Args, now: Date, cache: TranscriptCache | null, timing?: Timing): Promise<number> {
203
+ const days: Day[] = await report({ projects: a.projects, to: a.to, days: a.days, now }, timing, cache);
196
204
  const line = renderStatus(statusOf(days[0]!, now));
197
205
  await writeStatus(line, process.env);
198
206
  process.stdout.write(line);
@@ -209,8 +217,8 @@ function cardTarget(out: string | null): { path: string; label: string } {
209
217
  return { path: join(dir, "zapara-card.png"), label: "zapara-card.png to Downloads" };
210
218
  }
211
219
 
212
- async function card(a: Args, timing?: Timing): Promise<number> {
213
- const days: Day[] = await report({ projects: a.projects, to: a.to, days: a.days }, timing);
220
+ async function card(a: Args, cache: TranscriptCache | null, timing?: Timing): Promise<number> {
221
+ const days: Day[] = await report({ projects: a.projects, to: a.to, days: a.days }, timing, cache);
214
222
  const data = cardData(days, { days: a.days });
215
223
  if (data === null) throw new Error(`no activity in the last ${a.days} days`);
216
224
  if (a.json) {
@@ -251,10 +259,11 @@ function timingLines(t: Timing): string {
251
259
  let ver = "?";
252
260
  try { ver = version(); } catch {}
253
261
  const row = (label: string, what: string, ms: number) => `${label.padEnd(8)}${what.padEnd(44)}${String(Math.round(ms)).padStart(7)} ms\n`;
254
- const plural = (n: number, one: string) => `${n} ${one}${n === 1 ? "" : "s"}`;
262
+ const plural = (n: number, one: string, many = `${one}s`) => `${n} ${n === 1 ? one : many}`;
255
263
  return `zapara ${ver} · bun ${Bun.version} · ${process.platform} ${process.arch} · ${cpus().length} cpus\n`
256
264
  + row("scan", `${plural(t.files, "file")}, ${t.inWindow} in window, ${plural(t.tailChecks, "tail check")}`, t.scanMs)
257
265
  + row("read", `${plural(t.read, "file")}, ${(t.bytes / 1e6).toFixed(1)} MB, ${READERS} at a time`, t.readMs)
266
+ + `${"cache".padEnd(8)}${t.cache ? `${plural(t.cache.hits, "hit")}, ${plural(t.cache.misses, "miss", "misses")}` : "off"}\n`
258
267
  + row("analyze", "", t.analyzeMs)
259
268
  + (t.render ? row("render", t.render.format, t.render.ms) : "")
260
269
  + row("total", "", t.totalMs ?? performance.now());
package/src/report.ts CHANGED
@@ -1,43 +1,75 @@
1
- import { readFile } from "node:fs/promises";
2
- import { analyze } from "./analyze.ts";
1
+ import { open } from "node:fs/promises";
2
+ import { analyzeEvents } from "./analyze.ts";
3
+ import { tailHash, type Fresh, type TranscriptCache } from "./cache.ts";
3
4
  import { windowBounds } from "./derive.ts";
4
- import { scan, type ScanStats } from "./scan.ts";
5
- import type { Day, Transcript } from "./types.ts";
5
+ import { parseTranscript } from "./parse.ts";
6
+ import { scan, type ScanEntry, type ScanStats } from "./scan.ts";
7
+ import type { Day, Event } from "./types.ts";
6
8
 
7
9
  export type ReportOptions = { projects: string; to: string; days: number; now?: Date };
8
10
  // What --verbose prints about a run: counts and milliseconds, never a path.
9
- export type Timing = ScanStats & { inWindow: number; read: number; bytes: number; scanMs: number; readMs: number; analyzeMs: number; render?: { format: string; ms: number }; totalMs?: number };
11
+ export type Timing = ScanStats & { inWindow: number; read: number; bytes: number; cache: { hits: number; misses: number } | null; scanMs: number; readMs: number; analyzeMs: number; render?: { format: string; ms: number }; totalMs?: number };
10
12
 
11
13
  // Reading one file at a time left the disk idle between files: 1.6 GB took 5.4 s
12
14
  // sequentially and 0.9 s in parallel. The cap keeps open files well under the limit.
13
15
  export const READERS = 16;
14
16
 
15
- export async function report(o: ReportOptions, timing?: Timing): Promise<Day[]> {
17
+ type Read = { bytes: Buffer; stable: ScanEntry | null };
18
+
19
+ // `stable` only when both fstat agree and every byte was read: a file written
20
+ // during the read is used for this run and never cached.
21
+ async function readWhole(entry: ScanEntry): Promise<Read> {
22
+ const fh = await open(entry.path, "r");
23
+ try {
24
+ const before = await fh.stat();
25
+ const bytes = await fh.readFile();
26
+ const after = await fh.stat();
27
+ const same = before.dev === after.dev && before.ino === after.ino && before.size === after.size && before.mtimeMs === after.mtimeMs && before.size === bytes.length;
28
+ return { bytes, stable: same ? { path: entry.path, dev: after.dev, ino: after.ino, size: after.size, mtimeMs: after.mtimeMs } : null };
29
+ } finally {
30
+ await fh.close();
31
+ }
32
+ }
33
+
34
+ export async function report(o: ReportOptions, timing?: Timing, cache?: TranscriptCache | null): Promise<Day[]> {
16
35
  const window = { to: o.to, days: o.days, now: o.now };
36
+ const { cutoffMs } = windowBounds(window);
17
37
  const stats: ScanStats = { files: 0, tailChecks: 0 };
18
38
  let t = performance.now();
19
- const paths = await scan(o.projects, windowBounds(window).cutoffMs, stats);
39
+ const entries = await scan(o.projects, cutoffMs, stats);
20
40
  const scanMs = performance.now() - t;
21
41
  t = performance.now();
22
- const texts: (string | null)[] = new Array(paths.length).fill(null);
42
+ const hits = cache ? await cache.hits(entries, cutoffMs) : new Map<string, Event[]>();
43
+ const misses = entries.filter((e) => !hits.has(e.path));
44
+ const reads = new Map<string, Read>();
23
45
  let next = 0;
24
46
  let bytes = 0;
25
47
  const reader = async (): Promise<void> => {
26
- while (next < paths.length) {
27
- const i = next++;
48
+ while (next < misses.length) {
49
+ const entry = misses[next++]!;
28
50
  try {
29
- const buf = await readFile(paths[i]!);
30
- bytes += buf.length;
31
- texts[i] = buf.toString("utf8");
51
+ const r = await readWhole(entry);
52
+ bytes += r.bytes.length;
53
+ reads.set(entry.path, r);
32
54
  } catch { /* vanished or unreadable: skip */ }
33
55
  }
34
56
  };
35
- await Promise.all(Array.from({ length: Math.min(READERS, paths.length) }, reader));
57
+ await Promise.all(Array.from({ length: Math.min(READERS, misses.length) }, reader));
36
58
  const readMs = performance.now() - t;
37
- const transcripts: Transcript[] = [];
38
- paths.forEach((path, i) => { if (texts[i] !== null) transcripts.push({ path, text: texts[i]! }); });
39
59
  t = performance.now();
40
- const days = analyze(transcripts, window);
41
- if (timing) Object.assign(timing, { ...stats, inWindow: paths.length, read: transcripts.length, bytes, scanMs, readMs, analyzeMs: performance.now() - t });
60
+ const fresh: Fresh[] = [];
61
+ const events = entries.flatMap((e) => {
62
+ const hit = hits.get(e.path);
63
+ if (hit) return hit;
64
+ const r = reads.get(e.path);
65
+ if (!r) return [];
66
+ const parsed = parseTranscript(r.bytes.toString("utf8"), cutoffMs);
67
+ if (r.stable) fresh.push({ entry: r.stable, tail: tailHash(r.bytes), fromMs: cutoffMs, events: parsed });
68
+ return parsed;
69
+ });
70
+ const days = analyzeEvents(events, window);
71
+ const analyzeMs = performance.now() - t;
72
+ cache?.save(entries.filter((e) => hits.has(e.path)), fresh, Date.now());
73
+ if (timing) Object.assign(timing, { ...stats, inWindow: entries.length, read: reads.size, bytes, cache: cache ? { hits: hits.size, misses: misses.length } : null, scanMs, readMs, analyzeMs });
42
74
  return days;
43
75
  }
package/src/scan.ts CHANGED
@@ -1,13 +1,15 @@
1
1
  import type { Dirent } from "node:fs";
2
2
  import { open, readdir, stat } from "node:fs/promises";
3
3
  import { join } from "node:path";
4
+ import { compareStrings } from "./derive.ts";
4
5
 
5
6
  // What --verbose reports about the walk: transcripts seen, and how many of them
6
7
  // were opened only to read the tail of a file older than the window by mtime.
7
8
  export type ScanStats = { files: number; tailChecks: number };
9
+ export type ScanEntry = { path: string; dev: number; ino: number; size: number; mtimeMs: number };
8
10
 
9
11
  // A `subagents` directory holds the parent agent's conversation, not the human's.
10
- export async function scan(projects: string, cutoffMs: number, stats: ScanStats = { files: 0, tailChecks: 0 }): Promise<string[]> {
12
+ export async function scan(projects: string, cutoffMs: number, stats: ScanStats = { files: 0, tailChecks: 0 }): Promise<ScanEntry[]> {
11
13
  // No path in either message: the CLI never prints one.
12
14
  let root;
13
15
  try {
@@ -18,14 +20,14 @@ export async function scan(projects: string, cutoffMs: number, stats: ScanStats
18
20
  if (code === "EACCES" || code === "EPERM") throw new Error("projects directory cannot be read (check its permissions)");
19
21
  throw new Error("projects directory not found (pass --projects <dir>)");
20
22
  }
21
- const out: string[] = [];
23
+ const out: ScanEntry[] = [];
22
24
  await collect(projects, root, cutoffMs, out, stats);
23
- return out.sort();
25
+ return out.sort((a, b) => compareStrings(a.path, b.path));
24
26
  }
25
27
 
26
28
  // One unreadable directory costs only itself. Symlinks are not followed: Claude
27
29
  // Code never writes one, and following one is how a loop or $HOME would get in.
28
- async function collect(dir: string, entries: Dirent[], cutoffMs: number, out: string[], stats: ScanStats): Promise<void> {
30
+ async function collect(dir: string, entries: Dirent[], cutoffMs: number, out: ScanEntry[], stats: ScanStats): Promise<void> {
29
31
  for (const entry of entries) {
30
32
  const full = join(dir, entry.name);
31
33
  if (entry.isDirectory()) {
@@ -36,11 +38,12 @@ async function collect(dir: string, entries: Dirent[], cutoffMs: number, out: st
36
38
  } else if (entry.isFile() && entry.name.endsWith(".jsonl")) {
37
39
  stats.files++;
38
40
  try {
39
- if ((await stat(full)).mtimeMs >= cutoffMs) out.push(full);
40
- else {
41
+ const st = await stat(full);
42
+ if (st.mtimeMs < cutoffMs) {
41
43
  stats.tailChecks++;
42
- if ((await lastTimestampMs(full)) >= cutoffMs) out.push(full);
44
+ if ((await lastTimestampMs(full)) < cutoffMs) continue;
43
45
  }
46
+ out.push({ path: full, dev: st.dev, ino: st.ino, size: st.size, mtimeMs: st.mtimeMs });
44
47
  } catch {}
45
48
  }
46
49
  }