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 CHANGED
@@ -20,9 +20,9 @@ practice rules, the dashboard — is agent-agnostic, so adding another is one
20
20
  adapter, not a rewrite. `syndes sources` shows what it can see.
21
21
 
22
22
  **Records every move.** Every prompt, tool call, permission block, compaction,
23
- interrupt, token and dollar — appended to a hash-chained log that cannot be
24
- edited, re-ordered or back-dated without `syndes verify` naming the exact
25
- record where the chain breaks.
23
+ interrupt and token — appended to a hash-chained log that cannot be edited,
24
+ re-ordered or back-dated without `syndes verify` naming the exact record where
25
+ the chain breaks.
26
26
 
27
27
  **Tells you how to work better.** A dozen practice rules run over that ledger and
28
28
  surface at most one nudge, at a moment that does not interrupt you, always with
@@ -36,11 +36,18 @@ repo or a synced folder, so you get the actual split: who used what, when, and
36
36
  which hours you spent competing for the same rate-limit window. Counts and
37
37
  tokens are shared; prompts, paths and commands never leave the machine.
38
38
 
39
- **Shows you the whole picture.** `syndes dashboard` opens a local
40
- dashboard: your efficiency score and its five pillars, token and cost
41
- breakdowns, cache hit rate, active time versus wall-clock, permission friction,
42
- tool mix, per-project history every number clickable through to the records
43
- that produced it.
39
+ **Shows you the whole picture, a day at a time.** `syndes dashboard` opens a
40
+ local dashboard on today, with arrows and a day list to move: your efficiency
41
+ score and its five pillars, active minutes hour by hour, cache hit rate, token
42
+ volume and the rate your usage window is going at, permission friction, tool
43
+ mix, per-project history — every number clickable through to the records that
44
+ produced it. Widen to 7, 30 or 90 days when you want the trend instead.
45
+
46
+ **No prices.** A Claude plan bills a month, so a per-token cost is a number
47
+ nobody is charged, and SynDes does not invent one. What it measures instead is
48
+ the usage window: new tokens per active hour, what one prompt costs in them, how
49
+ much work each 100k buys, and how many times over your context was served from
50
+ cache rather than re-sent.
44
51
 
45
52
  **No password to invent.** The dashboard opens straight from the command line;
46
53
  the link it prints carries a one-time key. If you want a lock, use the one your
@@ -7,10 +7,9 @@
7
7
  */
8
8
 
9
9
  import { rangeFor, previousRange, localDay } from './ranges.mjs';
10
- import { rollupsFor, mergeRollups } from './rollup.mjs';
10
+ import { rollupsFor, mergeRollups, loadRollup, coveredDays } from './rollup.mjs';
11
11
  import { score } from './score.mjs';
12
- import { costOf, savingsFromCache } from './metrics/cost.mjs';
13
- import { loadConfig } from '../runtime/config.mjs';
12
+ import { windowRates } from './metrics/window.mjs';
14
13
  import { nameFor, loadProjects } from '../collect/projects.mjs';
15
14
  import { readRange, height } from '../ledger/read.mjs';
16
15
  import { KIND } from '../ledger/schema.mjs';
@@ -20,14 +19,7 @@ export { rangeFor, previousRange, localDay };
20
19
  async function summarise(range) {
21
20
  const rollups = await rollupsFor(range);
22
21
  const merged = mergeRollups(rollups);
23
- const prices = loadConfig().prices ?? {};
24
- return {
25
- range,
26
- rollups,
27
- merged,
28
- cost: costOf(merged.tokens, prices),
29
- cacheSavings: savingsFromCache(merged.tokens, prices),
30
- };
22
+ return { range, rollups, merged, rates: windowRates(merged) };
31
23
  }
32
24
 
33
25
  /** Everything the overview screen needs, plus the same numbers one period back. */
@@ -56,18 +48,19 @@ export async function overview(spec = '7d') {
56
48
  // the single most useful thing to spot across a week.
57
49
  compacts: (rollup.metrics?.context?.autoCompacts ?? 0) + (rollup.metrics?.context?.manualCompacts ?? 0),
58
50
  sessions: (rollup.metrics?.volume?.sessions ?? []).length,
59
- cost: 0,
60
51
  })),
61
52
  hours: current.merged.time.hours,
53
+ // Time, not event counts. This is what a single selected day is drawn from.
54
+ hourActiveMs: current.merged.time.hourActiveMs ?? new Array(24).fill(0),
62
55
  weekdays: current.merged.time.weekdays,
63
56
  metrics: current.merged,
64
- cost: current.cost,
65
- cacheSavings: current.cacheSavings,
57
+ rates: current.rates,
58
+ previousRates: previous.rates,
66
59
  ledger: height(),
67
60
  };
68
61
  }
69
62
 
70
- function headlineOf({ merged, cost }) {
63
+ function headlineOf({ merged, rates }) {
71
64
  return {
72
65
  sessions: merged.volume.sessionCount,
73
66
  prompts: merged.prompts.count,
@@ -76,7 +69,13 @@ function headlineOf({ merged, cost }) {
76
69
  wallMs: merged.time.wallMs,
77
70
  tokens: merged.tokens.total,
78
71
  cacheHitRate: merged.tokens.cacheHitRate,
79
- usd: cost.usd,
72
+ // No dollar figure. See analytics/metrics/window.mjs for why there is not one,
73
+ // and why these rates divide new tokens rather than the cache-read total.
74
+ fresh: rates.fresh,
75
+ freshPerHour: rates.freshPerHour,
76
+ freshPerPrompt: rates.freshPerPrompt,
77
+ callsPerWindow: rates.callsPerWindow,
78
+ cacheLeverage: rates.cacheLeverage,
80
79
  files: merged.volume.fileCount,
81
80
  commits: merged.volume.commits,
82
81
  compacts: merged.context.compacts,
@@ -161,6 +160,43 @@ export async function sessionDetail(sessionId) {
161
160
  return events;
162
161
  }
163
162
 
163
+ /**
164
+ * Every local day the ledger covers, newest first — what a day picker offers.
165
+ *
166
+ * Reads the rollup cache and never folds anything. This answers a dropdown, and
167
+ * a dropdown must not be the thing that rebuilds a year of chain the first time
168
+ * somebody opens it.
169
+ *
170
+ * A day with no rollup written yet reports `records: null`, not 0. The picker
171
+ * shows "not counted yet" differently from a day that was genuinely quiet —
172
+ * conflating the two would tell someone their work was not recorded.
173
+ *
174
+ * Today is always listed even before it has a segment, because "show me today"
175
+ * must be offered on a morning where nothing has happened yet.
176
+ */
177
+ export function dayIndex({ limit = 180 } = {}) {
178
+ const today = localDay(Date.now());
179
+ const days = coveredDays().filter((day) => day <= today);
180
+ if (!days.includes(today)) days.push(today);
181
+
182
+ return days.slice(-limit).reverse().map((day) => {
183
+ // Any version, because the record count survives a fold changing shape and
184
+ // the picker would otherwise go blank for every day after an upgrade. The
185
+ // metrics behind it are only read when the cache is current.
186
+ const rollup = loadRollup(day, { anyVersion: true });
187
+ const current = rollup && !rollup.stale;
188
+ return {
189
+ day,
190
+ records: rollup ? rollup.records : null,
191
+ activeMs: current ? rollup.metrics?.time?.activeMs ?? 0 : null,
192
+ tokens: current ? totalTokens(rollup.metrics?.tokens) : null,
193
+ prompts: current ? rollup.metrics?.prompts?.count ?? 0 : null,
194
+ final: rollup ? Boolean(rollup.final) : false,
195
+ stale: Boolean(rollup?.stale),
196
+ };
197
+ });
198
+ }
199
+
164
200
  export async function byProject(spec = '30d') {
165
201
  const range = rangeFor(spec);
166
202
  const buckets = new Map();
@@ -3,8 +3,15 @@
3
3
  *
4
4
  * Active time is the sum of gaps below the idle threshold. A session left open
5
5
  * over lunch is not an hour of work, and reporting it as one makes every
6
- * downstream ratio — cost per hour, tools per hour, the whole efficiency
6
+ * downstream ratio — tokens per hour, tools per hour, the whole efficiency
7
7
  * story — a lie in the user's favour.
8
+ *
9
+ * Two hour-of-day series, because they answer different questions. `hours`
10
+ * counts records, which is what the team contention grid compares across
11
+ * machines. `hourActiveMs` is time, which is what a single day's shape is
12
+ * actually made of — twelve events in an hour is not the same as twelve minutes
13
+ * of work, and a day view drawn from event counts reports a busy minute as a
14
+ * busy hour.
8
15
  */
9
16
 
10
17
  import { KIND } from '../../ledger/schema.mjs';
@@ -14,6 +21,7 @@ export const name = 'time';
14
21
  export function create() {
15
22
  return {
16
23
  hours: new Array(24).fill(0),
24
+ hourActiveMs: new Array(24).fill(0),
17
25
  weekdays: new Array(7).fill(0),
18
26
  activeMs: 0,
19
27
  firstAt: null,
@@ -37,7 +45,10 @@ export function add(state, record, ctx) {
37
45
  const previous = state.lastBySession.get(session);
38
46
  if (previous !== undefined) {
39
47
  const gap = ts - previous;
40
- if (gap > 0 && gap <= ctx.idleGapMs) state.activeMs += gap;
48
+ if (gap > 0 && gap <= ctx.idleGapMs) {
49
+ state.activeMs += gap;
50
+ chargeHours(state.hourActiveMs, previous, ts);
51
+ }
41
52
  }
42
53
  state.lastBySession.set(session, ts);
43
54
 
@@ -59,6 +70,7 @@ export function done(state) {
59
70
 
60
71
  return {
61
72
  hours: state.hours,
73
+ hourActiveMs: state.hourActiveMs,
62
74
  weekdays: state.weekdays,
63
75
  activeMs: state.activeMs,
64
76
  wallMs,
@@ -70,6 +82,7 @@ export function done(state) {
70
82
 
71
83
  export function merge(parts) {
72
84
  const hours = new Array(24).fill(0);
85
+ const hourActiveMs = new Array(24).fill(0);
73
86
  const weekdays = new Array(7).fill(0);
74
87
  let activeMs = 0;
75
88
  let wallMs = 0;
@@ -79,6 +92,7 @@ export function merge(parts) {
79
92
 
80
93
  for (const part of parts) {
81
94
  (part.hours ?? []).forEach((value, index) => { hours[index] += value; });
95
+ (part.hourActiveMs ?? []).forEach((value, index) => { hourActiveMs[index] += value; });
82
96
  (part.weekdays ?? []).forEach((value, index) => { weekdays[index] += value; });
83
97
  activeMs += part.activeMs ?? 0;
84
98
  wallMs += part.wallMs ?? 0;
@@ -88,7 +102,7 @@ export function merge(parts) {
88
102
  }
89
103
 
90
104
  return {
91
- hours, weekdays, activeMs, wallMs, firstAt, lastAt,
105
+ hours, hourActiveMs, weekdays, activeMs, wallMs, firstAt, lastAt,
92
106
  // Median, not mean: one 40-minute stall should not redefine a typical wait.
93
107
  medianFirstTool: median(delays),
94
108
  activeShare: wallMs ? activeMs / wallMs : 0,
@@ -96,6 +110,29 @@ export function merge(parts) {
96
110
  };
97
111
  }
98
112
 
113
+ /**
114
+ * Charge one active gap to the hours it actually crosses.
115
+ *
116
+ * A twenty-minute gap starting at 10:55 is five minutes of the 10:00 hour and
117
+ * fifteen of the 11:00 one. Charging the whole gap to the hour it ended in would
118
+ * bend a day's shape around where the clock happened to fall, and the hourly
119
+ * chart exists precisely to show that shape.
120
+ *
121
+ * Calendar arithmetic on the hour boundary, so the 23:00 hour rolls into the
122
+ * next day rather than overflowing an index.
123
+ */
124
+ function chargeHours(hourActiveMs, from, to) {
125
+ let cursor = from;
126
+ while (cursor < to) {
127
+ const date = new Date(cursor);
128
+ const hour = date.getHours();
129
+ const boundary = new Date(date.getFullYear(), date.getMonth(), date.getDate(), hour + 1).getTime();
130
+ const until = Math.min(to, boundary);
131
+ hourActiveMs[hour] += until - cursor;
132
+ cursor = until;
133
+ }
134
+ }
135
+
99
136
  function median(values) {
100
137
  if (!values.length) return null;
101
138
  const sorted = [...values].sort((a, b) => a - b);
@@ -0,0 +1,94 @@
1
+ /**
2
+ * What the usage window is being spent at, and how much work each window buys.
3
+ *
4
+ * This module exists because the one it replaced did not earn its place. SynDes
5
+ * used to price tokens from an API rate card and print a dollar figure beside
6
+ * the real counts. Nobody on a Claude subscription is charged that number — the
7
+ * plan bills a month, not a token — so the figure was a confident invention
8
+ * sitting next to measurements, and a reader who catches one made-up number
9
+ * stops trusting the counts beside it.
10
+ *
11
+ * The scarce resource on a subscription is the usage window, so that is what
12
+ * gets measured: how fast it empties, what a single ask costs in it, and how much
13
+ * work came out of it. All three move when you change your context, your
14
+ * batching or your prompts, which is the bar every metric here has to clear.
15
+ *
16
+ * ## Why "new" tokens and not all of them
17
+ *
18
+ * Cache reads are the overwhelming majority of a token total — typically 97–99%
19
+ * of it, because a stable prefix is re-read on every turn. A rate computed over
20
+ * the total is therefore a rate of cache reads wearing a different name: it
21
+ * reads in tens of millions per hour, moves with conversation length rather than
22
+ * with anything the user did, and is unusable as a headline.
23
+ *
24
+ * So every rate here divides NEW tokens — input + output + cache writes. Those
25
+ * are the tokens that had to be produced or sent for the first time. The cache
26
+ * reads are not discarded; they are already reported as the cache hit rate,
27
+ * which is the honest place for them, and the total still appears as volume.
28
+ *
29
+ * Rates, never totals. A rate compares across a day, a week, and somebody else's
30
+ * machine; a total only says who worked longer.
31
+ */
32
+
33
+ export const name = 'window';
34
+
35
+ const HOUR_MS = 3_600_000;
36
+ /** A rate over less measured work than this is noise wearing a decimal point. */
37
+ const MIN_ACTIVE_MS = 3 * 60_000;
38
+ /** The unit work is measured against. ~100k new tokens is about one full window. */
39
+ export const WINDOW_TOKENS = 100_000;
40
+
41
+ /** New tokens: produced or sent for the first time. Cache reads are not new. */
42
+ export function freshTokensOf(tokenSummary) {
43
+ if (!tokenSummary) return 0;
44
+ return (tokenSummary.input ?? 0) + (tokenSummary.output ?? 0) + (tokenSummary.cacheWrite ?? 0);
45
+ }
46
+
47
+ /**
48
+ * Rates from raw totals — for a pool, which is summed across people rather than
49
+ * folded from records.
50
+ *
51
+ * Every rate is `null` rather than 0 when its denominator is missing. "We could
52
+ * not measure this" and "you burned nothing" are different answers, and a figure
53
+ * that renders them the same is the quiet kind of lie: an idle morning would
54
+ * read as perfect efficiency.
55
+ */
56
+ export function ratesFromTotals({ fresh = 0, tokens = 0, activeMs = 0, calls = 0, prompts = 0 } = {}) {
57
+ const hours = activeMs / HOUR_MS;
58
+ const windows = fresh / WINDOW_TOKENS;
59
+ const measurable = activeMs >= MIN_ACTIVE_MS && fresh > 0;
60
+
61
+ return {
62
+ activeMs,
63
+ tokens,
64
+ fresh,
65
+ /** How fast the window empties while you are actually working. */
66
+ freshPerHour: measurable ? fresh / hours : null,
67
+ /** What one ask costs. Rises as a conversation accumulates context. */
68
+ freshPerPrompt: prompts ? fresh / prompts : null,
69
+ /** How much work a window buys. Low means tokens went to context, not work. */
70
+ callsPerWindow: windows ? calls / windows : null,
71
+ /** How many times over the context was served from cache rather than re-sent. */
72
+ cacheLeverage: fresh ? tokens / fresh : null,
73
+ };
74
+ }
75
+
76
+ /**
77
+ * @param {object} merged from rollup.mergeRollups()
78
+ * @returns {{activeMs, tokens, fresh, freshPerHour, freshPerPrompt, callsPerWindow, cacheLeverage}}
79
+ */
80
+ export function windowRates(merged) {
81
+ return ratesFromTotals({
82
+ fresh: freshTokensOf(merged?.tokens),
83
+ tokens: merged?.tokens?.total ?? 0,
84
+ activeMs: merged?.time?.activeMs ?? 0,
85
+ calls: merged?.tools?.calls ?? 0,
86
+ prompts: merged?.prompts?.count ?? 0,
87
+ });
88
+ }
89
+
90
+ /** This is a derived view, not a fold: it reads a merged summary and keeps nothing. */
91
+ export function create() { return {}; }
92
+ export function add() {}
93
+ export function done() { return {}; }
94
+ export function merge() { return {}; }
@@ -29,7 +29,7 @@ import * as prompts from './metrics/prompts.mjs';
29
29
  export const METRICS = [volume, time, tokens, tools, context, friction, rework, prompts];
30
30
 
31
31
  /** Bumped whenever a fold changes shape, so stale caches rebuild themselves. */
32
- export const ROLLUP_VERSION = 1;
32
+ export const ROLLUP_VERSION = 2;
33
33
 
34
34
  export async function buildDay(day) {
35
35
  const config = loadConfig();
@@ -65,12 +65,27 @@ export async function buildDay(day) {
65
65
  };
66
66
  }
67
67
 
68
- export function loadRollup(day) {
68
+ /**
69
+ * A cached rollup, or null.
70
+ *
71
+ * A version mismatch is a miss, because the metric shapes inside may have
72
+ * changed and reading them as if they had not is how a chart ends up drawing a
73
+ * field that moved.
74
+ *
75
+ * `anyVersion` is the exception, and it is narrow: the ENVELOPE — day, records,
76
+ * final — has never changed shape and cannot, since those fields are what the
77
+ * cache is keyed and invalidated on. A caller that only needs "did this day have
78
+ * anything in it" can have that answer across an upgrade instead of being told
79
+ * "not counted yet" about every day in the picker until the worker catches up.
80
+ * Such a rollup is flagged `stale`, so nothing can read its metrics by accident.
81
+ */
82
+ export function loadRollup(day, { anyVersion = false } = {}) {
69
83
  const file = rollupFile(day);
70
84
  if (!existsSync(file)) return null;
71
85
  try {
72
86
  const rollup = JSON.parse(readFileSync(file, 'utf8'));
73
- return rollup.version === ROLLUP_VERSION ? rollup : null;
87
+ if (rollup.version === ROLLUP_VERSION) return rollup;
88
+ return anyVersion ? { ...rollup, stale: true } : null;
74
89
  } catch {
75
90
  return null;
76
91
  }
@@ -2,11 +2,12 @@
2
2
  * The shared-account view: who used how much, and when you were in each
3
3
  * other's way.
4
4
  *
5
- * The headline number here is deliberately NOT dollars. On a shared plan there
6
- * is no per-token bill to split — the scarce thing is the usage window, and two
7
- * people working the same hour throttle each other in a way neither of them can
8
- * see from their own machine. So this module computes share, and it computes
9
- * contention, and it treats cost as the secondary figure it actually is.
5
+ * There is no dollar figure here, and there is not one anywhere else either. On
6
+ * a shared plan there is no per-token bill to split — the scarce thing is the
7
+ * usage window, and two people working the same hour throttle each other in a
8
+ * way neither of them can see from their own machine. So this module computes
9
+ * share, and it computes contention, and the secondary figure is the rate the
10
+ * shared window is being spent at (analytics/metrics/window.mjs).
10
11
  *
11
12
  * Everything is derived from sanitised peer rollups (sync/share.mjs), which
12
13
  * carry counts and no content. Nothing here reads a peer's records, because
@@ -15,15 +16,23 @@
15
16
 
16
17
  import { rangeFor, previousRange } from './ranges.mjs';
17
18
  import { mergeRollups, METRICS } from './rollup.mjs';
18
- import { costOf } from './metrics/cost.mjs';
19
+ import { ratesFromTotals, freshTokensOf } from './metrics/window.mjs';
19
20
  import { score } from './score.mjs';
20
21
  import { readPool, loadState, teamConfig, isEnabled, poolRoot } from '../sync/index.mjs';
21
22
  import { identity, initialsOf } from '../runtime/identity.mjs';
22
- import { loadConfig } from '../runtime/config.mjs';
23
23
 
24
24
  /** Three-hour bands: eight rows is a grid you can read, twenty-four is a wall. */
25
25
  const BAND_HOURS = 3;
26
26
  const BANDS = 24 / BAND_HOURS;
27
+ /**
28
+ * One selected day gets hourly resolution.
29
+ *
30
+ * A single day at three-hour bands is eight dots in one column — technically the
31
+ * same chart, practically unreadable, and it throws away the only axis a day
32
+ * view has. At one-hour bands the day has twenty-four cells to spread across,
33
+ * which is what makes "we were both on it at four" visible.
34
+ */
35
+ const DAY_BAND_HOURS = 1;
27
36
  /** Beyond this the grid stops being a picture and starts being a spreadsheet. */
28
37
  const MAX_GRID_DAYS = 14;
29
38
 
@@ -34,7 +43,6 @@ const MAX_GRID_DAYS = 14;
34
43
  export async function team(spec = '7d') {
35
44
  const range = rangeFor(spec);
36
45
  const before = previousRange(range);
37
- const prices = loadConfig().prices ?? {};
38
46
  const me = identity();
39
47
 
40
48
  const enabled = isEnabled();
@@ -76,8 +84,15 @@ export async function team(spec = '7d') {
76
84
  wallMs: merged.time.wallMs ?? 0,
77
85
  compacts: merged.context.compacts ?? 0,
78
86
  blocks: merged.friction.blocks ?? 0,
79
- usd: costOf(merged.tokens, prices).usd,
87
+ fresh: freshTokensOf(merged.tokens),
80
88
  },
89
+ rates: ratesFromTotals({
90
+ fresh: freshTokensOf(merged.tokens),
91
+ tokens,
92
+ activeMs: merged.time.activeMs ?? 0,
93
+ calls: merged.tools.calls ?? 0,
94
+ prompts: merged.prompts.count ?? 0,
95
+ }),
81
96
  previousTokens: totalTokens(mergedBefore.tokens),
82
97
  // A peer sharing at `summary` still has every pillar the score reads,
83
98
  // because the score never needed a path or a session id.
@@ -121,37 +136,58 @@ export async function team(spec = '7d') {
121
136
  peers,
122
137
  totals: {
123
138
  tokens: poolTokens,
124
- usd: peers.reduce((sum, peer) => sum + peer.totals.usd, 0),
125
- prompts: peers.reduce((sum, peer) => sum + peer.totals.prompts, 0),
126
- toolCalls: peers.reduce((sum, peer) => sum + peer.totals.toolCalls, 0),
127
- activeMs: peers.reduce((sum, peer) => sum + peer.totals.activeMs, 0),
128
- sessions: peers.reduce((sum, peer) => sum + peer.totals.sessions, 0),
139
+ fresh: sum(peers, (peer) => peer.totals.fresh),
140
+ prompts: sum(peers, (peer) => peer.totals.prompts),
141
+ toolCalls: sum(peers, (peer) => peer.totals.toolCalls),
142
+ activeMs: sum(peers, (peer) => peer.totals.activeMs),
143
+ sessions: sum(peers, (peer) => peer.totals.sessions),
129
144
  people: peers.length,
130
145
  deltaTokens: poolBefore ? poolTokens - poolBefore : null,
131
146
  },
147
+ // The rate the SHARED window is being spent at. Summed across people rather
148
+ // than folded, which is why it goes through ratesFromTotals and not
149
+ // windowRates — there is no one merged rollup for a pool to read.
150
+ rates: ratesFromTotals({
151
+ fresh: sum(peers, (peer) => peer.totals.fresh),
152
+ tokens: poolTokens,
153
+ activeMs: sum(peers, (peer) => peer.totals.activeMs),
154
+ calls: sum(peers, (peer) => peer.totals.toolCalls),
155
+ prompts: sum(peers, (peer) => peer.totals.prompts),
156
+ }),
132
157
  grid: contentionGrid(peers, range.days),
133
158
  lanes: laneRows(peers, range.days),
134
159
  };
135
160
  }
136
161
 
162
+ function sum(items, pick) {
163
+ return items.reduce((total, item) => total + (pick(item) ?? 0), 0);
164
+ }
165
+
137
166
  /**
138
167
  * The contention grid — the one picture that answers "were we in each other's
139
168
  * way".
140
169
  *
141
- * Each cell is a three-hour band of one day. Size carries volume; colour carries
142
- * how many people were working in it, which on a shared plan is the thing that
143
- * actually costs you: one person alone gets the whole window, two people split
144
- * it and both get throttled. Colour is therefore load-bearing here in exactly
145
- * the way the palette intends orange means look at this, and it means it
146
- * because that band is where your limits were being spent twice.
170
+ * Each cell is a band of one day three hours across a range, one hour when a
171
+ * single day is selected. Size carries volume; colour carries how many people
172
+ * were working in it, which on a shared plan is the thing that actually costs
173
+ * you: one person alone gets the whole window, two people split it and both get
174
+ * throttled. Colour is therefore load-bearing here in exactly the way the
175
+ * palette intends orange means look at this, and it means it because that band
176
+ * is where your limits were being spent twice.
177
+ *
178
+ * `byDevice` is kept per cell rather than reduced to a count, because a one-day
179
+ * view draws a lane per person out of it and the count alone cannot say WHO.
147
180
  */
148
181
  function contentionGrid(peers, days) {
149
182
  const window = days.slice(-MAX_GRID_DAYS);
183
+ const hourly = window.length === 1;
184
+ const bandHours = hourly ? DAY_BAND_HOURS : BAND_HOURS;
185
+ const bands = 24 / bandHours;
150
186
  const cells = [];
151
187
  let peak = 0;
152
188
 
153
189
  for (const day of window) {
154
- for (let band = 0; band < BANDS; band += 1) {
190
+ for (let band = 0; band < bands; band += 1) {
155
191
  const byDevice = {};
156
192
  let total = 0;
157
193
 
@@ -159,7 +195,7 @@ function contentionGrid(peers, days) {
159
195
  const entry = peer.series.find((point) => point.day === day);
160
196
  if (!entry) continue;
161
197
  let count = 0;
162
- for (let hour = band * BAND_HOURS; hour < (band + 1) * BAND_HOURS; hour += 1) {
198
+ for (let hour = band * bandHours; hour < (band + 1) * bandHours; hour += 1) {
163
199
  count += entry.hours[hour] ?? 0;
164
200
  }
165
201
  if (count > 0) byDevice[peer.deviceId] = count;
@@ -170,7 +206,7 @@ function contentionGrid(peers, days) {
170
206
  cells.push({
171
207
  day,
172
208
  band,
173
- hour: band * BAND_HOURS,
209
+ hour: band * bandHours,
174
210
  total,
175
211
  people: Object.keys(byDevice).length,
176
212
  byDevice,
@@ -181,8 +217,16 @@ function contentionGrid(peers, days) {
181
217
  const overlapping = cells.filter((cell) => cell.people > 1);
182
218
  return {
183
219
  days: window,
184
- bands: BANDS,
185
- bandHours: BAND_HOURS,
220
+ bands,
221
+ bandHours,
222
+ // The chart draws a different picture for one day than for a range, and it
223
+ // should not have to infer which from the length of an array.
224
+ hourly,
225
+ // So a one-day lane chart can label its rows without being handed the peers
226
+ // a second time. Ordered as the peers are: biggest share first.
227
+ people: peers.map((peer) => ({
228
+ deviceId: peer.deviceId, name: peer.name, initials: peer.initials, isMe: peer.isMe,
229
+ })),
186
230
  cells,
187
231
  peak,
188
232
  overlapBands: overlapping.length,
package/bin/cli.mjs CHANGED
@@ -17,7 +17,7 @@ import {
17
17
  } from '../runtime/paths.mjs';
18
18
  import {
19
19
  bold, grey, cyan, green, red, yellow, write, rule, table, OK, FAIL, WARN, DOT,
20
- confirm, compact, usd, duration, percent, bar, width, ask, pad, spinner, banner, beat, stream,
20
+ confirm, compact, perHour, duration, percent, bar, width, ask, pad, spinner, banner, beat, stream,
21
21
  } from '../src/term.mjs';
22
22
  import { install } from '../src/install.mjs';
23
23
  import { runBriefing, showBriefingIfPending, clearPending } from '../src/briefing.mjs';
@@ -292,7 +292,7 @@ async function cmdStatus() {
292
292
  write(table([
293
293
  [grey('prompts'), String(today.prompts), grey('tool calls'), compact(today.toolCalls)],
294
294
  [grey('active'), duration(today.activeMs), grey('tokens'), compact(today.tokens)],
295
- [grey('files'), String(today.files), grey('cost'), usd(today.usd)],
295
+ [grey('files'), String(today.files), grey('burn'), perHour(today.freshPerHour)],
296
296
  ], { align: ['left', 'right', 'left', 'right'] }).map((line) => ` ${line}`).join('\n'));
297
297
 
298
298
  write();
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * Incremental transcript tail → token usage, model, cache hits, turn shape.
3
3
  *
4
- * Tokens, cost and cache efficiency are not in the hook payload; they are in the
4
+ * Tokens and cache efficiency are not in the hook payload; they are in the
5
5
  * session transcript JSONL that `transcript_path` points at. Reading it whole on
6
6
  * every event would be quadratic over a long session, so a byte offset per
7
7
  * session is kept and only the new tail is parsed.
@@ -102,6 +102,17 @@ export async function projects({ url }) {
102
102
  return { data: await analytics.byProject(url.searchParams.get('range') ?? '30d') };
103
103
  }
104
104
 
105
+ /**
106
+ * The days the day picker offers.
107
+ *
108
+ * Cheap by construction: it reads the rollup cache and folds nothing, because a
109
+ * dropdown must never be the thing that rebuilds a year of chain.
110
+ */
111
+ export async function days({ url }) {
112
+ const limit = Math.min(Number(url.searchParams.get('limit') ?? 180), 740);
113
+ return { data: { days: analytics.dayIndex({ limit }), today: analytics.localDay(Date.now()) } };
114
+ }
115
+
105
116
  export async function timeline({ url }) {
106
117
  const range = analytics.rangeFor(url.searchParams.get('range') ?? '7d');
107
118
  const limit = Math.min(Number(url.searchParams.get('limit') ?? 3000), 20_000);
@@ -29,6 +29,7 @@ export const ROUTES = {
29
29
  'GET /api/session': api.sessionDetail,
30
30
  'GET /api/tools': api.tools,
31
31
  'GET /api/projects': api.projects,
32
+ 'GET /api/days': api.days,
32
33
  'GET /api/timeline': api.timeline,
33
34
  'GET /api/practices': api.practices,
34
35
  'POST /api/practices': api.practiceAction,
@@ -65,6 +65,7 @@ export const api = {
65
65
  session: (id) => get(`/api/session?id=${encodeURIComponent(id)}`),
66
66
  tools: (range) => get(`/api/tools?range=${encodeURIComponent(range)}`),
67
67
  projects: (range) => get(`/api/projects?range=${encodeURIComponent(range)}`),
68
+ days: () => get('/api/days'),
68
69
  timeline: (range) => get(`/api/timeline?range=${encodeURIComponent(range)}`),
69
70
 
70
71
  practices: () => get('/api/practices'),
@@ -191,6 +191,37 @@ a { color: inherit; text-decoration: none; }
191
191
  .dropdown[aria-expanded="true"] svg { transform: rotate(180deg); }
192
192
  .dropdown[aria-expanded="true"] { background: var(--surface-3); }
193
193
 
194
+ /* ── Day stepper ──────────────────────────────────────────────────────── */
195
+
196
+ /* One control, not three. The arrows and the dropdown share a single capsule so
197
+ the group reads as the day picker it is, rather than as two buttons that
198
+ happen to sit either side of a menu. The inner dropdown drops its own pill
199
+ background for the same reason. */
200
+ .stepper {
201
+ display: inline-flex;
202
+ align-items: center;
203
+ gap: 2px;
204
+ padding: 0 4px;
205
+ height: 44px;
206
+ border-radius: var(--r-pill);
207
+ background: var(--surface);
208
+ }
209
+ .stepper .dropdown { background: transparent; padding: 0 var(--s2); }
210
+ .stepper .dropdown:hover { background: var(--surface-3); }
211
+
212
+ .stepper__btn {
213
+ width: 32px; height: 32px;
214
+ border: 0; border-radius: var(--r-pill);
215
+ background: transparent; color: var(--ink-2);
216
+ display: grid; place-items: center;
217
+ cursor: pointer; flex: none;
218
+ }
219
+ .stepper__btn:hover { background: var(--surface-3); color: var(--ink); }
220
+ .stepper__btn svg { display: block; }
221
+ /* Disabled rather than removed: a control that vanishes reflows the row under
222
+ the pointer, and the dimmed arrow is what says "this is today". */
223
+ .stepper__btn:disabled { opacity: 0.3; cursor: default; background: transparent; }
224
+
194
225
  .dropdown__menu {
195
226
  position: fixed;
196
227
  z-index: 60;