@drakulavich/zapara 0.4.0 → 0.6.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 +12 -0
- package/README.md +17 -7
- 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 +99 -79
- package/src/format.ts +2 -6
- package/src/image.ts +7 -15
- package/src/index.ts +15 -31
- package/src/parse.ts +13 -28
- package/src/render.ts +18 -20
- package/src/report.ts +0 -1
- package/src/scan.ts +7 -14
- package/src/score.ts +5 -8
- package/src/status.ts +7 -19
- package/src/statusfile.ts +13 -26
- package/src/types.ts +7 -8
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,18 @@ All notable changes to this project are documented here. The format follows
|
|
|
5
5
|
|
|
6
6
|
## [Unreleased]
|
|
7
7
|
|
|
8
|
+
## [0.6.0] - 2026-09-22
|
|
9
|
+
|
|
10
|
+
### Changed
|
|
11
|
+
- The week grid dims a day no hour scored in, so a weekend recedes behind the
|
|
12
|
+
working days around it.
|
|
13
|
+
|
|
14
|
+
## [0.5.0] - 2026-09-22
|
|
15
|
+
|
|
16
|
+
### Changed
|
|
17
|
+
- The week grid's `peak` column is painted in a TTY with the color of the level its index falls in, by the same thresholds `zapara status` reads a level from; `--no-color` and a pipe are unchanged.
|
|
18
|
+
- The status file's `index` and `level` are the load of the sixty minutes ending at `asOf`, not of the calendar hour that contains it: the number no longer drops to nothing at every hour boundary (90 at 11:59:30, 6 at 12:00:30 on one machine) and no longer climbs through the hour as the bucket fills. `Day.live` in `--json` carries that bucket on the open day. Same formula, same norms; `schema` stays 1.
|
|
19
|
+
|
|
8
20
|
## [0.4.0] - 2026-09-21
|
|
9
21
|
|
|
10
22
|
### Changed
|
package/README.md
CHANGED
|
@@ -135,7 +135,7 @@ This one comes from the same `busy-week` fixture as the pictures above. A headle
|
|
|
135
135
|
| `--json` | Print the whole window as one JSON document instead of a table. |
|
|
136
136
|
| `--projects <dir>` | Read this directory instead of `~/.claude/projects`. |
|
|
137
137
|
| `--out <path>` | Where `card` writes; the extension picks the format. |
|
|
138
|
-
| `--no-color` | Plain glyphs with no ANSI codes. `NO_COLOR` in the environment does the same. |
|
|
138
|
+
| `--no-color` | Plain glyphs and peaks with no ANSI codes. `NO_COLOR` in the environment does the same. |
|
|
139
139
|
| `-h`, `--help` | Usage, exit 0. |
|
|
140
140
|
| `-V`, `--version` | The version from `package.json`, exit 0. |
|
|
141
141
|
|
|
@@ -143,7 +143,7 @@ Levels: calm 0–29, warming 30–59, heating 60–84, fried 85–100.
|
|
|
143
143
|
|
|
144
144
|
The grid and the day print a text table when stdout is a terminal and JSON otherwise, so `zapara | cat` prints JSON; no flag forces text in a pipe yet. `card` and `status` write their file and print their lines whether piped or not. `card --json` is the exception: it prints the card's data and writes no file. `--json` changes nothing for `status`, whose line is already JSON and whose file is written either way.
|
|
145
145
|
|
|
146
|
-
|
|
146
|
+
A run that works exits 0, and so does a window with no data, which prints an empty grid. Exit 1 is a failure zapara can name, printed as one line to stderr that never contains a path: the projects directory missing or unreadable, `status` unable to write its file, `card` unable to write its picture or to find a browser engine, and whatever else goes wrong below the command line. Exit 2 is a usage error, such as a bad date, an unknown flag or a value flag given twice; it prints one line and a hint to `--help`.
|
|
147
147
|
|
|
148
148
|
## Status line
|
|
149
149
|
|
|
@@ -159,8 +159,8 @@ A status line wants one number every thirty seconds and cannot wait half a secon
|
|
|
159
159
|
| `asOf` | When the snapshot was taken, ISO 8601 UTC: the `Day.asOf` of the base spec, the `now` of this run. A reader decides staleness from this field, never from the file's mtime. |
|
|
160
160
|
| `date` | The local calendar day the numbers describe, `YYYY-MM-DD`. |
|
|
161
161
|
| `hour` | The local hour that contains `asOf`, `0`..`23`. |
|
|
162
|
-
| `index` |
|
|
163
|
-
| `level` | That
|
|
162
|
+
| `index` | The load index of the sixty minutes ending at `asOf`, `0`..`100`, or `null` when they hold no session. It does not reset at an hour boundary: the hour buckets of `zapara today` are calendar hours, this one is the clock's last hour. |
|
|
163
|
+
| `level` | That index's level, `Calm`, `Warming`, `Heating` or `Fried`, or `null` with `index`. A reader colors by this field so it never needs the thresholds. |
|
|
164
164
|
| `peak` | The day's peak index so far, or `null` on a day with no activity. |
|
|
165
165
|
| `activeMin` | Minutes of your presence in the day so far: the 5-minute slots covered by your actions and the gaps of at most 10 minutes between them; `0` on a day with no action of yours. |
|
|
166
166
|
| `streakMin` | Minutes of your live presence streak as of `asOf`: from the streak's first action to `asOf`, when your last action is no more than 10 minutes before `asOf`; `0` once you have been away longer. It keeps growing while you sit there, and it does not reset at an hour boundary. |
|
|
@@ -214,7 +214,7 @@ index = round(25*parallel + 15*pace + 30*supervision + 10*reading + 10*streak +
|
|
|
214
214
|
| Heating | 60–84 |
|
|
215
215
|
| Fried | 85–100 |
|
|
216
216
|
|
|
217
|
-
The norms are the p90 of two weeks of real transcripts on two machines, 116 and 114 active hours. Why each one is what it is, and what surprised
|
|
217
|
+
The norms are the p90 of two weeks of real transcripts on two machines, 116 and 114 active hours. Why each one is what it is, and what surprised me in that data, is in [How the numbers are made](docs/how-the-numbers-are-made.md#5-the-index).
|
|
218
218
|
|
|
219
219
|
Weights and norms live in one exported constant in `src/score.ts`, so a recalibration is one diff there plus a line in `CHANGELOG.md`.
|
|
220
220
|
|
|
@@ -258,6 +258,16 @@ bun run check # tsc --noEmit, then the test suite under TZ=UTC
|
|
|
258
258
|
|
|
259
259
|
Tests are fixture-driven: they build or load transcripts in the real Claude Code format and assert the statistics that come out of the public seams, `analyze()`, `report()` and the CLI itself. No test imports the parser, the deriver or the scanner, so refactoring internals never touches a test. The rules every change follows are in [CLAUDE.md](CLAUDE.md), the design is in [docs/superpowers/specs/2026-09-17-zapara-design.md](docs/superpowers/specs/2026-09-17-zapara-design.md), and every change is recorded in [CHANGELOG.md](CHANGELOG.md).
|
|
260
260
|
|
|
261
|
-
##
|
|
261
|
+
## Where it comes from
|
|
262
262
|
|
|
263
|
-
|
|
263
|
+
The question comes from Addy Osmani's [Your parallel Agent limit](https://addyosmani.com/blog/cognitive-parallel-agents/). More agents running does not make more of you available, because "your cognitive bandwidth doesn't parallelize", and the cost of the ones you are not looking at is what he calls the ambient anxiety tax: "the part of your mind that can't fully relax because it knows something might be silently going sideways in a thread you haven't checked in twenty minutes." His own ceiling is "somewhere around three to four threads depending on complexity", and his advice is to start with one thread less than feels right.
|
|
264
|
+
|
|
265
|
+
The index was calibrated before I read that, from the 90th percentile of two weeks on two machines, and it arrived at the same number: `NORMS.parallelSpan` in [src/score.ts](src/score.ts) is 4, so the fifth session running at once spends all 25 points for parallel work.
|
|
266
|
+
|
|
267
|
+
The post and this tool disagree about what to watch. Osmani's signal is the quality of your own review: you have passed your ceiling when your confidence in what you are accepting starts dropping. A transcript cannot see that. It can see how much model output went past you, which is the `out-tok` column and ten of the hundred points, and it can see the shape of the hour around it. The index is a proxy with a known blind spot, and the number is worth something only next to your memory of the hour it scores.
|
|
268
|
+
|
|
269
|
+
Most of the advice in this area stops at fewer threads, smaller scope and more breaks, with few numbers you can hold yourself to. An hour with a score on it is at least something you can disagree with.
|
|
270
|
+
|
|
271
|
+
---
|
|
272
|
+
|
|
273
|
+
<p align="center">Made with ❤️ and 🥤 energy under <a href="LICENSE">MIT License</a></p>
|
package/package.json
CHANGED
package/src/analyze.ts
CHANGED
|
@@ -1,12 +1,11 @@
|
|
|
1
|
-
import { derive } from "./derive.ts";
|
|
1
|
+
import { compareStrings, derive } from "./derive.ts";
|
|
2
2
|
import { parseTranscript } from "./parse.ts";
|
|
3
3
|
import type { Day, Transcript, Window } from "./types.ts";
|
|
4
4
|
|
|
5
|
-
//
|
|
6
|
-
// Day[] out. Transcripts are sorted by path so file discovery order cannot change a result.
|
|
5
|
+
// Sorted by path so file discovery order cannot change a result.
|
|
7
6
|
export function analyze(transcripts: Transcript[], w: Window): Day[] {
|
|
8
7
|
const events = [...transcripts]
|
|
9
|
-
.sort((a, b) => (a.path
|
|
8
|
+
.sort((a, b) => compareStrings(a.path, b.path))
|
|
10
9
|
.flatMap((t) => parseTranscript(t.text));
|
|
11
10
|
return derive(events, w);
|
|
12
11
|
}
|
package/src/card.ts
CHANGED
|
@@ -1,7 +1,5 @@
|
|
|
1
|
-
//
|
|
2
|
-
//
|
|
3
|
-
// formatted for the page. Pure: Day[] in, CardData out. Nothing here knows a
|
|
4
|
-
// date, a path or a file, so nothing here can leak one.
|
|
1
|
+
// Pure: Day[] in, CardData out, every value already formatted for the page.
|
|
2
|
+
// Nothing here knows a date, a path or a file, so nothing here can leak one.
|
|
5
3
|
import { formatCount, formatTokens, plural } from "./format.ts";
|
|
6
4
|
import { NORMS, WEIGHTS } from "./score.ts";
|
|
7
5
|
import type { Day, HourBucket, Level, Score } from "./types.ts";
|
|
@@ -34,9 +32,8 @@ const MOTTOS: Record<Character, string> = {
|
|
|
34
32
|
marathoner: "You do not stop while it compiles.",
|
|
35
33
|
nightOwl: "The best commits happen after midnight.",
|
|
36
34
|
};
|
|
37
|
-
//
|
|
38
|
-
//
|
|
39
|
-
// never touch the index; the index's own norms stay in score.ts.
|
|
35
|
+
// Rank the third highlight, per active hour. They never touch the index, whose
|
|
36
|
+
// norms stay in score.ts.
|
|
40
37
|
const CARD_NORMS = { reportsPerHour: 12, tokensPerHour: 65_000, interruptsPerHour: 3, latePercent: 25 } as const;
|
|
41
38
|
const OWNED: Record<Character, [HighlightKey, HighlightKey]> = {
|
|
42
39
|
conductor: ["peakSessions", "contextSwitches"],
|
|
@@ -94,15 +91,15 @@ export function cardData(days: Day[], w: { days: number }): CardData | null {
|
|
|
94
91
|
const max = (f: (b: Active) => number): number => active.reduce((a, b) => Math.max(a, f(b)), 0);
|
|
95
92
|
const count = (level: Level): number => active.filter((b) => b.score.level === level).length;
|
|
96
93
|
|
|
97
|
-
//
|
|
98
|
-
//
|
|
94
|
+
// A share is the fraction of the character's maximum points, so a 10-point
|
|
95
|
+
// component competes fairly with a 40-point one.
|
|
99
96
|
const shares: Record<Character, number> = {
|
|
100
97
|
conductor: sum((b) => b.score.parts.parallel + b.score.parts.pace) / ((WEIGHTS.parallel + WEIGHTS.pace) * n),
|
|
101
98
|
supervisor: sum((b) => b.score.parts.supervision + b.score.parts.reading) / ((WEIGHTS.supervision + WEIGHTS.reading) * n),
|
|
102
99
|
marathoner: sum((b) => b.score.parts.streak) / (WEIGHTS.streak * n),
|
|
103
100
|
nightOwl: sum((b) => b.score.parts.late) / (WEIGHTS.late * n),
|
|
104
101
|
};
|
|
105
|
-
// Strict
|
|
102
|
+
// Strict >: CHARACTERS is the tie order.
|
|
106
103
|
const character = CHARACTERS.reduce((best, c) => (shares[c] > shares[best] ? c : best));
|
|
107
104
|
|
|
108
105
|
const maxSessions = max((b) => b.sessions);
|
|
@@ -143,7 +140,7 @@ export function cardData(days: Day[], w: { days: number }): CardData | null {
|
|
|
143
140
|
lateShare: late / CARD_NORMS.latePercent,
|
|
144
141
|
};
|
|
145
142
|
const owned = OWNED[character];
|
|
146
|
-
// A late-night number
|
|
143
|
+
// A late-night number belongs to the Night Owl alone.
|
|
147
144
|
const rest = POOL.filter((k) => !owned.includes(k) && (k !== "lateShare" || character === "nightOwl"));
|
|
148
145
|
const third = rest.reduce((best, k) => (norms[k] > norms[best] ? k : best));
|
|
149
146
|
const highlights = [...owned, third].map((key) => ({ key, value: values[key], caption: CAPTIONS[key] }));
|
package/src/cardhtml.ts
CHANGED
|
@@ -1,7 +1,6 @@
|
|
|
1
|
-
//
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
// The look is the table in docs/superpowers/specs/2026-09-18-zapara-card-design.md.
|
|
1
|
+
// Nothing is escaped because nothing from a transcript reaches this file: only
|
|
2
|
+
// CardData's fixed strings and formatted numbers. The look is the table in
|
|
3
|
+
// docs/superpowers/specs/2026-09-18-zapara-card-design.md.
|
|
5
4
|
import type { CardData, Character, Segment } from "./card.ts";
|
|
6
5
|
import type { Level } from "./types.ts";
|
|
7
6
|
|
|
@@ -10,10 +9,8 @@ export type CardAssets = {
|
|
|
10
9
|
characters: string;
|
|
11
10
|
};
|
|
12
11
|
|
|
13
|
-
//
|
|
14
|
-
//
|
|
15
|
-
// rectangle's longer side is BOX px and centred at CENTRE, with CSS background-size
|
|
16
|
-
// and background-position, so no cropping happens anywhere.
|
|
12
|
+
// Fractions [x, y, w, h] of the sheet's side, measured on assets/characters.webp.
|
|
13
|
+
// Scaled so the longer side is BOX px, centred at CENTRE; nothing is cropped.
|
|
17
14
|
export const CHARACTER_RECTS: Record<Character, [number, number, number, number]> = {
|
|
18
15
|
conductor: [0.02, 0.01, 0.53, 0.543],
|
|
19
16
|
supervisor: [0.55, 0.07, 0.38, 0.505],
|
|
@@ -23,7 +20,7 @@ export const CHARACTER_RECTS: Record<Character, [number, number, number, number]
|
|
|
23
20
|
const BOX = 360;
|
|
24
21
|
const CENTRE = { x: 195, y: 300 };
|
|
25
22
|
|
|
26
|
-
//
|
|
23
|
+
// main, its rgb for alpha gradients, and the light shade for the repo link.
|
|
27
24
|
const ACCENT: Record<Character, { main: string; rgb: string; light: string }> = {
|
|
28
25
|
conductor: { main: "#8b5cf6", rgb: "139,92,246", light: "#c4b5fd" },
|
|
29
26
|
supervisor: { main: "#22d3ee", rgb: "34,211,238", light: "#a5f3fc" },
|
package/src/derive.ts
CHANGED
|
@@ -1,11 +1,14 @@
|
|
|
1
1
|
import { score } from "./score.ts";
|
|
2
|
-
import type { Day, Event, EventKind, 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,25 +33,64 @@ const emptyMetrics = (): Metrics => ({
|
|
|
30
33
|
decisions: 0, contextSwitches: 0, activeMin: 0, streakMin: 0, lateNight: false,
|
|
31
34
|
});
|
|
32
35
|
|
|
33
|
-
// Presence is
|
|
34
|
-
//
|
|
35
|
-
// and the agent's own asks (`question`, `plan_review`) are never presence.
|
|
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?".
|
|
36
38
|
const PRESENCE: ReadonlySet<EventKind> = new Set<EventKind>(["prompt", "interrupt", "reject", "answer"]);
|
|
37
39
|
|
|
38
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 });
|
|
39
42
|
|
|
40
|
-
//
|
|
41
|
-
//
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
//
|
|
49
|
-
//
|
|
50
|
-
//
|
|
51
|
-
//
|
|
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.
|
|
52
94
|
function foldEvents(sorted: Event[], startMs: number): { acc: Map<string, Acc>; carried: { ts: number; streakStart: number } | null } {
|
|
53
95
|
const acc = new Map<string, Acc>(); // key "date|hour"
|
|
54
96
|
const key = (ts: number): string => {
|
|
@@ -57,7 +99,7 @@ function foldEvents(sorted: Event[], startMs: number): { acc: Map<string, Acc>;
|
|
|
57
99
|
};
|
|
58
100
|
const get = (k: string): Acc => {
|
|
59
101
|
let a = acc.get(k);
|
|
60
|
-
if (!a) { a =
|
|
102
|
+
if (!a) { a = newAcc(); acc.set(k, a); }
|
|
61
103
|
return a;
|
|
62
104
|
};
|
|
63
105
|
|
|
@@ -66,76 +108,53 @@ function foldEvents(sorted: Event[], startMs: number): { acc: Map<string, Acc>;
|
|
|
66
108
|
let carried: { ts: number; streakStart: number } | null = null;
|
|
67
109
|
for (const e of sorted) {
|
|
68
110
|
if (PRESENCE.has(e.kind)) {
|
|
69
|
-
const
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
// event that starts a streak covers nothing behind it.
|
|
73
|
-
let from = slot;
|
|
74
|
-
if (prevPresenceTs !== null && e.ts - prevPresenceTs <= GAP_MS) from = Math.floor(prevPresenceTs / SLOT_MS);
|
|
75
|
-
else streakStart = e.ts;
|
|
76
|
-
for (let s = from; s <= slot; s++) {
|
|
111
|
+
const c = coverage(prevPresenceTs, streakStart, e);
|
|
112
|
+
streakStart = c.streakStart;
|
|
113
|
+
for (let s = c.from; s <= c.to; s++) {
|
|
77
114
|
const slotStart = s * SLOT_MS;
|
|
78
115
|
if (slotStart >= startMs) get(key(slotStart)).slots.add(s); // a slot belongs to the bucket of its start
|
|
79
116
|
}
|
|
80
117
|
prevPresenceTs = e.ts;
|
|
81
|
-
//
|
|
82
|
-
//
|
|
83
|
-
// 00:03, and the day they are looking at holds nothing yet.
|
|
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.
|
|
84
120
|
if (e.ts < startMs) carried = { ts: e.ts, streakStart };
|
|
85
121
|
}
|
|
86
122
|
if (e.ts < startMs) continue; // look-back: presence bookkeeping only
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
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) {
|
|
92
139
|
if (PRESENCE.has(e.kind)) {
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
case "activity":
|
|
98
|
-
a.sessions.add(e.sessionId);
|
|
99
|
-
break;
|
|
100
|
-
case "prompt":
|
|
101
|
-
a.m.prompts++;
|
|
102
|
-
if (a.lastPromptSession !== null && a.lastPromptSession !== e.sessionId) a.m.contextSwitches++;
|
|
103
|
-
a.lastPromptSession = e.sessionId;
|
|
104
|
-
break;
|
|
105
|
-
case "report": a.m.reports++; break;
|
|
106
|
-
case "output": a.m.outputTokens += e.tokens ?? 0; break;
|
|
107
|
-
case "interrupt": a.m.interrupts++; break;
|
|
108
|
-
case "reject": a.m.rejects++; break;
|
|
109
|
-
case "question": a.m.questions++; break;
|
|
110
|
-
case "plan_review": a.m.plans++; break;
|
|
111
|
-
case "mode_change": a.m.modeSwitches++; break;
|
|
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;
|
|
112
144
|
}
|
|
145
|
+
if (inWindow(e.ts)) accumulate(a, e, streakStart);
|
|
113
146
|
}
|
|
114
|
-
return
|
|
147
|
+
return a;
|
|
115
148
|
}
|
|
116
149
|
|
|
117
|
-
// Builds one Day's 24 hour buckets from the accumulated counts, scores each,
|
|
118
|
-
// and rolls up totals, peak and mean.
|
|
119
150
|
function buildDay(date: string, acc: Map<string, Acc>): Day {
|
|
120
151
|
const buckets: HourBucket[] = [];
|
|
121
|
-
//
|
|
122
|
-
// Hours run in time order, so the last write wins.
|
|
152
|
+
// Hours run in time order, so the last write is the day's last action.
|
|
123
153
|
let lastPresence: { ts: number; streakStart: number } | null = null;
|
|
124
154
|
for (let hour = 0; hour < 24; hour++) {
|
|
125
155
|
const a = acc.get(`${date}|${hour}`);
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
if (a.lastPresence) lastPresence = a.lastPresence;
|
|
129
|
-
m.sessions = a.sessions.size;
|
|
130
|
-
m.activeMin = a.slots.size * 5;
|
|
131
|
-
m.streakMin = Math.round(a.maxStreakMs / 60000);
|
|
132
|
-
}
|
|
133
|
-
m.decisions = m.interrupts + m.rejects + m.questions + m.plans + m.modeSwitches;
|
|
134
|
-
// lateNight is a property of the hour label, so it is set on every bucket,
|
|
135
|
-
// including empty ones; score() returns null for buckets without
|
|
136
|
-
// sessions, so an empty late hour scores nothing.
|
|
137
|
-
m.lateNight = LATE_HOURS.has(hour);
|
|
138
|
-
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 });
|
|
139
158
|
}
|
|
140
159
|
const scored = buckets.filter((b) => b.score !== null);
|
|
141
160
|
const totals: Totals = buckets.reduce((t, b) => ({
|
|
@@ -158,24 +177,25 @@ function buildDay(date: string, acc: Map<string, Acc>): Day {
|
|
|
158
177
|
|
|
159
178
|
export function derive(events: Event[], w: Window): Day[] {
|
|
160
179
|
const { startMs, endMs, cutoffMs, dates } = windowBounds(w);
|
|
180
|
+
// sort() is stable, so events equal on both keys keep their input order.
|
|
161
181
|
const sorted = events
|
|
162
|
-
.
|
|
163
|
-
.
|
|
164
|
-
.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)
|
|
165
|
-
.map(({ e }) => e);
|
|
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));
|
|
166
184
|
const { acc, carried } = foldEvents(sorted, startMs);
|
|
167
185
|
const days = dates.map((date) => buildDay(date, acc));
|
|
168
|
-
// Only
|
|
169
|
-
//
|
|
170
|
-
// opened is still the one the person is in. It reaches `presence` and nothing
|
|
171
|
-
// else — no bucket, no total, no active minute belongs to a day before this one.
|
|
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.
|
|
172
188
|
const first = days[0];
|
|
173
189
|
if (first && first.presence === null && carried) {
|
|
174
190
|
first.presence = { lastAt: new Date(carried.ts).toISOString(), streakStartAt: new Date(carried.streakStart).toISOString() };
|
|
175
191
|
}
|
|
176
192
|
if (w.now) {
|
|
177
193
|
const today = localDate(w.now);
|
|
178
|
-
|
|
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
|
+
}
|
|
179
199
|
}
|
|
180
200
|
return days;
|
|
181
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 {
|
|
@@ -28,14 +26,9 @@ const WIDTH = 2400;
|
|
|
28
26
|
const HEIGHT = 1260;
|
|
29
27
|
const READY = 'document.fonts.ready.then(() => document.fonts.status === "loaded" && Array.from(document.images).every((i) => i.complete))';
|
|
30
28
|
|
|
31
|
-
//
|
|
32
|
-
//
|
|
33
|
-
//
|
|
34
|
-
// closed on every path. Any engine failure (constructor, navigate, evaluate,
|
|
35
|
-
// screenshot) is mapped to one line that never quotes the engine's own text; the
|
|
36
|
-
// timeout error passes through unchanged. A failed write (missing directory, no
|
|
37
|
-
// permission, a directory in the way, a read-only file system) becomes one line
|
|
38
|
-
// that names no path, because the node error quotes the whole path.
|
|
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.
|
|
39
32
|
export async function renderCard(html: string, out: string, timeoutMs = 15_000): Promise<void> {
|
|
40
33
|
const lower = out.toLowerCase();
|
|
41
34
|
if (lower.endsWith(".html")) {
|
|
@@ -71,8 +64,7 @@ export async function renderCard(html: string, out: string, timeoutMs = 15_000):
|
|
|
71
64
|
await write(out, bytes);
|
|
72
65
|
}
|
|
73
66
|
|
|
74
|
-
//
|
|
75
|
-
// error node raises quotes the path in full, and the CLI never prints one.
|
|
67
|
+
// One line for every failure: node's error quotes the path, and the CLI never prints one.
|
|
76
68
|
async function write(out: string, data: string | Uint8Array): Promise<void> {
|
|
77
69
|
try {
|
|
78
70
|
await writeFile(out, data);
|
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,10 +19,8 @@ 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
|
|
|
23
|
-
//
|
|
24
|
-
//
|
|
25
|
-
// screenshot puts an `image` block in front of what the person typed, so the
|
|
26
|
-
// text is not always the first block.
|
|
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].
|
|
27
24
|
const firstText = (content: unknown): string | null => {
|
|
28
25
|
if (typeof content === "string") return content;
|
|
29
26
|
if (!Array.isArray(content)) return null;
|
|
@@ -35,16 +32,12 @@ export function parseTranscript(text: string): Event[] {
|
|
|
35
32
|
const events: Event[] = [];
|
|
36
33
|
let lastTs: number | null = null;
|
|
37
34
|
let lastMode: string | null = null;
|
|
38
|
-
// Mode switches seen before this file's first timestamped record, waiting for
|
|
39
|
-
// a time to belong to.
|
|
40
35
|
let pendingModeChanges = 0;
|
|
41
|
-
//
|
|
42
|
-
//
|
|
43
|
-
// requestId and usage, so only the first qualifying record counts.
|
|
36
|
+
// Claude Code writes one record per content block of a response, repeating
|
|
37
|
+
// the requestId and usage; only the first qualifying record counts.
|
|
44
38
|
const seenRequestIds = new Set<string>();
|
|
45
|
-
//
|
|
46
|
-
//
|
|
47
|
-
// machine reporting back, so the pairing is what tells the two apart.
|
|
39
|
+
// Ids of the two tools whose result the human writes; every other
|
|
40
|
+
// `tool_result` is the machine reporting back.
|
|
48
41
|
const askedIds = new Set<string>();
|
|
49
42
|
|
|
50
43
|
for (const line of text.split("\n")) {
|
|
@@ -60,11 +53,8 @@ export function parseTranscript(text: string): Event[] {
|
|
|
60
53
|
if (type === "permission-mode") {
|
|
61
54
|
const m = str(rec.permissionMode);
|
|
62
55
|
if (m === null) continue;
|
|
63
|
-
//
|
|
64
|
-
//
|
|
65
|
-
// switch before any timestamp waits: switching the mode before typing
|
|
66
|
-
// anything is how a session often starts, and it is attributed to the
|
|
67
|
-
// first timestamped record that follows. A file with none drops them.
|
|
56
|
+
// A switch before the first timestamped record is how a session often
|
|
57
|
+
// starts; it waits and is attributed to that record.
|
|
68
58
|
if (lastMode !== null && m !== lastMode) {
|
|
69
59
|
if (lastTs === null) pendingModeChanges++;
|
|
70
60
|
else events.push({ ts: lastTs, sessionId, kind: "mode_change" });
|
|
@@ -106,14 +96,9 @@ export function parseTranscript(text: string): Event[] {
|
|
|
106
96
|
const id = str(b.id);
|
|
107
97
|
if (id !== null) askedIds.add(id);
|
|
108
98
|
}
|
|
109
|
-
//
|
|
110
|
-
//
|
|
111
|
-
//
|
|
112
|
-
// reads, so it never triggers this, even on its first (and only) record.
|
|
113
|
-
// A count only counts when it is one a token count can be: a finite
|
|
114
|
-
// non-negative integer. `1e309` parses to Infinity and a negative count
|
|
115
|
-
// to -Infinity, and a day holding both summed to NaN; such a record is
|
|
116
|
-
// still `activity`, it just carries no tokens.
|
|
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.
|
|
117
102
|
const requestId = str(rec.requestId);
|
|
118
103
|
const usage = isObj(rec.message) ? rec.message.usage : undefined;
|
|
119
104
|
const outputTokens = isObj(usage) ? usage.output_tokens : undefined;
|
package/src/render.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { formatCount, plural } from "./format.ts";
|
|
2
|
+
import { levelOf } from "./score.ts";
|
|
2
3
|
import type { Day, HourBucket, Level } from "./types.ts";
|
|
3
4
|
|
|
4
5
|
const GLYPH: Record<Level, string> = { Calm: "░", Warming: "▒", Heating: "▓", Fried: "█" };
|
|
@@ -11,9 +12,8 @@ const paint = (s: string, level: Level, color: boolean) => (color ? `\x1b[${ANSI
|
|
|
11
12
|
const dim = (s: string, color: boolean) => (color ? `\x1b[2m${s}\x1b[0m` : s);
|
|
12
13
|
const hm = (min: number) => `${Math.floor(min / 60)}h${String(min % 60).padStart(2, "0")}`;
|
|
13
14
|
const hhmm = (d: Date) => `${String(d.getHours()).padStart(2, "0")}:${String(d.getMinutes()).padStart(2, "0")}`;
|
|
14
|
-
//
|
|
15
|
-
//
|
|
16
|
-
// minutes apart are explained rather than silently disagreeing.
|
|
15
|
+
// Marks the snapshot time on the open day, so two runs minutes apart are
|
|
16
|
+
// explained rather than silently disagreeing.
|
|
17
17
|
const snapshotLine = (d: Day | undefined, color: boolean): string[] =>
|
|
18
18
|
d?.asOf ? [dim(` as of ${hhmm(new Date(d.asOf))}, this hour is still running`, color)] : [];
|
|
19
19
|
const label = (date: string) => {
|
|
@@ -22,14 +22,18 @@ const label = (date: string) => {
|
|
|
22
22
|
};
|
|
23
23
|
|
|
24
24
|
export function renderWeek(days: Day[], color: boolean): string {
|
|
25
|
-
//
|
|
25
|
+
// 12-char label, 24 cells of 3 chars, peak in 6, active in 8.
|
|
26
26
|
const header = " " + Array.from({ length: 24 }, (_, h) => `${String(h).padStart(2, "0")} `).join("") + " peak active";
|
|
27
27
|
const rows = days.map((d) => {
|
|
28
28
|
const cells = d.buckets.map((b) => ` ${b.score ? paint(GLYPH[b.score.level], b.score.level, color) : "·"} `).join("");
|
|
29
|
-
|
|
29
|
+
// Padding stays outside the paint so the escape codes add no width.
|
|
30
|
+
const peak = d.peak === null ? "-".padStart(6) : " ".repeat(6 - String(d.peak).length) + paint(String(d.peak), levelOf(d.peak), color);
|
|
31
|
+
const row = `${label(d.date).padEnd(12)}${cells}${peak}${hm(d.activeMin).padStart(8)}`;
|
|
32
|
+
// A day no hour scored in is 24 dots and a dash: no escape of its own, so
|
|
33
|
+
// one dim wrapper is enough.
|
|
34
|
+
return d.peak === null ? dim(row, color) : row;
|
|
30
35
|
});
|
|
31
|
-
// A painted glyph's own \x1b[0m
|
|
32
|
-
// color mode re-emit \x1b[2m right after it to keep the label dim too.
|
|
36
|
+
// A painted glyph's own \x1b[0m cancels the line's dim; re-emit it after.
|
|
33
37
|
const dimGlyph = (level: Level) => paint(GLYPH[level], level, color) + (color ? "\x1b[2m" : "");
|
|
34
38
|
const legend = dim(" " + LEVELS.map((l) => `${dimGlyph(l)} ${LEVEL_NAME[l]}`).join(" "), color);
|
|
35
39
|
const active = days.reduce((s, d) => s + d.activeMin, 0);
|
|
@@ -37,18 +41,16 @@ export function renderWeek(days: Day[], color: boolean): string {
|
|
|
37
41
|
const reports = days.reduce((s, d) => s + d.totals.reports, 0);
|
|
38
42
|
const decisions = days.reduce((s, d) => s + d.totals.decisions, 0);
|
|
39
43
|
const maxSessions = Math.max(0, ...days.map((d) => d.totals.maxSessions));
|
|
40
|
-
//
|
|
41
|
-
// fits inside the grid's 100 columns; hm(active) has no compact form, so it stays as is.
|
|
44
|
+
// Compact counts keep a very active window inside the grid's 100 columns.
|
|
42
45
|
const totals = dim(` ${hm(active)} active ${plural(prompts, "prompt")} ${plural(reports, "report")} ${plural(decisions, "decision")} ${plural(maxSessions, "session")} at once`, color);
|
|
43
46
|
return [header, ...rows, "", legend, totals, ...snapshotLine(days[days.length - 1], color)].join("\n");
|
|
44
47
|
}
|
|
45
48
|
|
|
46
|
-
// Values at or above 1000 are shown as one decimal of a thousand (e.g. "41.2k"); smaller values print as-is.
|
|
47
49
|
const fmtTokens = (n: number): string => (n >= 1000 ? `${(n / 1000).toFixed(1)}k` : String(n));
|
|
48
50
|
|
|
49
51
|
type Col = [string, number, (b: HourBucket) => string];
|
|
50
52
|
|
|
51
|
-
//
|
|
53
|
+
// Hour left-aligned, level left-aligned after two spaces, the rest right-aligned.
|
|
52
54
|
const COLS: Col[] = [
|
|
53
55
|
["hour", 5, (b) => `${String(b.hour).padStart(2, "0")}:00`],
|
|
54
56
|
["index", 7, (b) => String(b.score?.index ?? "")],
|
|
@@ -73,10 +75,8 @@ const EXPLAIN: Col[] = [
|
|
|
73
75
|
["strk", 6, (b) => String(b.score?.parts.streak ?? "")],
|
|
74
76
|
["late", 6, (b) => String(b.score?.parts.late ?? "")],
|
|
75
77
|
];
|
|
76
|
-
//
|
|
77
|
-
//
|
|
78
|
-
// The skeleton columns (hour, index, level, sess, prompts, streak, out-tok, and
|
|
79
|
-
// the six --explain parts) always show, so two days still line up.
|
|
78
|
+
// Left out of the table for a day where every active bucket reads zero; the
|
|
79
|
+
// other columns always show, so two days still line up.
|
|
80
80
|
const EVENT_COLS = new Set(["rep", "intr", "rej", "quest", "plan", "mode", "ctx-sw"]);
|
|
81
81
|
const FULL_NAME: Record<string, string> = {
|
|
82
82
|
rep: "reports", intr: "interrupts", rej: "rejects", quest: "questions",
|
|
@@ -89,9 +89,8 @@ export function renderDay(day: Day, opts: { explain: boolean; color: boolean }):
|
|
|
89
89
|
cells.map((c, i) => { const w = columns[i]![1]; if (i === 0) return c.padEnd(w); if (columns[i]![0] === "level") return ` ${c.padEnd(w - 2)}`; return c.padStart(w); }).join("").trimEnd();
|
|
90
90
|
|
|
91
91
|
const active = day.buckets.filter((b) => b.score !== null);
|
|
92
|
-
//
|
|
93
|
-
//
|
|
94
|
-
// empty today would print the identical line.
|
|
92
|
+
// The snapshot line still shows on an empty open day, or a run at 09:00 and
|
|
93
|
+
// one at 18:00 would print the identical line.
|
|
95
94
|
if (active.length === 0) return [line(cols, cols.map(([name]) => name)), ...snapshotLine(day, opts.color)].join("\n");
|
|
96
95
|
|
|
97
96
|
const visible = cols.filter(([name, , f]) => !EVENT_COLS.has(name) || active.some((b) => f(b) !== "0"));
|
|
@@ -101,8 +100,7 @@ export function renderDay(day: Day, opts: { explain: boolean; color: boolean }):
|
|
|
101
100
|
const rows = active.map((b) => {
|
|
102
101
|
const cells = visible.map(([, , f]) => f(b));
|
|
103
102
|
const text = line(visible, cells);
|
|
104
|
-
// Safe only
|
|
105
|
-
// if one ever could, this would need to target the level column's slice, not a string search.
|
|
103
|
+
// Safe only while no other column can contain a level word.
|
|
106
104
|
return opts.color && b.score ? text.replace(b.score.level, paint(b.score.level, b.score.level, true)) : text;
|
|
107
105
|
});
|
|
108
106
|
const lines = [header, ...rows];
|
package/src/report.ts
CHANGED
|
@@ -6,7 +6,6 @@ import type { Day, Transcript } from "./types.ts";
|
|
|
6
6
|
|
|
7
7
|
export type ReportOptions = { projects: string; to: string; days: number; now?: Date };
|
|
8
8
|
|
|
9
|
-
// The shell's seam: lists and reads files, then hands the text to the pure core.
|
|
10
9
|
export async function report(o: ReportOptions): Promise<Day[]> {
|
|
11
10
|
const window = { to: o.to, days: o.days, now: o.now };
|
|
12
11
|
const paths = await scan(o.projects, windowBounds(window).cutoffMs);
|
package/src/scan.ts
CHANGED
|
@@ -2,12 +2,9 @@ import type { Dirent } from "node:fs";
|
|
|
2
2
|
import { open, readdir, stat } from "node:fs/promises";
|
|
3
3
|
import { join } from "node:path";
|
|
4
4
|
|
|
5
|
-
//
|
|
6
|
-
// directory and are the parent agent's conversation, not the human's; mtime is checked
|
|
7
|
-
// first, and for a file mtime would drop, the last timestamp in its tail decides.
|
|
5
|
+
// A `subagents` directory holds the parent agent's conversation, not the human's.
|
|
8
6
|
export async function scan(projects: string, cutoffMs: number): Promise<string[]> {
|
|
9
|
-
// No path in either message:
|
|
10
|
-
// homedir-derived default, and the CLI must never print a filesystem path.
|
|
7
|
+
// No path in either message: the CLI never prints one.
|
|
11
8
|
let root;
|
|
12
9
|
try {
|
|
13
10
|
if (!(await stat(projects)).isDirectory()) throw new Error("not a directory");
|
|
@@ -22,9 +19,8 @@ export async function scan(projects: string, cutoffMs: number): Promise<string[]
|
|
|
22
19
|
return out.sort();
|
|
23
20
|
}
|
|
24
21
|
|
|
25
|
-
// One directory
|
|
26
|
-
//
|
|
27
|
-
// writes one, and following it is how a loop or a stray link to $HOME would get in.
|
|
22
|
+
// One unreadable directory costs only itself. Symlinks are not followed: Claude
|
|
23
|
+
// Code never writes one, and following one is how a loop or $HOME would get in.
|
|
28
24
|
async function collect(dir: string, entries: Dirent[], cutoffMs: number, out: string[]): Promise<void> {
|
|
29
25
|
for (const entry of entries) {
|
|
30
26
|
const full = join(dir, entry.name);
|
|
@@ -41,12 +37,9 @@ async function collect(dir: string, entries: Dirent[], cutoffMs: number, out: st
|
|
|
41
37
|
}
|
|
42
38
|
}
|
|
43
39
|
|
|
44
|
-
// mtime
|
|
45
|
-
//
|
|
46
|
-
//
|
|
47
|
-
// decides. 64 KB, not a few, because the last record of a conversation is often a big tool
|
|
48
|
-
// result (a file read, grep output) and its own timestamp field sits in front of all that
|
|
49
|
-
// text. The tail is matched for that one field and discarded; nothing else is read.
|
|
40
|
+
// mtime can be older than the records inside (sync, restore, clock skew), so for
|
|
41
|
+
// a file mtime would drop, the last "timestamp" in its final 64 KB decides. 64 KB
|
|
42
|
+
// because the last record is often a big tool result with its timestamp in front.
|
|
50
43
|
const TAIL_BYTES = 65_536;
|
|
51
44
|
async function lastTimestampMs(path: string): Promise<number> {
|
|
52
45
|
const fh = await open(path, "r");
|
package/src/score.ts
CHANGED
|
@@ -1,8 +1,7 @@
|
|
|
1
1
|
import type { Level, Metrics, Parts, Score } from "./types.ts";
|
|
2
2
|
|
|
3
|
-
// Calibration lives here and nowhere else.
|
|
4
|
-
//
|
|
5
|
-
// Norms are the p90 of two weeks of real data on two machines (see CHANGELOG).
|
|
3
|
+
// Calibration lives here and nowhere else. Integer points of 100, so 0.5 sums
|
|
4
|
+
// stay exact; norms are the p90 of two weeks on two machines (see CHANGELOG).
|
|
6
5
|
export const WEIGHTS = { parallel: 25, pace: 15, supervision: 30, reading: 10, streak: 10, late: 10 } as const;
|
|
7
6
|
export const NORMS = { parallelSpan: 4, pacePerHour: 20, supervisionPerHour: 45, decisionWeight: 3, readingTokens: 80_000, streakMin: 40 } as const;
|
|
8
7
|
export const LEVELS: readonly { max: number; level: Level }[] = [
|
|
@@ -23,8 +22,7 @@ export function score(m: Metrics): Score | null {
|
|
|
23
22
|
if (m.sessions === 0) return null;
|
|
24
23
|
const parallel = clamp01((m.sessions - 1) / NORMS.parallelSpan);
|
|
25
24
|
const pace = clamp01(m.prompts / NORMS.pacePerHour);
|
|
26
|
-
//
|
|
27
|
-
// reacting to one agent report or one session hop costs, and the three share a norm.
|
|
25
|
+
// A decision costs `decisionWeight` reports or session hops; the three share a norm.
|
|
28
26
|
const supervision = clamp01(
|
|
29
27
|
(NORMS.decisionWeight * m.decisions + m.reports + m.contextSwitches) / NORMS.supervisionPerHour,
|
|
30
28
|
);
|
|
@@ -40,7 +38,7 @@ export function score(m: Metrics): Score | null {
|
|
|
40
38
|
streak: WEIGHTS.streak * streak,
|
|
41
39
|
late: WEIGHTS.late * late,
|
|
42
40
|
};
|
|
43
|
-
//
|
|
41
|
+
// Rounded for display only.
|
|
44
42
|
const parts: Parts = {
|
|
45
43
|
parallel: Math.round(raw.parallel * 10) / 10,
|
|
46
44
|
pace: Math.round(raw.pace * 10) / 10,
|
|
@@ -49,8 +47,7 @@ export function score(m: Metrics): Score | null {
|
|
|
49
47
|
streak: Math.round(raw.streak * 10) / 10,
|
|
50
48
|
late: Math.round(raw.late * 10) / 10,
|
|
51
49
|
};
|
|
52
|
-
//
|
|
53
|
-
// rounding (parts, above) can never tip it across a boundary raw didn't.
|
|
50
|
+
// Rounded once from the raw points, so parts' rounding can never tip it across a boundary.
|
|
54
51
|
const index = Math.round(
|
|
55
52
|
raw.parallel + raw.pace + raw.supervision + raw.reading + raw.streak + raw.late,
|
|
56
53
|
);
|
package/src/status.ts
CHANGED
|
@@ -1,11 +1,7 @@
|
|
|
1
1
|
import { GAP_MS } from "./derive.ts";
|
|
2
2
|
import type { Day, Level } from "./types.ts";
|
|
3
3
|
|
|
4
|
-
// The status file's
|
|
5
|
-
// status line needs. Core, not shell — this file never reads the clock, the
|
|
6
|
-
// environment or the file system, and never writes one; `now` arrives as an
|
|
7
|
-
// argument and `src/statusfile.ts` does the writing. The field order below is
|
|
8
|
-
// the file format (see the status-file design spec) and JSON.stringify keeps it.
|
|
4
|
+
// The status file's nine values. The field order is the file format.
|
|
9
5
|
export type Status = {
|
|
10
6
|
schema: 1;
|
|
11
7
|
asOf: string;
|
|
@@ -18,34 +14,26 @@ export type Status = {
|
|
|
18
14
|
streakMin: number;
|
|
19
15
|
};
|
|
20
16
|
|
|
21
|
-
//
|
|
22
|
-
//
|
|
23
|
-
//
|
|
24
|
-
//
|
|
25
|
-
//
|
|
26
|
-
// The live streak, not the bucket's: a status line answers "how long have I
|
|
27
|
-
// been at this?", and the bucket's number drops to zero at every hour boundary
|
|
28
|
-
// and stops growing between two actions. While the last action is no more than
|
|
29
|
-
// GAP_MS behind `now`, the streak runs from its first action to `now`; once the
|
|
30
|
-
// break is longer than that, it is over and reads 0.
|
|
17
|
+
// index and level are the day's `live` bucket, not the hour's: an hour's bucket
|
|
18
|
+
// is nearly empty just after the hour turns. streakMin is measured against `now`,
|
|
19
|
+
// not the bucket's: it must not reset on the hour or stop between two actions,
|
|
20
|
+
// and it is over once the last action is more than GAP_MS behind `now`.
|
|
31
21
|
export function statusOf(day: Day, now: Date): Status {
|
|
32
22
|
const hour = now.getHours();
|
|
33
|
-
const bucket = day.buckets[hour];
|
|
34
23
|
const live = day.presence !== null && now.getTime() - Date.parse(day.presence.lastAt) <= GAP_MS;
|
|
35
24
|
return {
|
|
36
25
|
schema: 1,
|
|
37
26
|
asOf: day.asOf ?? now.toISOString(),
|
|
38
27
|
date: day.date,
|
|
39
28
|
hour,
|
|
40
|
-
index:
|
|
41
|
-
level:
|
|
29
|
+
index: day.live?.score?.index ?? null,
|
|
30
|
+
level: day.live?.score?.level ?? null,
|
|
42
31
|
peak: day.peak,
|
|
43
32
|
activeMin: day.activeMin,
|
|
44
33
|
streakMin: live ? Math.round((now.getTime() - Date.parse(day.presence!.streakStartAt)) / 60000) : 0,
|
|
45
34
|
};
|
|
46
35
|
}
|
|
47
36
|
|
|
48
|
-
// One line of JSON, the fields in the order declared above, newline at the end.
|
|
49
37
|
export function renderStatus(s: Status): string {
|
|
50
38
|
return JSON.stringify(s) + "\n";
|
|
51
39
|
}
|
package/src/statusfile.ts
CHANGED
|
@@ -1,41 +1,30 @@
|
|
|
1
|
-
// The only module that writes the status file.
|
|
2
|
-
// path, the directory, the temporary file, the rename and the modes. The line
|
|
3
|
-
// it is handed comes from the pure `src/status.ts`.
|
|
1
|
+
// The only module that writes the status file.
|
|
4
2
|
import { chmod, mkdir, open, rename, unlink } from "node:fs/promises";
|
|
5
3
|
import { join } from "node:path";
|
|
6
4
|
|
|
7
5
|
const FAILED = "cannot write the status file";
|
|
8
6
|
|
|
9
|
-
//
|
|
10
|
-
//
|
|
11
|
-
//
|
|
12
|
-
//
|
|
13
|
-
// the link. HOME comes from the env passed in rather than os.homedir(), so an
|
|
14
|
-
// empty HOME is the error the spec names instead of a silent fallback.
|
|
7
|
+
// Atomic: a temporary file in the same directory, then a rename over the
|
|
8
|
+
// target, so a reader never sees a partial line and a symlink at the target is
|
|
9
|
+
// replaced, not written through. HOME comes from `env`, not os.homedir(), so an
|
|
10
|
+
// empty HOME is an error rather than a silent fallback.
|
|
15
11
|
export async function writeStatus(line: string, env: NodeJS.ProcessEnv): Promise<void> {
|
|
16
12
|
const home = env.HOME;
|
|
17
13
|
if (!home) throw new Error(FAILED);
|
|
18
|
-
// The
|
|
19
|
-
//
|
|
20
|
-
//
|
|
21
|
-
// each path afterwards, is what makes `mkdir` and `open` come out exactly
|
|
22
|
-
// 0700 and 0600 on the first try — and it closes the window where one run has
|
|
23
|
-
// created `~/.claude` too narrow to enter and a second run, seeing a parent
|
|
24
|
-
// that already exists, fails inside it.
|
|
14
|
+
// The umask narrows the modes below; set it first so `mkdir` and `open` come
|
|
15
|
+
// out 0700 and 0600 on the first try, with no window where a second run finds
|
|
16
|
+
// a parent it cannot enter. Nothing else is written from here on.
|
|
25
17
|
process.umask(0o077);
|
|
26
18
|
const dir = join(home, ".claude", "zapara");
|
|
27
|
-
//
|
|
28
|
-
//
|
|
29
|
-
//
|
|
30
|
-
//
|
|
31
|
-
// create — from the outside a crashed run's leftover and a slow run's file
|
|
32
|
-
// in flight look the same, and removing the second would break that promise.
|
|
19
|
+
// Unique per run: concurrent runs each rename their own complete file. Opened
|
|
20
|
+
// with `wx` so a file or symlink already at the name is an error, and never
|
|
21
|
+
// reused or deleted if not ours: a crashed run's leftover and a slow run's
|
|
22
|
+
// file in flight look the same from outside.
|
|
33
23
|
const tmp = join(dir, `status.json.${process.pid}.${Math.random().toString(36).slice(2, 10)}.tmp`);
|
|
34
24
|
let created = false;
|
|
35
25
|
try {
|
|
36
26
|
await mkdir(dir, { recursive: true, mode: 0o700 });
|
|
37
|
-
// `mkdir` leaves
|
|
38
|
-
// status directory someone else created loose is tightened here.
|
|
27
|
+
// `mkdir` leaves an existing directory as it was; tighten it.
|
|
39
28
|
await chmod(dir, 0o700);
|
|
40
29
|
const handle = await open(tmp, "wx", 0o600);
|
|
41
30
|
created = true;
|
|
@@ -43,8 +32,6 @@ export async function writeStatus(line: string, env: NodeJS.ProcessEnv): Promise
|
|
|
43
32
|
await chmod(tmp, 0o600);
|
|
44
33
|
await rename(tmp, join(dir, "status.json"));
|
|
45
34
|
} catch {
|
|
46
|
-
// Only this run's own file, and a failure to remove it changes nothing:
|
|
47
|
-
// the error below is what the caller acts on either way.
|
|
48
35
|
if (created) await unlink(tmp).catch(() => {});
|
|
49
36
|
throw new Error(FAILED);
|
|
50
37
|
}
|
package/src/types.ts
CHANGED
|
@@ -10,12 +10,11 @@ export type Metrics = {
|
|
|
10
10
|
export type Level = "Calm" | "Warming" | "Heating" | "Fried";
|
|
11
11
|
export type Parts = { parallel: number; pace: number; supervision: number; reading: number; streak: number; late: number }; // weighted points, sum ≈ index
|
|
12
12
|
export type Score = { index: number; level: Level; parts: Parts };
|
|
13
|
-
export type
|
|
13
|
+
export type LiveBucket = Metrics & { score: Score | null };
|
|
14
|
+
export type HourBucket = LiveBucket & { hour: number };
|
|
14
15
|
export type Totals = { prompts: number; reports: number; outputTokens: number; interrupts: number; rejects: number; questions: number; plans: number; modeSwitches: number; decisions: number; contextSwitches: number; maxSessions: number };
|
|
15
|
-
// asOf
|
|
16
|
-
//
|
|
17
|
-
// `presence` is the day's last human action and the start of
|
|
18
|
-
//
|
|
19
|
-
|
|
20
|
-
// reader measure the streak against its own clock instead of the hour bucket.
|
|
21
|
-
export type Day = { date: string; peak: number | null; mean: number | null; activeMin: number; presence: { lastAt: string; streakStartAt: string } | null; totals: Totals; buckets: HourBucket[]; asOf?: string };
|
|
16
|
+
// `asOf` and `live` exist only on the day containing `now`: `live` is the sixty
|
|
17
|
+
// minutes `(asOf − 60 min, asOf]`, by the hour bucket's rule, look-back included.
|
|
18
|
+
// `presence` is the day's last human action and the start of its streak, which
|
|
19
|
+
// may lie on an earlier day; instants are ISO 8601 UTC.
|
|
20
|
+
export type Day = { date: string; peak: number | null; mean: number | null; activeMin: number; presence: { lastAt: string; streakStartAt: string } | null; totals: Totals; buckets: HourBucket[]; asOf?: string; live?: LiveBucket };
|