sloptimize 0.3.0 → 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.
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "sloptimize",
3
3
  "displayName": "sloptimize",
4
- "version": "0.3.0",
4
+ "version": "0.4.0",
5
5
  "description": "The agent-native profiler for browser games: always-on incident recording, zero-setup attach with file:line attribution, budgets with exit codes — the agent's senses and ruler for performance work.",
6
6
  "license": "MIT",
7
7
  "keywords": ["profiler", "three.js", "webgpu", "performance", "draw-calls"]
package/README.md CHANGED
@@ -1,5 +1,7 @@
1
1
  # sloptimize
2
2
 
3
+ [![npm](https://img.shields.io/npm/v/sloptimize.svg)](https://www.npmjs.com/package/sloptimize)
4
+
3
5
  sloptimize optimizes your game's rendering performance by finding the
4
6
  bottlenecks and reporting them to Claude Code to fix — all while you just play
5
7
  the game. No action is required on your end.
@@ -64,12 +66,18 @@ and `sloptimize history` read that ledger back.
64
66
 
65
67
  ## Install
66
68
 
69
+ Published on npm as [`sloptimize`](https://www.npmjs.com/package/sloptimize).
70
+ Node 22+.
71
+
67
72
  ```bash
68
- npm i -D sloptimize # in your game repo
69
- # or one-off: npx sloptimize attach --launch http://localhost:3000
70
- # or from a checkout: node sloptimize/bin/sloptimize.mjs … (bare Node, no install)
73
+ npm i -D sloptimize # in your game repo (recommended)
74
+ npx sloptimize --version # confirm: prints the installed version
71
75
  ```
72
76
 
77
+ No install at all for a one-off: `npx sloptimize attach --launch http://localhost:3000`.
78
+ Working from a git checkout instead? `node /path/to/sloptimize/bin/sloptimize.mjs …`
79
+ runs on bare Node.
80
+
73
81
  Zero dependencies, no postinstall, no supply chain — npm is delivery only.
74
82
 
75
83
  ## Quickest start: zero integration (tier 0)
@@ -164,14 +172,17 @@ top where the engine grants scene access — see `docs/SPEC.md` §4.
164
172
 
165
173
  ## Claude Code integration — the whole point
166
174
 
167
- This repo IS a Claude Code plugin. One install:
175
+ The npm package IS a Claude Code plugin skill, prompt hook, and MCP server
176
+ ship inside it. After `npm i -D sloptimize`, point Claude at it:
168
177
 
169
178
  ```bash
170
- claude --plugin-dir node_modules/sloptimize # after npm i -D sloptimize
171
- claude --plugin-dir /path/to/sloptimize # from a checkout
172
- # or via marketplace: /plugin marketplace add m0dE/sloptimize && /plugin install sloptimize
179
+ claude --plugin-dir node_modules/sloptimize
173
180
  ```
174
181
 
182
+ Alternatives: `claude --plugin-dir /path/to/sloptimize` from a git checkout,
183
+ or via the marketplace:
184
+ `/plugin marketplace add m0dE/sloptimize` then `/plugin install sloptimize`.
185
+
175
186
  Then let the agent wire your game: `/sloptimize:install` walks it through
176
187
  the tier-1 integration (runtime, sink, budgets, hooks) and refuses to call
177
188
  itself done until the feed is proven live end-to-end.
@@ -192,6 +203,72 @@ prompt needed), arm `sloptimize watch` as a session Monitor — one line, in
192
203
  `docs/INTEGRATION.md` §5. Wire it into a `SessionStart` hook and every
193
204
  session arms it by itself.
194
205
 
206
+ ## Cloud (paid, invite-only)
207
+
208
+ Everything above is local: one machine's `.sloptimize/` directory, read by
209
+ that machine's shell and that machine's Claude Code session. sloptimize
210
+ cloud is a separate, optional, invite-only service that widens the same
211
+ catalogue to **every player, every build** — not just the one in front of
212
+ you: 24h/7d/30d or any custom range, client incidents and server incidents
213
+ (`sloptimize/node`) folded into the same footprint identity, plus uncaught
214
+ errors (`sloptimize/errors`) as their own incident kind. The local product
215
+ stays the default story — nothing below changes what a project with no
216
+ cloud key does.
217
+
218
+ Where the endpoint and key live: your project's settings page on the
219
+ service (there is no public hostname to document here — sloptimize cloud
220
+ is invite-only, and the endpoint you use is whatever that page shows you).
221
+ It hands you three snippets:
222
+
223
+ ```js
224
+ // browser: the cloud sink is a TEE beside your existing drain, never instead
225
+ // of it — errors ride the same recorder as hitches, so one drain feeds both
226
+ import { createRecorder, createErrorMonitor, createCloudSink } from 'sloptimize';
227
+ const rec = createRecorder({ budgetFrameMs: 16.7 });
228
+ createErrorMonitor(rec);
229
+ const cloud = createCloudSink({ key: '<publishable key from settings>', endpoint: '<endpoint from settings>', build });
230
+ // in the ~2s drain you already have (docs/INTEGRATION.md §1):
231
+ const batch = rec.drainRecords();
232
+ post('records', batch); // unchanged: .sloptimize/perf.jsonl, still the source of truth
233
+ cloud.enqueue(batch); // the same records, teed to the cloud sink's own queue
234
+ ```
235
+
236
+ (The `sources: [rec]` option exists only for a host with no file sink at all:
237
+ the sink drains those sources itself, so anything it takes never reaches your
238
+ own `drainRecords()`.)
239
+
240
+ ```js
241
+ // game server (Node): ticks, event-loop stalls, and uncaught errors
242
+ import { createServerRuntime } from 'sloptimize/node';
243
+ const server = createServerRuntime({ key: '<secret key from settings>', endpoint: '<endpoint from settings>', build });
244
+ ```
245
+
246
+ The server runtime registers `uncaughtExceptionMonitor` only, so it observes
247
+ a crash without ever becoming part of the crash path. One consequence worth
248
+ knowing: under `--unhandled-rejections=warn` or `none`, unhandled rejections
249
+ are **not** captured — that event sees them only in Node's default `throw`
250
+ mode, and listening to `unhandledRejection` instead would suppress the throw
251
+ your process relies on.
252
+
253
+ ```bash
254
+ # CLI: read the cloud catalogue instead of this machine's ledger
255
+ export SLOPTIMIZE_KEY=<secret key from settings> SLOPTIMIZE_ENDPOINT=<endpoint from settings>
256
+ npx sloptimize issues --cloud --preset 7d
257
+ npx sloptimize fix --title "…" --push # records locally, then pushes
258
+ ```
259
+
260
+ Honesty is the whole pitch: a dropped-locally count rides every batch the
261
+ sink sends, so the dashboard's numbers say what they could not see rather
262
+ than pretending nothing was lost.
263
+
264
+ Two kinds of key, and the difference matters. The **publishable** key is
265
+ public and write-only (it can post incidents, never read anyone else's), so
266
+ shipping it in a client bundle is the intended use, not a leak — that is the
267
+ key in the browser snippet above. The **secret** key is the one the server
268
+ runtime, the CLI (`SLOPTIMIZE_KEY`) and the MCP server use: it reads your
269
+ whole catalogue (`/v1/issues`) and writes fixes (`/v1/fixes`). A secret key
270
+ never goes in a client bundle.
271
+
195
272
  ## Budgets: "fast enough" as an exit code
196
273
 
197
274
  `.sloptimize/budgets.json` (the one file a human reviews):
@@ -88,8 +88,24 @@ if (cmd === 'issues') {
88
88
  // The issue catalogue (SPEC §3.7): every incident type grouped by
89
89
  // footprint, with occurrences, first/last, builds, worst, and the fixes
90
90
  // applied to it. `--from/--to` scope the count; `--all` includes robots.
91
- const { buildIssues, agoText } = await import('../src/history.js');
92
91
  const get = (flag) => { const i = args.indexOf(flag); return i >= 0 ? args[i + 1] : undefined; };
92
+ const { buildIssues, agoText } = await import('../src/history.js');
93
+ if (args.includes('--cloud')) {
94
+ // The cloud catalogue (SPEC cloud §8.4): every player, every build, not
95
+ // just this machine's ledger. Configuration is explicit — a missing key
96
+ // or endpoint is said, never guessed.
97
+ const { cloudConfig, fetchIssues } = await import('../src/cloud-client.js');
98
+ const cfg = cloudConfig(process.env, args);
99
+ if (!cfg) { console.error('sloptimize issues --cloud: set SLOPTIMIZE_KEY and SLOPTIMIZE_ENDPOINT (or --key/--endpoint)'); process.exit(2); }
100
+ let rows;
101
+ try { rows = await fetchIssues(cfg, { preset: get('--preset'), from: get('--from'), to: get('--to'), source: get('--source'), kind: get('--kind') }); }
102
+ catch (e) { console.error(`sloptimize issues --cloud: ${e.message}`); process.exit(4); }
103
+ if (json) { out(rows); process.exit(0); }
104
+ if (rows.length === 0) { console.log('no incidents in this range on the cloud catalogue'); process.exit(4); }
105
+ console.log(`cloud ${cfg.endpoint} · ${get('--preset') ?? (get('--from') ? 'custom' : '24h')} · ${rows.length} footprints`);
106
+ for (const i of rows) console.log(`${i.glyph} fp=${i.id} ×${String(i.count).padEnd(5)} ${i.label.padEnd(44)} [${i.phase}] ${i.source} last ${agoText(i.lastAgoMs).padEnd(8)} first ${i.first.slice(0, 16)} builds ${i.builds.length}${i.fixCount ? ` fixes ${i.fixCount}` : ''}`);
107
+ process.exit(0);
108
+ }
93
109
  const issues = buildIssues(readJsonl('perf.jsonl', Infinity), {
94
110
  fixes: readJsonl('fixes.jsonl', Infinity), from: get('--from'), to: get('--to'), includeAutomated: args.includes('--all'),
95
111
  });
@@ -161,6 +177,8 @@ if (cmd === 'doctor') {
161
177
  console.log(' stated limits: no per-draw GPU timing; bisection ranks, never sums; workload repro not trajectory repro;');
162
178
  console.log(' gpu:* instruments fire only under a real WebGPU backend — a WebGL2-fallback session reads them as zeros, honestly;');
163
179
  console.log(' bench/gate (M3) not built yet in this install — verify fixes with counters (exact grade) + real-hardware sessions.');
180
+ const cfg = (await import('../src/cloud-client.js')).cloudConfig(process.env, args);
181
+ console.log(cfg ? ` cloud: configured (${cfg.endpoint})` : ' cloud: not configured (SLOPTIMIZE_KEY, SLOPTIMIZE_ENDPOINT)');
164
182
  process.exit(0);
165
183
  }
166
184
 
@@ -300,6 +318,14 @@ if (cmd === 'history' || cmd === 'fix') {
300
318
  const { appendFileSync, mkdirSync } = await import('node:fs');
301
319
  mkdirSync(DIR, { recursive: true });
302
320
  appendFileSync(join(DIR, 'fixes.jsonl'), JSON.stringify(fix) + '\n');
321
+ if (args.includes('--push')) {
322
+ // The local ledger is the source of truth — a push failure is
323
+ // reported but never turns a recorded fix into a failed command.
324
+ const { cloudConfig, pushFix } = await import('../src/cloud-client.js');
325
+ const cfg = cloudConfig(process.env, args);
326
+ if (!cfg) console.error('push skipped: set SLOPTIMIZE_KEY and SLOPTIMIZE_ENDPOINT');
327
+ else { try { await pushFix(cfg, fix); console.log('pushed to cloud'); } catch (e) { console.error(`push failed: ${e.message}`); } }
328
+ }
303
329
  out(fix, `fix recorded: ${fix.title}${fix.commit ? ` (${fix.commit})` : ''}\n before ${fix.before.build ?? fix.before.from}: ${line(fix.before)}\n after ${fix.after.build ?? fix.after.from}: ${line(fix.after)}`);
304
330
  process.exit(0);
305
331
  }
@@ -342,5 +368,5 @@ if (cmd === 'attach') {
342
368
  await new Promise(() => {});
343
369
  }
344
370
 
345
- console.log('usage: sloptimize <report|issues|check|census|history|fix|doctor|hook-status|watch|attach> [--json] [--dir <path>]... [--counters-only] [--interval <s>] [--min-hitch-ms N] [--launch <url>] [--port N] [--headless]\n sloptimize fix --title "…" [--issue "…"] [--solution "…"] [--commit sha] [--files a,b] [--footprints id,id] [--before <build|ISO..ISO>] [--after <build|ISO..ISO>]\n sloptimize issues [--json] [--from ISO] [--to ISO] [--fp <id>] [--all]');
371
+ console.log('usage: sloptimize <report|issues|check|census|history|fix|doctor|hook-status|watch|attach> [--json] [--dir <path>]... [--counters-only] [--interval <s>] [--min-hitch-ms N] [--launch <url>] [--port N] [--headless]\n sloptimize fix --title "…" [--issue "…"] [--solution "…"] [--commit sha] [--files a,b] [--footprints id,id] [--before <build|ISO..ISO>] [--after <build|ISO..ISO>] [--push]\n sloptimize issues [--json] [--from ISO] [--to ISO] [--fp <id>] [--all] [--cloud [--preset 24h|7d|30d] [--source s] [--kind k] [--key k] [--endpoint url]]');
346
372
  process.exit(2);
@@ -164,6 +164,61 @@ panel.open();
164
164
  Flush cadence: post `profile` every ~2s, drain records with it.
165
165
  Gitignore `.sloptimize/*` except `budgets.json`.
166
166
 
167
+ ### Cloud sink (optional, paid, invite-only)
168
+
169
+ The dev-endpoint sink above stays the default: it is what makes the files
170
+ useful with nobody watching. sloptimize cloud is a separate, additive tee —
171
+ never a replacement — that ships the same records to a service so the
172
+ catalogue spans every player and build, not just this machine. Run it
173
+ BESIDE the local `post()`, never instead of it:
174
+
175
+ ```js
176
+ import { createCloudSink } from 'sloptimize/cloud';
177
+ const cloud = createCloudSink({ key: '<publishable key>', endpoint: '<endpoint>', build }); // publishable: safe in the client bundle
178
+ // wherever the local sink drains and posts:
179
+ const batch = [...rec.drainRecords(), ...motion.drainRecords()];
180
+ post('records', batch); // unchanged: the local ledger, still the source of truth
181
+ cloud.enqueue(batch); // same records, teed to the cloud sink's own queue and flush timer
182
+ ```
183
+
184
+ `enqueue(records)` just appends to the sink's internal queue (capped at
185
+ `maxQueue`, oldest dropped and counted honestly) — it does not fetch or
186
+ flush itself; the sink's own timer (and `pagehide`/`visibilitychange`) drain
187
+ and post it on the usual backoff. Pair it with `createErrorMonitor(rec)` —
188
+ the SAME recorder from §1, not a bare call — so uncaught client errors land
189
+ in `rec` via `recorder.emit()` and ride `rec.drainRecords()` into `batch`
190
+ above like any hitch, with no separate wiring to the cloud sink needed.
191
+
192
+ ### Game server (optional, paid, invite-only)
193
+
194
+ The server side of the same catalogue: ticks that overran their budget,
195
+ event-loop stalls the runtime itself measured, and uncaught errors — each
196
+ attributed by a sampling profiler and shipped through the same cloud sink
197
+ the browser uses.
198
+
199
+ ```js
200
+ import { createServerRuntime } from 'sloptimize/node';
201
+ const server = createServerRuntime({ key: '<secret key>', endpoint: '<endpoint>', build, tickBudgetMs: 16 });
202
+
203
+ function gameLoop() {
204
+ server.tick(() => { // wraps one tick; records a server-hitch if it overran tickBudgetMs
205
+ // … the game's own tick work …
206
+ });
207
+ }
208
+ ```
209
+
210
+ The key here is a SECRET key (`server-*` record types and the read routes
211
+ are secret-only) — it never goes in a client bundle.
212
+
213
+ `createServerRuntime` registers `uncaughtExceptionMonitor` only (never
214
+ `uncaughtException`/`unhandledRejection`) — it observes a crash, it never
215
+ becomes part of the crash path. That also means that under
216
+ `--unhandled-rejections=warn` or `none`, unhandled rejections are NOT
217
+ captured: `uncaughtExceptionMonitor` sees them only in Node's default
218
+ `throw` mode. Call `await server.close()` on shutdown to
219
+ flush the queue; a `beforeExit` hook already races a best-effort flush so a
220
+ clean exit does not lose the last batch.
221
+
167
222
  ## 3. The CLI (the agent's shell surface)
168
223
 
169
224
  `sloptimize report|check|census|doctor --dir <game>/.sloptimize` — no
package/docs/SPEC.md CHANGED
@@ -359,7 +359,8 @@ fixes applied to it, or the exact command that would record one.
359
359
 
360
360
  Cloud path: the footprint is computed by the writer, so a service that
361
361
  ingests many clients' records dedupes on `footprint.id` from day one; the
362
- fold is the same code.
362
+ fold is the same code. The service is specified in the sloptimize-cloud repo
363
+ (`docs/superpowers/specs/2026-09-02-sloptimize-cloud-design.md`).
363
364
 
364
365
  ---
365
366
 
package/docs/USAGE.md CHANGED
@@ -160,6 +160,31 @@ npx sloptimize history --dir <game>/.sloptimize # p95/calls/hitches over time,
160
160
  npx sloptimize doctor --dir <game>/.sloptimize # what is wired/degraded
161
161
  ```
162
162
 
163
+ ## The cloud catalogue (optional, paid, invite-only)
164
+
165
+ With a project on sloptimize cloud, `SLOPTIMIZE_KEY` and `SLOPTIMIZE_ENDPOINT`
166
+ (or `--key`/`--endpoint`) point the same CLI at every player's ledger
167
+ instead of just this machine's:
168
+
169
+ ```bash
170
+ export SLOPTIMIZE_KEY=<secret key from the settings page> # secret, not publishable: /v1/issues is secret-only
171
+ export SLOPTIMIZE_ENDPOINT=<endpoint from the settings page>
172
+ npx sloptimize issues --cloud # last 24h across every player/build, by default
173
+ npx sloptimize issues --cloud --preset 7d # or 30d
174
+ npx sloptimize issues --cloud --from <ISO> --to <ISO> --source client --kind hitch
175
+ ```
176
+
177
+ Unconfigured, it exits 2 and names the missing variable rather than
178
+ silently falling back to the local ledger; a request that fails once
179
+ configured (bad key, unreachable endpoint) exits 4. `sloptimize doctor`
180
+ reports which state you're in (`cloud: configured (<endpoint>)` or
181
+ `cloud: not configured (...)`).
182
+
183
+ `sloptimize fix --push` records the fix locally exactly as `sloptimize fix`
184
+ always has — the local ledger stays the source of truth — and then also
185
+ POSTs it to the cloud service; a push failure prints `push failed: <reason>`
186
+ but the command still exits 0, since the fix was recorded either way.
187
+
163
188
  ## Recording a fix (the agent's last step)
164
189
 
165
190
  Once a fix is verified — the new build is live and the ledger has evidence
package/mcp/server.mjs CHANGED
@@ -13,9 +13,12 @@
13
13
  // wake is not a contract this host documents; when it becomes one, the
14
14
  // watcher moves here (SPEC-attach §2, delivery edge).
15
15
  import { readFileSync, existsSync } from 'node:fs';
16
- import { join } from 'node:path';
16
+ import { join, dirname } from 'node:path';
17
+ import { fileURLToPath } from 'node:url';
17
18
  import { createInterface } from 'node:readline';
18
19
 
20
+ const PKG_VERSION = JSON.parse(readFileSync(join(dirname(fileURLToPath(import.meta.url)), '..', 'package.json'), 'utf8')).version;
21
+
19
22
  const DIR = () => join(process.cwd(), '.sloptimize');
20
23
  let attachSession = null;
21
24
 
@@ -27,7 +30,9 @@ const TOOLS = [
27
30
  { name: 'get_history', description: 'The deployment’s timeline folded from perf.jsonl: time buckets (frame p95, draw calls, hitch spikes, build), one measured window per build, and the fix ledger (fixes.jsonl) — the before/after evidence behind every recorded fix.',
28
31
  inputSchema: { type: 'object', properties: { buckets: { type: 'number', description: 'time slices (default 24)' } } } },
29
32
  { name: 'get_issues', description: 'The issue catalogue (SPEC §3.7): every incident type on the ledger grouped by FOOTPRINT — the identity of a cause (type, phase, verdict, site, the game’s situation), never its time — with occurrences, first/last seen, builds, worst, the last verdict, and the fixes applied to it. Read this before proposing a fix: an issue with a fix already recorded is not new.',
30
- inputSchema: { type: 'object', properties: { fp: { type: 'string', description: 'one footprint id' }, from: { type: 'string', description: 'ISO lower bound' }, to: { type: 'string', description: 'ISO upper bound' }, includeAutomated: { type: 'boolean', description: 'count robots’ sessions too (default false)' }, limit: { type: 'number', description: 'max rows (default 50)' } } } },
33
+ inputSchema: { type: 'object', properties: { fp: { type: 'string', description: 'one footprint id' }, from: { type: 'string', description: 'ISO lower bound' }, to: { type: 'string', description: 'ISO upper bound' }, includeAutomated: { type: 'boolean', description: 'count robots’ sessions too (default false)' }, limit: { type: 'number', description: 'max rows (default 50)' },
34
+ cloud: { type: 'boolean', description: 'read the cloud catalogue (every player, every build) instead of this machine\'s ledger — requires SLOPTIMIZE_KEY/SLOPTIMIZE_ENDPOINT' },
35
+ preset: { type: 'string', description: 'cloud only: 24h | 7d | 30d' }, source: { type: 'string', description: 'cloud only: filter by source (client|server)' }, kind: { type: 'string', description: 'cloud only: filter by incident kind' } } } },
31
36
  { name: 'record_fix', description: 'Append a fix report to .sloptimize/fixes.jsonl: title, issue, solution, commit, the FOOTPRINTS it addresses (from get_issues — this is how the Issues tab shows which fixes were applied to an issue), and MEASURED before/after windows of the ledger (default: the previous build vs the latest build with evidence; or name a build / an <ISO>..<ISO> range). Call this after verifying a perf fix — never with numbers of your own.',
32
37
  inputSchema: { type: 'object', properties: { title: { type: 'string' }, issue: { type: 'string' }, solution: { type: 'string' }, commit: { type: 'string' },
33
38
  files: { type: 'array', items: { type: 'string' } }, footprints: { type: 'array', items: { type: 'string' }, description: 'footprint ids this fix addresses' },
@@ -69,6 +74,15 @@ async function callTool(name, args = {}) {
69
74
  return buildHistory(readJsonl('perf.jsonl', Infinity), { fixes: readJsonl('fixes.jsonl', Infinity), buckets: args.buckets ?? 24 });
70
75
  }
71
76
  if (name === 'get_issues') {
77
+ if (args.cloud === true) {
78
+ const { cloudConfig, fetchIssues } = await import('../src/cloud-client.js');
79
+ const cfg = cloudConfig(process.env, []);
80
+ if (!cfg) return { error: 'cloud not configured: set SLOPTIMIZE_KEY and SLOPTIMIZE_ENDPOINT' };
81
+ try {
82
+ const rows = await fetchIssues(cfg, { preset: args.preset, from: args.from, to: args.to, source: args.source, kind: args.kind });
83
+ return { source: 'cloud', endpoint: cfg.endpoint, footprints: rows.length, occurrences: rows.reduce((n, i) => n + i.count, 0), issues: args.fp ? rows.filter((i) => i.id === args.fp) : rows.slice(0, args.limit ?? 50) };
84
+ } catch (e) { return { error: e.message }; }
85
+ }
72
86
  const { buildIssues } = await import('../src/history.js');
73
87
  const issues = buildIssues(readJsonl('perf.jsonl', Infinity), { fixes: readJsonl('fixes.jsonl', Infinity), from: args.from, to: args.to, includeAutomated: args.includeAutomated === true });
74
88
  const rows = args.fp ? issues.filter((i) => i.id === args.fp) : issues.slice(0, args.limit ?? 50);
@@ -111,7 +125,7 @@ rl.on('line', (line) => {
111
125
  try {
112
126
  if (msg.method === 'initialize') {
113
127
  reply(msg.id, { protocolVersion: msg.params?.protocolVersion ?? '2024-11-05',
114
- capabilities: { tools: {} }, serverInfo: { name: 'sloptimize', version: '0.3.0' } });
128
+ capabilities: { tools: {} }, serverInfo: { name: 'sloptimize', version: PKG_VERSION } });
115
129
  } else if (msg.method === 'tools/list') {
116
130
  reply(msg.id, { tools: TOOLS });
117
131
  } else if (msg.method === 'tools/call') {
package/package.json CHANGED
@@ -1,11 +1,11 @@
1
1
  {
2
2
  "name": "sloptimize",
3
- "version": "0.3.0",
3
+ "version": "0.4.0",
4
4
  "type": "module",
5
5
  "description": "The agent-native profiler for three.js games: an always-on flight recorder, per-entity cost attribution, and a deterministic bench — so a coding agent can measure, attribute, and verify instead of guessing.",
6
6
  "license": "MIT",
7
7
  "engines": {
8
- "node": ">=18"
8
+ "node": ">=22"
9
9
  },
10
10
  "repository": {
11
11
  "type": "git",
@@ -21,7 +21,10 @@
21
21
  "exports": {
22
22
  ".": "./src/index.js",
23
23
  "./history": "./src/history.js",
24
- "./panel": "./src/panel.js"
24
+ "./panel": "./src/panel.js",
25
+ "./cloud": "./src/cloud-sink.js",
26
+ "./errors": "./src/errors.js",
27
+ "./node": "./src/node/index.js"
25
28
  },
26
29
  "files": [
27
30
  "bin",
@@ -29,7 +32,7 @@
29
32
  "skills",
30
33
  "hooks",
31
34
  "mcp",
32
- "docs",
35
+ "docs/*.md",
33
36
  ".claude-plugin",
34
37
  ".mcp.json",
35
38
  "LICENSE",
@@ -16,7 +16,8 @@ not report success until step 7 shows your test keyframe in
16
16
 
17
17
  ## 1. Locate the package and the game's shape
18
18
 
19
- - Find sloptimize: `node_modules/sloptimize` (npm) or a sibling checkout.
19
+ - Find sloptimize: `node_modules/sloptimize` (from `npm i -D sloptimize`) or a
20
+ sibling checkout. If neither exists, install it from npm first.
20
21
  - Identify: the render loop's stats site (wherever `renderer.info` is
21
22
  already read), the dev-server topology (vite? own server? esbuild?), and
22
23
  the dev-only switch the project already uses for debug endpoints.
@@ -0,0 +1,29 @@
1
+ // ============================================================
2
+ // cloud-client.js — read side of sloptimize cloud (SPEC cloud §8.4)
3
+ // ============================================================
4
+ // Read side of sloptimize cloud for the CLI and MCP: the catalogue over every
5
+ // player, not just this machine's ledger. Configuration is explicit — a key
6
+ // and an endpoint — and a missing one is said, never guessed.
7
+ export function cloudConfig(env = process.env, args = []) {
8
+ const flag = (f) => { const i = args.indexOf(f); return i >= 0 ? args[i + 1] : undefined; };
9
+ const key = flag('--key') ?? env.SLOPTIMIZE_KEY;
10
+ const endpoint = (flag('--endpoint') ?? env.SLOPTIMIZE_ENDPOINT ?? '').replace(/\/+$/, '');
11
+ return key && endpoint ? { key, endpoint } : null;
12
+ }
13
+
14
+ export async function fetchIssues(cfg, q = {}, fetchImpl = globalThis.fetch, now = Date.now) {
15
+ const u = new URL(`${cfg.endpoint}/v1/issues`);
16
+ for (const k of ['preset', 'from', 'to', 'source', 'kind']) if (q[k]) u.searchParams.set(k, q[k]);
17
+ const res = await fetchImpl(u.toString(), { headers: { authorization: `Bearer ${cfg.key}` } });
18
+ if (!res.ok) throw new Error(`cloud ${res.status}: ${(await res.json().catch(() => ({}))).error ?? 'request failed'}`);
19
+ const rows = await res.json();
20
+ const t = now();
21
+ return rows.map((r) => ({ id: r.id, key: r.key, type: r.kind, glyph: r.glyph, label: r.label, phase: r.phase, ctx: r.ctx, source: r.source,
22
+ count: r.count, first: r.firstSeen, last: r.lastSeen, lastAgoMs: Math.max(0, t - Date.parse(r.lastSeen)), builds: r.builds ?? [], fixCount: r.fixes ?? 0, fixes: [], daily: r.daily ?? [], exact: r.exact }));
23
+ }
24
+
25
+ export async function pushFix(cfg, fix, fetchImpl = globalThis.fetch) {
26
+ const res = await fetchImpl(`${cfg.endpoint}/v1/fixes`, { method: 'POST', headers: { authorization: `Bearer ${cfg.key}`, 'content-type': 'application/json' }, body: JSON.stringify(fix) });
27
+ if (!res.ok) throw new Error(`cloud ${res.status}: ${(await res.json().catch(() => ({}))).error ?? 'request failed'}`);
28
+ return res.json();
29
+ }
@@ -0,0 +1,159 @@
1
+ // ============================================================
2
+ // cloud-sink.js — ship records to sloptimize cloud (SPEC cloud §8.2)
3
+ // ============================================================
4
+ // Runs BESIDE the file sink, never instead of it. Drains every source on a
5
+ // timer, posts batches with the publishable key, backs off on 429/5xx, caps
6
+ // its queue, and tells the service how many it had to drop locally so the
7
+ // dashboard's "dropped" column is honest. Never throws into the host.
8
+ const BACKOFF_MS = [5000, 30000, 120000, 300000];
9
+
10
+ export function createCloudSink(opts = {}) {
11
+ if (!opts.key) throw new Error('createCloudSink: key is required');
12
+ if (!opts.endpoint) throw new Error('createCloudSink: endpoint is required');
13
+ const { key, endpoint, build } = opts;
14
+ const sources = opts.sources ?? [];
15
+ const flushMs = opts.flushMs ?? 5000, maxBatch = opts.maxBatch ?? 100, maxQueue = opts.maxQueue ?? 500;
16
+ // The Fetch spec caps a keepalive body at 64 KiB and sendBeacon has a limit
17
+ // of its own, so a batch is bounded by bytes as well as by count.
18
+ const maxBatchBytes = opts.maxBatchBytes ?? 60 * 1024;
19
+ const fetchImpl = opts.fetch ?? globalThis.fetch;
20
+ const beacon = opts.sendBeacon ?? (typeof navigator !== 'undefined' && navigator.sendBeacon ? navigator.sendBeacon.bind(navigator) : null);
21
+ const target = opts.target ?? globalThis;
22
+ const setI = opts.setInterval ?? globalThis.setInterval, clearI = opts.clearInterval ?? globalThis.clearInterval;
23
+ const now = opts.now ?? (() => Date.now());
24
+
25
+ let queue = [];
26
+ let droppedLocally = 0;
27
+ let failures = 0, backoffUntil = 0, inflight = false;
28
+ const stats = { sent: 0, lastError: null, lastStatus: null };
29
+
30
+ function trim() {
31
+ if (queue.length > maxQueue) { droppedLocally += queue.length - maxQueue; queue = queue.slice(queue.length - maxQueue); }
32
+ }
33
+ function drain() {
34
+ for (const s of sources) {
35
+ let r; try { r = s.drainRecords(); } catch { continue; }
36
+ if (r && r.length) queue.push(...r);
37
+ }
38
+ trim();
39
+ }
40
+ function body(records) {
41
+ const b = { records };
42
+ if (build) b.build = build;
43
+ if (droppedLocally) b.droppedLocally = droppedLocally;
44
+ return b;
45
+ }
46
+ const encoder = typeof TextEncoder !== 'undefined' ? new TextEncoder() : null;
47
+ const byteLen = (s) => (encoder ? encoder.encode(s).length : s.length);
48
+ /** How many of the queue's leading records fit in one body under
49
+ * `maxBatchBytes`. A single record too large to ever fit is DROPPED and
50
+ * counted, never retried: re-prepending it would wedge the sink forever,
51
+ * and a silent wedge is the one failure this sink must not have. */
52
+ function fitCount() {
53
+ let envelope = byteLen(JSON.stringify(body([])));
54
+ let total = envelope, n = 0;
55
+ while (n < queue.length && n < maxBatch) {
56
+ const size = byteLen(JSON.stringify(queue[n])) + (n > 0 ? 1 : 0); // +1 for the comma
57
+ if (total + size > maxBatchBytes) {
58
+ if (n > 0) break;
59
+ queue.shift();
60
+ droppedLocally++;
61
+ stats.lastError = `record dropped: ${size} bytes over maxBatchBytes (${maxBatchBytes})`;
62
+ envelope = byteLen(JSON.stringify(body([]))); // droppedLocally just grew
63
+ total = envelope;
64
+ continue;
65
+ }
66
+ total += size; n++;
67
+ }
68
+ return n;
69
+ }
70
+ async function flush() {
71
+ drain();
72
+ if (inflight || queue.length === 0 || now() < backoffUntil) return;
73
+ inflight = true;
74
+ // Remove the batch from the queue BEFORE sending, not after the await:
75
+ // otherwise a concurrent onHide()/enqueue() during the in-flight request
76
+ // sees records that are already (or about to be) accounted for elsewhere,
77
+ // causing duplicate delivery or silently corrupting the queue.
78
+ const errBefore = stats.lastError;
79
+ const n = fitCount();
80
+ // A success clears transport errors, but must not erase the report of a
81
+ // record this sink itself had to throw away in the same pass.
82
+ const droppedThisPass = stats.lastError !== errBefore;
83
+ if (n === 0) { inflight = false; return; } // everything queued was oversized
84
+ const batch = queue.splice(0, n);
85
+ const sentDropped = droppedLocally;
86
+ try {
87
+ // No `keepalive` here: it caps the body at 64 KiB in browsers, and the
88
+ // unload path already uses sendBeacon. This is the periodic flush.
89
+ const res = await fetchImpl(endpoint, {
90
+ method: 'POST',
91
+ headers: { 'content-type': 'application/json', authorization: `Bearer ${key}` },
92
+ body: JSON.stringify(body(batch)),
93
+ });
94
+ stats.lastStatus = res.status;
95
+ if (res.ok) {
96
+ droppedLocally = Math.max(0, droppedLocally - sentDropped);
97
+ failures = 0; backoffUntil = 0; stats.sent += batch.length; if (!droppedThisPass) stats.lastError = null;
98
+ } else if (res.status === 429 || res.status >= 500) {
99
+ // Retryable: put the batch back at the front (it's the oldest data)
100
+ // and re-apply the cap, counting any resulting drops.
101
+ queue = batch.concat(queue);
102
+ trim();
103
+ const ra = Number(res.headers?.get?.('retry-after'));
104
+ const wait = BACKOFF_MS[Math.min(failures, BACKOFF_MS.length - 1)];
105
+ backoffUntil = now() + (Number.isFinite(ra) && ra > 0 ? Math.max(ra * 1000, wait) : wait);
106
+ failures++;
107
+ stats.lastError = `HTTP ${res.status}`;
108
+ } else {
109
+ // 4xx other than 429: the batch is unacceptable and already out of
110
+ // the queue (spliced above); drop it rather than retry forever.
111
+ droppedLocally += batch.length;
112
+ stats.lastError = `HTTP ${res.status}`;
113
+ }
114
+ } catch (e) {
115
+ // Network failure: the batch never left, so put it back and retry later.
116
+ queue = batch.concat(queue);
117
+ trim();
118
+ stats.lastError = e?.message ?? String(e);
119
+ const wait = BACKOFF_MS[Math.min(failures, BACKOFF_MS.length - 1)];
120
+ backoffUntil = now() + wait; failures++;
121
+ } finally { inflight = false; }
122
+ }
123
+ function onHide() {
124
+ try {
125
+ drain();
126
+ if (queue.length === 0 || !beacon) return;
127
+ // Any batch currently in flight via flush() was already spliced out of
128
+ // `queue`, so what's here is guaranteed disjoint from it — no duplicate
129
+ // delivery risk.
130
+ const n = fitCount();
131
+ if (n === 0) return;
132
+ const batch = queue.slice(0, n);
133
+ const beaconedDropped = droppedLocally;
134
+ const ok = beacon(`${endpoint}?key=${encodeURIComponent(key)}`, new Blob([JSON.stringify(body(batch))], { type: 'application/json' }));
135
+ if (ok) { queue = queue.slice(batch.length); droppedLocally = Math.max(0, droppedLocally - beaconedDropped); stats.sent += batch.length; }
136
+ } catch { /* never throw into the host */ }
137
+ }
138
+ const timer = setI(() => { flush(); }, flushMs);
139
+ // Never keep a game server's (or any Node host's) event loop alive just
140
+ // to poll for records — this sink runs beside the host's own liveness,
141
+ // not instead of it. A browser's setInterval returns a number, so the
142
+ // optional chaining below is a no-op there.
143
+ timer?.unref?.();
144
+ target.addEventListener?.('pagehide', onHide);
145
+ const onVis = () => { if (typeof document !== 'undefined' && document.visibilityState === 'hidden') onHide(); };
146
+ target.addEventListener?.('visibilitychange', onVis);
147
+ return {
148
+ flush,
149
+ enqueue(records) {
150
+ // A host tee that hands over something other than an array is a wiring
151
+ // bug in the host, not a reason to throw into its drain loop.
152
+ if (!Array.isArray(records)) { stats.lastError = 'enqueue: expected an array of records'; return; }
153
+ if (records.length) queue.push(...records);
154
+ trim();
155
+ },
156
+ stats() { return { queued: queue.length, sent: stats.sent, droppedLocally, backoffUntil, lastError: stats.lastError, lastStatus: stats.lastStatus }; },
157
+ dispose() { clearI(timer); target.removeEventListener?.('pagehide', onHide); target.removeEventListener?.('visibilitychange', onVis); },
158
+ };
159
+ }
package/src/errors.js ADDED
@@ -0,0 +1,67 @@
1
+ // ============================================================
2
+ // errors.js — the browser's errors as incidents (SPEC cloud §8.1)
3
+ // ============================================================
4
+ // An uncaught error is a bottleneck of a different kind: the frame it killed
5
+ // never rendered. It is recorded with the same identity model as a hitch —
6
+ // the footprint names the cause (class, normalized message, top frame),
7
+ // never the occurrence — so a thousand players hitting one bug is one row.
8
+ // The monitor NEVER preventDefault()s: the console and the host's own
9
+ // handlers see exactly what they saw before.
10
+ import { footprintOf } from './footprint.js';
11
+
12
+ export function createErrorMonitor(recorder, opts = {}) {
13
+ const target = opts.target ?? globalThis;
14
+ const dedupeMs = opts.dedupeMs ?? 10000;
15
+ const maxFrames = opts.maxFrames ?? 10;
16
+ const now = opts.now ?? (() => Date.now());
17
+ const lastByFp = new Map();
18
+ const stats = { seen: 0, emitted: 0, deduped: 0 };
19
+
20
+ // V8 frames start with "at "; SpiderMonkey/JavaScriptCore frames are
21
+ // "fn@url:line:col" (or "@url:line:col" when anonymous). Keeping only the
22
+ // V8 shape gave every Firefox and Safari player an empty stack — and a
23
+ // second catalogue row for the same bug.
24
+ const FRAME_RE = /^at |^[^\s]*@.+:\d+/;
25
+ function frames(stack) {
26
+ return String(stack ?? '').split('\n').map((l) => l.trim()).filter((l) => FRAME_RE.test(l)).slice(0, maxFrames);
27
+ }
28
+
29
+ /** A rejection reason that is not an Error, said as plainly as it can be
30
+ * said. JSON.stringify throws on a circular object (and on a throwing
31
+ * toJSON); an uncaught throw here would lose the incident entirely. */
32
+ function reasonMessage(r) {
33
+ if (typeof r === 'string') return r;
34
+ try { const j = JSON.stringify(r ?? null); if (j !== undefined) return j; } catch { /* circular / throwing toJSON */ }
35
+ try { return String(r); } catch { return '[unstringifiable]'; }
36
+ }
37
+ function toRecord(name, message, stack) {
38
+ return { type: 'error', at: new Date().toISOString(), source: 'client', name, message: String(message ?? ''), stack: frames(stack) };
39
+ }
40
+ function handle(rec) {
41
+ stats.seen++;
42
+ const fp = footprintOf(rec);
43
+ const t = now();
44
+ const last = fp ? lastByFp.get(fp.id) : undefined;
45
+ if (last !== undefined && t - last < dedupeMs) { stats.deduped++; return; }
46
+ if (fp) lastByFp.set(fp.id, t);
47
+ if (recorder.emit(rec)) stats.emitted++;
48
+ }
49
+ const onError = (ev) => {
50
+ try {
51
+ const e = ev?.error;
52
+ handle(e instanceof Error ? toRecord(e.name || 'Error', e.message, e.stack) : toRecord('Error', ev?.message ?? String(e ?? ''), ''));
53
+ } catch { /* never throw into the host */ }
54
+ };
55
+ const onRejection = (ev) => {
56
+ try {
57
+ const r = ev?.reason;
58
+ handle(r instanceof Error ? toRecord(r.name || 'Error', r.message, r.stack) : toRecord('UnhandledRejection', reasonMessage(r), ''));
59
+ } catch { /* never throw into the host */ }
60
+ };
61
+ target.addEventListener('error', onError);
62
+ target.addEventListener('unhandledrejection', onRejection);
63
+ return {
64
+ dispose() { target.removeEventListener('error', onError); target.removeEventListener('unhandledrejection', onRejection); },
65
+ stats() { return { ...stats }; },
66
+ };
67
+ }
package/src/footprint.js CHANGED
@@ -71,6 +71,62 @@ export function contextOfKey(key) {
71
71
  return out;
72
72
  }
73
73
 
74
+ /** Scrub an error message down to its shape: quoted strings, hex ids and
75
+ * numbers collapse to placeholders, whitespace and `|` (the key separator)
76
+ * are normalized, and the result is capped so a huge message never bloats a
77
+ * key. Two errors that differ only by which mesh id or how many ms are the
78
+ * SAME cause once normalized. */
79
+ export function normalizeErrorMessage(msg) {
80
+ return String(msg ?? '')
81
+ .replace(/(['"`])(?:\\.|(?!\1).)*\1/g, '"…"')
82
+ .replace(/0x[0-9a-f]{6,}/gi, '#')
83
+ .replace(/\b[0-9a-f]{6,}\b/gi, '#')
84
+ .replace(/\d+(\.\d+)?/g, '#')
85
+ .replace(/\s+/g, ' ')
86
+ .replace(/\|/g, '¦')
87
+ .trim()
88
+ .slice(0, 120);
89
+ }
90
+
91
+ /** `<path>#<function>` of the top stack frame, without origin, query, hash,
92
+ * line or column — the site an error is thrown from, apart from which build
93
+ * served it or which line the minifier put it on. */
94
+ export function topFrameSite(stack) {
95
+ const lines = Array.isArray(stack) ? stack : String(stack ?? '').split('\n');
96
+ for (const raw of lines) {
97
+ const line = String(raw).trim();
98
+ let fn, loc;
99
+ if (line.startsWith('at ')) {
100
+ // V8/Chromium/Node: "at fn (loc)" | "at loc" | "at async fn (loc)" | "at new Cls (loc)"
101
+ const m = /^at (?:(.+?) \()?(.+?)\)?$/.exec(line);
102
+ if (!m) continue;
103
+ fn = m[1] ? m[1].replace(/^(?:async |new )+/, '') : '';
104
+ loc = m[2];
105
+ } else {
106
+ // SpiderMonkey/JavaScriptCore: "fn@loc" | "@loc" (anonymous). Half the
107
+ // players are on Firefox or Safari; without this their stacks parse to
108
+ // nothing and one bug becomes two catalogue rows.
109
+ const m = /^([^@]*)@((?:[a-z][a-z0-9+.-]*:)?\/\/?.+?:\d+(?::\d+)?)$/.exec(line);
110
+ if (!m) continue;
111
+ fn = m[1];
112
+ loc = m[2];
113
+ }
114
+ loc = loc.replace(/:\d+:\d+$/, '').replace(/:\d+$/, '');
115
+ try { const u = new URL(loc); loc = u.pathname; } catch { /* not a URL: a path */ }
116
+ loc = loc.replace(/[?#].*$/, '');
117
+ return `${loc}#${fn || 'anonymous'}`;
118
+ }
119
+ return 'unknown';
120
+ }
121
+
122
+ /** The site a server incident's top self-time frame names — `unattributed`
123
+ * when attribution was turned off or the server sent no frames at all. */
124
+ function serverSite(rec) {
125
+ const f = Array.isArray(rec.frames) ? rec.frames[0] : undefined;
126
+ if (rec.attribution === 'off' || !f) return 'unattributed';
127
+ return `${f.file ?? '?'}#${f.fn ?? 'anonymous'}`;
128
+ }
129
+
74
130
  /** The verdict a record leads with, or 'unclassified'. */
75
131
  function topGuess(rec) {
76
132
  return rec.classification?.[0]?.guess ?? 'unclassified';
@@ -132,6 +188,12 @@ function baseKey(rec) {
132
188
  case 'gpu-settle':
133
189
  // Only a cap hit is an incident; a settled wait is verification evidence.
134
190
  return rec.settled === false ? `gpu-settle|${rec.tag ?? '?'}` : null;
191
+ case 'error':
192
+ return `error|${rec.source ?? 'client'}|${rec.name ?? 'Error'}|${normalizeErrorMessage(rec.message)}|${topFrameSite(rec.stack)}`;
193
+ case 'server-hitch':
194
+ return `server-hitch|${phase}|${serverSite(rec)}`;
195
+ case 'server-stall':
196
+ return `server-stall|${phase}|${serverSite(rec)}`;
135
197
  default:
136
198
  return null;
137
199
  }
@@ -152,11 +214,11 @@ export function footprintOf(rec) {
152
214
  export function describeFootprint(key) {
153
215
  const parts = String(key ?? '').split('|').filter((p) => !p.startsWith('ctx:'));
154
216
  const ctx = contextOfKey(key);
155
- const d = describeBase(parts);
217
+ const d = describeBase(parts, String(key ?? ''));
156
218
  return { ...d, ctx };
157
219
  }
158
220
 
159
- function describeBase(parts) {
221
+ function describeBase(parts, key) {
160
222
  const [type] = parts;
161
223
  switch (type) {
162
224
  case 'hitch': return { glyph: '⚡', label: `hitch · ${parts[2] ?? '?'}${parts[3] ? ` · ${parts[3].split(',').length} mint site(s)` : ''}`, phase: parts[1] ?? '?' };
@@ -165,6 +227,9 @@ function describeBase(parts) {
165
227
  case 'warm': return { glyph: '🔥', label: `warm · ${parts[1] ?? '?'} (${parts[2] ?? '?'})`, phase: parts[3] ?? '?' };
166
228
  case 'gpu-stall': return { glyph: '⏳', label: 'gpu-process stall', phase: parts[1] ?? '?' };
167
229
  case 'gpu-settle': return { glyph: '⏳', label: `gpu-settle cap hit · ${parts[1] ?? '?'}`, phase: '' };
230
+ case 'error': return { glyph: '✖', label: `error · ${parts[2] ?? '?'} · ${(parts[3] ?? '').slice(0, 60)}`, phase: '' };
231
+ case 'server-hitch': return { glyph: '▣', label: `server tick over budget · ${parts[2] ?? '?'}`, phase: parts[1] ?? '?' };
232
+ case 'server-stall': return { glyph: '▦', label: `event-loop stall · ${parts[2] ?? '?'}`, phase: parts[1] ?? '?' };
168
233
  default: return { glyph: '·', label: String(key ?? ''), phase: '' };
169
234
  }
170
235
  }
package/src/history.js CHANGED
@@ -256,6 +256,9 @@ function worstOf(r) {
256
256
  case 'warm': return typeof r.worstBatchMs === 'number' ? { value: r.worstBatchMs, unit: 'ms' } : undefined;
257
257
  case 'gpu-stall': return typeof r.queueDoneMs === 'number' ? { value: r.queueDoneMs, unit: 'ms' } : undefined;
258
258
  case 'gpu-settle': return typeof r.ms === 'number' ? { value: r.ms, unit: 'ms' } : undefined;
259
+ case 'error': return undefined;
260
+ case 'server-hitch': return typeof r.tickMs === 'number' ? { value: r.tickMs, unit: 'ms' } : undefined;
261
+ case 'server-stall': return typeof r.p99Ms === 'number' ? { value: r.p99Ms, unit: 'ms' } : undefined;
259
262
  default: return undefined;
260
263
  }
261
264
  }
package/src/index.js CHANGED
@@ -3,6 +3,8 @@ export { createRecorder } from './recorder.js';
3
3
  export { buildCensus } from './census.js';
4
4
  export { classifyHitch } from './classify.js';
5
5
  export { createMotionMonitor } from './motion.js';
6
+ export { createErrorMonitor } from './errors.js';
7
+ export { createCloudSink } from './cloud-sink.js';
6
8
  export { footprintOf, footprintKey, describeFootprint, canonicalContext, contextOfKey, FOOTPRINT_VERSION } from './footprint.js';
7
9
  export { buildHistory, summarizeWindow, buildFix, latestBuilds, buildIssues, agoText } from './history.js';
8
10
  export { createPanel } from './panel.js';
@@ -0,0 +1,164 @@
1
+ // ============================================================
2
+ // sloptimize/node — the game server's recorder (SPEC cloud §8.3)
3
+ // ============================================================
4
+ // Three signals, one ledger: a tick the host timed that overran its budget
5
+ // (server-hitch), an event-loop stall the runtime saw on its own
6
+ // (server-stall), and an uncaught error (error, source: server). Each is
7
+ // attributed by the V8 sampler's top self-time frames, and shipped with the
8
+ // same cloud sink the browser uses. Nothing here changes how the process
9
+ // dies: only uncaughtExceptionMonitor is registered — never uncaughtException
10
+ // or unhandledRejection, which would turn this recorder into part of the
11
+ // crash path itself.
12
+ import { createCloudSink } from '../cloud-sink.js';
13
+ import { canonicalContext } from '../footprint.js';
14
+ // Static import: monitorEventLoopDelay() is synchronous and cheap, and
15
+ // createServerRuntime() must itself stay synchronous (callers do not await
16
+ // it), so this cannot be a dynamic `await import('node:perf_hooks')`.
17
+ import { monitorEventLoopDelay } from 'node:perf_hooks';
18
+
19
+ export function createServerRuntime(opts = {}) {
20
+ if (!opts.key) throw new Error('createServerRuntime: key is required');
21
+ if (!opts.endpoint) throw new Error('createServerRuntime: endpoint is required');
22
+ const tickBudgetMs = opts.tickBudgetMs ?? 16, stallMs = opts.stallMs ?? 50;
23
+ const phaseFn = opts.phase ?? (() => undefined), ctxFn = opts.context ?? (() => undefined);
24
+ const now = opts.now ?? (() => Date.now());
25
+ const proc = opts.process ?? process;
26
+ const setI = opts.setInterval ?? setInterval, clearI = opts.clearInterval ?? clearInterval;
27
+ const profileOn = opts.profile !== false;
28
+ let closed = false;
29
+
30
+ const pending = [];
31
+ const source = { drainRecords() { return pending.splice(0, pending.length); } };
32
+ const sink = createCloudSink({
33
+ key: opts.key, endpoint: opts.endpoint, build: opts.build, sources: [source], flushMs: opts.flushMs ?? 5000,
34
+ fetch: opts.fetch, sendBeacon: null, target: { addEventListener() {}, removeEventListener() {} }, setInterval: setI, clearInterval: clearI, now,
35
+ });
36
+
37
+ const stats = { hitches: 0, stalls: 0, errors: 0, droppedByRate: 0, hostErrors: 0 };
38
+ let lastHitchAt = -Infinity, lastStallAt = -Infinity;
39
+
40
+ // The profiler: injectable for tests (never touches node:inspector), or
41
+ // lazily imported for real use so the fake-profiler test path never loads
42
+ // the inspector module. `closed` is re-checked once the import (and the
43
+ // inspector session it builds) resolves, so a close() that races the lazy
44
+ // init stops the profiler instead of starting a session nobody will ever
45
+ // stop.
46
+ let profiler = opts.profiler ?? null;
47
+ let profilerReady = profileOn && profiler ? Promise.resolve(profiler.start()) : null;
48
+ if (profileOn && !profiler) {
49
+ profilerReady = import('./profiler.js')
50
+ .then(async (m) => {
51
+ if (closed) return;
52
+ const p = await m.createProfiler();
53
+ if (closed) { await p.stop?.(); return; }
54
+ profiler = p;
55
+ await profiler.start();
56
+ })
57
+ .catch((e) => { stats.profilerError = e?.message; profiler = null; });
58
+ }
59
+ async function frames() {
60
+ if (!profileOn) return { frames: [], attribution: 'off' };
61
+ try {
62
+ // Wait for the (possibly still-lazily-importing) profiler before
63
+ // deciding attribution is unavailable — otherwise every hitch/stall
64
+ // during startup is permanently recorded as attribution: 'off' even
65
+ // though the real profiler comes up moments later.
66
+ await profilerReady;
67
+ if (!profiler) return { frames: [], attribution: 'off' };
68
+ return { frames: await profiler.take(), attribution: 'profiler' };
69
+ } catch { return { frames: [], attribution: 'off' }; }
70
+ }
71
+ // Host-supplied phase()/context() callbacks run on every record; a
72
+ // throwing one must never break the record (or crash a bare setInterval
73
+ // callback) — it just leaves that field unset, counted in hostErrors.
74
+ function stamp(rec) {
75
+ try { const p = phaseFn(); if (p) rec.phase = p; } catch { stats.hostErrors++; }
76
+ try { const c = ctxFn(); if (c) rec.ctx = typeof c === 'string' ? c : canonicalContext(c); } catch { stats.hostErrors++; }
77
+ rec.at = new Date().toISOString();
78
+ return rec;
79
+ }
80
+ function pushAsync(rec, withFrames) {
81
+ // After close() the sink is disposed and nothing will ever drain `pending`
82
+ // again: a frame promise that resolves later must not grow it forever.
83
+ if (closed) return;
84
+ if (!withFrames) { pending.push(rec); return; }
85
+ frames().then((f) => {
86
+ if (closed) return;
87
+ rec.frames = f.frames; rec.attribution = f.attribution; pending.push(rec);
88
+ });
89
+ }
90
+
91
+ function endTick(startMs) {
92
+ try {
93
+ const tickMs = now() - startMs;
94
+ if (tickMs <= tickBudgetMs) return;
95
+ const t = now();
96
+ if (t - lastHitchAt < 1000) { stats.droppedByRate++; return; }
97
+ lastHitchAt = t; stats.hitches++;
98
+ const rec = stamp({ type: 'server-hitch', tickMs: +tickMs.toFixed(2), budgetMs: tickBudgetMs, frames: [], attribution: 'off' });
99
+ pushAsync(rec, profileOn);
100
+ } catch { stats.hostErrors++; }
101
+ }
102
+
103
+ // Event-loop delay: sampled every second, an incident when p99 crosses
104
+ // stallMs, rate-limited to one record per 5s.
105
+ const monitor = opts.monitor ?? monitorEventLoopDelay({ resolution: 20 });
106
+ monitor?.enable?.();
107
+ const sampler = setI(() => {
108
+ try {
109
+ if (!monitor) return;
110
+ const p99 = monitor.percentiles.get(99) / 1e6, p50 = monitor.percentiles.get(50) / 1e6, max = monitor.max / 1e6;
111
+ monitor.reset();
112
+ if (!(p99 > stallMs)) return;
113
+ const t = now();
114
+ if (t - lastStallAt < 5000) { stats.droppedByRate++; return; }
115
+ lastStallAt = t; stats.stalls++;
116
+ const rec = stamp({ type: 'server-stall', p50Ms: +p50.toFixed(1), p99Ms: +p99.toFixed(1), maxMs: +max.toFixed(1), frames: [], attribution: 'off' });
117
+ pushAsync(rec, profileOn);
118
+ } catch { stats.hostErrors++; }
119
+ }, 1000);
120
+ sampler?.unref?.();
121
+
122
+ const onUncaught = (err) => {
123
+ try {
124
+ stats.errors++;
125
+ const e = err instanceof Error ? err : new Error(String(err));
126
+ const stack = String(e.stack ?? '').split('\n').map((l) => l.trim()).filter((l) => l.startsWith('at ')).slice(0, 10);
127
+ pending.push(stamp({ type: 'error', source: 'server', name: e.name || 'Error', message: e.message, stack }));
128
+ } catch { /* never throw from a monitor */ }
129
+ };
130
+ proc.on('uncaughtExceptionMonitor', onUncaught);
131
+ // The watchdog timer must be unref'd too: Promise.race never cancels its
132
+ // losing arm, so a plain setTimeout here would itself become a handle
133
+ // that keeps a real host's event loop alive for up to 2s on every exit
134
+ // attempt (and, since that can make the loop look non-empty again,
135
+ // potentially re-trigger beforeExit indefinitely).
136
+ const onBeforeExit = () => {
137
+ Promise.race([sink.flush(), new Promise((r) => { const t = setTimeout(r, 2000); t?.unref?.(); })]).catch(() => {});
138
+ };
139
+ proc.on('beforeExit', onBeforeExit);
140
+
141
+ return {
142
+ tick(fn) { const s = now(); try { return fn(); } finally { endTick(s); } },
143
+ beginTick() { return now(); },
144
+ endTick,
145
+ mark(label, meta = {}) {
146
+ try { pending.push(stamp({ type: 'usermark', label, ...meta })); } catch { stats.hostErrors++; }
147
+ },
148
+ // Awaits one macrotask before draining the sink so that pushAsync's
149
+ // frame promise (resolved on a microtask, even for a synchronous fake
150
+ // profiler) has already landed its record in `pending`.
151
+ async flush() { await new Promise((r) => setTimeout(r, 0)); await sink.flush(); },
152
+ stats() { return { ...stats, sink: sink.stats() }; },
153
+ async close() {
154
+ closed = true;
155
+ clearI(sampler);
156
+ monitor?.disable?.();
157
+ proc.off?.('uncaughtExceptionMonitor', onUncaught);
158
+ proc.off?.('beforeExit', onBeforeExit);
159
+ await profiler?.stop?.();
160
+ await sink.flush();
161
+ sink.dispose();
162
+ },
163
+ };
164
+ }
@@ -0,0 +1,63 @@
1
+ // ============================================================
2
+ // profiler.js — the V8 sampling profiler over node:inspector (SPEC cloud §8.3)
3
+ // ============================================================
4
+ // The V8 sampling profiler over the inspector protocol: started once, read
5
+ // on demand. take() returns the top self-time frames since the last take and
6
+ // restarts the sampler — the same "rolling window" attach.mjs uses in the
7
+ // browser, here for the game server's own event loop.
8
+
9
+ /** Pure fold of a Profiler.stop() result's `.profile` into the top 5
10
+ * self-time frames as `{ file, fn, selfMs }`, keyed by (url, functionName).
11
+ * Runtime/VM bookkeeping frames (root/program/gc/idle with no url) are
12
+ * dropped, and any frame whose url contains `excludeUrl` (the profiler's
13
+ * own module) is dropped so the recorder never attributes its own cost. */
14
+ export function foldProfile(profile, excludeUrl) {
15
+ const byId = new Map((profile?.nodes ?? []).map((n) => [n.id, n]));
16
+ const selfUs = new Map();
17
+ const samples = profile?.samples ?? [], deltas = profile?.timeDeltas ?? [];
18
+ for (let i = 0; i < samples.length; i++) {
19
+ const n = byId.get(samples[i]); if (!n) continue;
20
+ const { functionName, url } = n.callFrame;
21
+ if (!url && /^\((root|program|garbage collector|idle)\)$/.test(functionName)) continue;
22
+ if (excludeUrl && url.includes(excludeUrl)) continue;
23
+ // NUL, not a space: getters fold as "get health" and real paths contain
24
+ // spaces, both of which a space-separated key would split in the wrong place.
25
+ const key = `${url}\u0000${functionName}`;
26
+ selfUs.set(key, (selfUs.get(key) ?? 0) + (deltas[i] ?? 0));
27
+ }
28
+ return [...selfUs.entries()].sort((a, b) => b[1] - a[1]).slice(0, 5).map(([k, us]) => {
29
+ const [url, fn] = k.split('\u0000');
30
+ return { file: url.replace(/^file:\/\//, ''), fn: fn || 'anonymous', selfMs: Math.round(us / 1000) };
31
+ });
32
+ }
33
+
34
+ /** Wraps an inspector Session (real or injected) in start/take/stop. `take()`
35
+ * stops the sampler, folds the profile, and immediately restarts it so the
36
+ * window is continuous. Never throws into the host — callers treat a
37
+ * rejected/absent profiler the same as attribution being off. */
38
+ export async function createProfiler(opts = {}) {
39
+ const session = opts.session ?? new (await import('node:inspector')).Session();
40
+ const post = (m, p) => new Promise((res, rej) => session.post(m, p ?? {}, (e, r) => (e ? rej(e) : res(r))));
41
+ let running = false;
42
+ return {
43
+ async start() {
44
+ if (running) return;
45
+ session.connect?.();
46
+ await post('Profiler.enable');
47
+ await post('Profiler.setSamplingInterval', { interval: opts.intervalUs ?? 1000 });
48
+ await post('Profiler.start');
49
+ running = true;
50
+ },
51
+ async take() {
52
+ if (!running) return [];
53
+ const { profile } = await post('Profiler.stop');
54
+ await post('Profiler.start');
55
+ return foldProfile(profile, '/sloptimize/src/node/');
56
+ },
57
+ async stop() {
58
+ if (!running) return;
59
+ running = false;
60
+ try { await post('Profiler.stop'); await post('Profiler.disable'); } catch { /* already gone */ }
61
+ },
62
+ };
63
+ }
package/src/recorder.js CHANGED
@@ -43,6 +43,8 @@ export function createRecorder(opts = {}) {
43
43
  let sessionRecords = 0;
44
44
  let droppedSinceLast = 0;
45
45
  let lastRecordAt = -Infinity;
46
+ let lastPhase; // most recent s.phase seen by frame(), for emit()'s stamping
47
+ let lastCtx; // most recent s.ctx seen by frame(), for emit()'s stamping
46
48
 
47
49
  function sortedNonPaused(field, sinceIdx = 0) {
48
50
  const vals = [];
@@ -73,6 +75,8 @@ export function createRecorder(opts = {}) {
73
75
  return {
74
76
  /** One frame's numbers. Zero-allocation on the steady path. */
75
77
  frame(s) {
78
+ if (s.phase) lastPhase = s.phase;
79
+ if (s.ctx) lastCtx = s.ctx;
76
80
  const idx = head;
77
81
  for (const f of FIELDS) lanes[f][idx] = s[f] ?? 0;
78
82
  pausedLane[idx] = s.paused ? 1 : 0;
@@ -229,6 +233,19 @@ export function createRecorder(opts = {}) {
229
233
  return mark;
230
234
  },
231
235
 
236
+ /** Append an externally built incident (errors, host-detected events). Same session cap as hitches. */
237
+ emit(rec) {
238
+ if (!rec || typeof rec !== 'object') return false;
239
+ if (sessionRecords >= MAX_RECORDS_PER_SESSION) { droppedSinceLast++; return false; }
240
+ sessionRecords++;
241
+ if (!rec.at) rec.at = new Date().toISOString();
242
+ if (rec.phase === undefined && lastPhase) rec.phase = lastPhase;
243
+ if (rec.ctx === undefined && lastCtx) rec.ctx = lastCtx;
244
+ if (droppedSinceLast > 0) { rec.droppedSinceLast = droppedSinceLast; droppedSinceLast = 0; }
245
+ records.push(rec);
246
+ return true;
247
+ },
248
+
232
249
  /** Hand back accumulated records and clear — the host owns transport. */
233
250
  drainRecords() { const r = records; records = []; return r; },
234
251
  };