@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/CHANGELOG.md +24 -0
- package/README.md +66 -53
- package/package.json +1 -1
- package/src/analyze.ts +3 -4
- package/src/card.ts +8 -11
- package/src/cardhtml.ts +6 -9
- package/src/derive.ts +117 -67
- package/src/format.ts +2 -6
- package/src/image.ts +18 -14
- package/src/index.ts +15 -31
- package/src/parse.ts +30 -21
- package/src/render.ts +15 -20
- package/src/report.ts +0 -1
- package/src/scan.ts +8 -13
- package/src/score.ts +6 -9
- package/src/status.ts +10 -13
- package/src/statusfile.ts +13 -26
- package/src/types.ts +8 -5
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
|
-
|
|
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
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
//
|
|
39
|
-
//
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
//
|
|
47
|
-
|
|
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 =
|
|
102
|
+
if (!a) { a = newAcc(); acc.set(k, a); }
|
|
56
103
|
return a;
|
|
57
104
|
};
|
|
58
105
|
|
|
59
|
-
let
|
|
60
|
-
let streakStart = 0; // always set by the first
|
|
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
|
|
63
|
-
const
|
|
64
|
-
|
|
65
|
-
|
|
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
|
-
|
|
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
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
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
|
|
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
|
-
|
|
107
|
-
|
|
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
|
-
.
|
|
141
|
-
.
|
|
142
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
2
|
-
//
|
|
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
|
|
2
|
-
//
|
|
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
|
-
//
|
|
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
|
-
//
|
|
31
|
-
//
|
|
32
|
-
//
|
|
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
|
|
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
|
|
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
|
-
//
|
|
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
|
|
17
|
-
//
|
|
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
|
|
54
|
-
//
|
|
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
|
|
62
|
-
//
|
|
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
|
-
//
|
|
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
|
-
//
|
|
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
|
|
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
|
|
124
|
-
//
|
|
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
|
-
//
|
|
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
|
-
//
|
|
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
|
-
//
|
|
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
|
|
9
|
-
//
|
|
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)
|
|
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
|
-
|
|
34
|
-
// writes one record per content block of a response, repeating
|
|
35
|
-
// requestId and usage
|
|
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
|
-
//
|
|
52
|
-
//
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
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
|
|
88
|
-
|
|
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
|
-
//
|
|
91
|
-
//
|
|
92
|
-
//
|
|
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 &&
|
|
107
|
+
if (requestId !== null && tokens !== null && hasText && !seenRequestIds.has(requestId)) {
|
|
99
108
|
seenRequestIds.add(requestId);
|
|
100
|
-
events.push({ ts, sessionId, kind: "output", tokens
|
|
109
|
+
events.push({ ts, sessionId, kind: "output", tokens });
|
|
101
110
|
}
|
|
102
111
|
}
|
|
103
112
|
}
|