syndes 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/README.md +15 -8
- package/analytics/index.mjs +52 -16
- package/analytics/metrics/time.mjs +40 -3
- package/analytics/metrics/window.mjs +94 -0
- package/analytics/rollup.mjs +18 -3
- package/analytics/team.mjs +69 -25
- package/bin/cli.mjs +13 -3
- package/collect/transcript.mjs +1 -1
- package/dashboard/api/index.mjs +11 -0
- package/dashboard/auth.mjs +35 -0
- package/dashboard/router.mjs +1 -0
- package/dashboard/server.mjs +42 -6
- package/dashboard/web/api.js +1 -0
- package/dashboard/web/app.css +79 -0
- package/dashboard/web/app.js +128 -27
- package/dashboard/web/charts.js +113 -16
- package/dashboard/web/ui.js +121 -5
- package/dashboard/web/views/overview.js +164 -27
- package/dashboard/web/views/settings.js +1 -1
- package/dashboard/web/views/team.js +138 -75
- package/package.json +1 -2
- package/practices/catalog.mjs +1 -1
- package/runtime/config.mjs +1 -1
- package/runtime/worker.mjs +1 -1
- package/src/report.mjs +8 -6
- package/src/term.mjs +14 -2
- package/analytics/metrics/cost.mjs +0 -83
|
@@ -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
|
|
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
|
|
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
|
|
@@ -16,21 +20,22 @@
|
|
|
16
20
|
|
|
17
21
|
import { api } from '../api.js';
|
|
18
22
|
import {
|
|
19
|
-
h, card, figure, trend, chip, dot, meter, legend, empty,
|
|
20
|
-
compact,
|
|
23
|
+
h, card, figure, trend, chip, dot, meter, legend, empty, modal,
|
|
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);
|
|
26
30
|
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
31
|
+
// Not `content--fit`. That pins the page to the viewport and divides what is
|
|
32
|
+
// left between two rows, which is right for a dense screen and wrong here:
|
|
33
|
+
// with one person in the pool the cards have little in them, and forcing them
|
|
34
|
+
// to fill a tall window leaves most of it empty. Letting the page size to its
|
|
35
|
+
// content and scroll adapts to any window instead of assuming one.
|
|
36
|
+
const outlet = h('div.content.team', {});
|
|
30
37
|
|
|
31
38
|
if (!data.enabled) {
|
|
32
|
-
outlet.style.display = 'grid';
|
|
33
|
-
outlet.style.gridTemplateRows = 'minmax(0,1fr)';
|
|
34
39
|
outlet.appendChild(setup(range, outlet));
|
|
35
40
|
return outlet;
|
|
36
41
|
}
|
|
@@ -79,12 +84,12 @@ function connect(outlet, range) {
|
|
|
79
84
|
function paint(outlet, data, range, { live = false } = {}) {
|
|
80
85
|
const me = data.peers.find((peer) => peer.isMe) ?? null;
|
|
81
86
|
|
|
87
|
+
// One auto-fitting grid rather than two fixed rows. Cards claim a column when
|
|
88
|
+
// there is room for one and wrap when there is not, so the same markup works
|
|
89
|
+
// on a laptop and on a wide monitor without a breakpoint deciding for it.
|
|
82
90
|
outlet.replaceChildren(
|
|
83
|
-
h('div.
|
|
84
|
-
h('div.
|
|
85
|
-
splitCard(data),
|
|
86
|
-
peopleCard(data, outlet, range),
|
|
87
|
-
]),
|
|
91
|
+
h('div.autogrid', {}, [shareCard(data, me), poolCard(data, live), contentionCard(data)]),
|
|
92
|
+
h('div.autogrid.autogrid--wide', {}, [splitCard(data), peopleCard(data, outlet, range)]),
|
|
88
93
|
);
|
|
89
94
|
}
|
|
90
95
|
|
|
@@ -106,9 +111,13 @@ function shareCard(data, me) {
|
|
|
106
111
|
}) : null,
|
|
107
112
|
}),
|
|
108
113
|
h('div', { style: 'display:grid;gap:4px' }, [
|
|
109
|
-
|
|
114
|
+
// Meaningless while you are the only one here — "an even split would be
|
|
115
|
+
// 100%" is true and says nothing.
|
|
116
|
+
data.peers.length > 1 ? h('span.card__note', { text: `Even split would be ${percent(fair)}` }) : null,
|
|
110
117
|
h('span.card__note', {
|
|
111
|
-
text:
|
|
118
|
+
text: data.peers.length > 1
|
|
119
|
+
? `${compact(me?.totals.tokens ?? 0)} of ${compact(data.totals.tokens)} tokens`
|
|
120
|
+
: `${compact(data.totals.tokens)} tokens · nobody else has joined yet`,
|
|
112
121
|
}),
|
|
113
122
|
]),
|
|
114
123
|
]),
|
|
@@ -124,7 +133,18 @@ function shareCard(data, me) {
|
|
|
124
133
|
]);
|
|
125
134
|
}
|
|
126
135
|
|
|
127
|
-
|
|
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
|
+
*/
|
|
128
148
|
function poolCard(data, live) {
|
|
129
149
|
const sync = data.sync ?? {};
|
|
130
150
|
const failed = sync.ok === false && sync.error;
|
|
@@ -132,12 +152,12 @@ function poolCard(data, live) {
|
|
|
132
152
|
return card('The pool', [
|
|
133
153
|
h('div.grid', { style: 'grid-template-columns:1fr 1fr;gap:var(--s4)' }, [
|
|
134
154
|
figure('people', String(data.totals.people), { small: true }),
|
|
135
|
-
figure('tokens', compact(data.totals.tokens), {
|
|
155
|
+
figure('tokens read', compact(data.totals.tokens), {
|
|
136
156
|
small: true,
|
|
137
157
|
trend: trend(data.totals.tokens, data.totals.tokens - (data.totals.deltaTokens ?? 0), { format: compact }),
|
|
138
158
|
}),
|
|
139
159
|
figure('active time', duration(data.totals.activeMs), { small: true }),
|
|
140
|
-
figure('
|
|
160
|
+
figure('new tokens / active hour', perHour(data.rates?.freshPerHour), { small: true }),
|
|
141
161
|
]),
|
|
142
162
|
|
|
143
163
|
h('div', { style: 'display:grid;gap:6px;margin-top:auto' }, [
|
|
@@ -152,44 +172,69 @@ function poolCard(data, live) {
|
|
|
152
172
|
h('span.card__note', { text: `Last exchange ${sync.at ? when(sync.at) : DASH}` }),
|
|
153
173
|
failed ? h('span.card__note', { style: 'color:var(--orange)', text: String(sync.error).slice(0, 120) }) : null,
|
|
154
174
|
h('span.card__note', {
|
|
155
|
-
// Said plainly on the page rather than
|
|
156
|
-
//
|
|
157
|
-
//
|
|
158
|
-
text: '
|
|
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.',
|
|
159
179
|
}),
|
|
160
180
|
]),
|
|
161
181
|
]);
|
|
162
182
|
}
|
|
163
183
|
|
|
164
|
-
|
|
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
|
+
*/
|
|
165
194
|
function contentionCard(data) {
|
|
166
195
|
const grid = data.grid;
|
|
167
196
|
const overlapping = grid.overlapBands > 0;
|
|
197
|
+
const unit = grid.hourly ? 'hour' : 'band';
|
|
168
198
|
|
|
169
|
-
return card('When each of you worked', [
|
|
199
|
+
return card(grid.hourly ? 'Who worked when' : 'When each of you worked', [
|
|
170
200
|
h('div', { style: 'display:flex;align-items:baseline;gap:var(--s3)' }, [
|
|
171
|
-
figure(
|
|
172
|
-
overlapping
|
|
201
|
+
figure(`of working ${unit}s overlapped`, percent(grid.overlapRate), { small: true }),
|
|
202
|
+
overlapping
|
|
203
|
+
? chip(`${grid.overlapBands} ${unit}${grid.overlapBands === 1 ? '' : 's'}`, 'orange')
|
|
204
|
+
: chip('no overlap', 'lime'),
|
|
173
205
|
]),
|
|
174
|
-
dotMatrix(grid),
|
|
206
|
+
h('div.scroll-x', {}, [grid.hourly ? hourMatrix(grid) : dotMatrix(grid)]),
|
|
175
207
|
legend([
|
|
176
|
-
{ tone: 'lime', label: '
|
|
177
|
-
{ tone: 'orange', label: '
|
|
208
|
+
{ tone: 'lime', label: 'had the window alone' },
|
|
209
|
+
{ tone: 'orange', label: 'shared it with someone' },
|
|
178
210
|
]),
|
|
179
211
|
h('span.card__note', {
|
|
180
212
|
text: overlapping
|
|
181
|
-
?
|
|
213
|
+
? `Orange ${unit}s are where you were both spending the same rate limit.`
|
|
182
214
|
: 'Nobody has collided in this period.',
|
|
183
215
|
}),
|
|
184
|
-
], { note: `${grid.bandHours}-hour bands` });
|
|
216
|
+
], { note: grid.hourly ? 'one day, by the hour' : `${grid.bandHours}-hour bands` });
|
|
185
217
|
}
|
|
186
218
|
|
|
187
|
-
|
|
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
|
+
*/
|
|
188
226
|
function splitCard(data) {
|
|
227
|
+
const oneDay = data.range.days.length === 1;
|
|
228
|
+
const title = oneDay ? 'The split' : 'Daily split';
|
|
189
229
|
const rows = data.lanes.filter((row) => row.total > 0);
|
|
190
|
-
if (!rows.length) return card('Daily split', [empty('No shared activity in this range yet.')]);
|
|
191
230
|
|
|
192
|
-
|
|
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, [
|
|
193
238
|
h('div.card__scroll', {}, [
|
|
194
239
|
laneSplit(rows, { format: compact }),
|
|
195
240
|
]),
|
|
@@ -197,60 +242,78 @@ function splitCard(data) {
|
|
|
197
242
|
{ tone: 'lime', label: 'you' },
|
|
198
243
|
{ tone: 'white', label: 'everyone else' },
|
|
199
244
|
], compact(data.totals.tokens)),
|
|
200
|
-
], { note: 'newest first' });
|
|
245
|
+
], { note: oneDay ? dayLabel(data.range.days[0]) : 'newest first' });
|
|
201
246
|
}
|
|
202
247
|
|
|
203
248
|
// 4b. The people
|
|
249
|
+
/**
|
|
250
|
+
* One block per person, not a table.
|
|
251
|
+
*
|
|
252
|
+
* This was a seven-column table, and it did not survive contact with a real
|
|
253
|
+
* card: the name truncated to "Gurupra…", "3h 21m" wrapped and clipped, and the
|
|
254
|
+
* remove control was pushed off the right edge behind a scrollbar. A table needs
|
|
255
|
+
* a width it can rely on, and a card in a reflowing grid cannot promise one.
|
|
256
|
+
*
|
|
257
|
+
* Rows fix that by stacking within each person rather than across them — the
|
|
258
|
+
* name gets the width, the numbers wrap as a group, and nothing is ever cut off.
|
|
259
|
+
*/
|
|
204
260
|
function peopleCard(data, outlet, range) {
|
|
205
|
-
const
|
|
206
|
-
h('
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
261
|
+
const actions = h('div', { style: 'display:flex;gap:8px;flex-wrap:wrap' }, [
|
|
262
|
+
h('button.btn.btn--lime.btn--sm', { text: 'Invite', onclick: () => invite() }),
|
|
263
|
+
h('button.btn.btn--ghost.btn--sm', { text: 'Rename', onclick: () => rename(data, outlet, range) }),
|
|
264
|
+
h('button.btn.btn--ghost.btn--sm', { text: 'Sync now', onclick: () => syncNow(outlet, range) }),
|
|
265
|
+
]);
|
|
266
|
+
|
|
267
|
+
return card('People', [
|
|
268
|
+
h('div.people', {}, data.peers.map((peer) => personRow(peer, outlet, range))),
|
|
269
|
+
|
|
270
|
+
data.peers.length === 1
|
|
271
|
+
? h('span.card__note', { text: 'Only this machine so far. Invite gives you a line to send someone.' })
|
|
272
|
+
: h('span.card__note', { text: 'A stale shelf has not synced in six hours — a machine that stopped reporting, not a quiet day.' }),
|
|
273
|
+
|
|
274
|
+
h('div', { style: 'margin-top:auto;display:flex;gap:8px;flex-wrap:wrap;padding-top:var(--s2)' }, [
|
|
275
|
+
h('button.btn.btn--ghost.btn--sm', { text: `Sharing: ${data.config.scope}`, onclick: () => changeScope(data, outlet, range) }),
|
|
276
|
+
h('button.btn.btn--ghost.btn--sm', { text: 'Leave pool', onclick: () => leavePool(outlet, range) }),
|
|
216
277
|
]),
|
|
217
|
-
|
|
218
|
-
|
|
278
|
+
], { actions });
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
function personRow(peer, outlet, range) {
|
|
282
|
+
return h('div.person', {}, [
|
|
283
|
+
h('span.avatar', { text: peer.initials, title: peer.host ?? peer.deviceId }),
|
|
284
|
+
|
|
285
|
+
h('div', { style: 'min-width:0;display:grid;gap:6px' }, [
|
|
286
|
+
h('div.person__head', {}, [
|
|
287
|
+
h('span.person__name', { text: peer.name, title: peer.name }),
|
|
288
|
+
peer.isMe ? h('span.person__tag', { text: 'this machine' }) : null,
|
|
289
|
+
peer.stale ? chip('stale', 'orange') : null,
|
|
290
|
+
h('span.person__share', { text: percent(peer.share) }),
|
|
291
|
+
]),
|
|
292
|
+
|
|
219
293
|
meter(peer.share, peer.isMe ? 'lime' : 'white'),
|
|
294
|
+
|
|
295
|
+
// Wraps as a group rather than being squeezed into columns, so a narrow
|
|
296
|
+
// card loses a line instead of losing a number.
|
|
297
|
+
h('div.person__stats', {}, [
|
|
298
|
+
stat(compact(peer.totals.tokens), 'tokens'),
|
|
299
|
+
stat(String(peer.totals.sessions), peer.totals.sessions === 1 ? 'session' : 'sessions'),
|
|
300
|
+
stat(duration(peer.totals.activeMs), 'active'),
|
|
301
|
+
stat(peer.score === null ? DASH : String(peer.score), 'score'),
|
|
302
|
+
]),
|
|
220
303
|
]),
|
|
221
|
-
|
|
222
|
-
String(peer.totals.sessions),
|
|
223
|
-
duration(peer.totals.activeMs),
|
|
224
|
-
peer.score === null ? DASH : String(peer.score),
|
|
304
|
+
|
|
225
305
|
peer.isMe
|
|
226
|
-
?
|
|
306
|
+
? null
|
|
227
307
|
: h('button.btn.btn--ghost.btn--sm', {
|
|
228
308
|
text: 'Remove',
|
|
229
309
|
title: `Remove ${peer.name} from the pool`,
|
|
230
310
|
onclick: () => remove(peer, outlet, range),
|
|
231
311
|
}),
|
|
232
312
|
]);
|
|
313
|
+
}
|
|
233
314
|
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
h('button.btn.btn--ghost', { text: 'Rename', onclick: () => rename(data, outlet, range) }),
|
|
237
|
-
h('button.btn.btn--ghost', { text: 'Sync now', onclick: () => syncNow(outlet, range) }),
|
|
238
|
-
]);
|
|
239
|
-
|
|
240
|
-
return card('People', [
|
|
241
|
-
table(
|
|
242
|
-
['Person', 'Share', 'Tokens', 'Sessions', 'Active', 'Score', ''],
|
|
243
|
-
rows,
|
|
244
|
-
{ align: ['left', 'left', 'right', 'right', 'right', 'right', 'right'] },
|
|
245
|
-
),
|
|
246
|
-
h('span.card__note', {
|
|
247
|
-
text: 'A stale shelf has not synced in six hours — that is a machine that stopped reporting, not a quiet day.',
|
|
248
|
-
}),
|
|
249
|
-
h('div', { style: 'margin-top:auto;display:flex;gap:8px;flex-wrap:wrap' }, [
|
|
250
|
-
h('button.btn.btn--ghost', { text: `Sharing: ${data.config.scope}`, onclick: () => changeScope(data, outlet, range) }),
|
|
251
|
-
h('button.btn.btn--ghost', { text: 'Leave pool', onclick: () => leavePool(outlet, range) }),
|
|
252
|
-
]),
|
|
253
|
-
], { actions });
|
|
315
|
+
function stat(value, label) {
|
|
316
|
+
return h('span.person__stat', {}, [h('b', { text: value }), h('span', { text: ` ${label}` })]);
|
|
254
317
|
}
|
|
255
318
|
|
|
256
319
|
// ── Actions ─────────────────────────────────────────────────────────────────
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "syndes",
|
|
3
|
-
"version": "0.
|
|
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",
|
package/practices/catalog.mjs
CHANGED
|
@@ -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.
|
|
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': {
|
package/runtime/config.mjs
CHANGED
|
@@ -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
|
|
25
|
+
transcript: true, // token and cache numbers
|
|
26
26
|
git: true,
|
|
27
27
|
},
|
|
28
28
|
|
package/runtime/worker.mjs
CHANGED
|
@@ -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
|
|
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,
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
}
|