@drakulavich/zapara 0.3.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -5,6 +5,29 @@ All notable changes to this project are documented here. The format follows
5
5
 
6
6
  ## [Unreleased]
7
7
 
8
+ ## [0.4.0] - 2026-09-21
9
+
10
+ ### Changed
11
+ - Streak norm recalibrated for the presence rule: 40 minutes of your own uninterrupted actions now score the full streak points (was 120, the p90 of the old rule that let agent activity keep a streak alive; the p90 under the new rule is 33 on one machine and 48 on another, and 40 sits between them).
12
+ - Presence counts every human action, not only prompts: an interrupt, a tool rejection and an answer to a question or a plan hold a streak open and fill active minutes the same way a prompt does. They remain decisions where they already were; nothing is counted twice.
13
+ - An hour's streak is the longest streak seen in it, not the one it happened to end on. A single prompt after a break no longer erases the run the hour held, nor the streak points of that hour's index.
14
+ - The status file's streak is live: it counts from the first action of the streak you are in up to `asOf`, and resets to `0` once you have been away for more than ten minutes. Before, it was the current hour's bucket, so it fell to zero at every hour boundary.
15
+ - `Day.presence` in `--json`: the day's last human action and the start of the streak it belongs to, as instants, or `null` on a day with no action of yours.
16
+
17
+ ### Fixed
18
+ - The card's longest streak is no longer under-reported: a run that ended in an hour where another began used to be measured only up to the end of the hour before.
19
+ - The status line's streak no longer drops to zero at each hour boundary.
20
+ - `card --out` into a directory that does not exist or refuses the write says `cannot write the card: check the --out directory` instead of printing the full path back in a file-system error.
21
+ - A permission-mode switch made before the first message of a session counts: it is attributed to the first timestamped record that follows, instead of vanishing.
22
+ - A transcript whose modification time is outside the window is read when its last record is a big one: the rescue that looks for the last timestamp reads 64 KB from the end instead of 4 KB, so a day that ended on a large tool result is no longer dropped in full.
23
+ - An output-token count that is not a finite non-negative integer is ignored instead of poisoning the day: a corrupt transcript could put `NaN` in the token column and `null` in the JSON, beside a made-up level.
24
+ - A prompt with a pasted screenshot counts again: the image block comes before the typed text, and such prompts were read as no text at all, so a day spent pasting screenshots showed no prompts and no active minutes.
25
+
26
+ ## [0.3.1] - 2026-09-19
27
+
28
+ ### Changed
29
+ - Streak and active minutes count your presence, not the agent's: a streak is your prompts no more than 10 minutes apart across sessions, and active minutes are the five-minute slots those streaks cover. Agent work while you are away no longer keeps a streak alive or fills the day.
30
+
8
31
  ## [0.3.0] - 2026-09-19
9
32
 
10
33
  ### Added
package/README.md CHANGED
@@ -12,75 +12,88 @@
12
12
  <img src="https://raw.githubusercontent.com/drakulavich/zapara/main/assets/demo.webp" alt="zapara demo: week heatmap, day table, JSON" width="800">
13
13
  </p>
14
14
 
15
- Claude Code writes a JSONL transcript for every session under `~/.claude/projects`. zapara reads those files, puts each record in the local hour it happened in, and turns the hour into one number. A week is a heatmap of seven rows by 24 cells; a day is a table with one row per hour and, with `--explain`, the weighted contribution of each component. Nothing is installed into Claude Code, no hook is registered, and no network call is made.
15
+ Claude Code writes a JSONL transcript for every session under `~/.claude/projects`. zapara reads those files, puts each record in the local hour it happened in, and turns the hour into one number. A week is a heatmap of seven rows by 24 cells; a day is a table with one row per hour and, with `--explain`, the weighted contribution of each component.
16
16
 
17
- ## Quick Start
17
+ ## Quick start
18
18
 
19
19
  ```bash
20
- # Install Bun if you do not have it (zapara needs 1.4 or newer)
20
+ # Skip this if you already have Bun 1.4 or newer
21
21
  curl -fsSL https://bun.sh/install | bash
22
22
 
23
- # The last 7 days, straight from the registry
23
+ # Run it once, without installing
24
24
  bunx @drakulavich/zapara@latest
25
+ ```
26
+
27
+ To keep it, install it with Bun. The same command upgrades it later.
28
+
29
+ ```bash
30
+ bun add -g @drakulavich/zapara
31
+ ```
25
32
 
26
- # Yesterday, hour by hour, with the components behind each index
27
- bunx @drakulavich/zapara@latest yesterday --explain
33
+ `zapara` lands in Bun's global bin directory, `~/.bun/bin` unless `BUN_INSTALL_BIN` says otherwise; `bun pm bin -g` prints the one in force. Bun's own installer puts that directory on your PATH; a Homebrew Bun does not, so add it yourself. Then:
28
34
 
29
- # The card
30
- bunx @drakulavich/zapara@latest card
35
+ ```bash
36
+ zapara # the last 7 days
37
+ zapara yesterday --explain # one day, with the components behind each index
38
+ zapara card # the picture
31
39
  ```
32
40
 
33
- To keep it around, `bun add -g @drakulavich/zapara` puts `zapara` on your PATH. There is no build step and no runtime dependency: Bun runs `src/index.ts` from the package as it is.
41
+ No build step and no runtime dependency: Bun runs `src/index.ts` from the package as it is.
34
42
 
35
43
  ## What it looks like
36
44
 
37
- Both pictures below come from the synthetic fixture in `tests/fixtures/busy-week`: a calm morning of one session, a five-session storm in the middle of the day, and a late tail that runs past midnight.
45
+ The week below, and the day behind the fold under it, come from the synthetic fixture in `tests/fixtures/busy-week`: a calm morning of one session, a five-session storm in the middle of the day, and a late tail that runs past midnight.
38
46
 
39
47
  ```
40
48
  00 01 02 03 04 05 06 07 08 09 10 11 12 13 14 15 16 17 18 19 20 21 22 23 peak active
41
- Mon 14/09 · · · · · · · · · ░ ░ ░ █ █ █ · · · · · ░ ░ · ░ 87 6h00
42
- Tue 15/09 · · · · · · · · · · ░ ░ ░ ░ ▒ ▒ ▒ ░ · · · · · · 33 4h00
49
+ Mon 14/09 · · · · · · · · · ░ ░ ░ █ █ █ · · · · · ░ ░ · ░ 87 8h50
50
+ Tue 15/09 · · · · · · · · · · ░ ░ ░ ░ ▒ ▒ ▒ ░ · · · · · · 33 7h55
43
51
  Wed 16/09 · · · · · · · · · · · · · · · · · · · · · · · · - 0h00
44
- Thu 17/09 · · · · · · · · · · · ░ ░ · · · · · · · · · · · 14 1h00
45
- Fri 18/09 · · · · · · · · · · · · · · · ▓ · · · · · · · · 82 1h00
46
- Sat 19/09 ░ ░ · · · · · · · · · · · · · · · · · · · · · · 24 1h00
52
+ Thu 17/09 · · · · · · · · · · · ░ ░ · · · · · · · · · · · 15 1h55
53
+ Fri 18/09 · · · · · · · · · · · · · · · ▓ · · · · · · · · 81 0h35
54
+ Sat 19/09 ░ ░ · · · · · · · · · · · · · · · · · · · · · · 25 1h55
47
55
  Sun 20/09 · · · · · · · · · · · · · · · · · · · · · · · · - 0h00
48
56
 
49
57
  ░ calm ▒ warming ▓ heating █ fried
50
- 13h00 active 346 prompts 0 reports 100 decisions 5 sessions at once
51
- ```
52
-
53
- ```
54
- hour index level sess prompts intr rej quest plan mode ctx-sw streak out-tok par pace sup read strk late
55
- 09:00 9 Calm 1 6 0 0 0 0 0 0 53m 600 0 4.5 0 0.1 4.4 0
56
- 10:00 14 Calm 1 6 0 0 0 0 0 0 113m 600 0 4.5 0 0.1 9.4 0
57
- 11:00 15 Calm 1 6 0 0 0 0 0 0 173m 600 0 4.5 0 0.1 10 0
58
- 12:00 87 Fried 5 55 20 1 1 1 2 54 237m 55.0k 25 15 30 6.9 10 0
59
- 13:00 87 Fried 5 55 20 1 1 1 2 54 297m 55.0k 25 15 30 6.9 10 0
60
- 14:00 87 Fried 5 55 20 1 1 1 2 54 357m 55.0k 25 15 30 6.9 10 0
61
- 20:00 9 Calm 1 6 0 0 0 0 0 0 53m 600 0 4.5 0 0.1 4.4 0
62
- 21:00 14 Calm 1 6 0 0 0 0 0 0 113m 600 0 4.5 0 0.1 9.4 0
63
- 23:00 19 Calm 1 6 0 0 0 0 0 0 53m 600 0 4.5 0 0.1 4.4 10
64
- no reports today
58
+ 21h10 active 323 prompts 0 reports 86 decisions 5 sessions at once
65
59
  ```
66
60
 
67
61
  <details>
68
- <summary><b>Reproduce these pictures from the repository</b></summary>
62
+ <summary><b>Monday hour by hour, and the commands that print both</b></summary>
69
63
 
70
- Two commands in a terminal reproduce them. The fixture's timestamps are UTC and zapara buckets by local time, so pin the zone to get these exact hours:
64
+ The fixture's timestamps are UTC and zapara buckets by local time, so pin the zone to get these exact hours:
71
65
 
72
66
  ```bash
73
67
  TZ=UTC bun src/index.ts --projects tests/fixtures/busy-week --to 2026-09-20 --no-color
68
+ ```
69
+
70
+ That prints the grid above. One day of it, with the weighted parts behind each index:
71
+
72
+ ```bash
74
73
  TZ=UTC bun src/index.ts 2026-09-14 --projects tests/fixtures/busy-week --explain --no-color
75
74
  ```
76
75
 
76
+ ```
77
+ hour index level sess prompts intr rej quest plan mode ctx-sw streak out-tok par pace sup read strk late
78
+ 09:00 15 Calm 1 6 0 0 0 0 0 0 50m 600 0 4.5 0 0.1 10 0
79
+ 10:00 15 Calm 1 6 0 0 0 0 0 0 110m 600 0 4.5 0 0.1 10 0
80
+ 11:00 15 Calm 1 7 0 0 0 0 0 0 179m 600 0 5.3 0 0.1 10 0
81
+ 12:00 87 Fried 5 55 20 1 1 1 2 54 235m 55.0k 25 15 30 6.9 10 0
82
+ 13:00 87 Fried 5 55 20 1 1 1 2 54 295m 55.0k 25 15 30 6.9 10 0
83
+ 14:00 87 Fried 5 55 20 1 1 1 2 54 355m 55.0k 25 15 30 6.9 10 0
84
+ 20:00 15 Calm 1 6 0 0 0 0 0 0 50m 600 0 4.5 0 0.1 10 0
85
+ 21:00 15 Calm 1 6 0 0 0 0 0 0 110m 600 0 4.5 0 0.1 10 0
86
+ 23:00 25 Calm 1 6 0 0 0 0 0 0 50m 600 0 4.5 0 0.1 10 10
87
+ no reports today
88
+ ```
89
+
77
90
  The grid is a fixed 98 columns wide, 100 with its hour header, and does not reflow, so it needs a terminal at least that wide.
78
91
 
79
92
  </details>
80
93
 
81
94
  ## Share a card
82
95
 
83
- `zapara card` turns your last two weeks into one picture: a character named after the kind of load that dominates your hours, the sentence behind it, the peak hour, the share of calm, warming, heating and fried hours, and three highlights. No dates, no hour totals, nothing that reads as a timesheet.
96
+ `zapara card` turns your last two weeks into one picture: a character named after the kind of load that dominates your hours, the sentence behind it, the peak hour, the share of calm, warming, heating and fried hours, and three highlights. It carries no dates and no hour totals, so it does not read as a timesheet.
84
97
 
85
98
  ```bash
86
99
  zapara card # writes zapara-card.png in the current directory
@@ -100,7 +113,7 @@ wrote zapara-card.png
100
113
 
101
114
  <p align="center"><img src="https://raw.githubusercontent.com/drakulavich/zapara/main/assets/card.webp" alt="zapara card: The Marathoner, longest streak 7h53m, 68% of hours calm" width="800"></p>
102
115
 
103
- This one comes from the same `busy-week` fixture as the pictures above. The picture is taken by a headless browser that Bun drives: WebKit on macOS, an installed Google Chrome elsewhere, so on Linux or Windows install Chrome, or write `--out card.html` and open the page in any browser.
116
+ This one comes from the same `busy-week` fixture as the pictures above. A headless browser that Bun drives takes the picture: WebKit on macOS, an installed Google Chrome elsewhere. On Linux or Windows, install Chrome or write `--out card.html` and open the page in any browser.
104
117
 
105
118
  ## Usage
106
119
 
@@ -128,13 +141,13 @@ This one comes from the same `busy-week` fixture as the pictures above. The pict
128
141
 
129
142
  Levels: calm 0–29, warming 30–59, heating 60–84, fried 85–100.
130
143
 
131
- The grid and the day print a text table when stdout is a terminal and JSON otherwise, so `zapara | cat` prints JSON; there is no flag to force text in a pipe yet. `card` always writes its file and prints its two lines, piped or not, and only `card --json` prints JSON. `status` always writes its file and prints the same JSON line, piped or not, and `--json` changes nothing there.
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.
132
145
 
133
- Exit codes are 0 on success, 1 when the projects directory is missing or cannot be read, or when `status` cannot write its file (three different messages, none with a path), and 2 for a usage error such as a bad date or an unknown flag, which prints one line and a hint to `--help`. A value flag given twice is a usage error. A window with no data prints an empty grid and exits 0.
146
+ Exit codes are 0 on success, 1 when the projects directory is missing or cannot be read, when `status` cannot write its file, or when `card` cannot write its picture (four different messages, none with a path), and 2 for a usage error such as a bad date, an unknown flag or a value flag given twice, which prints one line and a hint to `--help`. A window with no data prints an empty grid and exits 0.
134
147
 
135
148
  ## Status line
136
149
 
137
- A status line wants one number every thirty seconds and cannot wait half a second for a transcript scan, so zapara writes the number down and the status line reads it back. `zapara status` computes today exactly as `zapara today` does, writes it as one line of JSON to `~/.claude/zapara/status.json`, and prints the same line. Only `--projects` applies to it; the window flags, `--explain` and `--out` are usage errors, and `--json` changes nothing because the output is already JSON. There is one file per user, whatever `--projects` said, created with mode `0600` in a directory with mode `0700`; the write goes to a temporary file and is renamed into place, so a reader sees the old line or the new one and never half of one. The file holds the nine values below and nothing else: no path, no project, no session count, no text.
150
+ A status line wants one number every thirty seconds and cannot wait half a second for a transcript scan, so zapara writes the number down and the status line reads it back. `zapara status` computes today exactly as `zapara today` does, writes it as one line of JSON to `~/.claude/zapara/status.json`, and prints the same line. Only `--projects` applies; the window flags, `--explain` and `--out` are usage errors. There is one file per user, whatever `--projects` said, created with mode `0600` in a directory with mode `0700`; the write goes to a temporary file and is renamed into place, so a reader sees the old line or the new one and never half of one. The file holds the nine values below and nothing else: no path, no project, no session count, no text.
138
151
 
139
152
  ```json
140
153
  {"schema":1,"asOf":"2026-09-19T12:30:38.300Z","date":"2026-09-19","hour":15,"index":36,"level":"Warming","peak":41,"activeMin":555,"streakMin":166}
@@ -142,31 +155,31 @@ A status line wants one number every thirty seconds and cannot wait half a secon
142
155
 
143
156
  | Field | Meaning |
144
157
  |---|---|
145
- | `schema` | The shape of this file: `1`. A reader that sees a number it does not know shows nothing. It changes only when a field changes meaning or goes away; adding a field does not bump it. |
158
+ | `schema` | The shape of this file: `1`. A reader that sees a number it does not know shows nothing. It changes only when a field changes meaning or goes away; adding a field does not bump it. A field that keeps its name, unit and range but is measured differently (the presence rule of 2026-09-19 for `activeMin` and `streakMin`) does not bump it either: a reader shows the corrected number, and the change is a CHANGELOG entry. |
146
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. |
147
160
  | `date` | The local calendar day the numbers describe, `YYYY-MM-DD`. |
148
161
  | `hour` | The local hour that contains `asOf`, `0`..`23`. |
149
162
  | `index` | That hour's load index, `0`..`100`, or `null` when the hour has no activity yet. |
150
163
  | `level` | That hour's level, `Calm`, `Warming`, `Heating` or `Fried`, or `null` with `index`. A reader colors by this field so it never needs the thresholds. |
151
164
  | `peak` | The day's peak index so far, or `null` on a day with no activity. |
152
- | `activeMin` | Active minutes in the day so far; `0` on a day with no activity. |
153
- | `streakMin` | Minutes of the unbroken streak as of the current hour, `0` when there is none. |
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
+ | `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. |
154
167
 
155
- Refreshing is the reader's job, and zapara adds no hook, no timer and no daemon. A reader decodes the file strictly and treats anything that fails validation, and a missing or unreadable file, as no data: it draws nothing and counts the file as stale. When the snapshot is stale or missing it starts `zapara status` as a detached process, does not wait for it, and draws what it has, which is also how the file first comes to exist on a machine that never ran zapara. It starts at most one run per threshold and never one per render, so a file that never validates costs one run per threshold and no more; the whole contract is in [the status file spec](docs/superpowers/specs/2026-09-19-zapara-status-file-design.md), and [pult](https://github.com/drakulavich/pult) is the reader that exists, with a five-minute threshold.
168
+ Refreshing is the reader's job, and zapara adds no hook, no timer and no daemon. A reader that finds the file stale or missing starts `zapara status` detached and draws what it has, which is also how the file first comes to exist on a machine that never ran zapara. The rest of the contract, from strict decoding to concurrent runs, is in [the status file spec](docs/superpowers/specs/2026-09-19-zapara-status-file-design.md); [pult](https://github.com/drakulavich/pult) is the reader that exists, with a five-minute threshold.
156
169
 
157
170
  ## Privacy
158
171
 
159
- zapara reads `~/.claude/projects/**/*.jsonl`, skipping subagent transcripts under `subagents/`. Selection is by modification time first, and a file whose modification time is older than the window is opened only to read the last timestamp in its final 4 KB; nothing from that tail is kept or printed. Message text is compared against a few fixed markers, for interrupts, tool rejections and inbound agent messages, and then discarded. What survives into an event is a timestamp, a session id, an event kind and a token count.
172
+ zapara reads `~/.claude/projects/**/*.jsonl`, skipping subagent transcripts under `subagents/`. It picks files by modification time first, and opens one whose modification time is older than the window only to read the last timestamp in its final 64 KB; nothing from that tail is kept or printed. It compares message text against a few fixed markers, for interrupts, tool rejections and inbound agent messages, then discards it. What survives into an event is a timestamp, a session id, an event kind and a token count.
160
173
 
161
- No message text, prompt length, file path or session title is kept, written or printed. The CLI never prints a path it derived or read, not even the projects root when it cannot open it. Nothing is sent anywhere, no file is written except the card or the status file you ask for, and nothing is installed into Claude Code.
174
+ zapara keeps, writes and prints no message text, prompt length, file path or session title. The CLI never prints a path it derived or read, not even the projects root when it cannot open it. It sends nothing anywhere, writes no file except the card or the status file you ask for, and installs nothing into Claude Code.
162
175
 
163
176
  ## Limits
164
177
 
165
- - Time is local and buckets are whole hours, so an hour that straddles midnight or a daylight-saving change is bucketed by the local clock. On a fall-back day two wall-clock hours share one label and merge.
166
- - Only Claude Code transcripts are read. Work in other tools, and time away from the keyboard, is invisible.
167
- - Files are chosen by modification time, and a file whose modification time is older than the window is still read when the last timestamp in it falls inside the window, so a restored or synced transcript is not lost. A very old session touched today is read in full, but only its in-window events count.
178
+ - Time is local and buckets are whole hours, so an hour that straddles midnight or a daylight-saving change is bucketed by the local clock. On a fall-back day two wall-clock hours share one label and merge, so that bucket can hold up to 120 active minutes and the day up to 1500.
179
+ - zapara reads only Claude Code transcripts. Work in other tools, and time away from the keyboard, is invisible.
180
+ - A file whose modification time is older than the window is still read when the last timestamp in it falls inside the window, so a restored or synced transcript is not lost. A very old session touched today is read in full, but only its in-window events count.
168
181
  - The transcript format is Claude Code's private format, built against version 2.1.274, and it may drift. `bun run stats` shows when it has.
169
- - The norms come from two machines of one user working in auto mode. They are a starting point for a conversation about the metric, not a study.
182
+ - The norms come from two machines of one user working in auto mode, which makes them a starting point for a conversation about the metric rather than a study.
170
183
  - The 98-column grid does not adapt to a narrow terminal.
171
184
  - For the grid and the day a pipe always gets JSON, and there is no flag to ask for text instead.
172
185
  - The card needs a browser engine: WebKit comes with macOS, elsewhere Google Chrome must be installed. `--out card.html` works everywhere.
@@ -174,7 +187,7 @@ No message text, prompt length, file path or session title is kept, written or p
174
187
 
175
188
  ## Under the hood
176
189
 
177
- The formula behind the number and the transcript record behind every column, for when the number surprises you.
190
+ The formula behind the number and the transcript record behind every column, for when the number surprises you. For the whole path from a transcript line to the status line, with a diagram, read [How the numbers are made](docs/how-the-numbers-are-made.md).
178
191
 
179
192
  <details>
180
193
  <summary><b>How the index works</b></summary>
@@ -186,7 +199,7 @@ parallel = clamp((sessions - 1) / 4) # 1 se
186
199
  pace = clamp(prompts / 20) # 10 prompts/hour → 0.5, 20+ → 1
187
200
  supervision = clamp((3 * decisions + reports + contextSwitches) / 45) # 15 decisions alone → 1; 45 reports alone → 1
188
201
  reading = clamp(outputTokens / 80000) # 40k → 0.5, 80k+ → 1
189
- streak = clamp(streakMin / 120) # 60 min → 0.5, 2h+ → 1
202
+ streak = clamp(streakMin / 40) # 20 min → 0.5, 40+ → 1
190
203
  late = lateNight ? 1 : 0
191
204
 
192
205
  index = round(25*parallel + 15*pace + 30*supervision + 10*reading + 10*streak + 10*late)
@@ -201,7 +214,7 @@ index = round(25*parallel + 15*pace + 30*supervision + 10*reading + 10*streak +
201
214
  | Heating | 60–84 |
202
215
  | Fried | 85–100 |
203
216
 
204
- The norms come from two machines, 14 days each, of real transcripts covering 116 and 114 active hours. The surprise in that data was how rare explicit decisions are: in auto mode the p90 is 3 decisions per hour, so a component built on decisions alone reads near zero on hours that felt heavy. What those hours actually cost is reading the reports agents send back, switching between sessions, and getting through the volume of model output, which is why supervision carries 30 points and reading 10. Human prompts reached a p90 of 13 per hour on one machine and 20 on the other, hence the pace norm of 20. The parallel-session threshold follows the research this project started from rather than the transcripts: BCG and HBR report that productivity drops past three simultaneous AI tools, and Osmani makes the same point as three focused teammates beating five scattered ones.
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 us in that data, is in [How the numbers are made](docs/how-the-numbers-are-made.md#5-the-index).
205
218
 
206
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`.
207
220
 
@@ -221,7 +234,7 @@ Weights and norms live in one exported constant in `src/score.ts`, so a recalibr
221
234
  | `plan` | `ExitPlanMode` tool calls in an assistant message, one per block. |
222
235
  | `mode` | Permission-mode switches. A `permission-mode` record whose mode differs from the previous one; the first record of a session sets the baseline and repeats of the same mode count nothing. |
223
236
  | `ctx-sw` | Context switches. Over the hour's prompts in time order, the number of consecutive pairs that came from different sessions. |
224
- | `streak` | Minutes since the current activity streak began, which may reach back before the hour. A gap longer than 10 minutes between records breaks the streak. |
237
+ | `streak` | Minutes of the longest presence streak the hour saw. Presence is every action you take: a prompt, an interrupt, a tool rejection, an answer to a question or a plan. A streak is a run of them no more than 10 minutes apart, across sessions, and it may reach back before the hour. Agent activity between two of your actions does not bridge a gap. |
225
238
  | `out-tok` | Assistant output tokens, summed once per request and only for requests that produced a text block. Claude Code repeats the same usage on each content block of a response, and a request holding only tool calls is not text anyone reads. |
226
239
 
227
240
  `bun run stats --days 14` is the tool the norms were set with. It prints the per-hour distribution of each signal over the active hours of a window (n, p50, p75, p90, max, and how many hours were zero), the top hours by reports and by human prompts, and a format-drift line comparing records seen against events the parser recognised. Run it on another machine, or after a Claude Code update, to see whether the norms and the parser still fit. It prints numbers and nothing else.
@@ -237,7 +250,7 @@ bun install
237
250
  bun link
238
251
  ```
239
252
 
240
- `bun link` registers the clone's `bin` entry, so `zapara` runs this checkout; without it, `bun src/index.ts week` does the same thing.
253
+ `bun link` registers the clone's `bin` entry, so `zapara` runs this checkout; without it, `bun src/index.ts` does the same thing.
241
254
 
242
255
  ```bash
243
256
  bun run check # tsc --noEmit, then the test suite under TZ=UTC
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@drakulavich/zapara",
3
- "version": "0.3.0",
3
+ "version": "0.4.0",
4
4
  "description": "Cognitive load index for people driving Claude Code, computed locally from transcripts",
5
5
  "license": "MIT",
6
6
  "author": "Anton Yakutovich",
package/src/derive.ts CHANGED
@@ -1,5 +1,5 @@
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, 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;
@@ -30,13 +30,26 @@ const emptyMetrics = (): Metrics => ({
30
30
  decisions: 0, contextSwitches: 0, activeMin: 0, streakMin: 0, lateNight: false,
31
31
  });
32
32
 
33
- type Acc = { m: Metrics; sessions: Set<string>; slots: Set<number>; lastPromptSession: string | null; lastActivity: { ts: number; streakStart: number } | null };
33
+ // Presence is every action the human takes: what they typed, and the three ways
34
+ // they answer or stop the agent. Agent records (`activity`, `report`, `output`)
35
+ // and the agent's own asks (`question`, `plan_review`) are never presence.
36
+ const PRESENCE: ReadonlySet<EventKind> = new Set<EventKind>(["prompt", "interrupt", "reject", "answer"]);
37
+
38
+ type Acc = { m: Metrics; sessions: Set<string>; slots: Set<number>; lastPromptSession: string | null; maxStreakMs: number; lastPresence: { ts: number; streakStart: number } | null };
34
39
 
35
40
  // Walks the sorted, look-back-filtered events once, keyed by "date|hour",
36
- // tracking the running activity streak (which may start before startMs) and
37
- // accumulating each bucket's raw counts. Events before startMs update the
38
- // streak only; they are never attributed to a bucket.
39
- function foldEvents(sorted: Event[], startMs: number): Map<string, Acc> {
41
+ // tracking the running presence streak (which may start before startMs) and
42
+ // accumulating each bucket's raw counts.
43
+ //
44
+ // Presence is the human's: both the streak and the covered slots are built from
45
+ // the PRESENCE kinds alone, because both answer "is it time to rest?". Agents
46
+ // that work on while the human is away must not keep a streak alive or fill the
47
+ // day, so `activity` is left with session liveness and nothing else.
48
+ //
49
+ // Presence events before startMs update presence only: they are never counted in
50
+ // a bucket, and neither are the slots they cover before startMs, but a span from
51
+ // such an event into the window still covers the window's first slots.
52
+ function foldEvents(sorted: Event[], startMs: number): { acc: Map<string, Acc>; carried: { ts: number; streakStart: number } | null } {
40
53
  const acc = new Map<string, Acc>(); // key "date|hour"
41
54
  const key = (ts: number): string => {
42
55
  const d = new Date(ts);
@@ -44,24 +57,45 @@ function foldEvents(sorted: Event[], startMs: number): Map<string, Acc> {
44
57
  };
45
58
  const get = (k: string): Acc => {
46
59
  let a = acc.get(k);
47
- if (!a) { a = { m: emptyMetrics(), sessions: new Set(), slots: new Set(), lastPromptSession: null, lastActivity: null }; acc.set(k, a); }
60
+ if (!a) { a = { m: emptyMetrics(), sessions: new Set(), slots: new Set(), lastPromptSession: null, maxStreakMs: 0, lastPresence: null }; acc.set(k, a); }
48
61
  return a;
49
62
  };
50
63
 
51
- let prevActivityTs: number | null = null;
52
- let streakStart: number | null = null;
64
+ let prevPresenceTs: number | null = null;
65
+ let streakStart = 0; // always set by the first presence event, which starts a streak
66
+ let carried: { ts: number; streakStart: number } | null = null;
53
67
  for (const e of sorted) {
54
- if (e.kind === "activity") {
55
- streakStart = prevActivityTs === null || e.ts - prevActivityTs > GAP_MS ? e.ts : streakStart;
56
- prevActivityTs = e.ts;
68
+ if (PRESENCE.has(e.kind)) {
69
+ const slot = Math.floor(e.ts / SLOT_MS);
70
+ // A presence event covers its own slot. Inside a streak the human sat
71
+ // through the gap too, so the pair also covers every slot between them; an
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++) {
77
+ const slotStart = s * SLOT_MS;
78
+ if (slotStart >= startMs) get(key(slotStart)).slots.add(s); // a slot belongs to the bucket of its start
79
+ }
80
+ prevPresenceTs = e.ts;
81
+ // The last human action before the window opens, kept for the first day:
82
+ // a person still at the keyboard at 23:58 is still in that streak at
83
+ // 00:03, and the day they are looking at holds nothing yet.
84
+ if (e.ts < startMs) carried = { ts: e.ts, streakStart };
57
85
  }
58
- if (e.ts < startMs) continue; // look-back: streak bookkeeping only
86
+ if (e.ts < startMs) continue; // look-back: presence bookkeeping only
59
87
  const a = get(key(e.ts));
88
+ // The hour's streak is the longest it saw, not the one it happened to end
89
+ // on: a single prompt after a break would otherwise erase a run of hours.
90
+ // A streak is longest at its last event, and that event lies in some hour,
91
+ // so the maximum over hours is the run's true length.
92
+ if (PRESENCE.has(e.kind)) {
93
+ a.maxStreakMs = Math.max(a.maxStreakMs, e.ts - streakStart);
94
+ a.lastPresence = { ts: e.ts, streakStart }; // the bucket's last, for the day's live streak
95
+ }
60
96
  switch (e.kind) {
61
97
  case "activity":
62
98
  a.sessions.add(e.sessionId);
63
- a.slots.add(Math.floor(e.ts / SLOT_MS));
64
- a.lastActivity = { ts: e.ts, streakStart: streakStart! };
65
99
  break;
66
100
  case "prompt":
67
101
  a.m.prompts++;
@@ -77,20 +111,24 @@ function foldEvents(sorted: Event[], startMs: number): Map<string, Acc> {
77
111
  case "mode_change": a.m.modeSwitches++; break;
78
112
  }
79
113
  }
80
- return acc;
114
+ return { acc, carried };
81
115
  }
82
116
 
83
117
  // Builds one Day's 24 hour buckets from the accumulated counts, scores each,
84
118
  // and rolls up totals, peak and mean.
85
119
  function buildDay(date: string, acc: Map<string, Acc>): Day {
86
120
  const buckets: HourBucket[] = [];
121
+ // The day's last human action: the last one of the highest hour that saw any.
122
+ // Hours run in time order, so the last write wins.
123
+ let lastPresence: { ts: number; streakStart: number } | null = null;
87
124
  for (let hour = 0; hour < 24; hour++) {
88
125
  const a = acc.get(`${date}|${hour}`);
89
126
  const m = a ? a.m : emptyMetrics();
90
127
  if (a) {
128
+ if (a.lastPresence) lastPresence = a.lastPresence;
91
129
  m.sessions = a.sessions.size;
92
130
  m.activeMin = a.slots.size * 5;
93
- m.streakMin = a.lastActivity ? Math.round((a.lastActivity.ts - a.lastActivity.streakStart) / 60000) : 0;
131
+ m.streakMin = Math.round(a.maxStreakMs / 60000);
94
132
  }
95
133
  m.decisions = m.interrupts + m.rejects + m.questions + m.plans + m.modeSwitches;
96
134
  // lateNight is a property of the hour label, so it is set on every bucket,
@@ -112,6 +150,7 @@ function buildDay(date: string, acc: Map<string, Acc>): Day {
112
150
  peak: scored.length ? Math.max(...scored.map((b) => b.score!.index)) : null,
113
151
  mean: scored.length ? Math.round(scored.reduce((s, b) => s + b.score!.index, 0) / scored.length) : null,
114
152
  activeMin: buckets.reduce((s, b) => s + b.activeMin, 0),
153
+ presence: lastPresence && { lastAt: new Date(lastPresence.ts).toISOString(), streakStartAt: new Date(lastPresence.streakStart).toISOString() },
115
154
  totals,
116
155
  buckets,
117
156
  };
@@ -124,8 +163,16 @@ export function derive(events: Event[], w: Window): Day[] {
124
163
  .filter(({ e }) => e.ts >= cutoffMs && e.ts < endMs && (!w.now || e.ts <= w.now.getTime()))
125
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)
126
165
  .map(({ e }) => e);
127
- const acc = foldEvents(sorted, startMs);
166
+ const { acc, carried } = foldEvents(sorted, startMs);
128
167
  const days = dates.map((date) => buildDay(date, acc));
168
+ // Only the first day can be preceded by the look-back, and only a day with no
169
+ // action of its own needs it: the streak that was running when the window
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.
172
+ const first = days[0];
173
+ if (first && first.presence === null && carried) {
174
+ first.presence = { lastAt: new Date(carried.ts).toISOString(), streakStartAt: new Date(carried.streakStart).toISOString() };
175
+ }
129
176
  if (w.now) {
130
177
  const today = localDate(w.now);
131
178
  for (const d of days) if (d.date === today) d.asOf = w.now.toISOString();
package/src/image.ts CHANGED
@@ -23,6 +23,7 @@ export async function loadAssets(): Promise<CardAssets> {
23
23
 
24
24
  const BACKEND = process.platform === "darwin" ? "webkit" : "chrome";
25
25
  const ENGINE_LINE = "card needs a browser engine: install Google Chrome, or write --out card.html";
26
+ const WRITE_LINE = "cannot write the card: check the --out directory";
26
27
  const WIDTH = 2400;
27
28
  const HEIGHT = 1260;
28
29
  const READY = 'document.fonts.ready.then(() => document.fonts.status === "loaded" && Array.from(document.images).every((i) => i.complete))';
@@ -32,12 +33,13 @@ const READY = 'document.fonts.ready.then(() => document.fonts.status === "loaded
32
33
  // poll, screenshot, resize, encode), raced against a single timer; the view is
33
34
  // closed on every path. Any engine failure (constructor, navigate, evaluate,
34
35
  // 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.
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.
37
39
  export async function renderCard(html: string, out: string, timeoutMs = 15_000): Promise<void> {
38
40
  const lower = out.toLowerCase();
39
41
  if (lower.endsWith(".html")) {
40
- await writeFile(out, html);
42
+ await write(out, html);
41
43
  return;
42
44
  }
43
45
  let bytes: Uint8Array;
@@ -66,5 +68,15 @@ export async function renderCard(html: string, out: string, timeoutMs = 15_000):
66
68
  } finally {
67
69
  clearTimeout(timer!);
68
70
  }
69
- await writeFile(out, bytes);
71
+ await write(out, bytes);
72
+ }
73
+
74
+ // The only place the card is written. Every failure is the same one line: the
75
+ // error node raises quotes the path in full, and the CLI never prints one.
76
+ async function write(out: string, data: string | Uint8Array): Promise<void> {
77
+ try {
78
+ await writeFile(out, data);
79
+ } catch {
80
+ throw new Error(WRITE_LINE);
81
+ }
70
82
  }
package/src/parse.ts CHANGED
@@ -20,9 +20,14 @@ type Rec = Record<string, unknown>;
20
20
  const isObj = (v: unknown): v is Rec => typeof v === "object" && v !== null;
21
21
  const str = (v: unknown): string | null => (typeof v === "string" ? v : null);
22
22
 
23
+ // The text a marker is compared against: the content itself when it is a string,
24
+ // otherwise the first `{type:"text"}` block anywhere in the array. A pasted
25
+ // screenshot puts an `image` block in front of what the person typed, so the
26
+ // text is not always the first block.
23
27
  const firstText = (content: unknown): string | null => {
24
28
  if (typeof content === "string") return content;
25
- if (Array.isArray(content) && isObj(content[0]) && content[0].type === "text") return str(content[0].text);
29
+ if (!Array.isArray(content)) return null;
30
+ for (const b of content) if (isObj(b) && b.type === "text") return str(b.text);
26
31
  return null;
27
32
  };
28
33
 
@@ -30,10 +35,17 @@ export function parseTranscript(text: string): Event[] {
30
35
  const events: Event[] = [];
31
36
  let lastTs: number | null = null;
32
37
  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
+ let pendingModeChanges = 0;
33
41
  // Dedupes `output` events by requestId within this one file: Claude Code
34
42
  // writes one record per content block of a response, repeating the same
35
43
  // requestId and usage, so only the first qualifying record counts.
36
44
  const seenRequestIds = new Set<string>();
45
+ // `tool_use` ids of the two tools whose result the human writes: the option
46
+ // they picked, or their verdict on a plan. Every other `tool_result` is the
47
+ // machine reporting back, so the pairing is what tells the two apart.
48
+ const askedIds = new Set<string>();
37
49
 
38
50
  for (const line of text.split("\n")) {
39
51
  if (line.trim() === "") continue;
@@ -50,10 +62,12 @@ export function parseTranscript(text: string): Event[] {
50
62
  if (m === null) continue;
51
63
  // The baseline mode is tracked as soon as it is seen, even before any
52
64
  // timestamp exists, so a later switch away from it can be detected. A
53
- // switch is only ever emitted as an event once a timestamp is known;
54
- // a switch that happens before any timestamp is known is dropped.
55
- if (lastMode !== null && m !== lastMode && lastTs !== null) {
56
- events.push({ ts: lastTs, sessionId, kind: "mode_change" });
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.
68
+ if (lastMode !== null && m !== lastMode) {
69
+ if (lastTs === null) pendingModeChanges++;
70
+ else events.push({ ts: lastTs, sessionId, kind: "mode_change" });
57
71
  }
58
72
  lastMode = m;
59
73
  continue;
@@ -64,6 +78,7 @@ export function parseTranscript(text: string): Event[] {
64
78
  if (Number.isNaN(ts)) continue;
65
79
  lastTs = ts;
66
80
  events.push({ ts, sessionId, kind: "activity" });
81
+ for (; pendingModeChanges > 0; pendingModeChanges--) events.push({ ts, sessionId, kind: "mode_change" });
67
82
 
68
83
  const content = isObj(rec.message) ? rec.message.content : undefined;
69
84
  if (type === "user") {
@@ -72,6 +87,8 @@ export function parseTranscript(text: string): Event[] {
72
87
  if (!isObj(b) || b.type !== "tool_result") continue;
73
88
  const t = firstText(b.content);
74
89
  if (t !== null && t.startsWith(REJECT_PREFIX)) events.push({ ts, sessionId, kind: "reject" });
90
+ const toolUseId = str(b.tool_use_id);
91
+ if (toolUseId !== null && askedIds.has(toolUseId)) events.push({ ts, sessionId, kind: "answer" });
75
92
  }
76
93
  const head = firstText(content);
77
94
  if (head === null) continue;
@@ -84,20 +101,27 @@ export function parseTranscript(text: string): Event[] {
84
101
  const blocks = Array.isArray(content) ? content : [];
85
102
  for (const b of blocks) {
86
103
  if (!isObj(b) || b.type !== "tool_use") continue;
87
- if (b.name === QUESTION_TOOL) events.push({ ts, sessionId, kind: "question" });
88
- else if (b.name === PLAN_TOOL) events.push({ ts, sessionId, kind: "plan_review" });
104
+ if (b.name !== QUESTION_TOOL && b.name !== PLAN_TOOL) continue;
105
+ events.push({ ts, sessionId, kind: b.name === QUESTION_TOOL ? "question" : "plan_review" });
106
+ const id = str(b.id);
107
+ if (id !== null) askedIds.add(id);
89
108
  }
90
109
  // `output`: one event per distinct requestId per file, the first record seen
91
110
  // that has a string requestId, numeric usage.output_tokens and at least one
92
111
  // text block. A response holding only tool_use blocks is not text the human
93
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.
94
117
  const requestId = str(rec.requestId);
95
118
  const usage = isObj(rec.message) ? rec.message.usage : undefined;
96
119
  const outputTokens = isObj(usage) ? usage.output_tokens : undefined;
120
+ const tokens = typeof outputTokens === "number" && Number.isSafeInteger(outputTokens) && outputTokens >= 0 ? outputTokens : null;
97
121
  const hasText = blocks.some((b) => isObj(b) && b.type === "text");
98
- if (requestId !== null && typeof outputTokens === "number" && hasText && !seenRequestIds.has(requestId)) {
122
+ if (requestId !== null && tokens !== null && hasText && !seenRequestIds.has(requestId)) {
99
123
  seenRequestIds.add(requestId);
100
- events.push({ ts, sessionId, kind: "output", tokens: outputTokens });
124
+ events.push({ ts, sessionId, kind: "output", tokens });
101
125
  }
102
126
  }
103
127
  }
package/src/scan.ts CHANGED
@@ -43,9 +43,11 @@ async function collect(dir: string, entries: Dirent[], cutoffMs: number, out: st
43
43
 
44
44
  // mtime is a hint, not the truth: a transcript synced from another machine, restored by a
45
45
  // tool that rewrites times, or written under clock skew can be older by mtime than the
46
- // records inside it. For a file mtime would drop, the last "timestamp" in its final 4 KB
47
- // decides. The tail is matched for that one field and discarded; nothing else is read.
48
- const TAIL_BYTES = 4096;
46
+ // records inside it. For a file mtime would drop, the last "timestamp" in its final 64 KB
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.
50
+ const TAIL_BYTES = 65_536;
49
51
  async function lastTimestampMs(path: string): Promise<number> {
50
52
  const fh = await open(path, "r");
51
53
  try {
package/src/score.ts CHANGED
@@ -4,7 +4,7 @@ import type { Level, Metrics, Parts, Score } from "./types.ts";
4
4
  // Points of 100, kept as integers so 0.5 sums stay exact in floating point.
5
5
  // Norms are the p90 of two weeks of real data on two machines (see CHANGELOG).
6
6
  export const WEIGHTS = { parallel: 25, pace: 15, supervision: 30, reading: 10, streak: 10, late: 10 } as const;
7
- export const NORMS = { parallelSpan: 4, pacePerHour: 20, supervisionPerHour: 45, decisionWeight: 3, readingTokens: 80_000, streakMin: 120 } as const;
7
+ export const NORMS = { parallelSpan: 4, pacePerHour: 20, supervisionPerHour: 45, decisionWeight: 3, readingTokens: 80_000, streakMin: 40 } as const;
8
8
  export const LEVELS: readonly { max: number; level: Level }[] = [
9
9
  { max: 29, level: "Calm" },
10
10
  { max: 59, level: "Warming" },
package/src/status.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import { GAP_MS } from "./derive.ts";
1
2
  import type { Day, Level } from "./types.ts";
2
3
 
3
4
  // The status file's content, as data: today's load reduced to the nine values a
@@ -17,12 +18,20 @@ export type Status = {
17
18
  streakMin: number;
18
19
  };
19
20
 
20
- // `hour` is the local hour containing `now`; index, level and streakMin describe
21
- // that hour's bucket, peak and activeMin the whole day. A day the report did not
22
- // mark as open has no asOf, so `now` stands in and the function stays total.
21
+ // `hour` is the local hour containing `now`; index and level describe that
22
+ // hour's bucket, peak and activeMin the whole day, and streakMin the streak the
23
+ // person is in right now. A day the report did not mark as open has no asOf, so
24
+ // `now` stands in and the function stays total.
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.
23
31
  export function statusOf(day: Day, now: Date): Status {
24
32
  const hour = now.getHours();
25
33
  const bucket = day.buckets[hour];
34
+ const live = day.presence !== null && now.getTime() - Date.parse(day.presence.lastAt) <= GAP_MS;
26
35
  return {
27
36
  schema: 1,
28
37
  asOf: day.asOf ?? now.toISOString(),
@@ -32,7 +41,7 @@ export function statusOf(day: Day, now: Date): Status {
32
41
  level: bucket?.score?.level ?? null,
33
42
  peak: day.peak,
34
43
  activeMin: day.activeMin,
35
- streakMin: bucket?.streakMin ?? 0,
44
+ streakMin: live ? Math.round((now.getTime() - Date.parse(day.presence!.streakStartAt)) / 60000) : 0,
36
45
  };
37
46
  }
38
47
 
package/src/types.ts CHANGED
@@ -1,4 +1,4 @@
1
- export type EventKind = "prompt" | "report" | "output" | "interrupt" | "reject" | "question" | "plan_review" | "mode_change" | "activity";
1
+ export type EventKind = "prompt" | "report" | "output" | "interrupt" | "reject" | "answer" | "question" | "plan_review" | "mode_change" | "activity";
2
2
  export type Event = { ts: number; sessionId: string; kind: EventKind; tokens?: number };
3
3
  export type Transcript = { path: string; text: string };
4
4
  export type Window = { to: string; days: number; now?: Date }; // to = "YYYY-MM-DD" local
@@ -14,4 +14,8 @@ export type HourBucket = Metrics & { hour: number; score: Score | null };
14
14
  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
15
  // asOf is set only on the day that is still open when the report runs (the day
16
16
  // containing `now`), ISO 8601 UTC.
17
- export type Day = { date: string; peak: number | null; mean: number | null; activeMin: number; totals: Totals; buckets: HourBucket[]; asOf?: string };
17
+ // `presence` is the day's last human action and the start of the streak that
18
+ // action belongs to, both ISO 8601 UTC. The start may lie on an earlier day or
19
+ // in the look-back. `null` on a day with no human action. It is what lets a
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 };