@drakulavich/zapara 0.3.1 → 0.5.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/src/derive.ts CHANGED
@@ -1,11 +1,14 @@
1
1
  import { score } from "./score.ts";
2
- import type { Day, Event, HourBucket, Metrics, Totals, Window } from "./types.ts";
2
+ import type { Day, Event, EventKind, HourBucket, LiveBucket, Metrics, Totals, Window } from "./types.ts";
3
3
 
4
4
  export const LOOKBACK_MS = 3 * 60 * 60 * 1000;
5
5
  export const GAP_MS = 10 * 60 * 1000;
6
6
  export const SLOT_MS = 5 * 60 * 1000;
7
+ const LIVE_MS = 60 * 60 * 1000;
7
8
  const LATE_HOURS = new Set([23, 0, 1, 2, 3, 4, 5]);
8
9
 
10
+ // Not localeCompare: a result must not depend on the locale.
11
+ export const compareStrings = (a: string, b: string): number => (a < b ? -1 : a > b ? 1 : 0);
9
12
  const pad2 = (n: number) => String(n).padStart(2, "0");
10
13
  export const localDate = (d: Date) => `${d.getFullYear()}-${pad2(d.getMonth() + 1)}-${pad2(d.getDate())}`;
11
14
  const parseDate = (s: string): Date => {
@@ -30,21 +33,65 @@ const emptyMetrics = (): Metrics => ({
30
33
  decisions: 0, contextSwitches: 0, activeMin: 0, streakMin: 0, lateNight: false,
31
34
  });
32
35
 
33
- type Acc = { m: Metrics; sessions: Set<string>; slots: Set<number>; lastPromptSession: string | null; lastPrompt: { ts: number; streakStart: number } | null };
36
+ // Presence is the human's own actions only: agent records and the agent's asks
37
+ // are never presence, because presence answers "is it time to rest?".
38
+ const PRESENCE: ReadonlySet<EventKind> = new Set<EventKind>(["prompt", "interrupt", "reject", "answer"]);
34
39
 
35
- // Walks the sorted, look-back-filtered events once, keyed by "date|hour",
36
- // tracking the running presence streak (which may start before startMs) and
37
- // accumulating each bucket's raw counts.
38
- //
39
- // Presence is the human's: both the streak and the covered slots are built from
40
- // `prompt` events alone, because both answer "is it time to rest?". Agents that
41
- // work on while the human is away must not keep a streak alive or fill the day,
42
- // so `activity` is left with session liveness and nothing else.
43
- //
44
- // Prompts before startMs update presence only: they are never counted in a
45
- // bucket, and neither are the slots they cover before startMs, but a span from
46
- // such a prompt into the window still covers the window's first slots.
47
- function foldEvents(sorted: Event[], startMs: number): Map<string, Acc> {
40
+ type Acc = { m: Metrics; sessions: Set<string>; slots: Set<number>; lastPromptSession: string | null; maxStreakMs: number; lastPresence: { ts: number; streakStart: number } | null };
41
+ const newAcc = (): Acc => ({ m: emptyMetrics(), sessions: new Set(), slots: new Set(), lastPromptSession: null, maxStreakMs: 0, lastPresence: null });
42
+
43
+ // Inside a streak the human sat through the gap, so a pair of actions covers
44
+ // every slot between them; an action that starts a streak covers only its own.
45
+ const coverage = (prevTs: number | null, streakStart: number, e: Event): { from: number; to: number; streakStart: number } => {
46
+ const to = Math.floor(e.ts / SLOT_MS);
47
+ if (prevTs !== null && e.ts - prevTs <= GAP_MS) return { from: Math.floor(prevTs / SLOT_MS), to, streakStart };
48
+ return { from: to, to, streakStart: e.ts };
49
+ };
50
+
51
+ // The counts an hour bucket and the live bucket share. `streakStart` is the
52
+ // caller's: a streak may have begun before this accumulator's window. The streak
53
+ // kept is the longest seen, not the one the window ended on, or a single prompt
54
+ // after a break would erase the run.
55
+ function accumulate(a: Acc, e: Event, streakStart: number): void {
56
+ if (PRESENCE.has(e.kind)) {
57
+ a.maxStreakMs = Math.max(a.maxStreakMs, e.ts - streakStart);
58
+ a.lastPresence = { ts: e.ts, streakStart }; // for the day's live streak
59
+ }
60
+ switch (e.kind) {
61
+ case "activity":
62
+ a.sessions.add(e.sessionId);
63
+ break;
64
+ case "prompt":
65
+ a.m.prompts++;
66
+ if (a.lastPromptSession !== null && a.lastPromptSession !== e.sessionId) a.m.contextSwitches++;
67
+ a.lastPromptSession = e.sessionId;
68
+ break;
69
+ case "report": a.m.reports++; break;
70
+ case "output": a.m.outputTokens += e.tokens ?? 0; break;
71
+ case "interrupt": a.m.interrupts++; break;
72
+ case "reject": a.m.rejects++; break;
73
+ case "question": a.m.questions++; break;
74
+ case "plan_review": a.m.plans++; break;
75
+ case "mode_change": a.m.modeSwitches++; break;
76
+ }
77
+ }
78
+
79
+ // `lateNight` is the caller's: an hour's own label, or the hour of `now` for the
80
+ // live bucket. A fresh accumulator is an empty hour and scores null.
81
+ function finish(a: Acc, lateNight: boolean): LiveBucket {
82
+ const m = a.m;
83
+ m.sessions = a.sessions.size;
84
+ m.activeMin = a.slots.size * 5;
85
+ m.streakMin = Math.round(a.maxStreakMs / 60000);
86
+ m.decisions = m.interrupts + m.rejects + m.questions + m.plans + m.modeSwitches;
87
+ m.lateNight = lateNight;
88
+ return { ...m, score: score(m) };
89
+ }
90
+
91
+ // The 24 hour buckets, keyed "date|hour". Events before startMs are look-back:
92
+ // they move the streak but enter no bucket, though a span from one into the
93
+ // window still covers the window's first slots.
94
+ function foldEvents(sorted: Event[], startMs: number): { acc: Map<string, Acc>; carried: { ts: number; streakStart: number } | null } {
48
95
  const acc = new Map<string, Acc>(); // key "date|hour"
49
96
  const key = (ts: number): string => {
50
97
  const d = new Date(ts);
@@ -52,69 +99,62 @@ function foldEvents(sorted: Event[], startMs: number): Map<string, Acc> {
52
99
  };
53
100
  const get = (k: string): Acc => {
54
101
  let a = acc.get(k);
55
- if (!a) { a = { m: emptyMetrics(), sessions: new Set(), slots: new Set(), lastPromptSession: null, lastPrompt: null }; acc.set(k, a); }
102
+ if (!a) { a = newAcc(); acc.set(k, a); }
56
103
  return a;
57
104
  };
58
105
 
59
- let prevPromptTs: number | null = null;
60
- let streakStart = 0; // always set by the first prompt, which starts a streak
106
+ let prevPresenceTs: number | null = null;
107
+ let streakStart = 0; // always set by the first presence event, which starts a streak
108
+ let carried: { ts: number; streakStart: number } | null = null;
61
109
  for (const e of sorted) {
62
- if (e.kind === "prompt") {
63
- const slot = Math.floor(e.ts / SLOT_MS);
64
- // A prompt covers its own slot. Inside a streak the human sat through the
65
- // gap too, so the pair also covers every slot between them; a prompt that
66
- // starts a streak covers nothing behind it.
67
- let from = slot;
68
- if (prevPromptTs !== null && e.ts - prevPromptTs <= GAP_MS) from = Math.floor(prevPromptTs / SLOT_MS);
69
- else streakStart = e.ts;
70
- for (let s = from; s <= slot; s++) {
110
+ if (PRESENCE.has(e.kind)) {
111
+ const c = coverage(prevPresenceTs, streakStart, e);
112
+ streakStart = c.streakStart;
113
+ for (let s = c.from; s <= c.to; s++) {
71
114
  const slotStart = s * SLOT_MS;
72
115
  if (slotStart >= startMs) get(key(slotStart)).slots.add(s); // a slot belongs to the bucket of its start
73
116
  }
74
- prevPromptTs = e.ts;
117
+ prevPresenceTs = e.ts;
118
+ // A person at the keyboard at 23:58 is still in that streak at 00:03,
119
+ // and the day they are looking at holds nothing yet.
120
+ if (e.ts < startMs) carried = { ts: e.ts, streakStart };
75
121
  }
76
122
  if (e.ts < startMs) continue; // look-back: presence bookkeeping only
77
- const a = get(key(e.ts));
78
- switch (e.kind) {
79
- case "activity":
80
- a.sessions.add(e.sessionId);
81
- break;
82
- case "prompt":
83
- a.m.prompts++;
84
- if (a.lastPromptSession !== null && a.lastPromptSession !== e.sessionId) a.m.contextSwitches++;
85
- a.lastPromptSession = e.sessionId;
86
- a.lastPrompt = { ts: e.ts, streakStart };
87
- break;
88
- case "report": a.m.reports++; break;
89
- case "output": a.m.outputTokens += e.tokens ?? 0; break;
90
- case "interrupt": a.m.interrupts++; break;
91
- case "reject": a.m.rejects++; break;
92
- case "question": a.m.questions++; break;
93
- case "plan_review": a.m.plans++; break;
94
- case "mode_change": a.m.modeSwitches++; break;
123
+ accumulate(get(key(e.ts)), e, streakStart);
124
+ }
125
+ return { acc, carried };
126
+ }
127
+
128
+ // The sixty minutes ending at `now`, by the rule an hour bucket uses. Presence
129
+ // runs from the first event, so a streak older than the window is measured from
130
+ // where it began. Events before the day's start count here, unlike in a bucket:
131
+ // the window is a clock's hour, not a calendar's.
132
+ function foldLive(sorted: Event[], nowMs: number): Acc {
133
+ const a = newAcc();
134
+ const fromMs = nowMs - LIVE_MS;
135
+ const inWindow = (t: number): boolean => t > fromMs && t <= nowMs;
136
+ let prevPresenceTs: number | null = null;
137
+ let streakStart = 0;
138
+ for (const e of sorted) {
139
+ if (PRESENCE.has(e.kind)) {
140
+ const c = coverage(prevPresenceTs, streakStart, e);
141
+ streakStart = c.streakStart;
142
+ for (let s = c.from; s <= c.to; s++) if (inWindow(s * SLOT_MS)) a.slots.add(s);
143
+ prevPresenceTs = e.ts;
95
144
  }
145
+ if (inWindow(e.ts)) accumulate(a, e, streakStart);
96
146
  }
97
- return acc;
147
+ return a;
98
148
  }
99
149
 
100
- // Builds one Day's 24 hour buckets from the accumulated counts, scores each,
101
- // and rolls up totals, peak and mean.
102
150
  function buildDay(date: string, acc: Map<string, Acc>): Day {
103
151
  const buckets: HourBucket[] = [];
152
+ // Hours run in time order, so the last write is the day's last action.
153
+ let lastPresence: { ts: number; streakStart: number } | null = null;
104
154
  for (let hour = 0; hour < 24; hour++) {
105
155
  const a = acc.get(`${date}|${hour}`);
106
- const m = a ? a.m : emptyMetrics();
107
- if (a) {
108
- m.sessions = a.sessions.size;
109
- m.activeMin = a.slots.size * 5;
110
- m.streakMin = a.lastPrompt ? Math.round((a.lastPrompt.ts - a.lastPrompt.streakStart) / 60000) : 0;
111
- }
112
- m.decisions = m.interrupts + m.rejects + m.questions + m.plans + m.modeSwitches;
113
- // lateNight is a property of the hour label, so it is set on every bucket,
114
- // including empty ones; score() returns null for buckets without
115
- // sessions, so an empty late hour scores nothing.
116
- m.lateNight = LATE_HOURS.has(hour);
117
- buckets.push({ ...m, hour, score: score(m) });
156
+ if (a?.lastPresence) lastPresence = a.lastPresence;
157
+ buckets.push({ ...finish(a ?? newAcc(), LATE_HOURS.has(hour)), hour });
118
158
  }
119
159
  const scored = buckets.filter((b) => b.score !== null);
120
160
  const totals: Totals = buckets.reduce((t, b) => ({
@@ -129,6 +169,7 @@ function buildDay(date: string, acc: Map<string, Acc>): Day {
129
169
  peak: scored.length ? Math.max(...scored.map((b) => b.score!.index)) : null,
130
170
  mean: scored.length ? Math.round(scored.reduce((s, b) => s + b.score!.index, 0) / scored.length) : null,
131
171
  activeMin: buckets.reduce((s, b) => s + b.activeMin, 0),
172
+ presence: lastPresence && { lastAt: new Date(lastPresence.ts).toISOString(), streakStartAt: new Date(lastPresence.streakStart).toISOString() },
132
173
  totals,
133
174
  buckets,
134
175
  };
@@ -136,16 +177,25 @@ function buildDay(date: string, acc: Map<string, Acc>): Day {
136
177
 
137
178
  export function derive(events: Event[], w: Window): Day[] {
138
179
  const { startMs, endMs, cutoffMs, dates } = windowBounds(w);
180
+ // sort() is stable, so events equal on both keys keep their input order.
139
181
  const sorted = events
140
- .map((e, i) => ({ e, i }))
141
- .filter(({ e }) => e.ts >= cutoffMs && e.ts < endMs && (!w.now || e.ts <= w.now.getTime()))
142
- .sort((a, b) => a.e.ts - b.e.ts || (a.e.sessionId < b.e.sessionId ? -1 : a.e.sessionId > b.e.sessionId ? 1 : 0) || a.i - b.i)
143
- .map(({ e }) => e);
144
- const acc = foldEvents(sorted, startMs);
182
+ .filter((e) => e.ts >= cutoffMs && e.ts < endMs && (!w.now || e.ts <= w.now.getTime()))
183
+ .sort((a, b) => a.ts - b.ts || compareStrings(a.sessionId, b.sessionId));
184
+ const { acc, carried } = foldEvents(sorted, startMs);
145
185
  const days = dates.map((date) => buildDay(date, acc));
186
+ // Only a first day with no action of its own borrows the look-back's streak,
187
+ // and only into `presence`: no bucket, total or active minute crosses back.
188
+ const first = days[0];
189
+ if (first && first.presence === null && carried) {
190
+ first.presence = { lastAt: new Date(carried.ts).toISOString(), streakStartAt: new Date(carried.streakStart).toISOString() };
191
+ }
146
192
  if (w.now) {
147
193
  const today = localDate(w.now);
148
- for (const d of days) if (d.date === today) d.asOf = w.now.toISOString();
194
+ const open = days.find((d) => d.date === today);
195
+ if (open) {
196
+ open.asOf = w.now.toISOString();
197
+ open.live = finish(foldLive(sorted, w.now.getTime()), LATE_HOURS.has(w.now.getHours()));
198
+ }
149
199
  }
150
200
  return days;
151
201
  }
package/src/format.ts CHANGED
@@ -1,8 +1,5 @@
1
- // Compact number formats shared by the card and the week footer. Pure: no
2
- // imports from node:/Bun, no clock, no file system.
3
-
4
- // Compact formats with a fixed longest form of five characters, so the layout
5
- // is sized once. Decimals are truncated, not rounded: 9.96M stays "9.9M".
1
+ // Longest form is five characters, so the layout is sized once. Decimals are
2
+ // truncated, not rounded: 9.96M stays "9.9M".
6
3
  function ladder(n: number): string {
7
4
  if (n >= 1e12) return "999B+";
8
5
  for (const [unit, size] of [["B", 1e9], ["M", 1e6], ["k", 1e3]] as const) {
@@ -16,5 +13,4 @@ function ladder(n: number): string {
16
13
  export const formatCount = (n: number): string => (n < 10_000 ? String(n) : ladder(n));
17
14
  export const formatTokens = (n: number): string => (n < 1000 ? String(n) : ladder(n));
18
15
 
19
- // "1 session", "5 sessions": a count with its noun, singular only for exactly one.
20
16
  export const plural = (n: number, one: string, many = `${one}s`, count = formatCount): string => `${count(n)} ${n === 1 ? one : many}`;
package/src/image.ts CHANGED
@@ -1,14 +1,12 @@
1
- // The card's shell: the only module that reads the card assets, opens a
2
- // Bun.WebView or a Bun.Image, and writes a file. Everything it writes is the one
3
- // file the person named; nothing here prints.
1
+ // The only module that reads the card assets, opens a Bun.WebView or Bun.Image,
2
+ // and writes the card.
4
3
  import { readFile, writeFile } from "node:fs/promises";
5
4
  import type { CardAssets } from "./cardhtml.ts";
6
5
 
7
6
  const ASSETS = new URL("../assets/", import.meta.url);
8
7
  const FILES = ["fonts/inter-400.woff2", "fonts/inter-700.woff2", "fonts/inter-800.woff2", "fonts/jetbrains-mono-500.woff2", "characters.webp"] as const;
9
8
 
10
- // Reads the five files next to the source. A missing, unreadable or empty one is
11
- // a broken install, reported without a path: the CLI never prints one.
9
+ // A missing or empty asset is a broken install, reported without a path.
12
10
  export async function loadAssets(): Promise<CardAssets> {
13
11
  let parts: string[];
14
12
  try {
@@ -23,21 +21,18 @@ export async function loadAssets(): Promise<CardAssets> {
23
21
 
24
22
  const BACKEND = process.platform === "darwin" ? "webkit" : "chrome";
25
23
  const ENGINE_LINE = "card needs a browser engine: install Google Chrome, or write --out card.html";
24
+ const WRITE_LINE = "cannot write the card: check the --out directory";
26
25
  const WIDTH = 2400;
27
26
  const HEIGHT = 1260;
28
27
  const READY = 'document.fonts.ready.then(() => document.fonts.status === "loaded" && Array.from(document.images).every((i) => i.complete))';
29
28
 
30
- // Writes the page as is for `.html`; otherwise photographs it at 2400x1260 and
31
- // writes PNG or WebP. The 15s budget bounds the whole render (construct, navigate,
32
- // poll, screenshot, resize, encode), raced against a single timer; the view is
33
- // closed on every path. Any engine failure (constructor, navigate, evaluate,
34
- // screenshot) is mapped to one line that never quotes the engine's own text; the
35
- // timeout error passes through unchanged. `writeFile` failures are the one
36
- // exception, left unmapped, so they keep reporting the user's own `--out` string.
29
+ // The budget bounds the whole render, raced against one timer; the view is
30
+ // closed on every path. An engine failure becomes one line that never quotes
31
+ // the engine's text; the timeout error passes through unchanged.
37
32
  export async function renderCard(html: string, out: string, timeoutMs = 15_000): Promise<void> {
38
33
  const lower = out.toLowerCase();
39
34
  if (lower.endsWith(".html")) {
40
- await writeFile(out, html);
35
+ await write(out, html);
41
36
  return;
42
37
  }
43
38
  let bytes: Uint8Array;
@@ -66,5 +61,14 @@ export async function renderCard(html: string, out: string, timeoutMs = 15_000):
66
61
  } finally {
67
62
  clearTimeout(timer!);
68
63
  }
69
- await writeFile(out, bytes);
64
+ await write(out, bytes);
65
+ }
66
+
67
+ // One line for every failure: node's error quotes the path, and the CLI never prints one.
68
+ async function write(out: string, data: string | Uint8Array): Promise<void> {
69
+ try {
70
+ await writeFile(out, data);
71
+ } catch {
72
+ throw new Error(WRITE_LINE);
73
+ }
70
74
  }
package/src/index.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  #!/usr/bin/env bun
2
- // zapara CLI. Argument parsing, the clock, stdout and exit codes live here; everything else is pure.
2
+ // Argument parsing, the clock, stdout and exit codes live here; everything else is pure.
3
3
  import { readFileSync } from "node:fs";
4
4
  import { homedir } from "node:os";
5
5
  import { join } from "node:path";
@@ -13,9 +13,8 @@ import { renderStatus, statusOf } from "./status.ts";
13
13
  import { writeStatus } from "./statusfile.ts";
14
14
  import type { Day } from "./types.ts";
15
15
 
16
- // Read lazily, only when --version is actually handled, so a broken install
17
- // (missing or corrupt package.json) fails inside the guarded catch below
18
- // instead of throwing at module load, before any try/catch is in place.
16
+ // Read only when --version is handled, so a broken package.json fails inside
17
+ // the guarded catch instead of at module load.
19
18
  function version(): string {
20
19
  const parsed: unknown = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8"));
21
20
  if (typeof parsed === "object" && parsed !== null && typeof (parsed as { version?: unknown }).version === "string") {
@@ -45,22 +44,18 @@ options:
45
44
  levels: calm 0-29 warming 30-59 heating 60-84 fried 85-100`;
46
45
  const HINT = "run 'zapara --help' for usage";
47
46
 
48
- // A grid is the window as one cell per hour; a day is one date as one row per
49
- // hour; status is today, written to the status file for a status line to read.
50
47
  type Args = { command: "grid" | "day" | "card" | "status"; to: string; days: number; explain: boolean; json: boolean; out: string; projects: string; color: boolean };
51
48
 
52
49
  class UsageError extends Error {}
53
- // Thrown only at a flag position (never when a token was consumed as another
54
- // flag's value, e.g. `--to --help`), so `main()` can short-circuit to exit 0
55
- // without parseArgs having to also validate the rest of a help/version call.
50
+ // Thrown only at a flag position, never for a token consumed as another flag's
51
+ // value (`--to --help`).
56
52
  class HelpRequested extends Error {}
57
53
  class VersionRequested extends Error {}
58
54
 
59
55
  const DATE = /^(\d{4})-(\d{2})-(\d{2})$/;
60
56
 
61
- // A usage error quotes the offending value only when it is short, printable ASCII
62
- // with no path separator: the CLI never prints a filesystem path or an escape,
63
- // not even one the person typed.
57
+ // A usage error quotes the value only when it is short, printable ASCII with no
58
+ // path separator: the CLI never prints a path or an escape, even one typed in.
64
59
  const quotable = (v: string): boolean => /^[\x21-\x7e]{1,24}$/.test(v) && !/[\/\\]/.test(v);
65
60
  const got = (v: string): string => (quotable(v) ? `, got ${v}` : "");
66
61
  const named = (v: string): string => (quotable(v) ? ` ${v}` : "");
@@ -73,7 +68,6 @@ function validDate(s: string): boolean {
73
68
  return dt.getFullYear() === y && dt.getMonth() === mo - 1 && dt.getDate() === d;
74
69
  }
75
70
 
76
- // A date on the command line: YYYY-MM-DD, today or yesterday, in local time.
77
71
  function resolveDate(what: string, s: string, now: Date): string {
78
72
  if (s === "today") return localDate(now);
79
73
  if (s === "yesterday") return localDate(new Date(now.getFullYear(), now.getMonth(), now.getDate() - 1));
@@ -81,15 +75,13 @@ function resolveDate(what: string, s: string, now: Date): string {
81
75
  return s;
82
76
  }
83
77
 
84
- // Calendar days from one date to another, inclusive; UTC arithmetic so a DST day is still one day.
78
+ // UTC arithmetic, so a DST day is still one day.
85
79
  function spanDays(from: string, to: string): number {
86
80
  const utc = (s: string): number => { const [y = 0, m = 0, d = 0] = s.split("-").map(Number); return Date.UTC(y, m - 1, d); };
87
81
  return Math.round((utc(to) - utc(from)) / 86_400_000) + 1;
88
82
  }
89
83
 
90
- // The window from its flags: --days ending today or at --to, or --from/--to, both
91
- // inclusive. --from with --days is one length too many. Checks run in the order
92
- // a reader meets the flags in --help.
84
+ // Checks run in the order a reader meets the flags in --help.
93
85
  function windowOf(days: string | null, from: string | null, to: string | null, defaultDays: number, now: Date): { to: string; days: number } {
94
86
  if (from !== null && days !== null) throw new UsageError("--from sets the length; drop --days");
95
87
  if (days !== null && (!/^\d+$/.test(days) || Number(days) < 1 || Number(days) > 90)) throw new UsageError(`--days must be 1..90${got(days)}`);
@@ -110,19 +102,16 @@ function parseArgs(argv: string[], now: Date, env: NodeJS.ProcessEnv, isTTY: boo
110
102
  let jsonFlag = false;
111
103
  let outGiven = false;
112
104
  const positional: string[] = [];
113
- // A value flag given twice is a usage error, not the last value winning
114
- // silently; bare flags (--json, --explain, --no-color) are idempotent and stay untracked.
105
+ // A value flag given twice is a usage error; bare flags are idempotent and untracked.
115
106
  const seen = new Set<string>();
116
107
  for (let i = 0; i < argv.length; i++) {
117
108
  let arg = argv[i]!;
118
- // `--days=30` is `--days 30`. A flag that takes no value refuses an inline one.
119
109
  let inline: string | null = null;
120
110
  const eq = arg.startsWith("--") ? arg.indexOf("=") : -1;
121
111
  if (eq > 0) { inline = arg.slice(eq + 1); arg = arg.slice(0, eq); }
122
112
  const bare = (): void => { if (inline !== null) throw new UsageError(`unknown flag${named(argv[i]!)}`); };
123
- // A missing value or one that looks like another flag is a usage error,
124
- // never treated as this flag's value (e.g. `--projects --json`). Only --days
125
- // takes a negative number as a value, so `--days -1` reaches the range check.
113
+ // A value that looks like another flag is a usage error (`--projects --json`);
114
+ // only --days accepts a negative number, so `--days -1` reaches the range check.
126
115
  const value = (negativeNumberIsValue = false): string => {
127
116
  let v: string;
128
117
  if (inline !== null) v = inline;
@@ -161,19 +150,16 @@ function parseArgs(argv: string[], now: Date, env: NodeJS.ProcessEnv, isTTY: boo
161
150
  else if (word === "today" || word === "yesterday" || DATE.test(word)) { a.command = "day"; a.to = resolveDate("date", word, now); a.days = 1; }
162
151
  else throw new UsageError(`unknown command${named(word)} (try today, yesterday, a date, card or status)`);
163
152
 
164
- // Both commands fix their own window: a named day is the date given, status is today.
165
153
  if (a.command === "day" || a.command === "status") {
166
154
  if (days !== null || from !== null || to !== null) throw new UsageError(`--days, --from and --to do not apply to ${a.command === "day" ? "a named day" : "status"}`);
167
155
  } else {
168
- // Two weeks make a pattern; a week makes a picture of one week.
169
156
  ({ to: a.to, days: a.days } = windowOf(days, from, to, a.command === "card" ? 14 : 7, now));
170
157
  }
171
- // Tables turn into JSON in a pipe; the card is a file either way, so only an explicit --json switches it.
158
+ // The card is a file either way, so only an explicit --json switches it.
172
159
  a.json = a.command === "card" ? jsonFlag : jsonFlag || !isTTY;
173
160
  if (a.command !== "day" && a.explain) throw new UsageError("--explain applies to a named day only");
174
161
  if (a.command !== "card" && outGiven) throw new UsageError("--out applies to card only");
175
- // The value is printed back verbatim in `wrote \u2026`, so it must be one plain line:
176
- // no control character, and the message never quotes it.
162
+ // Printed back verbatim in `wrote \u2026`, so it must be one plain line.
177
163
  if (/[\x00-\x1f\x7f]/.test(a.out)) throw new UsageError("--out must not contain control characters");
178
164
  if (!/\.(png|webp|html)$/i.test(a.out)) throw new UsageError("--out must end in .png, .webp or .html");
179
165
  return a;
@@ -193,9 +179,7 @@ async function main(): Promise<number> {
193
179
  return 0;
194
180
  }
195
181
 
196
- // Today's load, written to the status file and then printed. The write comes
197
- // first so that a run whose write failed prints its one-line error and nothing
198
- // else: a caller never reads a line that was not saved for the status line.
182
+ // The write comes first: a caller never reads a line that was not saved.
199
183
  async function status(a: Args, now: Date): Promise<number> {
200
184
  const days: Day[] = await report({ projects: a.projects, to: a.to, days: a.days, now });
201
185
  const line = renderStatus(statusOf(days[0]!, now));
package/src/parse.ts CHANGED
@@ -5,9 +5,8 @@ const INTERRUPT_PREFIX = "[Request interrupted by user";
5
5
  const REJECT_PREFIX = "The user doesn't want to proceed with this tool use";
6
6
  const QUESTION_TOOL = "AskUserQuestion";
7
7
  const PLAN_TOOL = "ExitPlanMode";
8
- // Inbound messages from subagents, other sessions and background tasks: the human
9
- // has to read and react to these, but did not type them, so they are `report`
10
- // events, never `prompt`. An interrupt marker is checked first and always wins.
8
+ // Inbound agent messages: read and reacted to, but not typed, so `report`, never
9
+ // `prompt`. An interrupt marker is checked first and wins.
11
10
  const AGENT_MARKERS = [
12
11
  "Another Claude session sent a message:",
13
12
  "<teammate-message",
@@ -20,9 +19,12 @@ type Rec = Record<string, unknown>;
20
19
  const isObj = (v: unknown): v is Rec => typeof v === "object" && v !== null;
21
20
  const str = (v: unknown): string | null => (typeof v === "string" ? v : null);
22
21
 
22
+ // A pasted screenshot puts an `image` block before the typed text, so the text
23
+ // block is searched for, not taken from content[0].
23
24
  const firstText = (content: unknown): string | null => {
24
25
  if (typeof content === "string") return content;
25
- if (Array.isArray(content) && isObj(content[0]) && content[0].type === "text") return str(content[0].text);
26
+ if (!Array.isArray(content)) return null;
27
+ for (const b of content) if (isObj(b) && b.type === "text") return str(b.text);
26
28
  return null;
27
29
  };
28
30
 
@@ -30,10 +32,13 @@ export function parseTranscript(text: string): Event[] {
30
32
  const events: Event[] = [];
31
33
  let lastTs: number | null = null;
32
34
  let lastMode: string | null = null;
33
- // Dedupes `output` events by requestId within this one file: Claude Code
34
- // writes one record per content block of a response, repeating the same
35
- // requestId and usage, so only the first qualifying record counts.
35
+ let pendingModeChanges = 0;
36
+ // Claude Code writes one record per content block of a response, repeating
37
+ // the requestId and usage; only the first qualifying record counts.
36
38
  const seenRequestIds = new Set<string>();
39
+ // Ids of the two tools whose result the human writes; every other
40
+ // `tool_result` is the machine reporting back.
41
+ const askedIds = new Set<string>();
37
42
 
38
43
  for (const line of text.split("\n")) {
39
44
  if (line.trim() === "") continue;
@@ -48,12 +53,11 @@ export function parseTranscript(text: string): Event[] {
48
53
  if (type === "permission-mode") {
49
54
  const m = str(rec.permissionMode);
50
55
  if (m === null) continue;
51
- // The baseline mode is tracked as soon as it is seen, even before any
52
- // timestamp exists, so a later switch away from it can be detected. A
53
- // switch is only ever emitted as an event once a timestamp is known;
54
- // a switch that happens before any timestamp is known is dropped.
55
- if (lastMode !== null && m !== lastMode && lastTs !== null) {
56
- events.push({ ts: lastTs, sessionId, kind: "mode_change" });
56
+ // A switch before the first timestamped record is how a session often
57
+ // starts; it waits and is attributed to that record.
58
+ if (lastMode !== null && m !== lastMode) {
59
+ if (lastTs === null) pendingModeChanges++;
60
+ else events.push({ ts: lastTs, sessionId, kind: "mode_change" });
57
61
  }
58
62
  lastMode = m;
59
63
  continue;
@@ -64,6 +68,7 @@ export function parseTranscript(text: string): Event[] {
64
68
  if (Number.isNaN(ts)) continue;
65
69
  lastTs = ts;
66
70
  events.push({ ts, sessionId, kind: "activity" });
71
+ for (; pendingModeChanges > 0; pendingModeChanges--) events.push({ ts, sessionId, kind: "mode_change" });
67
72
 
68
73
  const content = isObj(rec.message) ? rec.message.content : undefined;
69
74
  if (type === "user") {
@@ -72,6 +77,8 @@ export function parseTranscript(text: string): Event[] {
72
77
  if (!isObj(b) || b.type !== "tool_result") continue;
73
78
  const t = firstText(b.content);
74
79
  if (t !== null && t.startsWith(REJECT_PREFIX)) events.push({ ts, sessionId, kind: "reject" });
80
+ const toolUseId = str(b.tool_use_id);
81
+ if (toolUseId !== null && askedIds.has(toolUseId)) events.push({ ts, sessionId, kind: "answer" });
75
82
  }
76
83
  const head = firstText(content);
77
84
  if (head === null) continue;
@@ -84,20 +91,22 @@ export function parseTranscript(text: string): Event[] {
84
91
  const blocks = Array.isArray(content) ? content : [];
85
92
  for (const b of blocks) {
86
93
  if (!isObj(b) || b.type !== "tool_use") continue;
87
- if (b.name === QUESTION_TOOL) events.push({ ts, sessionId, kind: "question" });
88
- else if (b.name === PLAN_TOOL) events.push({ ts, sessionId, kind: "plan_review" });
94
+ if (b.name !== QUESTION_TOOL && b.name !== PLAN_TOOL) continue;
95
+ events.push({ ts, sessionId, kind: b.name === QUESTION_TOOL ? "question" : "plan_review" });
96
+ const id = str(b.id);
97
+ if (id !== null) askedIds.add(id);
89
98
  }
90
- // `output`: one event per distinct requestId per file, the first record seen
91
- // that has a string requestId, numeric usage.output_tokens and at least one
92
- // text block. A response holding only tool_use blocks is not text the human
93
- // reads, so it never triggers this, even on its first (and only) record.
99
+ // A response of only tool_use blocks is not text the human reads and carries
100
+ // no output tokens. A count must be a finite non-negative integer: `1e309`
101
+ // parses to Infinity, and a day once summed to NaN.
94
102
  const requestId = str(rec.requestId);
95
103
  const usage = isObj(rec.message) ? rec.message.usage : undefined;
96
104
  const outputTokens = isObj(usage) ? usage.output_tokens : undefined;
105
+ const tokens = typeof outputTokens === "number" && Number.isSafeInteger(outputTokens) && outputTokens >= 0 ? outputTokens : null;
97
106
  const hasText = blocks.some((b) => isObj(b) && b.type === "text");
98
- if (requestId !== null && typeof outputTokens === "number" && hasText && !seenRequestIds.has(requestId)) {
107
+ if (requestId !== null && tokens !== null && hasText && !seenRequestIds.has(requestId)) {
99
108
  seenRequestIds.add(requestId);
100
- events.push({ ts, sessionId, kind: "output", tokens: outputTokens });
109
+ events.push({ ts, sessionId, kind: "output", tokens });
101
110
  }
102
111
  }
103
112
  }