@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 +23 -0
- package/README.md +65 -52
- package/package.json +1 -1
- package/src/derive.ts +65 -18
- package/src/image.ts +16 -4
- package/src/parse.ts +33 -9
- package/src/scan.ts +5 -3
- package/src/score.ts +1 -1
- package/src/status.ts +13 -4
- package/src/types.ts +6 -2
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.
|
|
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
|
|
17
|
+
## Quick start
|
|
18
18
|
|
|
19
19
|
```bash
|
|
20
|
-
#
|
|
20
|
+
# Skip this if you already have Bun 1.4 or newer
|
|
21
21
|
curl -fsSL https://bun.sh/install | bash
|
|
22
22
|
|
|
23
|
-
#
|
|
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
|
-
|
|
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
|
-
|
|
30
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
42
|
-
Tue 15/09 · · · · · · · · · · ░ ░ ░ ░ ▒ ▒ ▒ ░ · · · · · · 33
|
|
49
|
+
Mon 14/09 · · · · · · · · · ░ ░ ░ █ █ █ · · · · · ░ ░ · ░ 87 8h50
|
|
50
|
+
Tue 15/09 · · · · · · · · · · ░ ░ ░ ░ ▒ ▒ ▒ ░ · · · · · · 33 7h55
|
|
43
51
|
Wed 16/09 · · · · · · · · · · · · · · · · · · · · · · · · - 0h00
|
|
44
|
-
Thu 17/09 · · · · · · · · · · · ░ ░ · · · · · · · · · · ·
|
|
45
|
-
Fri 18/09 · · · · · · · · · · · · · · · ▓ · · · · · · · ·
|
|
46
|
-
Sat 19/09 ░ ░ · · · · · · · · · · · · · · · · · · · · · ·
|
|
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
|
-
|
|
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>
|
|
62
|
+
<summary><b>Monday hour by hour, and the commands that print both</b></summary>
|
|
69
63
|
|
|
70
|
-
|
|
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.
|
|
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.
|
|
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;
|
|
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,
|
|
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
|
|
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` |
|
|
153
|
-
| `streakMin` | Minutes of
|
|
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
|
|
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/`.
|
|
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
|
-
|
|
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
|
-
-
|
|
167
|
-
-
|
|
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
|
|
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 /
|
|
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
|
|
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
|
|
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
|
|
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
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
|
-
|
|
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
|
|
37
|
-
// accumulating each bucket's raw counts.
|
|
38
|
-
//
|
|
39
|
-
|
|
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,
|
|
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
|
|
52
|
-
let streakStart
|
|
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
|
|
55
|
-
|
|
56
|
-
|
|
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:
|
|
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 =
|
|
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.
|
|
36
|
-
//
|
|
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
|
|
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
|
|
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)
|
|
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
|
|
54
|
-
//
|
|
55
|
-
|
|
56
|
-
|
|
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
|
|
88
|
-
|
|
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 &&
|
|
122
|
+
if (requestId !== null && tokens !== null && hasText && !seenRequestIds.has(requestId)) {
|
|
99
123
|
seenRequestIds.add(requestId);
|
|
100
|
-
events.push({ ts, sessionId, kind: "output", tokens
|
|
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
|
|
47
|
-
// decides.
|
|
48
|
-
|
|
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:
|
|
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
|
|
21
|
-
//
|
|
22
|
-
// mark as open has no asOf, so
|
|
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:
|
|
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
|
-
|
|
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 };
|