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.
- package/LICENSE +21 -0
- package/README.md +77 -0
- package/adapters/claude-code.mjs +59 -0
- package/adapters/codex.mjs +256 -0
- package/adapters/index.mjs +92 -0
- package/analytics/index.mjs +189 -0
- package/analytics/metrics/context.mjs +95 -0
- package/analytics/metrics/cost.mjs +83 -0
- package/analytics/metrics/friction.mjs +86 -0
- package/analytics/metrics/prompts.mjs +93 -0
- package/analytics/metrics/rework.mjs +113 -0
- package/analytics/metrics/time.mjs +104 -0
- package/analytics/metrics/tokens.mjs +88 -0
- package/analytics/metrics/tools.mjs +118 -0
- package/analytics/metrics/volume.mjs +98 -0
- package/analytics/ranges.mjs +98 -0
- package/analytics/rollup.mjs +151 -0
- package/analytics/score.mjs +194 -0
- package/bin/cli.mjs +596 -0
- package/bin/postinstall.mjs +44 -0
- package/collect/classify.mjs +226 -0
- package/collect/git.mjs +78 -0
- package/collect/projects.mjs +82 -0
- package/collect/redact.mjs +85 -0
- package/collect/sessions.mjs +119 -0
- package/collect/tail.mjs +126 -0
- package/collect/tools.mjs +121 -0
- package/collect/transcript.mjs +128 -0
- package/dashboard/api/index.mjs +296 -0
- package/dashboard/auth.mjs +235 -0
- package/dashboard/router.mjs +55 -0
- package/dashboard/security.mjs +95 -0
- package/dashboard/server.mjs +156 -0
- package/dashboard/static.mjs +47 -0
- package/dashboard/web/SynDes.icns +0 -0
- package/dashboard/web/api.js +80 -0
- package/dashboard/web/app.css +532 -0
- package/dashboard/web/app.js +261 -0
- package/dashboard/web/charts.js +273 -0
- package/dashboard/web/index.html +23 -0
- package/dashboard/web/logo.png +0 -0
- package/dashboard/web/ui.js +434 -0
- package/dashboard/web/views/habits.js +166 -0
- package/dashboard/web/views/ledger.js +164 -0
- package/dashboard/web/views/overview.js +214 -0
- package/dashboard/web/views/sessions.js +133 -0
- package/dashboard/web/views/settings.js +180 -0
- package/ledger/append.mjs +126 -0
- package/ledger/chain.mjs +53 -0
- package/ledger/keys.mjs +72 -0
- package/ledger/read.mjs +77 -0
- package/ledger/retention.mjs +104 -0
- package/ledger/schema.mjs +96 -0
- package/ledger/segments.mjs +109 -0
- package/ledger/verify.mjs +174 -0
- package/notify/index.mjs +67 -0
- package/notify/linux.mjs +41 -0
- package/notify/mac.mjs +44 -0
- package/notify/terminal.mjs +15 -0
- package/notify/windows.mjs +61 -0
- package/package.json +66 -0
- package/practices/budget.mjs +97 -0
- package/practices/catalog.mjs +64 -0
- package/practices/deliver.mjs +101 -0
- package/practices/engine.mjs +107 -0
- package/practices/rules/batch-tool-calls.mjs +15 -0
- package/practices/rules/context-hygiene.mjs +17 -0
- package/practices/rules/delegate-wide-search.mjs +15 -0
- package/practices/rules/index.mjs +28 -0
- package/practices/rules/permission-friction.mjs +16 -0
- package/practices/rules/project-memory.mjs +27 -0
- package/practices/rules/prompt-specificity.mjs +15 -0
- package/practices/rules/read-before-edit.mjs +16 -0
- package/practices/rules/retry-storm.mjs +22 -0
- package/practices/rules/session-sprawl.mjs +15 -0
- package/practices/rules/verify-after-change.mjs +16 -0
- package/runtime/config.mjs +116 -0
- package/runtime/hook.mjs +154 -0
- package/runtime/jsonl.mjs +104 -0
- package/runtime/lock.mjs +98 -0
- package/runtime/log.mjs +37 -0
- package/runtime/paths.mjs +116 -0
- package/runtime/platform.mjs +74 -0
- package/runtime/spool.mjs +92 -0
- package/runtime/worker.mjs +275 -0
- package/src/briefing.mjs +94 -0
- package/src/doctor.mjs +153 -0
- package/src/export.mjs +68 -0
- package/src/install.mjs +95 -0
- package/src/open.mjs +23 -0
- package/src/report.mjs +120 -0
- package/src/settings.mjs +173 -0
- package/src/status.mjs +61 -0
- package/src/systemauth.mjs +179 -0
- package/src/term.mjs +272 -0
- package/src/uninstall.mjs +43 -0
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Ledger — proof, not a data dump.
|
|
3
|
+
*
|
|
4
|
+
* The point of this page is that "tamper-evident" is something you can check
|
|
5
|
+
* rather than a claim you have to accept. So the verify result comes first, and
|
|
6
|
+
* the records read as sentences.
|
|
7
|
+
*
|
|
8
|
+
* Hashes are deliberately NOT in the table. A column of truncated hex teaches a
|
|
9
|
+
* reader nothing and pushes out the column that does. They live in the drawer,
|
|
10
|
+
* where someone who actually wants to check a link will look.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { api } from '../api.js';
|
|
14
|
+
import { h, card, figure, table, chip, empty, drawer, dropdown, kv, bytes, when, clock, DASH } from '../ui.js';
|
|
15
|
+
|
|
16
|
+
const KIND_LABEL = {
|
|
17
|
+
'prompt.submit': 'Prompt', 'tool.post': 'Tool call', 'tool.blocked': 'Permission stop',
|
|
18
|
+
'file.touch': 'File change', 'session.start': 'Session start', 'session.end': 'Session end',
|
|
19
|
+
'session.compact': 'Compaction', 'git.commit': 'Commit', 'coach.nudge': 'Coach nudge',
|
|
20
|
+
'ledger.seal': 'Day sealed', 'ledger.prune': 'Records pruned', 'ledger.genesis': 'Chain opened',
|
|
21
|
+
'ledger.install': 'Installed', 'ledger.tracking': 'Tracking changed',
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
/** Machine records. Present in the chain, useless in a list a human reads. */
|
|
25
|
+
const HIDDEN = new Set(['usage.sample', 'tool.pre', 'turn.stop', 'turn.subagent_stop', 'turn.notify']);
|
|
26
|
+
|
|
27
|
+
export async function render({ range }) {
|
|
28
|
+
const outlet = h('div.content--fit', {
|
|
29
|
+
style: 'display:grid;grid-template-rows:auto auto minmax(0,1fr);gap:var(--s4);min-height:0',
|
|
30
|
+
});
|
|
31
|
+
await paint(outlet, { range, kind: 'meaningful' });
|
|
32
|
+
return outlet;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
async function paint(outlet, filter) {
|
|
36
|
+
const params = { limit: '400', range: filter.range };
|
|
37
|
+
if (filter.kind && filter.kind !== 'meaningful' && filter.kind !== 'all') params.kind = filter.kind;
|
|
38
|
+
|
|
39
|
+
const [chain, verified] = await Promise.all([api.ledger(params), api.verify(true)]);
|
|
40
|
+
|
|
41
|
+
const visible = chain.records
|
|
42
|
+
.filter((record) => (filter.kind === 'meaningful' ? !HIDDEN.has(record.kind) : true))
|
|
43
|
+
.slice(-120)
|
|
44
|
+
.reverse();
|
|
45
|
+
|
|
46
|
+
outlet.replaceChildren(
|
|
47
|
+
proof(verified),
|
|
48
|
+
h('div.grid.g-4', {}, [
|
|
49
|
+
card(null, figure('Records', ((chain.height.seq ?? -1) + 1).toLocaleString())),
|
|
50
|
+
card(null, figure('Days', String(chain.days.length))),
|
|
51
|
+
card(null, figure('Sealed', String(chain.seals.length))),
|
|
52
|
+
card(null, figure('On disk', bytes(chain.usage.bytes))),
|
|
53
|
+
]),
|
|
54
|
+
card('Activity', visible.length
|
|
55
|
+
? h('div.scroll-y', { style: 'flex:1' }, [table(
|
|
56
|
+
['Seq', 'Time', 'What happened', 'Session'],
|
|
57
|
+
visible.map((record) => [
|
|
58
|
+
String(record.seq),
|
|
59
|
+
clock(record.ts),
|
|
60
|
+
h('div', { style: 'display:flex;align-items:center;gap:10px' }, [
|
|
61
|
+
chip(KIND_LABEL[record.kind] ?? record.kind),
|
|
62
|
+
h('span', { style: 'color:var(--ink-2)', text: sentence(record) }),
|
|
63
|
+
]),
|
|
64
|
+
record.session ? record.session.slice(0, 8) : DASH,
|
|
65
|
+
]),
|
|
66
|
+
{ align: ['right', null, null, null], onRow: (index) => openRecord(visible[index]) },
|
|
67
|
+
)])
|
|
68
|
+
: empty('Nothing in this range.'), {
|
|
69
|
+
className: 'fill',
|
|
70
|
+
note: 'newest first · click a row to see its place in the chain',
|
|
71
|
+
actions: kindFilter(filter, outlet),
|
|
72
|
+
}),
|
|
73
|
+
);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function proof(verified) {
|
|
77
|
+
const ok = verified.ok;
|
|
78
|
+
return card(null, [
|
|
79
|
+
h('div', { style: 'display:flex;align-items:center;gap:18px;flex-wrap:wrap' }, [
|
|
80
|
+
h('div', {
|
|
81
|
+
style: `width:52px;height:52px;border-radius:999px;display:grid;place-items:center;flex:none;background:var(--${ok ? 'lime' : 'orange'});color:var(--black);font-size:24px;font-weight:700`,
|
|
82
|
+
text: ok ? '✓' : '!',
|
|
83
|
+
}),
|
|
84
|
+
h('div', {}, [
|
|
85
|
+
h('div', { style: 'font-size:19px;font-weight:700;letter-spacing:-0.01em', text: ok ? 'The chain is intact' : `${verified.problems.length} problem(s) found` }),
|
|
86
|
+
h('div', { style: 'font-size:13px;color:var(--ink-2);margin-top:3px', text: ok
|
|
87
|
+
? `Every one of ${verified.records.toLocaleString()} records still hashes to the one before it, across ${verified.days} day(s).`
|
|
88
|
+
: verified.problems[0]?.detail ?? '' }),
|
|
89
|
+
]),
|
|
90
|
+
h('div', { style: 'margin-left:auto;display:flex;gap:8px;flex-wrap:wrap' }, [
|
|
91
|
+
chip(`${verified.checkedSeals} seals checked`),
|
|
92
|
+
chip(`tip ${verified.tip?.seq ?? '—'}`),
|
|
93
|
+
]),
|
|
94
|
+
]),
|
|
95
|
+
]);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function kindFilter(filter, outlet) {
|
|
99
|
+
return dropdown({
|
|
100
|
+
onCard: true,
|
|
101
|
+
value: filter.kind,
|
|
102
|
+
options: [
|
|
103
|
+
{ id: 'meaningful', label: 'What a person did', hint: 'hides machine records' },
|
|
104
|
+
{ id: 'all', label: 'Everything', hint: 'including usage samples' },
|
|
105
|
+
{ id: 'prompt.submit', label: 'Prompts only' },
|
|
106
|
+
{ id: 'file.touch', label: 'File changes only' },
|
|
107
|
+
{ id: 'tool.blocked', label: 'Permission stops only' },
|
|
108
|
+
],
|
|
109
|
+
onChange: async (value) => { filter.kind = value; await paint(outlet, filter); },
|
|
110
|
+
});
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** Plain language. The envelope is in the drawer; the list is for reading. */
|
|
114
|
+
function sentence(record) {
|
|
115
|
+
const data = record.data ?? {};
|
|
116
|
+
switch (record.kind) {
|
|
117
|
+
case 'prompt.submit': return data.text ? clip(data.text) : `${data.words} words`;
|
|
118
|
+
case 'tool.post': return `${data.tool}${data.target ? ` · ${clip(data.target, 44)}` : ''}${data.failed ? ' · failed' : ''}`;
|
|
119
|
+
case 'tool.blocked': return `${data.tool}${data.target ? ` · ${clip(data.target, 40)}` : ''}`;
|
|
120
|
+
case 'file.touch': return `${clip(data.path, 48)}${data.readFirst ? '' : ' · not read first'}`;
|
|
121
|
+
case 'session.start': return data.projectName ?? data.source ?? '';
|
|
122
|
+
case 'session.end': return data.reason ?? '';
|
|
123
|
+
case 'session.compact': return data.trigger === 'auto' ? 'window ran out' : 'by hand';
|
|
124
|
+
case 'git.commit': return clip(data.subject, 50);
|
|
125
|
+
case 'coach.nudge': return clip(data.evidence ?? data.rule, 60);
|
|
126
|
+
case 'ledger.seal': return `${data.day} · ${data.count} records`;
|
|
127
|
+
case 'ledger.prune': return `${data.days?.length ?? 0} day(s) removed, declared in the chain`;
|
|
128
|
+
default: return '';
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function clip(text, limit = 70) {
|
|
133
|
+
if (typeof text !== 'string') return '';
|
|
134
|
+
const flat = text.replace(/\s+/g, ' ').trim();
|
|
135
|
+
return flat.length > limit ? `${flat.slice(0, limit - 1)}…` : flat;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/** Where the hashes live — for someone who actually wants to check a link. */
|
|
139
|
+
function openRecord(record) {
|
|
140
|
+
drawer(KIND_LABEL[record.kind] ?? record.kind, `Sequence ${record.seq} · ${when(record.ts)}`, [
|
|
141
|
+
h('div', { style: 'font-size:14px', text: sentence(record) || 'No further detail recorded.' }),
|
|
142
|
+
h('div.kv', {}, [
|
|
143
|
+
kv('Kind', record.kind),
|
|
144
|
+
kv('Session', record.session ?? DASH),
|
|
145
|
+
kv('Recorded', new Date(record.ts).toISOString()),
|
|
146
|
+
]),
|
|
147
|
+
h('div', {}, [
|
|
148
|
+
h('h3.card__title', { style: 'margin-bottom:12px', text: 'Chain link' }),
|
|
149
|
+
h('div', { style: 'display:grid;gap:10px' }, [
|
|
150
|
+
hashRow('Follows', record.prev),
|
|
151
|
+
hashRow('This record', record.hash),
|
|
152
|
+
]),
|
|
153
|
+
h('p', { style: 'font-size:12px;color:var(--ink-3);margin-top:12px;line-height:1.6',
|
|
154
|
+
text: 'Each hash covers the record and the one before it. Changing any field here would break every hash after it, which is what syndes verify walks.' }),
|
|
155
|
+
]),
|
|
156
|
+
]);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function hashRow(label, value) {
|
|
160
|
+
return h('div', {}, [
|
|
161
|
+
h('div', { style: 'font-size:11px;color:var(--ink-3);margin-bottom:4px', text: label }),
|
|
162
|
+
h('div', { style: 'font-family:var(--mono);font-size:11px;word-break:break-all;background:var(--surface-2);padding:10px 12px;border-radius:12px', text: value }),
|
|
163
|
+
]);
|
|
164
|
+
}
|
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Overview — everything you need to know before you click anything.
|
|
3
|
+
*
|
|
4
|
+
* Four questions, in the order people actually ask them:
|
|
5
|
+
* 1. Am I working well? the score, and what dragged it
|
|
6
|
+
* 2. What did it cost me? throughput and spend, with the shape of the trend
|
|
7
|
+
* 3. When did I work? a day-by-day timeline
|
|
8
|
+
* 4. What should I change? the top findings, with their evidence
|
|
9
|
+
*
|
|
10
|
+
* Anything that does not answer one of those four is not on this page.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { api } from '../api.js';
|
|
14
|
+
import { h, card, figure, trend, chip, dot, meter, legend, empty, compact, usd, duration, percent } from '../ui.js';
|
|
15
|
+
import { dial, capsules, timeline, dualLine } from '../charts.js';
|
|
16
|
+
|
|
17
|
+
/** Tone by what a tool costs you, which is what the colour is claiming. */
|
|
18
|
+
const FAMILY_TONE = {
|
|
19
|
+
read: 'lime', search: 'lime', delegate: 'lime', plan: 'lime',
|
|
20
|
+
edit: 'white', execute: 'white', web: 'white', mcp: 'white', other: 'white',
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
export async function render({ range, go }) {
|
|
24
|
+
const [data, practices] = await Promise.all([api.overview(range), api.practices()]);
|
|
25
|
+
|
|
26
|
+
if ((data.ledger?.seq ?? -1) < 0) return firstRun();
|
|
27
|
+
|
|
28
|
+
// Fixed top row, and the bottom row takes whatever is left — so the view is
|
|
29
|
+
// exactly one screen and the findings list scrolls inside its own card.
|
|
30
|
+
return h('div.content--fit', {
|
|
31
|
+
style: 'display:grid;grid-template-rows:auto minmax(0,1fr);gap:var(--s4);min-height:0',
|
|
32
|
+
}, [
|
|
33
|
+
h('div.grid.g-3', {}, [scoreCard(data), throughputCard(data), timelineCard(data)]),
|
|
34
|
+
h('div.grid.fill', { style: 'grid-template-columns:minmax(0,1fr) minmax(0,1.6fr)' }, [
|
|
35
|
+
distributionCard(data),
|
|
36
|
+
fixCard(practices.findings, go),
|
|
37
|
+
]),
|
|
38
|
+
]);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Nothing recorded yet.
|
|
43
|
+
*
|
|
44
|
+
* An empty dashboard with five empty charts tells the user their tool is
|
|
45
|
+
* broken. It is not — it has simply never been given anything, and the fix is
|
|
46
|
+
* one command.
|
|
47
|
+
*/
|
|
48
|
+
function firstRun() {
|
|
49
|
+
return h('div', { style: 'display:grid;place-items:center;height:100%' }, [
|
|
50
|
+
h('div', { style: 'max-width:520px;display:grid;gap:18px;justify-items:center;text-align:center' }, [
|
|
51
|
+
h('div', { style: 'font-size:26px;font-weight:750;letter-spacing:-0.02em;text-transform:uppercase', text: 'Nothing recorded yet' }),
|
|
52
|
+
h('p', { style: 'color:var(--ink-2);line-height:1.7;margin:0', text: 'syndes has not been wired into Claude Code on this machine, so there is nothing to show. Installing takes a second and starts recording from your next session.' }),
|
|
53
|
+
h('code', {
|
|
54
|
+
style: 'font-family:var(--mono);font-size:14px;color:var(--black);background:var(--lime);padding:12px 20px;border-radius:var(--r-pill)',
|
|
55
|
+
text: 'syndes install',
|
|
56
|
+
}),
|
|
57
|
+
h('p', { style: 'color:var(--ink-3);font-size:12px;margin:0', text: 'Already installed? Open a Claude Code session — the first prompt starts the chain.' }),
|
|
58
|
+
]),
|
|
59
|
+
]);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// ── 1. Score ───────────────────────────────────────────────────────────────
|
|
63
|
+
|
|
64
|
+
function scoreCard({ score, scoreDelta }) {
|
|
65
|
+
const rows = score.pillars.map((pillar) => h('div.pillar', {}, [
|
|
66
|
+
h('span.pillar__n', { text: pillar.score === null ? '—' : String(pillar.score) }),
|
|
67
|
+
h('div', {}, [
|
|
68
|
+
h('div.pillar__name', { text: pillar.label }),
|
|
69
|
+
meter((pillar.score ?? 0) / 100, pillar.score === null ? 'white' : pillar.score >= 70 ? 'lime' : pillar.score < 45 ? 'orange' : 'white'),
|
|
70
|
+
]),
|
|
71
|
+
chip(String(pillar.weight)),
|
|
72
|
+
]));
|
|
73
|
+
|
|
74
|
+
return card('Score', [
|
|
75
|
+
h('div', { style: 'display:flex;align-items:center;gap:22px' }, [
|
|
76
|
+
h('div', { style: 'position:relative;display:grid;place-items:center' }, [
|
|
77
|
+
dial(score.total, { size: 132 }),
|
|
78
|
+
h('div', { style: 'position:absolute;display:grid;justify-items:center;gap:1px' }, [
|
|
79
|
+
h('div', { style: 'font-size:34px;font-weight:750;letter-spacing:-0.04em;line-height:1', text: score.total === null ? '—' : String(score.total) }),
|
|
80
|
+
h('div', { style: 'font-size:10px;color:var(--ink-3)', text: 'OF 100' }),
|
|
81
|
+
]),
|
|
82
|
+
]),
|
|
83
|
+
h('div', { style: 'display:grid;gap:8px' }, [
|
|
84
|
+
scoreDelta === null ? null : trend(score.total, score.total - scoreDelta, { format: (value) => `${Math.round(value)} pts` }),
|
|
85
|
+
chip(`${score.confidence} confidence`),
|
|
86
|
+
]),
|
|
87
|
+
]),
|
|
88
|
+
h('div.pillars', {}, rows),
|
|
89
|
+
], { note: 'weighted' });
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// ── 2. Throughput and spend ────────────────────────────────────────────────
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Which agent the work came from.
|
|
96
|
+
*
|
|
97
|
+
* Shown only when there is more than one: a single-agent user does not need to
|
|
98
|
+
* be told every day that all their work came from the one tool they use.
|
|
99
|
+
*/
|
|
100
|
+
function sourceChips(metrics) {
|
|
101
|
+
const sources = Object.entries(metrics.volume?.bySource ?? {})
|
|
102
|
+
.filter(([, bucket]) => bucket.toolCalls || bucket.prompts)
|
|
103
|
+
.sort((a, b) => b[1].records - a[1].records);
|
|
104
|
+
if (sources.length < 2) return [];
|
|
105
|
+
return sources.map(([id, bucket]) => chip(`${NAMES[id] ?? id} · ${compact(bucket.toolCalls)}`));
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const NAMES = { 'claude-code': 'Claude Code', codex: 'Codex' };
|
|
109
|
+
|
|
110
|
+
function throughputCard({ headline, previousHeadline: before, series, cost, metrics }) {
|
|
111
|
+
return card('Throughput', [
|
|
112
|
+
h('div.pair', {}, [
|
|
113
|
+
figure('Active time', duration(headline.activeMs), { small: true }),
|
|
114
|
+
figure('Spend', usd(headline.usd), { small: true }),
|
|
115
|
+
]),
|
|
116
|
+
// One trend line, not one per figure: repeating "no prior period" twice
|
|
117
|
+
// spends two rows saying nothing.
|
|
118
|
+
trend(headline.toolCalls, before.toolCalls, { format: (value) => `${compact(value)} calls` }),
|
|
119
|
+
h('div', { style: 'flex:1;min-height:0;display:grid;align-items:center' }, [
|
|
120
|
+
dualLine(
|
|
121
|
+
series.map((day) => day.toolCalls),
|
|
122
|
+
series.map((day) => day.tokens / 1000),
|
|
123
|
+
{ height: 110 },
|
|
124
|
+
),
|
|
125
|
+
]),
|
|
126
|
+
legend([
|
|
127
|
+
{ tone: 'lime', label: 'tool calls' },
|
|
128
|
+
{ tone: 'orange', label: 'tokens' },
|
|
129
|
+
]),
|
|
130
|
+
h('div', { style: 'display:flex;gap:10px;flex-wrap:wrap' }, [
|
|
131
|
+
chip(`${percent(headline.cacheHitRate)} from cache`, 'lime'),
|
|
132
|
+
chip(`${compact(headline.tokens)} tokens`),
|
|
133
|
+
cost.estimated ? chip('estimated pricing') : null,
|
|
134
|
+
...sourceChips(metrics),
|
|
135
|
+
]),
|
|
136
|
+
]);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// ── 3. When the work happened ──────────────────────────────────────────────
|
|
140
|
+
|
|
141
|
+
function timelineCard({ series }) {
|
|
142
|
+
const days = series.slice(-9);
|
|
143
|
+
const maxMinutes = Math.max(...days.map((day) => day.activeMs / 60_000), 10);
|
|
144
|
+
const step = Math.max(10, Math.ceil(maxMinutes / 4 / 10) * 10);
|
|
145
|
+
const ticks = [];
|
|
146
|
+
for (let value = 0; value <= maxMinutes + step; value += step) ticks.push({ at: value, label: String(value) });
|
|
147
|
+
|
|
148
|
+
const bars = days.map((day) => ({
|
|
149
|
+
row: day.day.slice(5).replace('-', '.'),
|
|
150
|
+
start: 0,
|
|
151
|
+
end: day.activeMs / 60_000,
|
|
152
|
+
value: `${Math.round(day.activeMs / 60_000)}m`,
|
|
153
|
+
empty: 'no activity',
|
|
154
|
+
// Orange marks a day the context window ran out — the thing worth spotting
|
|
155
|
+
// in a week at a glance.
|
|
156
|
+
tone: day.compacts > 0 ? 'orange' : 'lime',
|
|
157
|
+
}));
|
|
158
|
+
|
|
159
|
+
return card('Active minutes by day', [
|
|
160
|
+
days.length
|
|
161
|
+
? timeline(bars, { min: 0, max: Math.max(...ticks.map((tick) => tick.at)), ticks })
|
|
162
|
+
: empty('Nothing recorded in this range'),
|
|
163
|
+
legend([
|
|
164
|
+
{ tone: 'lime', label: 'clean day' },
|
|
165
|
+
{ tone: 'orange', label: 'context ran out' },
|
|
166
|
+
], days.reduce((sum, day) => sum + Math.round(day.activeMs / 60_000), 0)),
|
|
167
|
+
]);
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// ── 4. Where the calls went ────────────────────────────────────────────────
|
|
171
|
+
|
|
172
|
+
function distributionCard({ metrics }) {
|
|
173
|
+
const families = Object.entries(metrics.tools.byFamily ?? {})
|
|
174
|
+
.sort((a, b) => b[1] - a[1])
|
|
175
|
+
.slice(0, 7)
|
|
176
|
+
.map(([name, value]) => ({ label: name, value, tone: FAMILY_TONE[name] ?? 'white' }));
|
|
177
|
+
|
|
178
|
+
const friction = [];
|
|
179
|
+
if (metrics.friction.blocks) friction.push({ label: 'blocked', value: metrics.friction.blocks, tone: 'orange' });
|
|
180
|
+
if (metrics.rework.errors) friction.push({ label: 'failed', value: metrics.rework.errors, tone: 'orange' });
|
|
181
|
+
|
|
182
|
+
const items = [...families, ...friction];
|
|
183
|
+
|
|
184
|
+
return card('Where the calls went', [
|
|
185
|
+
h('div.card__scroll', { style: 'display:grid;align-content:center' }, [
|
|
186
|
+
items.length ? capsules(items, { height: 170 }) : empty('No tool calls in this range'),
|
|
187
|
+
]),
|
|
188
|
+
legend([
|
|
189
|
+
{ tone: 'lime', label: 'cheap to run' },
|
|
190
|
+
{ tone: 'white', label: 'edits and commands' },
|
|
191
|
+
{ tone: 'orange', label: 'friction' },
|
|
192
|
+
], compact(metrics.tools.calls)),
|
|
193
|
+
]);
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
// ── 5. What to change ──────────────────────────────────────────────────────
|
|
197
|
+
|
|
198
|
+
function fixCard(findings, go) {
|
|
199
|
+
const live = findings.filter((finding) => !finding.muted).slice(0, 3);
|
|
200
|
+
|
|
201
|
+
return card('What to change', h('div.card__scroll', {}, live.length
|
|
202
|
+
? live.map((finding) => h('div.finding', {}, [
|
|
203
|
+
h('div.finding__head', {}, [
|
|
204
|
+
dot(finding.severity >= 3 ? 'orange' : finding.severity === 2 ? 'white' : 'lime'),
|
|
205
|
+
h('span.finding__title', { text: finding.title }),
|
|
206
|
+
chip(finding.pillar),
|
|
207
|
+
]),
|
|
208
|
+
h('div.finding__evidence', { text: finding.evidence }),
|
|
209
|
+
h('div.finding__fix', {}, [h('span', { text: finding.fix })]),
|
|
210
|
+
]))
|
|
211
|
+
: [empty('No rule found a pattern worth changing.')]), {
|
|
212
|
+
actions: h('button.btn.btn--ghost.btn--sm', { text: 'All habits', style: 'margin-left:auto', onclick: () => go('habits') }),
|
|
213
|
+
});
|
|
214
|
+
}
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Sessions — where the time actually went.
|
|
3
|
+
*
|
|
4
|
+
* The columns are chosen so the interesting row is visible without reading:
|
|
5
|
+
* a long session with few prompts and no edits is the one worth opening, and
|
|
6
|
+
* duration sits next to prompts and edits for exactly that reason.
|
|
7
|
+
*
|
|
8
|
+
* The drawer shows a readable timeline, never raw records. Anyone who wants the
|
|
9
|
+
* envelope with its hashes goes to the Ledger, where that is the point.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { api } from '../api.js';
|
|
13
|
+
import { h, card, figure, table, chip, dot, empty, drawer, kv, compact, duration, when, clock, DASH } from '../ui.js';
|
|
14
|
+
|
|
15
|
+
const MARK = {
|
|
16
|
+
'session.start': '▸', 'session.end': '◾', 'session.compact': '⊗',
|
|
17
|
+
'prompt.submit': '»', 'turn.stop': '·', 'turn.subagent_stop': '·', 'turn.notify': '!',
|
|
18
|
+
'tool.pre': '→', 'tool.post': '←', 'tool.blocked': '⏸',
|
|
19
|
+
'file.touch': '✎', 'git.commit': '⎇', 'coach.nudge': '★',
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
/** Records that exist for the metrics, not for a human reading a timeline. */
|
|
23
|
+
const NOISE = new Set(['usage.sample', 'tool.pre']);
|
|
24
|
+
|
|
25
|
+
export async function render({ range }) {
|
|
26
|
+
const sessions = await api.sessions(range);
|
|
27
|
+
if (!sessions.length) return card('Sessions', empty('No sessions in this range.'));
|
|
28
|
+
|
|
29
|
+
const totalMs = sessions.reduce((sum, session) => sum + session.durationMs, 0);
|
|
30
|
+
const rough = sessions.filter((session) => session.compacts > 0 || session.errors > 0).length;
|
|
31
|
+
|
|
32
|
+
const rows = sessions.map((session) => [
|
|
33
|
+
h('div', { style: 'display:flex;align-items:center;gap:10px' }, [
|
|
34
|
+
dot(health(session)),
|
|
35
|
+
h('span', { text: when(session.startedAt) }),
|
|
36
|
+
]),
|
|
37
|
+
session.projectName ?? DASH,
|
|
38
|
+
duration(session.durationMs),
|
|
39
|
+
String(session.prompts),
|
|
40
|
+
compact(session.toolCalls),
|
|
41
|
+
String(session.writes),
|
|
42
|
+
session.blocks ? String(session.blocks) : DASH,
|
|
43
|
+
compact(session.tokens),
|
|
44
|
+
]);
|
|
45
|
+
|
|
46
|
+
return h('div.content--fit', {
|
|
47
|
+
style: 'display:grid;grid-template-rows:auto minmax(0,1fr);gap:var(--s4);min-height:0',
|
|
48
|
+
}, [
|
|
49
|
+
h('div.grid.g-4', {}, [
|
|
50
|
+
card(null, figure('Sessions', sessions.length)),
|
|
51
|
+
card(null, figure('Time in session', duration(totalMs))),
|
|
52
|
+
card(null, figure('Needed attention', rough, { unit: rough === 1 ? 'session' : 'sessions' })),
|
|
53
|
+
card(null, figure('Longest', duration(Math.max(...sessions.map((session) => session.durationMs))))),
|
|
54
|
+
]),
|
|
55
|
+
|
|
56
|
+
card('Sessions', [
|
|
57
|
+
h('div.scroll-y', { style: 'flex:1' }, [table(
|
|
58
|
+
['Started', 'Project', 'Duration', 'Prompts', 'Tools', 'Edits', 'Blocked', 'Tokens'],
|
|
59
|
+
rows,
|
|
60
|
+
{
|
|
61
|
+
align: [null, null, 'right', 'right', 'right', 'right', 'right', 'right'],
|
|
62
|
+
onRow: (index) => openSession(sessions[index]),
|
|
63
|
+
},
|
|
64
|
+
)]),
|
|
65
|
+
h('div.legend', {}, [
|
|
66
|
+
h('span.legend__item', {}, [dot('lime'), h('span', { text: 'clean' })]),
|
|
67
|
+
h('span.legend__item', {}, [dot('orange'), h('span', { text: 'context ran out or tools failed' })]),
|
|
68
|
+
h('span.legend__total', { text: 'click a row for the timeline' }),
|
|
69
|
+
]),
|
|
70
|
+
], { note: `${sessions.length} in range`, className: 'fill' }),
|
|
71
|
+
]);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function health(session) {
|
|
75
|
+
return session.compacts > 0 || session.errors > 2 ? 'orange' : 'lime';
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
async function openSession(session) {
|
|
79
|
+
const body = h('div', {}, [h('div.skel', { style: 'height:220px' })]);
|
|
80
|
+
drawer(session.projectName ?? 'Session', `${when(session.startedAt)} · ${duration(session.durationMs)}`, body);
|
|
81
|
+
|
|
82
|
+
const { events } = await api.session(session.id);
|
|
83
|
+
const readable = events.filter((record) => !NOISE.has(record.kind));
|
|
84
|
+
|
|
85
|
+
body.replaceChildren(
|
|
86
|
+
h('div.grid.g-3', { style: 'gap:14px' }, [
|
|
87
|
+
figure('Prompts', session.prompts, { small: true }),
|
|
88
|
+
figure('Tool calls', compact(session.toolCalls), { small: true }),
|
|
89
|
+
figure('Files', session.files, { small: true }),
|
|
90
|
+
]),
|
|
91
|
+
h('div.kv', {}, [
|
|
92
|
+
kv('Model', session.models.join(', ') || DASH),
|
|
93
|
+
kv('Tokens', compact(session.tokens)),
|
|
94
|
+
kv('Permission stops', String(session.blocks)),
|
|
95
|
+
kv('Compactions', String(session.compacts)),
|
|
96
|
+
kv('Tool failures', String(session.errors)),
|
|
97
|
+
kv('Ended', session.ended ? session.reason : 'still open'),
|
|
98
|
+
]),
|
|
99
|
+
h('div', {}, [
|
|
100
|
+
h('h3.card__title', { style: 'margin-bottom:12px', text: `Timeline · ${readable.length} events` }),
|
|
101
|
+
h('div.tl', {}, readable.map((record) => h('div.tl__row', {}, [
|
|
102
|
+
h('span.tl__t', { text: clock(record.ts) }),
|
|
103
|
+
h('span.tl__x', { text: MARK[record.kind] ?? '·' }),
|
|
104
|
+
h('span.tl__d', { text: describe(record), title: describe(record) }),
|
|
105
|
+
]))),
|
|
106
|
+
]),
|
|
107
|
+
);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** One plain sentence per record. No JSON reaches this view. */
|
|
111
|
+
function describe(record) {
|
|
112
|
+
const data = record.data ?? {};
|
|
113
|
+
switch (record.kind) {
|
|
114
|
+
case 'session.start': return `Session started (${data.source})`;
|
|
115
|
+
case 'session.end': return `Session ended (${data.reason})`;
|
|
116
|
+
case 'session.compact': return data.trigger === 'auto' ? 'Context ran out and was summarised' : 'Context compacted by hand';
|
|
117
|
+
case 'prompt.submit': return data.text ?? `Prompt · ${data.words} words`;
|
|
118
|
+
case 'tool.post': return `${data.tool}${data.target ? ` · ${short(data.target)}` : ''}${data.failed ? ' · failed' : ''}`;
|
|
119
|
+
case 'tool.blocked': return `Waiting for permission · ${data.tool}`;
|
|
120
|
+
case 'file.touch': return `${data.op === 'write' ? 'Wrote' : 'Edited'} ${short(data.path)}${data.readFirst ? '' : ' · not read first'}`;
|
|
121
|
+
case 'git.commit': return `Commit · ${data.subject}`;
|
|
122
|
+
case 'turn.notify': return data.message ?? 'Notification';
|
|
123
|
+
case 'coach.nudge': return `Coach · ${data.evidence ?? data.rule}`;
|
|
124
|
+
case 'turn.stop': return 'Turn finished';
|
|
125
|
+
default: return record.kind.replace(/\./g, ' ');
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function short(path) {
|
|
130
|
+
if (typeof path !== 'string') return DASH;
|
|
131
|
+
const parts = path.split(/[\\/]/);
|
|
132
|
+
return parts.length > 2 ? `…/${parts.slice(-2).join('/')}` : path;
|
|
133
|
+
}
|