syndes 0.3.3 → 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.
@@ -3,16 +3,25 @@
3
3
  *
4
4
  * Four questions, in the order people actually ask them:
5
5
  * 1. Am I working well? the score, and what dragged it
6
- * 2. What did it cost me? throughput and spend, with the shape of the trend
7
- * 3. When did I work? a day-by-day timeline
6
+ * 2. What did it take? throughput and burn rate, with the shape of it
7
+ * 3. When did I work? hour by hour on one day, day by day over a range
8
8
  * 4. What should I change? the top findings, with their evidence
9
9
  *
10
10
  * Anything that does not answer one of those four is not on this page.
11
+ *
12
+ * The page has two shapes, because one day and thirty days are different
13
+ * questions rather than the same question at different widths. Over a range the
14
+ * x axis is days; on a single day it is hours. A week's chart drawn with one
15
+ * point on it is not a simpler chart, it is a broken one — and the single day is
16
+ * now the default, so it is the shape that has to be right first.
11
17
  */
12
18
 
13
19
  import { api } from '../api.js';
14
- import { h, card, figure, trend, chip, dot, meter, legend, empty, compact, usd, duration, percent } from '../ui.js';
15
- import { dial, capsules, timeline, dualLine } from '../charts.js';
20
+ import {
21
+ h, card, figure, trend, chip, dot, meter, legend, empty,
22
+ compact, duration, percent, perHour, times, dayLabel,
23
+ } from '../ui.js';
24
+ import { dial, capsules, timeline, dualLine, dayStrip } from '../charts.js';
16
25
 
17
26
  /** Tone by what a tool costs you, which is what the colour is claiming. */
18
27
  const FAMILY_TONE = {
@@ -20,17 +29,25 @@ const FAMILY_TONE = {
20
29
  edit: 'white', execute: 'white', web: 'white', mcp: 'white', other: 'white',
21
30
  };
22
31
 
23
- export async function render({ range, go }) {
32
+ const NAMES = { 'claude-code': 'Claude Code', codex: 'Codex' };
33
+
34
+ export async function render({ range, days, go, setDay, setScope }) {
24
35
  const [data, practices] = await Promise.all([api.overview(range), api.practices()]);
25
36
 
26
37
  if ((data.ledger?.seq ?? -1) < 0) return firstRun();
27
38
 
39
+ // The server's answer, not the client's guess: it resolved the spec, so it is
40
+ // the one that knows whether this turned out to be a single day.
41
+ const oneDay = data.range.days.length === 1;
42
+ if (oneDay && !data.metrics.records) return quietDay(data.range.days[0], days, { setDay, setScope });
43
+
44
+
28
45
  // Fixed top row, and the bottom row takes whatever is left — so the view is
29
46
  // exactly one screen and the findings list scrolls inside its own card.
30
47
  return h('div.content--fit', {
31
48
  style: 'display:grid;grid-template-rows:auto minmax(0,1fr);gap:var(--s4);min-height:0',
32
49
  }, [
33
- h('div.grid.g-3', {}, [scoreCard(data), throughputCard(data), timelineCard(data)]),
50
+ h('div.grid.g-3', {}, [scoreCard(data), throughputCard(data, oneDay), shapeCard(data, oneDay)]),
34
51
  h('div.grid.fill', { style: 'grid-template-columns:minmax(0,1fr) minmax(0,1.6fr)' }, [
35
52
  distributionCard(data),
36
53
  fixCard(practices.findings, go),
@@ -59,6 +76,54 @@ function firstRun() {
59
76
  ]);
60
77
  }
61
78
 
79
+ /**
80
+ * A day with nothing on it.
81
+ *
82
+ * This became the common case the moment one day became the default: open the
83
+ * dashboard before you have started and every chart is empty. Five empty charts
84
+ * read as a broken tool, so the day says it is quiet in words and then offers the
85
+ * two ways out — the last day that does have something on it, and the week.
86
+ *
87
+ * The suggested day comes from the picker's own index, so it is never a guess
88
+ * about which day had work: it is the same list the dropdown is showing. The
89
+ * shell fetches that list in the background, which on a first load can land
90
+ * after this panel — so the panel asks for it when it arrives empty rather than
91
+ * dropping the one button that gets somebody out of an empty day.
92
+ */
93
+ async function quietDay(theDay, days = [], { setDay, setScope }) {
94
+ let index = days;
95
+ if (!index.length) {
96
+ index = await api.days().then((body) => body.days ?? []).catch(() => []);
97
+ }
98
+ const previous = index.find((entry) => entry.day < theDay && entry.records > 0);
99
+
100
+ return h('div', { style: 'display:grid;place-items:center;height:100%' }, [
101
+ h('div', { style: 'max-width:540px;display:grid;gap:18px;justify-items:center;text-align:center' }, [
102
+ h('div', {
103
+ style: 'font-size:24px;font-weight:750;letter-spacing:-0.02em;text-transform:uppercase',
104
+ text: `Nothing recorded ${preposition(theDay)}`,
105
+ }),
106
+ h('p', { style: 'color:var(--ink-2);line-height:1.7;margin:0',
107
+ text: 'The chain is intact and recording — this day simply has no sessions on it yet. Step back a day with the arrows, or widen the window.' }),
108
+ h('div', { style: 'display:flex;gap:10px;flex-wrap:wrap;justify-content:center' }, [
109
+ previous
110
+ ? h('button.btn.btn--lime', {
111
+ text: `Go to ${dayLabel(previous.day)}`,
112
+ onclick: () => setDay(previous.day),
113
+ })
114
+ : null,
115
+ h('button.btn.btn--ghost', { text: 'Last 7 days', onclick: () => setScope('7d') }),
116
+ ]),
117
+ ]),
118
+ ]);
119
+ }
120
+
121
+ /** "today" reads better than "on today"; a date needs the preposition. */
122
+ function preposition(theDay) {
123
+ const label = dayLabel(theDay, { long: true });
124
+ return label === 'Today' || label === 'Yesterday' ? label.toLowerCase() : `on ${label}`;
125
+ }
126
+
62
127
  // ── 1. Score ───────────────────────────────────────────────────────────────
63
128
 
64
129
  function scoreCard({ score, scoreDelta }) {
@@ -89,7 +154,7 @@ function scoreCard({ score, scoreDelta }) {
89
154
  ], { note: 'weighted' });
90
155
  }
91
156
 
92
- // ── 2. Throughput and spend ────────────────────────────────────────────────
157
+ // ── 2. Throughput and what it took ─────────────────────────────────────────
93
158
 
94
159
  /**
95
160
  * Which agent the work came from.
@@ -105,55 +170,118 @@ function sourceChips(metrics) {
105
170
  return sources.map(([id, bucket]) => chip(`${NAMES[id] ?? id} · ${compact(bucket.toolCalls)}`));
106
171
  }
107
172
 
108
- const NAMES = { 'claude-code': 'Claude Code', codex: 'Codex' };
109
-
110
- function throughputCard({ headline, previousHeadline: before, series, cost, metrics }) {
173
+ /**
174
+ * Throughput, and the rate the usage window went at.
175
+ *
176
+ * The second figure used to be a dollar amount. It is a burn rate now, because
177
+ * a Claude plan bills a month and not a token: the invented price was the one
178
+ * number on this page nobody is charged. New tokens per active hour IS the
179
+ * scarcity on a subscription, and unlike a price it moves when you change how
180
+ * you work. analytics/metrics/window.mjs has the full argument, including why
181
+ * the rate divides new tokens rather than the cache-read total.
182
+ */
183
+ function throughputCard({ headline, previousHeadline: before, series, metrics }, oneDay) {
111
184
  return card('Throughput', [
112
185
  h('div.pair', {}, [
113
186
  figure('Active time', duration(headline.activeMs), { small: true }),
114
- figure('Spend', usd(headline.usd), { small: true }),
187
+ figure('New tokens / active hour', perHour(headline.freshPerHour), { small: true }),
115
188
  ]),
189
+
190
+ // A single day gets counts where a range gets a chart. The hourly SHAPE of a
191
+ // day is the next card's entire job, and two charts of the same series is
192
+ // worse than one — so this side spends the space on the numbers instead.
193
+ oneDay
194
+ ? h('div.pair', {}, [
195
+ figure('Tool calls', compact(headline.toolCalls), { small: true }),
196
+ figure('Prompts', String(headline.prompts), { small: true }),
197
+ figure('Sessions', String(headline.sessions), { small: true }),
198
+ ])
199
+ : null,
200
+
116
201
  // One trend line, not one per figure: repeating "no prior period" twice
117
202
  // spends two rows saying nothing.
118
203
  trend(headline.toolCalls, before.toolCalls, { format: (value) => `${compact(value)} calls` }),
119
- h('div', { style: 'flex:1;min-height:0;display:grid;align-items:center' }, [
204
+
205
+ oneDay ? null : h('div', { style: 'flex:1;min-height:0;display:grid;align-items:center' }, [
120
206
  dualLine(
121
- series.map((day) => day.toolCalls),
122
- series.map((day) => day.tokens / 1000),
207
+ series.map((entry) => entry.toolCalls),
208
+ series.map((entry) => entry.tokens / 1000),
123
209
  { height: 110 },
124
210
  ),
125
211
  ]),
126
- legend([
212
+ oneDay ? null : legend([
127
213
  { tone: 'lime', label: 'tool calls' },
128
214
  { tone: 'orange', label: 'tokens' },
129
215
  ]),
130
- h('div', { style: 'display:flex;gap:10px;flex-wrap:wrap' }, [
216
+
217
+ h('div', {
218
+ style: `display:flex;gap:10px;flex-wrap:wrap;align-content:end${oneDay ? ';flex:1;min-height:0' : ''}`,
219
+ }, [
131
220
  chip(`${percent(headline.cacheHitRate)} from cache`, 'lime'),
132
- chip(`${compact(headline.tokens)} tokens`),
133
- cost.estimated ? chip('estimated pricing') : null,
221
+ chip(`${compact(headline.tokens)} tokens read`),
222
+ chip(`${compact(headline.fresh)} new`),
223
+ // Restating the total under another name is not a second number, so this
224
+ // only appears once there is more than one ask to average over.
225
+ headline.prompts > 1 && headline.freshPerPrompt !== null
226
+ ? chip(`${compact(headline.freshPerPrompt)} per prompt`)
227
+ : null,
228
+ headline.cacheLeverage === null ? null : chip(`${times(headline.cacheLeverage)} context reuse`),
134
229
  ...sourceChips(metrics),
135
230
  ]),
136
- ]);
231
+ ], { note: oneDay ? 'this day' : null });
137
232
  }
138
233
 
139
234
  // ── 3. When the work happened ──────────────────────────────────────────────
140
235
 
141
- function timelineCard({ series }) {
236
+ function shapeCard(data, oneDay) {
237
+ return oneDay ? hourCard(data) : dayCard(data);
238
+ }
239
+
240
+ /**
241
+ * One day, by the hour, in minutes worked.
242
+ *
243
+ * Minutes and not event counts: twelve tool calls in an hour is not the same
244
+ * thing as twelve minutes of work, and a day drawn from record counts reports a
245
+ * busy minute as a busy hour. analytics/metrics/time.mjs folds both series for
246
+ * exactly this reason.
247
+ */
248
+ function hourCard({ hourActiveMs, hours }) {
249
+ const minutes = (hourActiveMs ?? []).map((ms) => Math.round(ms / 60_000));
250
+ const worked = minutes.reduce((sum, value) => sum + value, 0);
251
+
252
+ const slots = minutes.map((value, hour) => ({
253
+ label: String(hour).padStart(2, '0'),
254
+ value,
255
+ tone: 'lime',
256
+ title: value
257
+ ? `${String(hour).padStart(2, '0')}:00 · ${value}m active · ${hours?.[hour] ?? 0} events`
258
+ : `${String(hour).padStart(2, '0')}:00 · nothing recorded`,
259
+ }));
260
+
261
+ return card('Active minutes by hour', [
262
+ worked
263
+ ? dayStrip(slots, { height: 150, labelEvery: 3, label: 'active minutes by hour of the day' })
264
+ : empty('No active time recorded on this day'),
265
+ legend([{ tone: 'lime', label: 'minutes worked' }], `${worked}m`),
266
+ ], { note: '24 hours, local' });
267
+ }
268
+
269
+ function dayCard({ series }) {
142
270
  const days = series.slice(-9);
143
- const maxMinutes = Math.max(...days.map((day) => day.activeMs / 60_000), 10);
271
+ const maxMinutes = Math.max(...days.map((entry) => entry.activeMs / 60_000), 10);
144
272
  const step = Math.max(10, Math.ceil(maxMinutes / 4 / 10) * 10);
145
273
  const ticks = [];
146
274
  for (let value = 0; value <= maxMinutes + step; value += step) ticks.push({ at: value, label: String(value) });
147
275
 
148
- const bars = days.map((day) => ({
149
- row: day.day.slice(5).replace('-', '.'),
276
+ const bars = days.map((entry) => ({
277
+ row: entry.day.slice(5).replace('-', '.'),
150
278
  start: 0,
151
- end: day.activeMs / 60_000,
152
- value: `${Math.round(day.activeMs / 60_000)}m`,
279
+ end: entry.activeMs / 60_000,
280
+ value: `${Math.round(entry.activeMs / 60_000)}m`,
153
281
  empty: 'no activity',
154
282
  // Orange marks a day the context window ran out — the thing worth spotting
155
283
  // in a week at a glance.
156
- tone: day.compacts > 0 ? 'orange' : 'lime',
284
+ tone: entry.compacts > 0 ? 'orange' : 'lime',
157
285
  }));
158
286
 
159
287
  return card('Active minutes by day', [
@@ -163,7 +291,7 @@ function timelineCard({ series }) {
163
291
  legend([
164
292
  { tone: 'lime', label: 'clean day' },
165
293
  { tone: 'orange', label: 'context ran out' },
166
- ], days.reduce((sum, day) => sum + Math.round(day.activeMs / 60_000), 0)),
294
+ ], days.reduce((sum, entry) => sum + Math.round(entry.activeMs / 60_000), 0)),
167
295
  ]);
168
296
  }
169
297
 
@@ -195,6 +323,14 @@ function distributionCard({ metrics }) {
195
323
 
196
324
  // ── 5. What to change ──────────────────────────────────────────────────────
197
325
 
326
+ /**
327
+ * The findings are NOT scoped to the selected window.
328
+ *
329
+ * The practice engine reads its own recent history, because a rule about retry
330
+ * storms needs the run of events that produced one and cannot answer from a day
331
+ * you happen to be looking at. The note says so rather than letting the card
332
+ * imply it is about the day above it.
333
+ */
198
334
  function fixCard(findings, go) {
199
335
  const live = findings.filter((finding) => !finding.muted).slice(0, 3);
200
336
 
@@ -209,6 +345,7 @@ function fixCard(findings, go) {
209
345
  h('div.finding__fix', {}, [h('span', { text: finding.fix })]),
210
346
  ]))
211
347
  : [empty('No rule found a pattern worth changing.')]), {
348
+ note: 'recent habits, not this window',
212
349
  actions: h('button.btn.btn--ghost.btn--sm', { text: 'All habits', style: 'margin-left:auto', onclick: () => go('habits') }),
213
350
  });
214
351
  }
@@ -88,7 +88,7 @@ async function paint(page) {
88
88
  toggle('Tool calls', config.track.tools, (v) => save('track.tools', v), 'Most of the score is built from these.'),
89
89
  toggle('Prompts', config.track.prompts, (v) => save('track.prompts', v)),
90
90
  toggle('Permission requests', config.track.permissions, (v) => save('track.permissions', v)),
91
- toggle('Tokens and cost', config.track.transcript, (v) => save('track.transcript', v), 'Read incrementally from the session transcript.'),
91
+ toggle('Tokens and cache', config.track.transcript, (v) => save('track.transcript', v), 'Read incrementally from the session transcript.'),
92
92
  toggle('Git commits', config.track.git, (v) => save('track.git', v)),
93
93
  ]),
94
94
 
@@ -4,9 +4,13 @@
4
4
  * Four questions, in the order two people sharing a plan actually ask them:
5
5
  * 1. Whose share is it? the split, and which way it moved
6
6
  * 2. What is the pool doing? totals, and whether the numbers are live
7
- * 3. Were we in each other's way? the contention grid — the one thing
7
+ * 3. Were we in each other's way? the contention chart — the one thing
8
8
  * neither person can see from their own machine
9
- * 4. How did each day break down? the daily split, and who is in the pool
9
+ * 4. How did it break down? the split, and who is in the pool
10
+ *
11
+ * The page defaults to one day, like the rest of the dashboard, because that is
12
+ * the question a shared plan actually raises: not "who used more this month" but
13
+ * "are we throttling each other right now". A week's average cannot answer it.
10
14
  *
11
15
  * The page is live while it is open: it holds one event stream, and the server
12
16
  * polls the pool only while that stream exists. When the view is replaced the
@@ -17,9 +21,9 @@
17
21
  import { api } from '../api.js';
18
22
  import {
19
23
  h, card, figure, trend, chip, dot, meter, legend, empty, modal,
20
- compact, usd, duration, percent, when, DASH,
24
+ compact, duration, percent, perHour, when, dayLabel, DASH,
21
25
  } from '../ui.js';
22
- import { dotMatrix, laneSplit, splitBar } from '../charts.js';
26
+ import { dotMatrix, hourMatrix, laneSplit, splitBar } from '../charts.js';
23
27
 
24
28
  export async function render({ range }) {
25
29
  const data = await api.team(range);
@@ -129,7 +133,18 @@ function shareCard(data, me) {
129
133
  ]);
130
134
  }
131
135
 
132
- // 2. What is the pool doing
136
+ /**
137
+ * 2. What is the pool doing
138
+ *
139
+ * The fourth figure used to be a notional dollar amount with a paragraph under
140
+ * it apologising for being notional. Both are gone. On a shared plan nobody is
141
+ * billed per token, so the number was an invention — and a figure that needs a
142
+ * disclaimer to be read correctly has already failed.
143
+ *
144
+ * What replaces it is the rate the SHARED window is being spent at, which is the
145
+ * actual scarcity two people on one account are splitting. See
146
+ * analytics/metrics/window.mjs.
147
+ */
133
148
  function poolCard(data, live) {
134
149
  const sync = data.sync ?? {};
135
150
  const failed = sync.ok === false && sync.error;
@@ -137,12 +152,12 @@ function poolCard(data, live) {
137
152
  return card('The pool', [
138
153
  h('div.grid', { style: 'grid-template-columns:1fr 1fr;gap:var(--s4)' }, [
139
154
  figure('people', String(data.totals.people), { small: true }),
140
- figure('tokens', compact(data.totals.tokens), {
155
+ figure('tokens read', compact(data.totals.tokens), {
141
156
  small: true,
142
157
  trend: trend(data.totals.tokens, data.totals.tokens - (data.totals.deltaTokens ?? 0), { format: compact }),
143
158
  }),
144
159
  figure('active time', duration(data.totals.activeMs), { small: true }),
145
- figure('notional cost', usd(data.totals.usd), { small: true }),
160
+ figure('new tokens / active hour', perHour(data.rates?.freshPerHour), { small: true }),
146
161
  ]),
147
162
 
148
163
  h('div', { style: 'display:grid;gap:6px;margin-top:auto' }, [
@@ -157,46 +172,69 @@ function poolCard(data, live) {
157
172
  h('span.card__note', { text: `Last exchange ${sync.at ? when(sync.at) : DASH}` }),
158
173
  failed ? h('span.card__note', { style: 'color:var(--orange)', text: String(sync.error).slice(0, 120) }) : null,
159
174
  h('span.card__note', {
160
- // Said plainly on the page rather than buried in a doc: on a shared plan
161
- // the dollars are what these tokens WOULD have cost at API rates, and
162
- // presenting an estimate as a bill would be the wrong kind of confident.
163
- text: 'Cost is notional — a shared plan bills a subscription, not tokens.',
175
+ // Said plainly on the page rather than left to be inferred: the scarce
176
+ // thing here is the window, so that is what the rate measures. Cache
177
+ // reads are excluded because they are context nobody had to re-send.
178
+ text: 'No prices: a shared plan bills a subscription. The rate counts new tokens only.',
164
179
  }),
165
180
  ]),
166
181
  ]);
167
182
  }
168
183
 
169
- // 3. Were we in each other's way
184
+ /**
185
+ * 3. Were we in each other's way
186
+ *
187
+ * Two pictures of one question, because a day and a fortnight are not the same
188
+ * shape. Over a range: days across, three-hour bands down, one dot per band —
189
+ * good for spotting a pattern. On a single day: hours across, a lane per person
190
+ * down — good for seeing that the two of you were both on it at four, and which
191
+ * two. The server decides which (analytics/team.mjs sets `grid.hourly`), because
192
+ * it is the side that resolved the range.
193
+ */
170
194
  function contentionCard(data) {
171
195
  const grid = data.grid;
172
196
  const overlapping = grid.overlapBands > 0;
197
+ const unit = grid.hourly ? 'hour' : 'band';
173
198
 
174
- return card('When each of you worked', [
199
+ return card(grid.hourly ? 'Who worked when' : 'When each of you worked', [
175
200
  h('div', { style: 'display:flex;align-items:baseline;gap:var(--s3)' }, [
176
- figure('of working hours overlapped', percent(grid.overlapRate), { small: true }),
201
+ figure(`of working ${unit}s overlapped`, percent(grid.overlapRate), { small: true }),
177
202
  overlapping
178
- ? chip(`${grid.overlapBands} band${grid.overlapBands === 1 ? '' : 's'}`, 'orange')
203
+ ? chip(`${grid.overlapBands} ${unit}${grid.overlapBands === 1 ? '' : 's'}`, 'orange')
179
204
  : chip('no overlap', 'lime'),
180
205
  ]),
181
- dotMatrix(grid),
206
+ h('div.scroll-x', {}, [grid.hourly ? hourMatrix(grid) : dotMatrix(grid)]),
182
207
  legend([
183
- { tone: 'lime', label: 'one person had the window' },
184
- { tone: 'orange', label: 'two or more at once' },
208
+ { tone: 'lime', label: 'had the window alone' },
209
+ { tone: 'orange', label: 'shared it with someone' },
185
210
  ]),
186
211
  h('span.card__note', {
187
212
  text: overlapping
188
- ? 'Orange bands are where you were both spending the same rate limit.'
213
+ ? `Orange ${unit}s are where you were both spending the same rate limit.`
189
214
  : 'Nobody has collided in this period.',
190
215
  }),
191
- ], { note: `${grid.bandHours}-hour bands` });
216
+ ], { note: grid.hourly ? 'one day, by the hour' : `${grid.bandHours}-hour bands` });
192
217
  }
193
218
 
194
- // 4a. How did each day break down
219
+ /**
220
+ * 4a. How each day broke down.
221
+ *
222
+ * One row per day with a capsule per person. A single selected day is one row,
223
+ * which is small but not degraded — it still answers who took the day, and the
224
+ * contention chart beside it is the one that spreads a single day out.
225
+ */
195
226
  function splitCard(data) {
227
+ const oneDay = data.range.days.length === 1;
228
+ const title = oneDay ? 'The split' : 'Daily split';
196
229
  const rows = data.lanes.filter((row) => row.total > 0);
197
- if (!rows.length) return card('Daily split', [empty('No shared activity in this range yet.')]);
198
230
 
199
- return card('Daily split', [
231
+ if (!rows.length) {
232
+ return card(title, [empty(oneDay
233
+ ? `Nobody in the pool recorded anything on ${dayLabel(data.range.days[0])}.`
234
+ : 'No shared activity in this range yet.')]);
235
+ }
236
+
237
+ return card(title, [
200
238
  h('div.card__scroll', {}, [
201
239
  laneSplit(rows, { format: compact }),
202
240
  ]),
@@ -204,7 +242,7 @@ function splitCard(data) {
204
242
  { tone: 'lime', label: 'you' },
205
243
  { tone: 'white', label: 'everyone else' },
206
244
  ], compact(data.totals.tokens)),
207
- ], { note: 'newest first' });
245
+ ], { note: oneDay ? dayLabel(data.range.days[0]) : 'newest first' });
208
246
  }
209
247
 
210
248
  // 4b. The people
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "syndes",
3
- "version": "0.3.3",
3
+ "version": "0.4.0",
4
4
  "description": "SynDes — a tamper-evident ledger of everything you do in Claude Code, an efficiency score built from it, and a local dashboard that shows you how you actually work. Splits one shared account between the people on it. macOS, Windows and Linux. Zero dependencies.",
5
5
  "keywords": [
6
6
  "syndes",
@@ -16,7 +16,6 @@
16
16
  "dashboard",
17
17
  "hooks",
18
18
  "tokens",
19
- "cost",
20
19
  "macos",
21
20
  "windows",
22
21
  "linux",
@@ -29,7 +29,7 @@ export const CATALOG = {
29
29
  },
30
30
  'prompt-specificity': {
31
31
  title: 'A lot of prompts are corrections',
32
- why: 'A correction means the previous turn missed. The cost is not the correction — it is the whole turn that was thrown away, at full token price.',
32
+ why: 'A correction means the previous turn missed. What it costs you is not the correction — it is the whole turn that was thrown away, and every token that went into it.',
33
33
  fix: 'Name the file, the function and the expected outcome in the first prompt.',
34
34
  },
35
35
  'delegate-wide-search': {
@@ -22,7 +22,7 @@ export const DEFAULTS = {
22
22
  tools: true, // PreToolUse + PostToolUse — the expensive pair, on by default
23
23
  prompts: true,
24
24
  permissions: true,
25
- transcript: true, // token, cost and cache numbers
25
+ transcript: true, // token and cache numbers
26
26
  git: true,
27
27
  },
28
28
 
@@ -162,7 +162,7 @@ function count(session, event, last) {
162
162
  if (last?.kind === KIND.TOOL_POST && last.data.failed) counts.errors += 1;
163
163
  }
164
164
 
165
- /** Token, cost and cache numbers, read incrementally from each transcript. */
165
+ /** Token and cache numbers, read incrementally from each transcript. */
166
166
  function collectUsage(touched) {
167
167
  const drafts = [];
168
168
 
package/src/report.mjs CHANGED
@@ -7,7 +7,7 @@
7
7
 
8
8
  import {
9
9
  bold, grey, cyan, green, red, yellow, write, rule, table, bar, sparkline,
10
- compact, usd, duration, percent, width, DOT,
10
+ compact, perHour, times, duration, percent, width, DOT,
11
11
  } from './term.mjs';
12
12
  import { overview } from '../analytics/index.mjs';
13
13
  import { evaluate } from '../practices/engine.mjs';
@@ -15,7 +15,7 @@ import { nameFor } from '../collect/projects.mjs';
15
15
 
16
16
  export async function report(spec = '7d', { practices = true } = {}) {
17
17
  const data = await overview(spec);
18
- const { headline, previousHeadline: before, score, metrics, cost } = data;
18
+ const { headline, previousHeadline: before, score, metrics } = data;
19
19
 
20
20
  write();
21
21
  write(`${bold('syndes')} ${grey(DOT)} ${data.range.label}`);
@@ -42,14 +42,16 @@ export async function report(spec = '7d', { practices = true } = {}) {
42
42
  [grey('prompts'), String(headline.prompts), delta(headline.prompts, before.prompts)],
43
43
  [grey('tool calls'), compact(headline.toolCalls), delta(headline.toolCalls, before.toolCalls)],
44
44
  [grey('active time'), duration(headline.activeMs), grey(`of ${duration(headline.wallMs)} open`)],
45
- [grey('tokens'), compact(headline.tokens), grey(`${percent(headline.cacheHitRate)} from cache`)],
46
- [grey('cost'), usd(headline.usd) + (cost.estimated ? grey('*') : ''), delta(headline.usd, before.usd, usd)],
45
+ [grey('tokens read'), compact(headline.tokens), grey(`${percent(headline.cacheHitRate)} from cache`)],
46
+ // No dollars. A Claude plan bills a month, so the scarce thing is the window
47
+ // and that is what these two measure. analytics/metrics/window.mjs says why.
48
+ [grey('new tokens'), compact(headline.fresh), grey(`${times(headline.cacheLeverage)} context reuse`)],
49
+ [grey('burn rate'), perHour(headline.freshPerHour), delta(headline.freshPerHour, before.freshPerHour, perHour)],
50
+ [grey('per prompt'), compact(headline.freshPerPrompt), grey(`${(headline.callsPerWindow ?? 0).toFixed(1)} calls per 100k`)],
47
51
  [grey('files changed'), String(headline.files), headline.commits ? grey(`${headline.commits} commits`) : ''],
48
52
  [grey('interruptions'), String(headline.blocks), headline.compacts ? grey(`${headline.compacts} compactions`) : ''],
49
53
  ], { align: ['left', 'right', 'left'] }).map((line) => ` ${line}`).join('\n'));
50
54
 
51
- if (cost.estimated) write(grey(` * priced from the ${cost.tableVersion} table by model family`));
52
-
53
55
  // ── Trend ─────────────────────────────────────────────────────────────────
54
56
  if (data.series.length > 1) {
55
57
  write();
package/src/term.mjs CHANGED
@@ -291,9 +291,21 @@ export function compact(value) {
291
291
  return String(Math.round(value));
292
292
  }
293
293
 
294
- export function usd(value) {
294
+ /**
295
+ * A per-hour rate, and a multiplier. Mirrors dashboard/web/ui.js so the terminal
296
+ * report and the dashboard never disagree about what "1.2M/h" means.
297
+ *
298
+ * An em dash for null, never 0: a rate we could not measure must not read as a
299
+ * rate of nothing. See analytics/metrics/window.mjs.
300
+ */
301
+ export function perHour(value) {
302
+ if (value === null || value === undefined) return '—';
303
+ return `${compact(value)}/h`;
304
+ }
305
+
306
+ export function times(value, digits = 0) {
295
307
  if (value === null || value === undefined) return '—';
296
- return value >= 100 ? `$${Math.round(value)}` : `$${value.toFixed(2)}`;
308
+ return `${value.toFixed(digits)}×`;
297
309
  }
298
310
 
299
311
  export function duration(ms) {
@@ -1,83 +0,0 @@
1
- /**
2
- * Cost from token counts and a dated price table.
3
- *
4
- * The table is versioned and the UI states which one produced a number. Prices
5
- * change; a cost history silently recomputed under new prices is worse than no
6
- * cost history, because it looks authoritative and is not.
7
- *
8
- * A model we do not recognise is priced by its family and marked `estimated`.
9
- * A guess labelled as a guess is useful; a guess presented as a fact is not.
10
- */
11
-
12
- export const name = 'cost';
13
-
14
- /** USD per million tokens. Override in syndes.json under `prices`. */
15
- export const TABLE_VERSION = '2026-09';
16
-
17
- const TABLE = [
18
- [/opus/i, { input: 15, output: 75, cacheWrite: 18.75, cacheRead: 1.5 }],
19
- [/sonnet/i, { input: 3, output: 15, cacheWrite: 3.75, cacheRead: 0.3 }],
20
- [/haiku/i, { input: 0.8, output: 4, cacheWrite: 1, cacheRead: 0.08 }],
21
- ];
22
-
23
- /** This metric folds nothing: it is a pure function of the token summary. */
24
- export function create() { return {}; }
25
- export function add() {}
26
- export function done() { return {}; }
27
- export function merge() { return {}; }
28
-
29
- export function priceFor(model, overrides = {}) {
30
- for (const [key, price] of Object.entries(overrides)) {
31
- if (model.includes(key)) return { price, exact: true };
32
- }
33
- for (const [pattern, price] of TABLE) {
34
- if (pattern.test(model)) return { price, exact: false };
35
- }
36
- return { price: null, exact: false };
37
- }
38
-
39
- /**
40
- * @param {object} tokenSummary from metrics/tokens.mjs merge()
41
- * @returns {{usd, byModel, estimated, unpriced, tableVersion}}
42
- */
43
- export function costOf(tokenSummary, overrides = {}) {
44
- const byModel = {};
45
- let usd = 0;
46
- let estimated = false;
47
- const unpriced = [];
48
-
49
- for (const [model, bucket] of Object.entries(tokenSummary.byModel ?? {})) {
50
- const { price, exact } = priceFor(model, overrides);
51
- if (!price) {
52
- unpriced.push(model);
53
- byModel[model] = { usd: null, tokens: bucket };
54
- continue;
55
- }
56
- if (!exact) estimated = true;
57
-
58
- const amount =
59
- (bucket.input * price.input +
60
- bucket.output * price.output +
61
- bucket.cacheWrite * price.cacheWrite +
62
- bucket.cacheRead * price.cacheRead) / 1_000_000;
63
-
64
- byModel[model] = { usd: amount, tokens: bucket, estimated: !exact };
65
- usd += amount;
66
- }
67
-
68
- return { usd, byModel, estimated, unpriced, tableVersion: TABLE_VERSION };
69
- }
70
-
71
- /**
72
- * What the same tokens would have cost with no cache reads — the number that
73
- * turns "95% cache hit rate" from a statistic into a dollar figure.
74
- */
75
- export function savingsFromCache(tokenSummary, overrides = {}) {
76
- let saved = 0;
77
- for (const [model, bucket] of Object.entries(tokenSummary.byModel ?? {})) {
78
- const { price } = priceFor(model, overrides);
79
- if (!price) continue;
80
- saved += (bucket.cacheRead * (price.input - price.cacheRead)) / 1_000_000;
81
- }
82
- return saved;
83
- }