@drakulavich/zapara 0.7.3 → 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 +30 -0
- package/README.md +5 -2
- package/package.json +1 -1
- package/src/analyze.ts +6 -1
- package/src/cache.ts +207 -0
- package/src/image.ts +10 -1
- package/src/index.ts +23 -13
- package/src/report.ts +50 -18
- package/src/scan.ts +10 -7
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,36 @@ 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
|
+
|
|
28
|
+
## [0.7.4] - 2026-09-26
|
|
29
|
+
|
|
30
|
+
### Changed
|
|
31
|
+
- On a high-density screen, `zapara card` draws the picture at its final size
|
|
32
|
+
instead of at double size and scaling it down. The PNG render on a Mac went
|
|
33
|
+
from about 3.4 s to 1.6 s. The picture is the same.
|
|
34
|
+
|
|
35
|
+
### Fixed
|
|
36
|
+
- `--verbose` no longer counts the wait at `open it? [Y/n]` in the `total` row.
|
|
37
|
+
|
|
8
38
|
## [0.7.3] - 2026-09-26
|
|
9
39
|
|
|
10
40
|
### Added
|
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
|
-
| `--
|
|
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
|
|
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
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
|
@@ -41,14 +41,23 @@ export async function renderCard(html: string, out: string, timeoutMs = 15_000):
|
|
|
41
41
|
try {
|
|
42
42
|
bytes = await Promise.race([
|
|
43
43
|
(async () => {
|
|
44
|
+
// WebKit draws at the screen's density: at 2x, a 2400-wide viewport made a
|
|
45
|
+
// 4800-wide shot, 2.2 s of a 3 s render. The viewport and the page's zoom
|
|
46
|
+
// (2 in cardHtml) shrink by the density, so the shot is 2400 wide already.
|
|
47
|
+
// Headless Chrome (or Edge) shoots at 1x and cannot evaluate before a navigate.
|
|
44
48
|
const view = new Bun.WebView({ width: WIDTH, height: HEIGHT, backend: BACKEND });
|
|
45
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));
|
|
46
52
|
await view.navigate("data:text/html;charset=utf-8," + encodeURIComponent(html));
|
|
53
|
+
await view.evaluate(`document.documentElement.style.zoom = "${2 / dpr}"`);
|
|
47
54
|
while (!(await view.evaluate<boolean>(READY))) await Bun.sleep(50);
|
|
48
55
|
const shot = await view.screenshot({ encoding: "buffer", format: "png" });
|
|
49
56
|
const image = new Bun.Image(shot);
|
|
50
57
|
const meta = await image.metadata();
|
|
51
|
-
|
|
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" });
|
|
52
61
|
return lower.endsWith(".webp") ? await image.webp({ quality: 90 }).bytes() : await image.png().bytes();
|
|
53
62
|
} finally {
|
|
54
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
|
|
180
|
-
|
|
181
|
-
|
|
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) {
|
|
@@ -236,6 +244,7 @@ async function card(a: Args, timing?: Timing): Promise<number> {
|
|
|
236
244
|
}
|
|
237
245
|
if (timing) timing.render = { format: target.path.slice(target.path.lastIndexOf(".") + 1).toLowerCase(), ms: performance.now() - t };
|
|
238
246
|
console.log(`${data.name}: ${sentenceText(data.sentence)}\nwrote ${target.label}`);
|
|
247
|
+
if (timing) timing.totalMs = performance.now(); // the wait for an answer is not zapara's time
|
|
239
248
|
if (process.stdin.isTTY && process.stdout.isTTY && process.platform !== "win32") {
|
|
240
249
|
process.stdout.write("open it? [Y/n] ");
|
|
241
250
|
let answer: string | null = null;
|
|
@@ -250,13 +259,14 @@ function timingLines(t: Timing): string {
|
|
|
250
259
|
let ver = "?";
|
|
251
260
|
try { ver = version(); } catch {}
|
|
252
261
|
const row = (label: string, what: string, ms: number) => `${label.padEnd(8)}${what.padEnd(44)}${String(Math.round(ms)).padStart(7)} ms\n`;
|
|
253
|
-
const plural = (n: number, one: string) => `${n} ${
|
|
262
|
+
const plural = (n: number, one: string, many = `${one}s`) => `${n} ${n === 1 ? one : many}`;
|
|
254
263
|
return `zapara ${ver} · bun ${Bun.version} · ${process.platform} ${process.arch} · ${cpus().length} cpus\n`
|
|
255
264
|
+ row("scan", `${plural(t.files, "file")}, ${t.inWindow} in window, ${plural(t.tailChecks, "tail check")}`, t.scanMs)
|
|
256
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`
|
|
257
267
|
+ row("analyze", "", t.analyzeMs)
|
|
258
268
|
+ (t.render ? row("render", t.render.format, t.render.ms) : "")
|
|
259
|
-
+ row("total", "", performance.now());
|
|
269
|
+
+ row("total", "", t.totalMs ?? performance.now());
|
|
260
270
|
}
|
|
261
271
|
|
|
262
272
|
if (import.meta.main) {
|
package/src/report.ts
CHANGED
|
@@ -1,43 +1,75 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
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 {
|
|
5
|
-
import
|
|
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 } };
|
|
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
|
-
|
|
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
|
|
39
|
+
const entries = await scan(o.projects, cutoffMs, stats);
|
|
20
40
|
const scanMs = performance.now() - t;
|
|
21
41
|
t = performance.now();
|
|
22
|
-
const
|
|
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 <
|
|
27
|
-
const
|
|
48
|
+
while (next < misses.length) {
|
|
49
|
+
const entry = misses[next++]!;
|
|
28
50
|
try {
|
|
29
|
-
const
|
|
30
|
-
bytes +=
|
|
31
|
-
|
|
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,
|
|
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
|
|
41
|
-
|
|
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<
|
|
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:
|
|
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:
|
|
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
|
-
|
|
40
|
-
|
|
41
|
+
const st = await stat(full);
|
|
42
|
+
if (st.mtimeMs < cutoffMs) {
|
|
41
43
|
stats.tailChecks++;
|
|
42
|
-
if ((await lastTimestampMs(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
|
}
|