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
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
|
|
24
|
-
|
|
25
|
-
|
|
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
|
|
40
|
-
dashboard
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
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
|
package/analytics/index.mjs
CHANGED
|
@@ -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 {
|
|
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
|
-
|
|
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
|
-
|
|
65
|
-
|
|
57
|
+
rates: current.rates,
|
|
58
|
+
previousRates: previous.rates,
|
|
66
59
|
ledger: height(),
|
|
67
60
|
};
|
|
68
61
|
}
|
|
69
62
|
|
|
70
|
-
function headlineOf({ merged,
|
|
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
|
-
|
|
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 —
|
|
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)
|
|
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 {}; }
|
package/analytics/rollup.mjs
CHANGED
|
@@ -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 =
|
|
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
|
-
|
|
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
|
-
|
|
87
|
+
if (rollup.version === ROLLUP_VERSION) return rollup;
|
|
88
|
+
return anyVersion ? { ...rollup, stale: true } : null;
|
|
74
89
|
} catch {
|
|
75
90
|
return null;
|
|
76
91
|
}
|
package/analytics/team.mjs
CHANGED
|
@@ -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
|
-
*
|
|
6
|
-
* is no per-token bill to split — the scarce thing is the
|
|
7
|
-
* people working the same hour throttle each other in a
|
|
8
|
-
* see from their own machine. So this module computes
|
|
9
|
-
*
|
|
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 {
|
|
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
|
-
|
|
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
|
-
|
|
125
|
-
prompts: peers
|
|
126
|
-
toolCalls: peers
|
|
127
|
-
activeMs: peers
|
|
128
|
-
sessions: peers
|
|
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
|
|
142
|
-
*
|
|
143
|
-
*
|
|
144
|
-
*
|
|
145
|
-
*
|
|
146
|
-
*
|
|
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 <
|
|
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 *
|
|
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 *
|
|
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
|
|
185
|
-
bandHours
|
|
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,
|
|
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('
|
|
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();
|
package/collect/transcript.mjs
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Incremental transcript tail → token usage, model, cache hits, turn shape.
|
|
3
3
|
*
|
|
4
|
-
* Tokens
|
|
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.
|
package/dashboard/api/index.mjs
CHANGED
|
@@ -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);
|
package/dashboard/router.mjs
CHANGED
|
@@ -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,
|
package/dashboard/web/api.js
CHANGED
|
@@ -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'),
|
package/dashboard/web/app.css
CHANGED
|
@@ -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;
|