syndes 0.1.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.
Files changed (96) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +77 -0
  3. package/adapters/claude-code.mjs +59 -0
  4. package/adapters/codex.mjs +256 -0
  5. package/adapters/index.mjs +92 -0
  6. package/analytics/index.mjs +189 -0
  7. package/analytics/metrics/context.mjs +95 -0
  8. package/analytics/metrics/cost.mjs +83 -0
  9. package/analytics/metrics/friction.mjs +86 -0
  10. package/analytics/metrics/prompts.mjs +93 -0
  11. package/analytics/metrics/rework.mjs +113 -0
  12. package/analytics/metrics/time.mjs +104 -0
  13. package/analytics/metrics/tokens.mjs +88 -0
  14. package/analytics/metrics/tools.mjs +118 -0
  15. package/analytics/metrics/volume.mjs +98 -0
  16. package/analytics/ranges.mjs +98 -0
  17. package/analytics/rollup.mjs +151 -0
  18. package/analytics/score.mjs +194 -0
  19. package/bin/cli.mjs +596 -0
  20. package/bin/postinstall.mjs +44 -0
  21. package/collect/classify.mjs +226 -0
  22. package/collect/git.mjs +78 -0
  23. package/collect/projects.mjs +82 -0
  24. package/collect/redact.mjs +85 -0
  25. package/collect/sessions.mjs +119 -0
  26. package/collect/tail.mjs +126 -0
  27. package/collect/tools.mjs +121 -0
  28. package/collect/transcript.mjs +128 -0
  29. package/dashboard/api/index.mjs +296 -0
  30. package/dashboard/auth.mjs +235 -0
  31. package/dashboard/router.mjs +55 -0
  32. package/dashboard/security.mjs +95 -0
  33. package/dashboard/server.mjs +156 -0
  34. package/dashboard/static.mjs +47 -0
  35. package/dashboard/web/SynDes.icns +0 -0
  36. package/dashboard/web/api.js +80 -0
  37. package/dashboard/web/app.css +532 -0
  38. package/dashboard/web/app.js +261 -0
  39. package/dashboard/web/charts.js +273 -0
  40. package/dashboard/web/index.html +23 -0
  41. package/dashboard/web/logo.png +0 -0
  42. package/dashboard/web/ui.js +434 -0
  43. package/dashboard/web/views/habits.js +166 -0
  44. package/dashboard/web/views/ledger.js +164 -0
  45. package/dashboard/web/views/overview.js +214 -0
  46. package/dashboard/web/views/sessions.js +133 -0
  47. package/dashboard/web/views/settings.js +180 -0
  48. package/ledger/append.mjs +126 -0
  49. package/ledger/chain.mjs +53 -0
  50. package/ledger/keys.mjs +72 -0
  51. package/ledger/read.mjs +77 -0
  52. package/ledger/retention.mjs +104 -0
  53. package/ledger/schema.mjs +96 -0
  54. package/ledger/segments.mjs +109 -0
  55. package/ledger/verify.mjs +174 -0
  56. package/notify/index.mjs +67 -0
  57. package/notify/linux.mjs +41 -0
  58. package/notify/mac.mjs +44 -0
  59. package/notify/terminal.mjs +15 -0
  60. package/notify/windows.mjs +61 -0
  61. package/package.json +66 -0
  62. package/practices/budget.mjs +97 -0
  63. package/practices/catalog.mjs +64 -0
  64. package/practices/deliver.mjs +101 -0
  65. package/practices/engine.mjs +107 -0
  66. package/practices/rules/batch-tool-calls.mjs +15 -0
  67. package/practices/rules/context-hygiene.mjs +17 -0
  68. package/practices/rules/delegate-wide-search.mjs +15 -0
  69. package/practices/rules/index.mjs +28 -0
  70. package/practices/rules/permission-friction.mjs +16 -0
  71. package/practices/rules/project-memory.mjs +27 -0
  72. package/practices/rules/prompt-specificity.mjs +15 -0
  73. package/practices/rules/read-before-edit.mjs +16 -0
  74. package/practices/rules/retry-storm.mjs +22 -0
  75. package/practices/rules/session-sprawl.mjs +15 -0
  76. package/practices/rules/verify-after-change.mjs +16 -0
  77. package/runtime/config.mjs +116 -0
  78. package/runtime/hook.mjs +154 -0
  79. package/runtime/jsonl.mjs +104 -0
  80. package/runtime/lock.mjs +98 -0
  81. package/runtime/log.mjs +37 -0
  82. package/runtime/paths.mjs +116 -0
  83. package/runtime/platform.mjs +74 -0
  84. package/runtime/spool.mjs +92 -0
  85. package/runtime/worker.mjs +275 -0
  86. package/src/briefing.mjs +94 -0
  87. package/src/doctor.mjs +153 -0
  88. package/src/export.mjs +68 -0
  89. package/src/install.mjs +95 -0
  90. package/src/open.mjs +23 -0
  91. package/src/report.mjs +120 -0
  92. package/src/settings.mjs +173 -0
  93. package/src/status.mjs +61 -0
  94. package/src/systemauth.mjs +179 -0
  95. package/src/term.mjs +272 -0
  96. package/src/uninstall.mjs +43 -0
@@ -0,0 +1,95 @@
1
+ /**
2
+ * Compaction, session starts, and the tokens burned before each compact.
3
+ *
4
+ * The pillar with the largest single effect on both cost and output quality. An
5
+ * automatic compact means the context ran out; a manual one means the user chose
6
+ * the moment. Only the first is a problem, which is why `trigger` is recorded.
7
+ */
8
+
9
+ import { KIND } from '../../ledger/schema.mjs';
10
+
11
+ export const name = 'context';
12
+
13
+ export function create() {
14
+ return {
15
+ autoCompacts: 0, manualCompacts: 0,
16
+ fresh: 0, resumed: 0, cleared: 0,
17
+ tokensBeforeCompact: [], sinceCompact: new Map(),
18
+ sessionsWithCompact: new Set(), sessionEnds: 0,
19
+ };
20
+ }
21
+
22
+ export function add(state, record) {
23
+ const session = record.session ?? 'unknown';
24
+
25
+ if (record.kind === KIND.USAGE) {
26
+ const data = record.data;
27
+ const readable = (data.input ?? 0) + (data.cacheRead ?? 0) + (data.cacheWrite ?? 0);
28
+ // The last turn before a compact is the high-water mark of the window.
29
+ state.sinceCompact.set(session, Math.max(state.sinceCompact.get(session) ?? 0, readable));
30
+ return;
31
+ }
32
+
33
+ if (record.kind === KIND.COMPACT) {
34
+ if (record.data.trigger === 'manual') state.manualCompacts += 1;
35
+ else state.autoCompacts += 1;
36
+ state.sessionsWithCompact.add(session);
37
+
38
+ const peak = state.sinceCompact.get(session);
39
+ if (peak) state.tokensBeforeCompact.push(peak);
40
+ state.sinceCompact.set(session, 0);
41
+ return;
42
+ }
43
+
44
+ if (record.kind === KIND.SESSION_START) {
45
+ const source = record.data.source;
46
+ if (source === 'resume' || source === 'compact') state.resumed += 1;
47
+ else if (source === 'clear') state.cleared += 1;
48
+ else state.fresh += 1;
49
+ return;
50
+ }
51
+
52
+ if (record.kind === KIND.SESSION_END) state.sessionEnds += 1;
53
+ }
54
+
55
+ export function done(state) {
56
+ return {
57
+ autoCompacts: state.autoCompacts,
58
+ manualCompacts: state.manualCompacts,
59
+ fresh: state.fresh,
60
+ resumed: state.resumed,
61
+ cleared: state.cleared,
62
+ sessionEnds: state.sessionEnds,
63
+ sessionsWithCompact: [...state.sessionsWithCompact],
64
+ tokensBeforeCompact: state.tokensBeforeCompact.slice(0, 200),
65
+ peakWindow: Math.max(0, ...state.sinceCompact.values()),
66
+ };
67
+ }
68
+
69
+ export function merge(parts) {
70
+ const out = {
71
+ autoCompacts: 0, manualCompacts: 0, fresh: 0, resumed: 0, cleared: 0,
72
+ sessionEnds: 0, peakWindow: 0,
73
+ };
74
+ const compacted = new Set();
75
+ const peaks = [];
76
+
77
+ for (const part of parts) {
78
+ for (const key of ['autoCompacts', 'manualCompacts', 'fresh', 'resumed', 'cleared', 'sessionEnds']) {
79
+ out[key] += part[key] ?? 0;
80
+ }
81
+ out.peakWindow = Math.max(out.peakWindow, part.peakWindow ?? 0);
82
+ for (const id of part.sessionsWithCompact ?? []) compacted.add(id);
83
+ peaks.push(...(part.tokensBeforeCompact ?? []));
84
+ }
85
+
86
+ const starts = out.fresh + out.resumed + out.cleared;
87
+ return {
88
+ ...out,
89
+ compacts: out.autoCompacts + out.manualCompacts,
90
+ sessionsWithCompact: compacted.size,
91
+ starts,
92
+ resumeRate: starts ? out.resumed / starts : 0,
93
+ avgTokensBeforeCompact: peaks.length ? Math.round(peaks.reduce((a, b) => a + b, 0) / peaks.length) : null,
94
+ };
95
+ }
@@ -0,0 +1,83 @@
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
+ }
@@ -0,0 +1,86 @@
1
+ /**
2
+ * Permission blocks, time spent blocked, and which tools cause them.
3
+ *
4
+ * Friction is measurable and mostly fixable with an allowlist entry, so this
5
+ * metric exists to point at the specific rules worth adding — not to inform the
6
+ * user that friction exists, which they already know.
7
+ */
8
+
9
+ import { KIND } from '../../ledger/schema.mjs';
10
+
11
+ export const name = 'friction';
12
+ /** Beyond this, the user walked away; it is not the permission prompt's fault. */
13
+ const MAX_BLOCK_MS = 10 * 60 * 1000;
14
+
15
+ export function create() {
16
+ return {
17
+ blocks: 0, byTool: new Map(), byTarget: new Map(),
18
+ blockedMs: 0, waiting: 0, interrupts: 0,
19
+ open: new Map(),
20
+ };
21
+ }
22
+
23
+ export function add(state, record) {
24
+ const session = record.session ?? 'unknown';
25
+
26
+ if (record.kind === KIND.TOOL_BLOCKED) {
27
+ state.blocks += 1;
28
+ bump(state.byTool, record.data.tool ?? 'Unknown');
29
+ const key = `${record.data.tool}${record.data.target ? ` ${record.data.target}` : ''}`;
30
+ bump(state.byTarget, key.slice(0, 80));
31
+ state.open.set(session, record.ts);
32
+ return;
33
+ }
34
+
35
+ // Any later event in that session means the block cleared.
36
+ const opened = state.open.get(session);
37
+ if (opened !== undefined) {
38
+ const waited = record.ts - opened;
39
+ if (waited > 0 && waited < MAX_BLOCK_MS) state.blockedMs += waited;
40
+ state.open.delete(session);
41
+ }
42
+
43
+ if (record.kind === KIND.NOTIFY && record.data.kind === 'waiting') state.waiting += 1;
44
+ if (record.kind === KIND.TOOL_POST && record.data.failed && record.data.tool === 'Bash') {
45
+ // interrupted commands surface as failures; counted separately in rework
46
+ }
47
+ }
48
+
49
+ export function done(state) {
50
+ return {
51
+ blocks: state.blocks,
52
+ blockedMs: state.blockedMs,
53
+ waiting: state.waiting,
54
+ byTool: Object.fromEntries(state.byTool),
55
+ byTarget: Object.fromEntries([...state.byTarget.entries()].sort((a, b) => b[1] - a[1]).slice(0, 25)),
56
+ };
57
+ }
58
+
59
+ export function merge(parts) {
60
+ const byTool = {};
61
+ const byTarget = {};
62
+ let blocks = 0;
63
+ let blockedMs = 0;
64
+ let waiting = 0;
65
+
66
+ for (const part of parts) {
67
+ blocks += part.blocks ?? 0;
68
+ blockedMs += part.blockedMs ?? 0;
69
+ waiting += part.waiting ?? 0;
70
+ for (const [key, count] of Object.entries(part.byTool ?? {})) byTool[key] = (byTool[key] ?? 0) + count;
71
+ for (const [key, count] of Object.entries(part.byTarget ?? {})) byTarget[key] = (byTarget[key] ?? 0) + count;
72
+ }
73
+
74
+ const ranked = Object.entries(byTarget).sort((a, b) => b[1] - a[1]);
75
+ return {
76
+ blocks, blockedMs, waiting, byTool,
77
+ byTarget: Object.fromEntries(ranked.slice(0, 25)),
78
+ // The single allowlist entry that would remove the most interruptions.
79
+ worstOffender: ranked[0] ? { target: ranked[0][0], count: ranked[0][1] } : null,
80
+ avgBlockMs: blocks ? Math.round(blockedMs / blocks) : 0,
81
+ };
82
+ }
83
+
84
+ function bump(map, key) {
85
+ map.set(key, (map.get(key) ?? 0) + 1);
86
+ }
@@ -0,0 +1,93 @@
1
+ /**
2
+ * Prompt length, corrections, and one-shot success.
3
+ *
4
+ * All proxies, and labelled as proxies wherever they are shown. In 'metadata'
5
+ * privacy mode nothing here reads text; it degrades to lengths and counts, which
6
+ * is most of the signal anyway — the correction flag is computed at classify
7
+ * time, before the text is dropped.
8
+ */
9
+
10
+ import { KIND } from '../../ledger/schema.mjs';
11
+
12
+ export const name = 'prompts';
13
+
14
+ export function create() {
15
+ return {
16
+ count: 0, chars: 0, words: 0, corrections: 0, redactions: 0,
17
+ lengths: [], gaps: [], lastPrompt: new Map(),
18
+ sinceLastPrompt: new Map(), oneShot: 0, followUp: 0,
19
+ };
20
+ }
21
+
22
+ export function add(state, record) {
23
+ const session = record.session ?? 'unknown';
24
+
25
+ if (record.kind === KIND.PROMPT) {
26
+ state.count += 1;
27
+ state.chars += record.data.chars ?? 0;
28
+ state.words += record.data.words ?? 0;
29
+ state.redactions += record.data.redactions ?? 0;
30
+ state.lengths.push(record.data.words ?? 0);
31
+ if (record.data.correction) state.corrections += 1;
32
+
33
+ const previous = state.lastPrompt.get(session);
34
+ if (previous !== undefined) {
35
+ state.gaps.push(record.ts - previous);
36
+ // A prompt with no tool call since the last one is a re-prompt: the
37
+ // previous turn produced nothing the user wanted to build on.
38
+ if (state.sinceLastPrompt.get(session) === 0) state.followUp += 1;
39
+ else state.oneShot += 1;
40
+ }
41
+ state.lastPrompt.set(session, record.ts);
42
+ state.sinceLastPrompt.set(session, 0);
43
+ return;
44
+ }
45
+
46
+ if (record.kind === KIND.TOOL_POST) {
47
+ state.sinceLastPrompt.set(session, (state.sinceLastPrompt.get(session) ?? 0) + 1);
48
+ }
49
+ }
50
+
51
+ export function done(state) {
52
+ return {
53
+ count: state.count,
54
+ chars: state.chars,
55
+ words: state.words,
56
+ corrections: state.corrections,
57
+ redactions: state.redactions,
58
+ oneShot: state.oneShot,
59
+ followUp: state.followUp,
60
+ lengths: state.lengths.slice(0, 500),
61
+ gaps: state.gaps.slice(0, 500),
62
+ };
63
+ }
64
+
65
+ export function merge(parts) {
66
+ const out = { count: 0, chars: 0, words: 0, corrections: 0, redactions: 0, oneShot: 0, followUp: 0 };
67
+ const lengths = [];
68
+ const gaps = [];
69
+
70
+ for (const part of parts) {
71
+ for (const key of Object.keys(out)) out[key] += part[key] ?? 0;
72
+ lengths.push(...(part.lengths ?? []));
73
+ gaps.push(...(part.gaps ?? []));
74
+ }
75
+
76
+ return {
77
+ ...out,
78
+ avgWords: out.count ? Math.round(out.words / out.count) : 0,
79
+ medianWords: median(lengths),
80
+ correctionRate: out.count ? out.corrections / out.count : 0,
81
+ // Deliberately not "success rate". It measures whether the user had to
82
+ // re-ask, which is a proxy, and calling it anything stronger would be a lie.
83
+ reprompRate: (out.oneShot + out.followUp) ? out.followUp / (out.oneShot + out.followUp) : null,
84
+ medianGapMs: median(gaps),
85
+ };
86
+ }
87
+
88
+ function median(values) {
89
+ if (!values.length) return null;
90
+ const sorted = [...values].sort((a, b) => a - b);
91
+ const middle = Math.floor(sorted.length / 2);
92
+ return sorted.length % 2 ? sorted[middle] : Math.round((sorted[middle - 1] + sorted[middle]) / 2);
93
+ }
@@ -0,0 +1,113 @@
1
+ /**
2
+ * Repeat edits, retry storms, and the error→fix cycle.
3
+ *
4
+ * The clearest signal that a loop is not converging. Detected sequentially — the
5
+ * fold sees records in chain order, which is exactly why this lives in the fold
6
+ * and not in a query.
7
+ */
8
+
9
+ import { KIND } from '../../ledger/schema.mjs';
10
+
11
+ export const name = 'rework';
12
+ /** Two edits to one file inside this window are one change, not two. */
13
+ const CHURN_WINDOW_MS = 12 * 60 * 1000;
14
+ /** The same command failing this many times running is a storm, not a retry. */
15
+ const STORM_THRESHOLD = 3;
16
+
17
+ export function create() {
18
+ return {
19
+ lastEdit: new Map(), churn: new Map(),
20
+ failStreak: new Map(), storms: [],
21
+ errors: 0, calls: 0,
22
+ editsSinceVerify: 0, verifiedRuns: 0, unverifiedRuns: 0,
23
+ };
24
+ }
25
+
26
+ export function add(state, record) {
27
+ if (record.kind === KIND.FILE_TOUCH) {
28
+ const path = record.data.path;
29
+ state.editsSinceVerify += 1;
30
+ if (!path) return;
31
+
32
+ const previous = state.lastEdit.get(path);
33
+ if (previous !== undefined && record.ts - previous <= CHURN_WINDOW_MS) {
34
+ state.churn.set(path, (state.churn.get(path) ?? 1) + 1);
35
+ }
36
+ state.lastEdit.set(path, record.ts);
37
+ return;
38
+ }
39
+
40
+ if (record.kind === KIND.TOOL_PRE && record.data.verification) {
41
+ // A verification run closes the current stretch of unverified edits.
42
+ if (state.editsSinceVerify > 0) state.verifiedRuns += 1;
43
+ state.editsSinceVerify = 0;
44
+ return;
45
+ }
46
+
47
+ if (record.kind !== KIND.TOOL_POST) return;
48
+ state.calls += 1;
49
+
50
+ const key = `${record.data.tool}|${record.data.target ?? ''}`;
51
+ if (record.data.failed) {
52
+ state.errors += 1;
53
+ const streak = (state.failStreak.get(key) ?? 0) + 1;
54
+ state.failStreak.set(key, streak);
55
+ if (streak === STORM_THRESHOLD) {
56
+ state.storms.push({ key: key.slice(0, 80), at: record.ts, streak });
57
+ } else if (streak > STORM_THRESHOLD) {
58
+ state.storms[state.storms.length - 1].streak = streak;
59
+ }
60
+ } else {
61
+ state.failStreak.set(key, 0);
62
+ }
63
+ }
64
+
65
+ export function done(state) {
66
+ // Edits still outstanding when the day ended were never verified.
67
+ if (state.editsSinceVerify > 0) state.unverifiedRuns += 1;
68
+
69
+ return {
70
+ churn: Object.fromEntries([...state.churn.entries()].sort((a, b) => b[1] - a[1]).slice(0, 25)),
71
+ storms: state.storms.slice(0, 25),
72
+ errors: state.errors,
73
+ calls: state.calls,
74
+ verifiedRuns: state.verifiedRuns,
75
+ unverifiedRuns: state.unverifiedRuns,
76
+ danglingEdits: state.editsSinceVerify,
77
+ };
78
+ }
79
+
80
+ export function merge(parts) {
81
+ const churn = {};
82
+ const storms = [];
83
+ let errors = 0;
84
+ let calls = 0;
85
+ let verifiedRuns = 0;
86
+ let unverifiedRuns = 0;
87
+
88
+ for (const part of parts) {
89
+ for (const [path, count] of Object.entries(part.churn ?? {})) {
90
+ churn[path] = Math.max(churn[path] ?? 0, count);
91
+ }
92
+ storms.push(...(part.storms ?? []));
93
+ errors += part.errors ?? 0;
94
+ calls += part.calls ?? 0;
95
+ verifiedRuns += part.verifiedRuns ?? 0;
96
+ unverifiedRuns += part.unverifiedRuns ?? 0;
97
+ }
98
+
99
+ const ranked = Object.entries(churn).sort((a, b) => b[1] - a[1]);
100
+ const runs = verifiedRuns + unverifiedRuns;
101
+ return {
102
+ churn: Object.fromEntries(ranked.slice(0, 25)),
103
+ worstChurn: ranked[0] ? { path: ranked[0][0], edits: ranked[0][1] } : null,
104
+ storms: storms.slice(0, 25),
105
+ stormCount: storms.length,
106
+ errors,
107
+ calls,
108
+ errorRate: calls ? errors / calls : 0,
109
+ verifiedRuns,
110
+ unverifiedRuns,
111
+ verifyRate: runs ? verifiedRuns / runs : null,
112
+ };
113
+ }
@@ -0,0 +1,104 @@
1
+ /**
2
+ * Active time vs wall-clock, work blocks, hour-of-day, time to first tool.
3
+ *
4
+ * Active time is the sum of gaps below the idle threshold. A session left open
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
7
+ * story — a lie in the user's favour.
8
+ */
9
+
10
+ import { KIND } from '../../ledger/schema.mjs';
11
+
12
+ export const name = 'time';
13
+
14
+ export function create() {
15
+ return {
16
+ hours: new Array(24).fill(0),
17
+ weekdays: new Array(7).fill(0),
18
+ activeMs: 0,
19
+ firstAt: null,
20
+ lastAt: null,
21
+ lastBySession: new Map(),
22
+ spans: new Map(),
23
+ firstToolDelays: [],
24
+ pendingPrompt: new Map(),
25
+ };
26
+ }
27
+
28
+ export function add(state, record, ctx) {
29
+ const ts = record.ts;
30
+ const date = new Date(ts);
31
+ state.hours[date.getHours()] += 1;
32
+ state.weekdays[date.getDay()] += 1;
33
+ state.firstAt = state.firstAt === null ? ts : Math.min(state.firstAt, ts);
34
+ state.lastAt = state.lastAt === null ? ts : Math.max(state.lastAt, ts);
35
+
36
+ const session = record.session ?? 'unknown';
37
+ const previous = state.lastBySession.get(session);
38
+ if (previous !== undefined) {
39
+ const gap = ts - previous;
40
+ if (gap > 0 && gap <= ctx.idleGapMs) state.activeMs += gap;
41
+ }
42
+ state.lastBySession.set(session, ts);
43
+
44
+ const span = state.spans.get(session);
45
+ if (!span) state.spans.set(session, { first: ts, last: ts });
46
+ else span.last = ts;
47
+
48
+ // How long from asking to the first thing actually happening.
49
+ if (record.kind === KIND.PROMPT) state.pendingPrompt.set(session, ts);
50
+ else if (record.kind === KIND.TOOL_PRE && state.pendingPrompt.has(session)) {
51
+ state.firstToolDelays.push(ts - state.pendingPrompt.get(session));
52
+ state.pendingPrompt.delete(session);
53
+ }
54
+ }
55
+
56
+ export function done(state) {
57
+ let wallMs = 0;
58
+ for (const span of state.spans.values()) wallMs += span.last - span.first;
59
+
60
+ return {
61
+ hours: state.hours,
62
+ weekdays: state.weekdays,
63
+ activeMs: state.activeMs,
64
+ wallMs,
65
+ firstAt: state.firstAt,
66
+ lastAt: state.lastAt,
67
+ firstToolDelays: state.firstToolDelays.slice(0, 400),
68
+ };
69
+ }
70
+
71
+ export function merge(parts) {
72
+ const hours = new Array(24).fill(0);
73
+ const weekdays = new Array(7).fill(0);
74
+ let activeMs = 0;
75
+ let wallMs = 0;
76
+ let firstAt = null;
77
+ let lastAt = null;
78
+ const delays = [];
79
+
80
+ for (const part of parts) {
81
+ (part.hours ?? []).forEach((value, index) => { hours[index] += value; });
82
+ (part.weekdays ?? []).forEach((value, index) => { weekdays[index] += value; });
83
+ activeMs += part.activeMs ?? 0;
84
+ wallMs += part.wallMs ?? 0;
85
+ if (part.firstAt != null) firstAt = firstAt === null ? part.firstAt : Math.min(firstAt, part.firstAt);
86
+ if (part.lastAt != null) lastAt = lastAt === null ? part.lastAt : Math.max(lastAt, part.lastAt);
87
+ delays.push(...(part.firstToolDelays ?? []));
88
+ }
89
+
90
+ return {
91
+ hours, weekdays, activeMs, wallMs, firstAt, lastAt,
92
+ // Median, not mean: one 40-minute stall should not redefine a typical wait.
93
+ medianFirstTool: median(delays),
94
+ activeShare: wallMs ? activeMs / wallMs : 0,
95
+ activeDays: parts.filter((part) => (part.activeMs ?? 0) > 0).length,
96
+ };
97
+ }
98
+
99
+ function median(values) {
100
+ if (!values.length) return null;
101
+ const sorted = [...values].sort((a, b) => a - b);
102
+ const middle = Math.floor(sorted.length / 2);
103
+ return sorted.length % 2 ? sorted[middle] : Math.round((sorted[middle - 1] + sorted[middle]) / 2);
104
+ }
@@ -0,0 +1,88 @@
1
+ /**
2
+ * Input, output, cache-read and cache-creation tokens, by model.
3
+ *
4
+ * Cache hit rate is the headline. It is the cheapest efficiency win available in
5
+ * Claude Code and almost nobody measures it — a session that keeps its prefix
6
+ * stable reads context at a fraction of the price of one that keeps reshuffling it.
7
+ */
8
+
9
+ import { KIND } from '../../ledger/schema.mjs';
10
+
11
+ export const name = 'tokens';
12
+
13
+ export function create() {
14
+ return { byModel: new Map(), turnsWithTools: 0, toolUses: 0, turns: 0, sidechainTurns: 0 };
15
+ }
16
+
17
+ export function add(state, record) {
18
+ if (record.kind !== KIND.USAGE) return;
19
+ const data = record.data;
20
+ const model = data.model ?? 'unknown';
21
+
22
+ if (!state.byModel.has(model)) {
23
+ state.byModel.set(model, { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, thinking: 0, turns: 0 });
24
+ }
25
+ const bucket = state.byModel.get(model);
26
+ bucket.input += data.input ?? 0;
27
+ bucket.output += data.output ?? 0;
28
+ bucket.cacheRead += data.cacheRead ?? 0;
29
+ bucket.cacheWrite += data.cacheWrite ?? 0;
30
+ bucket.thinking += data.thinking ?? 0;
31
+ bucket.turns += 1;
32
+
33
+ state.turns += 1;
34
+ if (data.sidechain) state.sidechainTurns += 1;
35
+ if (data.toolUses > 0) {
36
+ state.turnsWithTools += 1;
37
+ state.toolUses += data.toolUses;
38
+ }
39
+ }
40
+
41
+ export function done(state) {
42
+ return {
43
+ byModel: Object.fromEntries(state.byModel),
44
+ turns: state.turns,
45
+ turnsWithTools: state.turnsWithTools,
46
+ toolUses: state.toolUses,
47
+ sidechainTurns: state.sidechainTurns,
48
+ };
49
+ }
50
+
51
+ export function merge(parts) {
52
+ const byModel = {};
53
+ let turns = 0;
54
+ let turnsWithTools = 0;
55
+ let toolUses = 0;
56
+ let sidechainTurns = 0;
57
+
58
+ for (const part of parts) {
59
+ for (const [model, bucket] of Object.entries(part.byModel ?? {})) {
60
+ byModel[model] ??= { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, thinking: 0, turns: 0 };
61
+ for (const key of Object.keys(bucket)) byModel[model][key] += bucket[key] ?? 0;
62
+ }
63
+ turns += part.turns ?? 0;
64
+ turnsWithTools += part.turnsWithTools ?? 0;
65
+ toolUses += part.toolUses ?? 0;
66
+ sidechainTurns += part.sidechainTurns ?? 0;
67
+ }
68
+
69
+ const totals = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, thinking: 0 };
70
+ for (const bucket of Object.values(byModel)) {
71
+ for (const key of Object.keys(totals)) totals[key] += bucket[key];
72
+ }
73
+
74
+ const readable = totals.input + totals.cacheRead + totals.cacheWrite;
75
+ return {
76
+ ...totals,
77
+ byModel,
78
+ turns,
79
+ turnsWithTools,
80
+ toolUses,
81
+ sidechainTurns,
82
+ total: totals.input + totals.output + totals.cacheRead + totals.cacheWrite,
83
+ cacheHitRate: readable ? totals.cacheRead / readable : 0,
84
+ // Independent calls issued in one turn cost one round-trip; the same three
85
+ // across three turns cost three. This is the batching number.
86
+ callsPerTurn: turnsWithTools ? toolUses / turnsWithTools : 0,
87
+ };
88
+ }