sloptimize 0.3.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/src/watch.mjs ADDED
@@ -0,0 +1,242 @@
1
+ // ============================================================
2
+ // watch.mjs — the push channel (SPEC §8.1.1): perf.jsonl → agent wake events
3
+ // ============================================================
4
+ // The recorder is already the auto-detector; this is the other half of
5
+ // "auto identifies bottleneck, sends signal to claude code, I just play".
6
+ // A byte cursor over each watched ledger, polled on an interval, printing
7
+ // ONE line per record an agent should act on — and nothing else, because
8
+ // every line is a conversation message to whoever armed the watch
9
+ // (Claude Code's Monitor primitive, INTEGRATION.md §5).
10
+ //
11
+ // Wakes: any usermark · an auto hitch ≥ minHitchMs (default 100) · a
12
+ // gpu-settle that hit its cap without settling · a warm run whose worst
13
+ // batch ≥ minHitchMs (with its per-batch builds) · any gpu-stall
14
+ // record · a coordinate jitter (a snap or an oscillation of the unit or the
15
+ // camera, SPEC §3.6) · the feed going quiet (> staleMin without a record)
16
+ // and coming back. Silent: heartbeats, arm-probes, sub-threshold hitches,
17
+ // a jitter whose verdict is long-frame-catch-up (the stall it rode already
18
+ // woke as a hitch) or one explained as the passenger of another track.
19
+ //
20
+ // Starts at EOF: the ledger's history is the report's business, not a wake
21
+ // storm at arm time. Cursor is in memory, per watcher — two sessions
22
+ // watching the same directory each see every record (USAGE.md "who gets
23
+ // told? all of them"); nothing on disk to race over.
24
+ import { existsSync, openSync, readSync, closeSync, fstatSync, readFileSync } from 'node:fs';
25
+ import { join } from 'node:path';
26
+ import { footprintOf } from './footprint.js';
27
+
28
+ const DEFAULTS = { minHitchMs: 100, staleMin: 45, now: () => Date.now() };
29
+
30
+ /** The line an agent is woken with for one record, or null when the record
31
+ * is not worth a wake. Pure; the classification vocabulary is the
32
+ * recorder's own, forwarded verbatim (SPEC §8.1.1: the schema is a PUSH
33
+ * CONTRACT, fields surface directly). */
34
+ export function wakeLine(rec, dir, opts = {}) {
35
+ // A robot's session is not an incident: headless verifies and preview
36
+ // health checks arm the recorder and post real-looking records, and a
37
+ // 16-minute cadence of them woke the agent all day (2026-08-28). The
38
+ // report still lists them; the wake channel is for a human's session.
39
+ if (rec.automated === true) return null;
40
+ const minHitchMs = opts.minHitchMs ?? DEFAULTS.minHitchMs;
41
+ const where = `[${dir}]`;
42
+ // The footprint (SPEC §3.7) and how many times this cause has been seen on
43
+ // this ledger — the watcher's count when it keeps one, else nothing: the
44
+ // number is a fact about the ledger, never a guess.
45
+ const fp = footprintOf(rec);
46
+ const seen = fp && opts.counts ? opts.counts.get(fp.id) : undefined;
47
+ const fpTxt = fp ? ` fp=${fp.id}${seen ? ` ×${seen}` : ''}` : '';
48
+ const ctx = [rec.phase && `phase=${rec.phase}`, rec.ctx && `ctx=${rec.ctx}`, rec.build && `build=${rec.build}`].filter(Boolean).join(' ') + fpTxt;
49
+ const cls = (c) => c?.[0] ? `${c[0].guess} (${c[0].evidence})` : 'unclassified';
50
+ switch (rec.type) {
51
+ case 'usermark': {
52
+ const w = rec.worstFrames?.[0];
53
+ return `sloptimize ★ usermark "${rec.note ?? 'Ctrl+F12'}" @ ${rec.at}: window ${rec.window?.frames}f median ${rec.window?.medianMs}ms; worst ${w?.frameMs}ms → ${cls(w?.classification)} ${ctx} ${where}`;
54
+ }
55
+ case 'hitch':
56
+ if (!(rec.frameMs >= minHitchMs)) return null;
57
+ return `sloptimize ⚡ hitch ${round(rec.frameMs)}ms (median ${rec.medianMs}ms, ${round(rec.insideRenderMs)}ms in render) @ ${rec.at} → ${cls(rec.classification)}${mints(rec)} ${ctx} ${where}`;
58
+ case 'gpu-settle':
59
+ // A settled wait is the verification channel (the freeze stayed behind
60
+ // the cover) — the report's business. Only a cap hit is an incident.
61
+ if (rec.settled) return null;
62
+ return `sloptimize ⏳ gpu-settle ${rec.tag} ${rec.ms}ms NOT settled (cap hit) @ ${rec.at} ${ctx} ${where}`;
63
+ case 'warm': {
64
+ // A warm run whose worst batch crossed the hitch bar: the sweep IS the
65
+ // freeze, and the per-batch builds say whether one build cost that or
66
+ // the batcher packed many into one task.
67
+ if (!(rec.worstBatchMs >= minHitchMs)) return null;
68
+ // A warm in a hidden/unfocused tab is not an incident: no frame was
69
+ // drawn around it, and its wall clock is the browser's throttling.
70
+ if (rec.hidden === true) return null;
71
+ const built = Array.isArray(rec.batchBuilt) && rec.batchBuilt.length ? ` builds/batch=[${rec.batchBuilt.join(',')}]` : '';
72
+ return `sloptimize 🔥 warm ${rec.tag} (${rec.kind}, budget ${rec.budgetMs ?? 'atomic'}ms): ${rec.keys} key(s) in ${rec.batches} batch(es), worst ${round(rec.worstBatchMs)}ms${built}${rec.costliest ? ` — ${rec.costliest}` : ''} @ ${rec.at} ${ctx} ${where}`;
73
+ }
74
+ case 'gpu-stall':
75
+ return `sloptimize ⏳ gpu-stall ${rec.queueDoneMs}ms @ ${rec.at} → ${cls(rec.classification)} ${ctx} ${where}`;
76
+ case 'jitter': {
77
+ // The unit/camera landed off its own trajectory (SPEC §3.6). Not an
78
+ // incident of its own when the stall it rode already woke (the catch-up
79
+ // verdict), nor when it merely followed the track it is attached to —
80
+ // that track's record wakes, and names it as coincident.
81
+ const g = rec.classification?.[0]?.guess;
82
+ if (g === 'long-frame-catch-up' || g === 'follows-track') return null;
83
+ const shape = rec.kind === 'oscillation'
84
+ ? `oscillation ×${rec.frames} over ${rec.durationMs}ms, amplitude ${rec.amplitude}`
85
+ : `snap ${rec.units} (jump [${(rec.jump ?? []).join(', ')}], expected ${rec.travelUnits} of travel at ${rec.speed}/s in a ${rec.dtMs}ms frame)`;
86
+ const with_ = Array.isArray(rec.coincident) && rec.coincident.length ? ` with=${rec.coincident.join(',')}` : '';
87
+ return `sloptimize ↯ jitter ${rec.track} ${shape}${with_} @ ${rec.at} → ${cls(rec.classification)} ${ctx} ${where}`;
88
+ }
89
+ default:
90
+ return null;
91
+ }
92
+ }
93
+
94
+ /** The mints a hitch carries, each with WHY it minted when the record says
95
+ * (`changed`: the cache-key parts that moved since that material's previous
96
+ * mint; `[]` = same key compiled again; absent = first mint). */
97
+ function mints(rec) {
98
+ if (!Array.isArray(rec.mints) || rec.mints.length === 0) return '';
99
+ const one = (m) => {
100
+ const why = Array.isArray(m.changed) ? (m.changed.length ? ` changed:${m.changed.join(',')}` : ' re-minted:same-key') : '';
101
+ return `${m.material}@${m.object}${why}`;
102
+ };
103
+ return ` mints=[${rec.mints.map(one).join('; ')}]`;
104
+ }
105
+
106
+ function round(n) { return typeof n === 'number' ? +n.toFixed(1) : n; }
107
+
108
+ /** One ledger's byte cursor. Reads only what was appended since last time,
109
+ * holds an unterminated tail until its newline lands (the sink appends a
110
+ * whole batch per post, but a poll can still land mid-write), and resets
111
+ * when the file shrinks (rotated or recreated). */
112
+ function ledgerCursor(path) {
113
+ // Positioned at CREATION, not at the first poll: anything appended between
114
+ // arming and the first tick is new and must wake. A ledger that does not
115
+ // exist yet starts at 0 so its first record wakes too.
116
+ let { size: offset, mtimeMs } = fileStat(path);
117
+ let tail = '';
118
+ return function readNew() {
119
+ if (!existsSync(path)) { offset = 0; mtimeMs = 0; tail = ''; return []; }
120
+ const fd = openSync(path, 'r');
121
+ try {
122
+ const st = fstatSync(fd);
123
+ const size = st.size;
124
+ // Shrunk: rotated or recreated. Same size but touched: rewritten — an
125
+ // append always grows the file, so this can only be a rewrite (a
126
+ // rotation that happened to land on the same byte count). Stated
127
+ // limit: a same-size rewrite inside the same mtime tick as the last
128
+ // read is invisible to stat — not a shape an append-only ledger has.
129
+ if (size < offset || (size === offset && offset > 0 && st.mtimeMs !== mtimeMs)) { offset = 0; tail = ''; }
130
+ mtimeMs = st.mtimeMs;
131
+ if (size === offset) return [];
132
+ const buf = Buffer.alloc(size - offset);
133
+ const n = readSync(fd, buf, 0, buf.length, offset);
134
+ offset += n;
135
+ const chunk = tail + buf.toString('utf8', 0, n);
136
+ const parts = chunk.split('\n');
137
+ tail = parts.pop();
138
+ return parts.filter(Boolean).map((l) => { try { return JSON.parse(l); } catch { return null; } }).filter(Boolean);
139
+ } finally { closeSync(fd); }
140
+ };
141
+ }
142
+
143
+ /** A watcher over one or more `.sloptimize` directories. `poll()` returns
144
+ * the wake lines since the previous poll — the CLI prints them, a test
145
+ * asserts on them. */
146
+ export function createWatcher(dirs, opts = {}) {
147
+ const o = { ...DEFAULTS, ...opts };
148
+ const ledgers = dirs.map((dir) => ({
149
+ dir,
150
+ read: ledgerCursor(join(dir, 'perf.jsonl')),
151
+ lastAt: 0, // ms of the newest record seen (any type)
152
+ dark: false,
153
+ // Occurrences per footprint on this ledger so far (SPEC §3.7): seeded from
154
+ // the file at arm, advanced per record — the `×N` on every wake line.
155
+ counts: footprintCounts(join(dir, 'perf.jsonl')),
156
+ }));
157
+ // Seed liveness from what is already on disk so a ledger that died before
158
+ // the watch was armed still reports quiet — once.
159
+ for (const l of ledgers) l.lastAt = lastRecordTime(join(l.dir, 'perf.jsonl'));
160
+
161
+ function poll() {
162
+ const lines = [];
163
+ for (const l of ledgers) {
164
+ let recs;
165
+ try { recs = l.read(); } catch { recs = []; } // a transient read error is not a wake
166
+ for (const r of recs) {
167
+ const t = Date.parse(r.at);
168
+ if (t > l.lastAt) l.lastAt = t;
169
+ const fp = footprintOf(r);
170
+ if (fp) l.counts.set(fp.id, (l.counts.get(fp.id) ?? 0) + 1);
171
+ const w = wakeLine(r, l.dir, { ...o, counts: l.counts });
172
+ if (w) lines.push(w);
173
+ }
174
+ if (l.lastAt > 0) {
175
+ const ageMin = (o.now() - l.lastAt) / 60_000;
176
+ if (ageMin > o.staleMin && !l.dark) {
177
+ l.dark = true;
178
+ lines.push(`sloptimize ◌ feed quiet ${Math.round(ageMin)}min [${l.dir}] — session over, or the feed went dark (ingest disarmed?)`);
179
+ } else if (ageMin <= o.staleMin && l.dark) {
180
+ l.dark = false;
181
+ lines.push(`sloptimize ◉ feed live again [${l.dir}]`);
182
+ }
183
+ }
184
+ }
185
+ return lines;
186
+ }
187
+ return { poll };
188
+ }
189
+
190
+ /** Occurrences per footprint id over a whole ledger — read once at arm. A
191
+ * ledger is megabytes at most; the count is what makes "this again" a
192
+ * number on the wake line instead of a feeling. */
193
+ function footprintCounts(path) {
194
+ const counts = new Map();
195
+ if (!existsSync(path)) return counts;
196
+ let text;
197
+ try { text = readFileSync(path, 'utf8'); } catch { return counts; }
198
+ for (const line of text.split('\n')) {
199
+ if (!line) continue;
200
+ let r; try { r = JSON.parse(line); } catch { continue; }
201
+ const fp = footprintOf(r);
202
+ if (fp) counts.set(fp.id, (counts.get(fp.id) ?? 0) + 1);
203
+ }
204
+ return counts;
205
+ }
206
+
207
+ function fileStat(path) {
208
+ if (!existsSync(path)) return { size: 0, mtimeMs: 0 };
209
+ const fd = openSync(path, 'r');
210
+ try { const st = fstatSync(fd); return { size: st.size, mtimeMs: st.mtimeMs }; } finally { closeSync(fd); }
211
+ }
212
+
213
+ /** Timestamp (ms) of the last parseable record in a ledger, 0 if none.
214
+ * Reads only the final few KB — the ledger can be megabytes. */
215
+ function lastRecordTime(path) {
216
+ if (!existsSync(path)) return 0;
217
+ const fd = openSync(path, 'r');
218
+ try {
219
+ const size = fstatSync(fd).size;
220
+ const span = Math.min(size, 64 * 1024);
221
+ const buf = Buffer.alloc(span);
222
+ readSync(fd, buf, 0, span, size - span);
223
+ const lines = buf.toString('utf8').split('\n').filter(Boolean);
224
+ for (let i = lines.length - 1; i >= 0; i--) {
225
+ try { const t = Date.parse(JSON.parse(lines[i]).at); if (t > 0) return t; } catch { /* partial or foreign line */ }
226
+ }
227
+ return 0;
228
+ } finally { closeSync(fd); }
229
+ }
230
+
231
+ /** The CLI loop: poll every `intervalMs`, print each wake line, never exit
232
+ * (Monitor semantics: exit ends the watch). Flushes per line — stdout is
233
+ * the event stream. */
234
+ export async function runWatch(dirs, opts = {}) {
235
+ const intervalMs = opts.intervalMs ?? 20_000;
236
+ const w = createWatcher(dirs, opts);
237
+ process.stdout.write(`sloptimize watch armed: ${dirs.join(', ')} (every ${Math.round(intervalMs / 1000)}s; hitches ≥${opts.minHitchMs ?? DEFAULTS.minHitchMs}ms, every usermark)\n`);
238
+ for (;;) {
239
+ for (const line of w.poll()) process.stdout.write(line + '\n');
240
+ await new Promise((r) => setTimeout(r, intervalMs));
241
+ }
242
+ }