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.
- 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 +2 -2
- package/collect/transcript.mjs +1 -1
- package/dashboard/api/index.mjs +11 -0
- package/dashboard/router.mjs +1 -0
- package/dashboard/web/api.js +1 -0
- package/dashboard/web/app.css +31 -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 +62 -24
- 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
|
@@ -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
|
|
7
|
-
* 3. When did I work?
|
|
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 {
|
|
15
|
-
|
|
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
|
-
|
|
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),
|
|
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
|
|
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
|
-
|
|
109
|
-
|
|
110
|
-
|
|
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('
|
|
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
|
-
|
|
204
|
+
|
|
205
|
+
oneDay ? null : h('div', { style: 'flex:1;min-height:0;display:grid;align-items:center' }, [
|
|
120
206
|
dualLine(
|
|
121
|
-
series.map((
|
|
122
|
-
series.map((
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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((
|
|
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((
|
|
149
|
-
row:
|
|
276
|
+
const bars = days.map((entry) => ({
|
|
277
|
+
row: entry.day.slice(5).replace('-', '.'),
|
|
150
278
|
start: 0,
|
|
151
|
-
end:
|
|
152
|
-
value: `${Math.round(
|
|
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:
|
|
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,
|
|
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
|
|
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
|
|
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
|
|
@@ -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,
|
|
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
|
-
|
|
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('
|
|
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
|
|
161
|
-
//
|
|
162
|
-
//
|
|
163
|
-
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.',
|
|
164
179
|
}),
|
|
165
180
|
]),
|
|
166
181
|
]);
|
|
167
182
|
}
|
|
168
183
|
|
|
169
|
-
|
|
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(
|
|
201
|
+
figure(`of working ${unit}s overlapped`, percent(grid.overlapRate), { small: true }),
|
|
177
202
|
overlapping
|
|
178
|
-
? chip(`${grid.overlapBands}
|
|
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: '
|
|
184
|
-
{ tone: 'orange', label: '
|
|
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
|
-
?
|
|
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
|
-
|
|
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
|
-
|
|
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
|
+
"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
|
-
}
|