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/.claude-plugin/marketplace.json +11 -0
- package/.claude-plugin/plugin.json +8 -0
- package/.mcp.json +9 -0
- package/LICENSE +21 -0
- package/README.md +263 -0
- package/bin/sloptimize.mjs +346 -0
- package/docs/DESIGN-mecharoyale-v0.md +32 -0
- package/docs/INTEGRATION.md +230 -0
- package/docs/JITTER-AND-FOOTPRINTS.md +236 -0
- package/docs/SPEC-attach.md +176 -0
- package/docs/SPEC.md +845 -0
- package/docs/USAGE.md +227 -0
- package/hooks/hooks.json +16 -0
- package/mcp/server.mjs +127 -0
- package/package.json +64 -0
- package/skills/install/SKILL.md +143 -0
- package/skills/sloptimize/SKILL.md +74 -0
- package/src/attach.mjs +197 -0
- package/src/census.js +193 -0
- package/src/classify.js +70 -0
- package/src/footprint.js +170 -0
- package/src/history.js +273 -0
- package/src/index.js +8 -0
- package/src/inject-body.js +152 -0
- package/src/motion.js +345 -0
- package/src/panel.js +530 -0
- package/src/proposals.mjs +268 -0
- package/src/recorder.js +235 -0
- package/src/watch.mjs +242 -0
package/docs/USAGE.md
ADDED
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
# Using sloptimize day to day
|
|
2
|
+
|
|
3
|
+
INTEGRATION.md is for wiring a game once. This is the manual for everyone
|
|
4
|
+
AFTER that: the operator who plays, and the Claude Code sessions that read.
|
|
5
|
+
Everything here was verified live against the reference deployment
|
|
6
|
+
(mecharoyale) — the multi-session semantics by running the actual hook
|
|
7
|
+
binary from parallel directories.
|
|
8
|
+
|
|
9
|
+
## The mental model (one sentence)
|
|
10
|
+
|
|
11
|
+
The game never talks to a Claude session: it posts to **its own server**,
|
|
12
|
+
the server writes files into `<server cwd>/.sloptimize/`, and every Claude
|
|
13
|
+
session **reads those files** — a broadcast over the filesystem, not a
|
|
14
|
+
channel to anyone.
|
|
15
|
+
|
|
16
|
+
```
|
|
17
|
+
game tab ──POST──► game server ──writes──► .sloptimize/{profile.json, perf.jsonl, census.json}
|
|
18
|
+
▲ ▲ ▲
|
|
19
|
+
session A session B session C
|
|
20
|
+
(reads at its next prompt, independently)
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
## A normal day
|
|
24
|
+
|
|
25
|
+
1. **Start the game server with its dev switch** (reference deployment:
|
|
26
|
+
`ALLOW_DEBUG_SPAWN=1` or `SLOPTIMIZE_INGEST=1`; the standalone launcher
|
|
27
|
+
sets the latter by default). Without it the ingest endpoint does not
|
|
28
|
+
exist and every tab stays inert — by design, that is what production
|
|
29
|
+
looks like.
|
|
30
|
+
2. **Open the game and just play.** The in-page runtime arms itself:
|
|
31
|
+
instantly on localhost or `?sloptimize`, otherwise by probing the ingest
|
|
32
|
+
endpoint (204 arms; the probe retries on a backoff, so a server that
|
|
33
|
+
comes up late still lights the recorder without a refresh). Your
|
|
34
|
+
confirmation is the **PERF chip** (bottom-left, dev sessions only):
|
|
35
|
+
- `◉ PERF` — armed, feed flowing.
|
|
36
|
+
- `◌ PERF DARK` — armed but the server refused/vanished; recording
|
|
37
|
+
continues, posts buffer and retry. Hover the chip for the reason.
|
|
38
|
+
3. **When something feels wrong, press Ctrl+F12** (or click the chip).
|
|
39
|
+
The trailing 5 seconds freeze **at the press** — typing the note cannot
|
|
40
|
+
shift the window. Then:
|
|
41
|
+
- type one line ("huge stutter when the buildings loaded") + Enter →
|
|
42
|
+
keyframe + note land in the ledger;
|
|
43
|
+
- Esc or just closing → you were only looking; nothing is minted.
|
|
44
|
+
The debugger has three tabs (← → switch them):
|
|
45
|
+
- **Session** — this tab's incident list; every row already reached the
|
|
46
|
+
ledger when it happened.
|
|
47
|
+
- **Timeline** — the deployment's history: frame p95, draw calls and
|
|
48
|
+
hitch spikes on one time axis, build boundaries dashed, and a
|
|
49
|
+
"now vs first build" line. Hover a bucket for its numbers. A gap is
|
|
50
|
+
"nothing measured then", never zero; a caret is a spike past the
|
|
51
|
+
strip's ceiling (hover reads the real value).
|
|
52
|
+
- **Fixes** — what sloptimize + Claude Code changed and what it bought:
|
|
53
|
+
one card per recorded fix — date, commit, was → now, and the measured
|
|
54
|
+
before/after (p95, draw calls, hitches/h, worst frame) sparklined.
|
|
55
|
+
4. **Go to a Claude Code session and type anything.** The evidence arrives
|
|
56
|
+
attached to that prompt. That's the whole handoff.
|
|
57
|
+
|
|
58
|
+
## A new Claude Code session: zero setup
|
|
59
|
+
|
|
60
|
+
Any session started in a repo with the prompt hook installed (README
|
|
61
|
+
"Claude Code integration") is integrated from its first prompt. The hook
|
|
62
|
+
runs `sloptimize hook-status` on every prompt-submit and prints at most 5
|
|
63
|
+
lines, only when something is NEW:
|
|
64
|
+
|
|
65
|
+
- `★ NEW perf keyframe (f12: <your note>) …` — a usermark, with the worst
|
|
66
|
+
frame's classification and evidence inline.
|
|
67
|
+
- `⚠ budget breach (<regime>): perf.budget.… n/limit` — a budget crossed
|
|
68
|
+
an edge (said once per distinct breach signature, re-said if it clears
|
|
69
|
+
and returns).
|
|
70
|
+
- `◌ feed quiet Nmin …` — the ledger went stale (>45min). Heartbeats keep
|
|
71
|
+
it fresh once a minute while a session is armed, so quiet MEANS the feed
|
|
72
|
+
is dark or the play session ended — never merely "idle".
|
|
73
|
+
|
|
74
|
+
Silence means nothing new — the hook exits 0 with no output.
|
|
75
|
+
|
|
76
|
+
The watcher (below) adds the live lines. Reading one:
|
|
77
|
+
|
|
78
|
+
```
|
|
79
|
+
sloptimize ↯ jitter unit snap 17.542 (jump [-16.25, 5.552, 3.584], expected 1.363 of travel
|
|
80
|
+
at 33.25/s in a 41ms frame) @ … → snap (17.542m off its trajectory …; motion resumed from
|
|
81
|
+
the new place) phase=play ctx=combat=yes,hull=droyd-g,squad=duo,stance=helm,view=tps
|
|
82
|
+
build=v178… fp=b5b203ba ×2 [.sloptimize]
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
`↯` is a coordinate jump: which track (the unit or the camera), how far off
|
|
86
|
+
its own trajectory it landed, what constant velocity predicted for that
|
|
87
|
+
frame, and the verdict — `snap` (one displacement, motion resumed),
|
|
88
|
+
`oscillation` (frame-to-frame reversals), `follows-track` (the camera rode
|
|
89
|
+
along with the unit; that record is the cause), `reach-change` (the camera
|
|
90
|
+
boom clamped or zoomed), `long-frame-catch-up` (the stall it rode is the
|
|
91
|
+
incident — silent on the watcher, listed in the ledger). `ctx=` is the
|
|
92
|
+
game's situation when it happened; `fp=` is the cause's id and `×N` how
|
|
93
|
+
often this ledger has seen it — `sloptimize issues --fp <id>` is its history
|
|
94
|
+
and the fixes already applied.
|
|
95
|
+
|
|
96
|
+
## Several sessions at once: who gets told?
|
|
97
|
+
|
|
98
|
+
**All of them, once each.** Dedup state is **per working directory**
|
|
99
|
+
(`.sloptimize/.hook-state.json` in the session's own cwd, keyed by watched
|
|
100
|
+
dir), not global. Three sessions in three worktrees each surface the same
|
|
101
|
+
new keyframe at their own next prompt, then never again.
|
|
102
|
+
|
|
103
|
+
Consequences worth knowing:
|
|
104
|
+
|
|
105
|
+
- **You pick the acting session by typing into it.** The game cannot
|
|
106
|
+
address a session and the F12 box cannot target one. Work the incident
|
|
107
|
+
wherever you type; the other sessions merely mention it once.
|
|
108
|
+
- **Delivery is pull, not push.** A session you never prompt never hears
|
|
109
|
+
anything, no matter how many freezes land in the ledger.
|
|
110
|
+
- **Same-directory sessions race.** Two sessions sharing one cwd share one
|
|
111
|
+
`.hook-state.json`; whichever prompts first consumes the "new" flag and
|
|
112
|
+
the other never sees that record. One session per directory (worktrees
|
|
113
|
+
make this automatic).
|
|
114
|
+
- **Scope follows the `--dir` flags.** The reference hook watches the
|
|
115
|
+
session's own `./.sloptimize` **plus** the main deployment's directory.
|
|
116
|
+
So a worktree's private dev server is seen only by that worktree's
|
|
117
|
+
session; the shared deployment is seen by everyone.
|
|
118
|
+
|
|
119
|
+
## Do I need a dedicated monitor session?
|
|
120
|
+
|
|
121
|
+
**No** for the normal loop above — the hook makes every session ambient.
|
|
122
|
+
|
|
123
|
+
**Yes, one, if you want unprompted reaction** (or none, if the
|
|
124
|
+
`SessionStart` hook arms the watcher for you) — an agent that starts
|
|
125
|
+
investigating while you keep playing and never type. Two recipes:
|
|
126
|
+
|
|
127
|
+
- **`sloptimize watch` as a Monitor** (INTEGRATION.md §5): the shipped
|
|
128
|
+
watcher polls `perf.jsonl` with a byte cursor (~20s) and wakes the
|
|
129
|
+
agent with one line per new usermark, ≥100ms hitch, gpu cap-hit, coordinate jitter or
|
|
130
|
+
feed-dark edge. A `SessionStart` hook can arm it in every session
|
|
131
|
+
automatically — then nobody types anything, ever.
|
|
132
|
+
- **`/loop` in one session**: e.g.
|
|
133
|
+
`/loop check <game>/.sloptimize for new keyframes or budget breaches; investigate any new incident`
|
|
134
|
+
— that session self-paces polls and works incidents autonomously.
|
|
135
|
+
|
|
136
|
+
Either coexists with the ambient hook in the other sessions; dedup state
|
|
137
|
+
is per directory, so the watcher consuming its own view hides nothing from
|
|
138
|
+
anyone else.
|
|
139
|
+
|
|
140
|
+
## The Issues tab: what keeps happening, and what was done about it
|
|
141
|
+
|
|
142
|
+
Every incident carries a **footprint** — the identity of its cause (kind,
|
|
143
|
+
phase, verdict, site, and the game's own situation: which machine, at the
|
|
144
|
+
helm or on foot, squad size, in combat), never the time it happened. The
|
|
145
|
+
debugger's **Issues** tab groups the whole ledger by footprint: one row per
|
|
146
|
+
cause, `×N` occurrences, `last 3h ago`, the situation as chips. Click a row
|
|
147
|
+
for its history: first and last seen, builds, worst, the last verdict, and
|
|
148
|
+
every fix a session recorded against it (`sloptimize fix propose
|
|
149
|
+
--footprints <id> …`). The same catalogue is `sloptimize issues` in a
|
|
150
|
+
shell, and every watcher wake line ends with `fp=<id> ×N` so an agent knows
|
|
151
|
+
on arrival whether it is looking at something new or the seventh time.
|
|
152
|
+
|
|
153
|
+
## Reading on demand (any session, any shell)
|
|
154
|
+
|
|
155
|
+
```bash
|
|
156
|
+
npx sloptimize report --dir <game>/.sloptimize # profile + incidents
|
|
157
|
+
npx sloptimize check --dir <game>/.sloptimize # budgets → exit 0/1/4
|
|
158
|
+
npx sloptimize census --dir <game>/.sloptimize # per-entity costs
|
|
159
|
+
npx sloptimize history --dir <game>/.sloptimize # p95/calls/hitches over time, per build + fixes
|
|
160
|
+
npx sloptimize doctor --dir <game>/.sloptimize # what is wired/degraded
|
|
161
|
+
```
|
|
162
|
+
|
|
163
|
+
## Recording a fix (the agent's last step)
|
|
164
|
+
|
|
165
|
+
Once a fix is verified — the new build is live and the ledger has evidence
|
|
166
|
+
from it — the session records it:
|
|
167
|
+
|
|
168
|
+
```bash
|
|
169
|
+
npx sloptimize fix --dir <game>/.sloptimize \
|
|
170
|
+
--title "Launch: pipelines pre-warmed behind the reveal cover" \
|
|
171
|
+
--issue "programs +4 mid-launch, 1.4s freeze on first draw" \
|
|
172
|
+
--solution "compile the launch material set inside the hangar settle gate" \
|
|
173
|
+
--commit $(git rev-parse --short HEAD)
|
|
174
|
+
```
|
|
175
|
+
|
|
176
|
+
The before/after are **measured**: by default the previous build's window
|
|
177
|
+
vs the latest build's (a fix ships as a build), or name them —
|
|
178
|
+
`--before v1787863993876 --after v1787881270783`, or a range
|
|
179
|
+
`--before 2026-08-27T20:00Z..2026-08-27T22:00Z`. The record lands in
|
|
180
|
+
`fixes.jsonl`; the Fixes tab and `sloptimize history` read it back. From
|
|
181
|
+
Claude Code the same verb is the MCP tool `record_fix`.
|
|
182
|
+
|
|
183
|
+
Rules that keep the numbers honest (the doctrine skill enforces them):
|
|
184
|
+
counters (calls/triangles/programs) compare exactly on any renderer;
|
|
185
|
+
timing counts only in `regime: hardware`; an absent field means
|
|
186
|
+
unmeasured, never zero; `long-script` escalates to a CPU profiler —
|
|
187
|
+
sloptimize answers *which frame, which entity, which workload*, not
|
|
188
|
+
*which function* (unless the attach tier's sampler is running).
|
|
189
|
+
|
|
190
|
+
The armed page also exposes a console surface: `__sloptimize.mark(note)`,
|
|
191
|
+
`.census()`, `.summary()`, `.feed()` (transport state), `.gpu()`
|
|
192
|
+
(WebGPU-wrapper stats), `.mints()` / `.remint()` (pipeline-mint
|
|
193
|
+
attribution).
|
|
194
|
+
|
|
195
|
+
## When it seems dead
|
|
196
|
+
|
|
197
|
+
- **Chip says DARK / hook says feed quiet**: the usual cause is the game
|
|
198
|
+
server restarted **without its dev switch** — the ingest 404s exactly
|
|
199
|
+
like production. Restore the switch; the client re-probes every 5
|
|
200
|
+
minutes and flushes what it buffered. No refresh needed.
|
|
201
|
+
- **No chip at all**: the tab never armed — not localhost, no
|
|
202
|
+
`?sloptimize`, and the arm probe never got its 204. Same fix.
|
|
203
|
+
- **`no census.json`**: nobody has walked the scene yet — run
|
|
204
|
+
`__sloptimize.census()` in the game tab.
|
|
205
|
+
- **Ctrl+F12 does nothing**: click the chip instead (keyboard geography
|
|
206
|
+
is why it exists); failures log `[sloptimize]` lines to the console
|
|
207
|
+
rather than dying silently.
|
|
208
|
+
|
|
209
|
+
## The fix loop (git only)
|
|
210
|
+
|
|
211
|
+
A fix is a PROPOSAL: a `sloptimize/<slug>` branch and one entry in
|
|
212
|
+
`.sloptimize/fixes.jsonl`. No forge, no API, no token — a repo with no remote
|
|
213
|
+
works the same; with a remote the branch and `main` are pushed.
|
|
214
|
+
|
|
215
|
+
```bash
|
|
216
|
+
sloptimize fix propose --title "…" --issue "…" --solution "…" # branch + commit + ledger entry
|
|
217
|
+
sloptimize fixes # every proposal, status as git sees it
|
|
218
|
+
sloptimize fix merge <id> # merge commit into main (+ push); refuses a stale branch
|
|
219
|
+
sloptimize fix reject <id> # delete the branch, keep the entry
|
|
220
|
+
sloptimize policy # automation: propose | merge
|
|
221
|
+
sloptimize settings --automation merge
|
|
222
|
+
```
|
|
223
|
+
|
|
224
|
+
The debugger's **Fixes** tab lists proposals with Merge / Reject and a badge
|
|
225
|
+
counting the ones you have not looked at; **Settings** holds the automation
|
|
226
|
+
level (server-side, `.sloptimize/settings.json`). A project that is not a
|
|
227
|
+
git repo is told so in both places.
|
package/hooks/hooks.json
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
{
|
|
2
|
+
"hooks": {
|
|
3
|
+
"UserPromptSubmit": [
|
|
4
|
+
{
|
|
5
|
+
"hooks": [
|
|
6
|
+
{
|
|
7
|
+
"type": "command",
|
|
8
|
+
"command": "node ${CLAUDE_PLUGIN_ROOT}/bin/sloptimize.mjs hook-status --dir .sloptimize 2>/dev/null || true",
|
|
9
|
+
"timeout": 10,
|
|
10
|
+
"statusMessage": "sloptimize perf check"
|
|
11
|
+
}
|
|
12
|
+
]
|
|
13
|
+
}
|
|
14
|
+
]
|
|
15
|
+
}
|
|
16
|
+
}
|
package/mcp/server.mjs
ADDED
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// ============================================================
|
|
3
|
+
// mcp/server.mjs — the plugin's live tier (SPEC §8.1 tier 4, M-A2)
|
|
4
|
+
// ============================================================
|
|
5
|
+
// A dependency-free stdio MCP server (JSON-RPC 2.0, newline-delimited),
|
|
6
|
+
// exposing the agent-facing verbs against the CURRENT PROJECT's
|
|
7
|
+
// .sloptimize/ plus tier-0 attach. Files stay the primary interface —
|
|
8
|
+
// these tools are the same reads, typed; attach_start is the one verb a
|
|
9
|
+
// plain file read cannot do.
|
|
10
|
+
//
|
|
11
|
+
// Push: incident→wakeup stays with the session Monitor / prompt hook for
|
|
12
|
+
// now — MCP notifications exist in the protocol, but a server-initiated
|
|
13
|
+
// wake is not a contract this host documents; when it becomes one, the
|
|
14
|
+
// watcher moves here (SPEC-attach §2, delivery edge).
|
|
15
|
+
import { readFileSync, existsSync } from 'node:fs';
|
|
16
|
+
import { join } from 'node:path';
|
|
17
|
+
import { createInterface } from 'node:readline';
|
|
18
|
+
|
|
19
|
+
const DIR = () => join(process.cwd(), '.sloptimize');
|
|
20
|
+
let attachSession = null;
|
|
21
|
+
|
|
22
|
+
const TOOLS = [
|
|
23
|
+
{ name: 'get_report', description: 'Current profile, recent incidents (classified, clustered), and census hints from the project’s .sloptimize/ directory.',
|
|
24
|
+
inputSchema: { type: 'object', properties: { limit: { type: 'number', description: 'max incident records (default 20)' } } } },
|
|
25
|
+
{ name: 'check_budgets', description: 'Check the measured profile against .sloptimize/budgets.json. Returns per-budget verdicts; "fast enough" as data.',
|
|
26
|
+
inputSchema: { type: 'object', properties: {} } },
|
|
27
|
+
{ 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
|
+
inputSchema: { type: 'object', properties: { buckets: { type: 'number', description: 'time slices (default 24)' } } } },
|
|
29
|
+
{ 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)' } } } },
|
|
31
|
+
{ 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
|
+
inputSchema: { type: 'object', properties: { title: { type: 'string' }, issue: { type: 'string' }, solution: { type: 'string' }, commit: { type: 'string' },
|
|
33
|
+
files: { type: 'array', items: { type: 'string' } }, footprints: { type: 'array', items: { type: 'string' }, description: 'footprint ids this fix addresses' },
|
|
34
|
+
before: { type: 'string' }, after: { type: 'string' } }, required: ['title'] } },
|
|
35
|
+
{ name: 'attach_start', description: 'Tier-0 attach: launch a Chromium at a URL with the injected recorder + rolling profiler (zero game integration). Records land in .sloptimize/ and incidents are clustered with file:line attribution.',
|
|
36
|
+
inputSchema: { type: 'object', properties: { url: { type: 'string' }, headless: { type: 'boolean' }, port: { type: 'number' } }, required: ['url'] } },
|
|
37
|
+
{ name: 'attach_stop', description: 'Stop the running attach session and report its cluster summary.',
|
|
38
|
+
inputSchema: { type: 'object', properties: {} } },
|
|
39
|
+
];
|
|
40
|
+
|
|
41
|
+
function readJson(name) { try { return JSON.parse(readFileSync(join(DIR(), name), 'utf8')); } catch { return null; } }
|
|
42
|
+
function readJsonl(name, limit) {
|
|
43
|
+
try {
|
|
44
|
+
return readFileSync(join(DIR(), name), 'utf8').trim().split('\n').filter(Boolean)
|
|
45
|
+
.slice(-limit).map((l) => { try { return JSON.parse(l); } catch { return null; } }).filter(Boolean);
|
|
46
|
+
} catch { return []; }
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
async function callTool(name, args = {}) {
|
|
50
|
+
if (name === 'get_report') {
|
|
51
|
+
return { profile: readJson('profile.json'), incidents: readJsonl('perf.jsonl', args.limit ?? 20),
|
|
52
|
+
clusters: readJson('clusters.json'), census: readJson('census.json'),
|
|
53
|
+
note: existsSync(DIR()) ? undefined : 'no .sloptimize/ in this project — run the game with the tier-1 feed, or attach_start' };
|
|
54
|
+
}
|
|
55
|
+
if (name === 'check_budgets') {
|
|
56
|
+
const profile = readJson('profile.json');
|
|
57
|
+
const budgets = readJson('budgets.json');
|
|
58
|
+
if (!profile) return { error: 'no measurement to check' };
|
|
59
|
+
if (!budgets) return { warning: 'no budgets declared (.sloptimize/budgets.json)', breached: [] };
|
|
60
|
+
const countersOnly = profile.regime !== 'hardware';
|
|
61
|
+
const read = { 'perf.budget.draw_calls': profile.render?.calls, 'perf.budget.triangles': profile.render?.triangles,
|
|
62
|
+
'perf.budget.frame_ms_p95': countersOnly ? undefined : profile.frame?.p95Ms, 'perf.budget.programs': profile.memory?.programs };
|
|
63
|
+
const results = Object.entries(budgets).map(([k, b]) => ({ budget: k, value: read[k] ?? null, limit: b,
|
|
64
|
+
verdict: read[k] === undefined ? 'unmeasured' : read[k] > b ? `over by ${(read[k] / b).toFixed(1)}x` : 'inside' }));
|
|
65
|
+
return { regime: profile.regime, results, breached: results.filter((r) => String(r.verdict).startsWith('over')).length };
|
|
66
|
+
}
|
|
67
|
+
if (name === 'get_history') {
|
|
68
|
+
const { buildHistory } = await import('../src/history.js');
|
|
69
|
+
return buildHistory(readJsonl('perf.jsonl', Infinity), { fixes: readJsonl('fixes.jsonl', Infinity), buckets: args.buckets ?? 24 });
|
|
70
|
+
}
|
|
71
|
+
if (name === 'get_issues') {
|
|
72
|
+
const { buildIssues } = await import('../src/history.js');
|
|
73
|
+
const issues = buildIssues(readJsonl('perf.jsonl', Infinity), { fixes: readJsonl('fixes.jsonl', Infinity), from: args.from, to: args.to, includeAutomated: args.includeAutomated === true });
|
|
74
|
+
const rows = args.fp ? issues.filter((i) => i.id === args.fp) : issues.slice(0, args.limit ?? 50);
|
|
75
|
+
return { footprints: issues.length, occurrences: issues.reduce((n, i) => n + i.count, 0), issues: rows };
|
|
76
|
+
}
|
|
77
|
+
if (name === 'record_fix') {
|
|
78
|
+
const { buildFix } = await import('../src/history.js');
|
|
79
|
+
const { appendFileSync, mkdirSync } = await import('node:fs');
|
|
80
|
+
const fix = buildFix(readJsonl('perf.jsonl', Infinity), args);
|
|
81
|
+
mkdirSync(DIR(), { recursive: true });
|
|
82
|
+
appendFileSync(join(DIR(), 'fixes.jsonl'), JSON.stringify(fix) + '\n');
|
|
83
|
+
return { ok: true, fix };
|
|
84
|
+
}
|
|
85
|
+
if (name === 'attach_start') {
|
|
86
|
+
if (attachSession) return { error: 'an attach session is already running — attach_stop first' };
|
|
87
|
+
const { attach } = await import('../src/attach.mjs');
|
|
88
|
+
attachSession = await attach({ launch: args.url, headless: args.headless ?? true,
|
|
89
|
+
port: args.port ?? 9222, dir: DIR(), log: () => {} });
|
|
90
|
+
return { ok: true, note: 'recording into .sloptimize/ — read with get_report; new causes cluster in clusters.json' };
|
|
91
|
+
}
|
|
92
|
+
if (name === 'attach_stop') {
|
|
93
|
+
if (!attachSession) return { error: 'no attach session running' };
|
|
94
|
+
const clusters = [...attachSession.clusters.entries()].map(([k, v]) => ({ key: k, count: v.count }));
|
|
95
|
+
await attachSession.close();
|
|
96
|
+
attachSession = null;
|
|
97
|
+
return { ok: true, clusters };
|
|
98
|
+
}
|
|
99
|
+
throw new Error(`unknown tool ${name}`);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// ── JSON-RPC over stdio (newline-delimited; the SDK-free minimum) ──
|
|
103
|
+
const rl = createInterface({ input: process.stdin });
|
|
104
|
+
const reply = (id, result, error) => {
|
|
105
|
+
process.stdout.write(JSON.stringify(error ? { jsonrpc: '2.0', id, error } : { jsonrpc: '2.0', id, result }) + '\n');
|
|
106
|
+
};
|
|
107
|
+
rl.on('line', (line) => {
|
|
108
|
+
let msg;
|
|
109
|
+
try { msg = JSON.parse(line); } catch { return; }
|
|
110
|
+
void (async () => {
|
|
111
|
+
try {
|
|
112
|
+
if (msg.method === 'initialize') {
|
|
113
|
+
reply(msg.id, { protocolVersion: msg.params?.protocolVersion ?? '2024-11-05',
|
|
114
|
+
capabilities: { tools: {} }, serverInfo: { name: 'sloptimize', version: '0.3.0' } });
|
|
115
|
+
} else if (msg.method === 'tools/list') {
|
|
116
|
+
reply(msg.id, { tools: TOOLS });
|
|
117
|
+
} else if (msg.method === 'tools/call') {
|
|
118
|
+
const out = await callTool(msg.params.name, msg.params.arguments);
|
|
119
|
+
reply(msg.id, { content: [{ type: 'text', text: JSON.stringify(out, null, 2) }] });
|
|
120
|
+
} else if (msg.id !== undefined) {
|
|
121
|
+
reply(msg.id, null, { code: -32601, message: `unknown method ${msg.method}` });
|
|
122
|
+
}
|
|
123
|
+
} catch (e) {
|
|
124
|
+
if (msg.id !== undefined) reply(msg.id, null, { code: -32000, message: String(e?.message ?? e) });
|
|
125
|
+
}
|
|
126
|
+
})();
|
|
127
|
+
});
|
package/package.json
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "sloptimize",
|
|
3
|
+
"version": "0.3.0",
|
|
4
|
+
"type": "module",
|
|
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
|
+
"license": "MIT",
|
|
7
|
+
"engines": {
|
|
8
|
+
"node": ">=18"
|
|
9
|
+
},
|
|
10
|
+
"repository": {
|
|
11
|
+
"type": "git",
|
|
12
|
+
"url": "git+https://github.com/m0dE/sloptimize.git"
|
|
13
|
+
},
|
|
14
|
+
"homepage": "https://github.com/m0dE/sloptimize#readme",
|
|
15
|
+
"bugs": {
|
|
16
|
+
"url": "https://github.com/m0dE/sloptimize/issues"
|
|
17
|
+
},
|
|
18
|
+
"bin": {
|
|
19
|
+
"sloptimize": "bin/sloptimize.mjs"
|
|
20
|
+
},
|
|
21
|
+
"exports": {
|
|
22
|
+
".": "./src/index.js",
|
|
23
|
+
"./history": "./src/history.js",
|
|
24
|
+
"./panel": "./src/panel.js"
|
|
25
|
+
},
|
|
26
|
+
"files": [
|
|
27
|
+
"bin",
|
|
28
|
+
"src",
|
|
29
|
+
"skills",
|
|
30
|
+
"hooks",
|
|
31
|
+
"mcp",
|
|
32
|
+
"docs",
|
|
33
|
+
".claude-plugin",
|
|
34
|
+
".mcp.json",
|
|
35
|
+
"LICENSE",
|
|
36
|
+
"README.md"
|
|
37
|
+
],
|
|
38
|
+
"peerDependencies": {
|
|
39
|
+
"@slopjs/inspector": ">=0.1.0",
|
|
40
|
+
"three": ">=0.150.0"
|
|
41
|
+
},
|
|
42
|
+
"peerDependenciesMeta": {
|
|
43
|
+
"@slopjs/inspector": {
|
|
44
|
+
"optional": true
|
|
45
|
+
},
|
|
46
|
+
"three": {
|
|
47
|
+
"optional": true
|
|
48
|
+
}
|
|
49
|
+
},
|
|
50
|
+
"scripts": {
|
|
51
|
+
"test": "node --test './test/**/*.test.js'",
|
|
52
|
+
"prepublishOnly": "npm test"
|
|
53
|
+
},
|
|
54
|
+
"keywords": [
|
|
55
|
+
"three.js",
|
|
56
|
+
"profiler",
|
|
57
|
+
"performance",
|
|
58
|
+
"claude-code",
|
|
59
|
+
"ai-agent",
|
|
60
|
+
"draw-calls",
|
|
61
|
+
"benchmark",
|
|
62
|
+
"slopjs"
|
|
63
|
+
]
|
|
64
|
+
}
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: install
|
|
3
|
+
description: Use when asked to install, integrate, or wire sloptimize into a game project — performs the tier-1 integration (in-page runtime, ingest sink, budgets, Claude hooks) and verifies the feed live before claiming success.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Installing sloptimize into a game
|
|
7
|
+
|
|
8
|
+
You are wiring the always-on tier (tier 1) into a real game repo. The
|
|
9
|
+
reference deployment's mistakes are encoded below as MUST rules — do not
|
|
10
|
+
rediscover them. `docs/INTEGRATION.md` in the sloptimize package is the
|
|
11
|
+
narrative version; `docs/USAGE.md` is what the result behaves like.
|
|
12
|
+
|
|
13
|
+
**The definition of done is a measured round-trip, not written code.** Do
|
|
14
|
+
not report success until step 7 shows your test keyframe in
|
|
15
|
+
`sloptimize report`.
|
|
16
|
+
|
|
17
|
+
## 1. Locate the package and the game's shape
|
|
18
|
+
|
|
19
|
+
- Find sloptimize: `node_modules/sloptimize` (npm) or a sibling checkout.
|
|
20
|
+
- Identify: the render loop's stats site (wherever `renderer.info` is
|
|
21
|
+
already read), the dev-server topology (vite? own server? esbuild?), and
|
|
22
|
+
the dev-only switch the project already uses for debug endpoints.
|
|
23
|
+
|
|
24
|
+
## 2. The in-page runtime
|
|
25
|
+
|
|
26
|
+
Create a dev module (reference: mecharoyale's `dev/sloptimize-runtime.ts`)
|
|
27
|
+
that:
|
|
28
|
+
|
|
29
|
+
- `createRecorder({ budgetFrameMs: <the game's frame budget> })`.
|
|
30
|
+
- **MUST give the recorder its own rAF clock** — a game-loop-fed clock is
|
|
31
|
+
blind to boot/menu/loading, exactly the windows that freeze. The game
|
|
32
|
+
loop only *enriches* counters; treat counters staler than ~250ms as
|
|
33
|
+
zeros.
|
|
34
|
+
- `frameMs` MUST bound `insideRenderMs` (rAF-to-rAF delta, wall time
|
|
35
|
+
inside render).
|
|
36
|
+
- Feed `paused: true` while the tab is hidden or unfocused (and for the
|
|
37
|
+
first frame after refocus) — throttled-tab gaps must never classify as
|
|
38
|
+
hitches.
|
|
39
|
+
- Bind a keyframe chord (Ctrl+F12 recommended) AND a small clickable chip:
|
|
40
|
+
capture the trailing 5s FIRST, then open the package's debugger
|
|
41
|
+
(`createPanel` — Current Session · Issues · Optimizations · Settings + the
|
|
42
|
+
note box) and post the keyframe only if a note comes back. The incident
|
|
43
|
+
rows you hand it carry `fp` (the footprint id) and, for rows that are not
|
|
44
|
+
milliseconds, `label`/`glyph`.
|
|
45
|
+
- Stamp `build`, `phase`, `automated` (navigator.webdriver), `ctx` and
|
|
46
|
+
`footprint` (`footprintOf(record)`) on every posted record — the writer
|
|
47
|
+
names the cause; every reader agrees without re-deriving.
|
|
48
|
+
|
|
49
|
+
### 2b. The jitter detector (the second incident type)
|
|
50
|
+
|
|
51
|
+
Read `docs/JITTER-AND-FOOTPRINTS.md` §1–2, then:
|
|
52
|
+
|
|
53
|
+
- `createMotionMonitor({ unit, longFrameMs: <the sim's dt clamp, ms>,
|
|
54
|
+
tracks: { unit: { floor }, camera: { floor, reach: 'boom', follows: 'unit' } } })`.
|
|
55
|
+
`longFrameMs` MUST be the game's real clamp, found in its loop — never a
|
|
56
|
+
guess; the floor is the smallest pop a player can see in the game's units.
|
|
57
|
+
- Feed once per RENDERED frame, after the render (transient shakes must not
|
|
58
|
+
be sampled): the `unit` track is the point the view is arranged around
|
|
59
|
+
(the pivot — rotation-invariant), NEVER the raw camera; the `camera` track
|
|
60
|
+
is the eye, HELD on any frame that consumed look/zoom input, with `reach`
|
|
61
|
+
= its distance to the pivot.
|
|
62
|
+
- Hold both tracks while paused/unfocused and in phases without a
|
|
63
|
+
continuous view (boot, a cinematic that cuts). Derive a view-configuration
|
|
64
|
+
key (pivot publisher · camera mode · spectate target) and `cut()` when it
|
|
65
|
+
changes — never enumerate call sites.
|
|
66
|
+
- Drain beside the recorder into the same post.
|
|
67
|
+
|
|
68
|
+
### 2c. The situation and footprints
|
|
69
|
+
|
|
70
|
+
Read `docs/JITTER-AND-FOOTPRINTS.md` §3–4, then:
|
|
71
|
+
|
|
72
|
+
- Write a `context()` returning ≤6 LOW-CARDINALITY facets of the player's
|
|
73
|
+
situation (the reference: stance, hull, squad, view, combat) — categories,
|
|
74
|
+
never positions or counters. Refresh `canonicalContext(context())` once a
|
|
75
|
+
second; pass the string as `ctx` to `frame()`, `usermark()` and every
|
|
76
|
+
motion `sample()`.
|
|
77
|
+
- At post, for every record: `r.ctx ??= ctx; const fp = footprintOf(r); if (fp) r.footprint = fp;`.
|
|
78
|
+
- Post a `{type:'heartbeat', medianFrameMs, p95Ms, calls, triangles,
|
|
79
|
+
programs}` ledger line once a minute while armed — the counters are the
|
|
80
|
+
Timeline's draw-call history.
|
|
81
|
+
|
|
82
|
+
## 3. Activation — MUST NOT gate on hostname
|
|
83
|
+
|
|
84
|
+
Probe the ingest endpoint at boot: 204 arms, anything else stays inert,
|
|
85
|
+
and the probe retries on a backoff (5s/30s/2min, then 5min). A dev preview
|
|
86
|
+
behind a proxy looks like production to a hostname test; only the server
|
|
87
|
+
knows what it is.
|
|
88
|
+
|
|
89
|
+
## 4. The sink
|
|
90
|
+
|
|
91
|
+
- Vite host: the vite plugin surface (when present), else the same
|
|
92
|
+
endpoint as below in the dev server.
|
|
93
|
+
- Any other host: one dev-only `POST /api/sloptimize/ingest` accepting
|
|
94
|
+
`{kind: 'profile'|'records'|'census', payload}` → writes
|
|
95
|
+
`.sloptimize/profile.json`, appends `perf.jsonl`, writes `census.json`.
|
|
96
|
+
Refuse with a plain 404 when the dev switch is off — identical to an
|
|
97
|
+
unknown route. Beside it, one dev-gated `GET /api/sloptimize/ledger` →
|
|
98
|
+
`{perf, fixes}` (the ~2MB tail of `perf.jsonl` + all of `fixes.jsonl`,
|
|
99
|
+
raw JSONL) for the debugger's history tabs. Prefer arming by topology (the presence of
|
|
100
|
+
`.sloptimize/budgets.json` in the server cwd) with env overrides, over
|
|
101
|
+
a bare env flag someone must remember.
|
|
102
|
+
- **MUST NOT die silently**: once armed, a refused or failed post flips
|
|
103
|
+
the feed DARK — buffer (bounded, count drops), retry on the backoff,
|
|
104
|
+
and SHOW the state on the chip. The first deployment lost an hour of
|
|
105
|
+
real freezes to "first 404 disables posting".
|
|
106
|
+
|
|
107
|
+
## 5. Budgets and gitignore
|
|
108
|
+
|
|
109
|
+
- Write `.sloptimize/budgets.json` with the game's real limits, e.g.
|
|
110
|
+
`{"perf.budget.frame_ms_p95": 16.7, "perf.budget.draw_calls": 400}`.
|
|
111
|
+
- Gitignore `.sloptimize/*` **except** `budgets.json`.
|
|
112
|
+
|
|
113
|
+
## 6. The Claude Code session surface
|
|
114
|
+
|
|
115
|
+
- If installed as a plugin, the prompt hook ships already. Otherwise add
|
|
116
|
+
the `UserPromptSubmit` hook to the game's `.claude/settings.json`:
|
|
117
|
+
`npx sloptimize hook-status --dir .sloptimize` (add a second `--dir`
|
|
118
|
+
for a shared deployment directory if one exists).
|
|
119
|
+
- Copy the doctrine skill into `.claude/skills/sloptimize/` when not
|
|
120
|
+
running as a plugin.
|
|
121
|
+
|
|
122
|
+
## 7. Verify — the gate
|
|
123
|
+
|
|
124
|
+
1. Start the game server in its dev topology; open the game.
|
|
125
|
+
2. Confirm the chip reads armed (`◉`), or `__sloptimize.feed()` says ok.
|
|
126
|
+
3. Fire a synthetic keyframe: `__sloptimize.mark('install-test')` in the
|
|
127
|
+
console (or the chord).
|
|
128
|
+
4. Run `npx sloptimize report --dir <game>/.sloptimize` and confirm the
|
|
129
|
+
`install-test` usermark is in it — with a `footprint` and a `ctx` on the
|
|
130
|
+
ledger line.
|
|
131
|
+
5. Run `npx sloptimize check` and report the budget verdict.
|
|
132
|
+
6. Jitter: `__sloptimize.motion()` (or your equivalent) shows both tracks
|
|
133
|
+
sampling with `held` counting up on look input; force one jump of the
|
|
134
|
+
unit (move the body/hull by a metre in one frame from the console) and
|
|
135
|
+
confirm a `jitter` record with `kind: 'snap'` and the right `units` lands
|
|
136
|
+
— and that a camera flick, a mode flip and a respawn land NOTHING.
|
|
137
|
+
7. `npx sloptimize issues --dir <game>/.sloptimize` lists the footprints
|
|
138
|
+
seen so far, the `install-test` keyframe among them.
|
|
139
|
+
|
|
140
|
+
Only after 4 succeeds may you tell the user the install is complete —
|
|
141
|
+
report what was wired, the verify evidence, and the one daily obligation
|
|
142
|
+
that remains (start the server in its dev topology; everything else is
|
|
143
|
+
automatic).
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: sloptimize
|
|
3
|
+
description: Use when investigating or fixing rendering performance in a three.js game instrumented with sloptimize — reading .sloptimize/ evidence, classifying hitches, attributing cost, and verifying fixes by measurement.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# The sloptimize loop
|
|
7
|
+
|
|
8
|
+
**Never claim a performance fix without a measured before/after.** A fix
|
|
9
|
+
without one is a hypothesis.
|
|
10
|
+
|
|
11
|
+
The playbook, in order:
|
|
12
|
+
1. `sloptimize report --dir <game>/.sloptimize` — the current profile,
|
|
13
|
+
recorded hitches (each classified WITH evidence), usermarks (the human's
|
|
14
|
+
Ctrl+F11 "it felt wrong here" captures), and census hints.
|
|
15
|
+
2. Classify before touching code: shader-compile / texture-upload /
|
|
16
|
+
spawn-burst / long-render / long-script / gc-or-upload-by-elimination.
|
|
17
|
+
The record's `evidence` string says why the guess exists.
|
|
18
|
+
3. `sloptimize census` — per-entity meshes/triangles/materials/shadow
|
|
19
|
+
casters, plus the closed hint vocabulary (instancing-candidate,
|
|
20
|
+
material-dedup-candidate, oversized-texture, undisposed-suspect).
|
|
21
|
+
4. ONE change at a time.
|
|
22
|
+
5. Verify: counters (draw calls, triangles, programs) compare EXACTLY on any
|
|
23
|
+
renderer, including software rasterizers. Timing numbers only count in
|
|
24
|
+
`regime: hardware`; never quote a timing from a `software` regime.
|
|
25
|
+
6. `sloptimize check` against `.sloptimize/budgets.json` — "fast enough" is
|
|
26
|
+
an exit code, and distance-to-budget is part of every verdict you report.
|
|
27
|
+
7. Record the fix once the new build has ledger evidence:
|
|
28
|
+
`sloptimize fix --title "…" --issue "…" --solution "…" --commit <sha>`
|
|
29
|
+
(MCP: `record_fix`). The before/after are measured windows of the
|
|
30
|
+
ledger — previous build vs new build by default — never numbers you
|
|
31
|
+
type. `sloptimize history` and the debugger's Fixes tab show it; that is
|
|
32
|
+
how the human sees what the loop bought.
|
|
33
|
+
|
|
34
|
+
The fix loop (git only): a verified fix is PROPOSED, never pushed to main —
|
|
35
|
+
`sloptimize fix propose --title "…" --issue "…" --solution "…"` turns the
|
|
36
|
+
working tree (or the current branch) into a `sloptimize/<slug>` branch plus
|
|
37
|
+
a ledger entry the debugger's Fixes tab lists with merge/reject. Read
|
|
38
|
+
`sloptimize policy` first: `propose` means stop there; `merge` means
|
|
39
|
+
`sloptimize fix merge <id>` yourself once the tests are green. A repo
|
|
40
|
+
without git says so — nothing to do until `git init`.
|
|
41
|
+
|
|
42
|
+
↯ jitter records (SPEC §3.6) are the OTHER incident: the unit or the camera
|
|
43
|
+
landed off its own trajectory — a `snap` (one displacement, motion resumed
|
|
44
|
+
from the new place) or an `oscillation` (frame-to-frame reversals: a
|
|
45
|
+
fixed-step sim drawn without interpolation, or two writers on one
|
|
46
|
+
transform). This is a correctness incident, not a timing one — census and
|
|
47
|
+
counters will not explain it. Read the record: `track`, `jump` per axis (a
|
|
48
|
+
vertical-only jump is a step or a ground snap; horizontal is a correction
|
|
49
|
+
or a teleport), `speed`, `dtMs`, `coincident`, `reach`. `reach-change` =
|
|
50
|
+
the camera's boom clamped or zoomed; `follows-track` = the camera was a
|
|
51
|
+
passenger — work the unit's record; `long-frame-catch-up` = the stall it
|
|
52
|
+
rode is the incident (already woke as a hitch). Find the writer of that
|
|
53
|
+
transform in that phase and fix the discontinuity at its source. Verify
|
|
54
|
+
with `sloptimize history` (jitters/h before → after) and a played session
|
|
55
|
+
— never by raising the floor.
|
|
56
|
+
|
|
57
|
+
Every wake line ends with the record's FOOTPRINT — `fp=<id> ×N` (SPEC §3.7):
|
|
58
|
+
the identity of the cause (kind · phase · verdict · site · the game's
|
|
59
|
+
situation: hull, stance, squad, combat), and how many times this ledger has
|
|
60
|
+
seen it. ×1 is new; ×40 is the same issue again — read
|
|
61
|
+
`sloptimize issues --fp <id>` FIRST: its history, and every fix already
|
|
62
|
+
applied to it (do not re-propose one). When you record a fix, name the
|
|
63
|
+
footprints it addresses: `sloptimize fix propose --footprints <id>,<id> …`
|
|
64
|
+
— that is how the Issues tab and the catalogue show "which fixes were
|
|
65
|
+
applied", and how the before/after says whether yours landed.
|
|
66
|
+
|
|
67
|
+
Push channel: if a `sloptimize watch` Monitor is armed in this session,
|
|
68
|
+
its lines (★ usermark / ⚡ hitch / ⏳ gpu / ◌ feed quiet) arrive as
|
|
69
|
+
notifications; each one starts this playbook unprompted at step 1.
|
|
70
|
+
|
|
71
|
+
Trust rules: census estimates are estimates (the field names say so); an
|
|
72
|
+
absent field means unmeasured, never zero; `long-script` hitches escalate to
|
|
73
|
+
a CPU profiler (Chrome DevTools), per-draw GL state to Spector — sloptimize
|
|
74
|
+
answers WHICH entity, WHICH frame, WHICH workload, only.
|