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
|
@@ -0,0 +1,268 @@
|
|
|
1
|
+
// ============================================================
|
|
2
|
+
// proposals.mjs — the fix loop, in git and nothing else (SPEC §8.5)
|
|
3
|
+
// ============================================================
|
|
4
|
+
// A fix an agent wants to land is a PROPOSAL: a `sloptimize/<slug>` branch
|
|
5
|
+
// holding the change, and one entry in `.sloptimize/fixes.jsonl` naming the
|
|
6
|
+
// issue, the solution, the commit and the measured before/after. The
|
|
7
|
+
// debugger lists proposals with a status READ FROM GIT — proposed while the
|
|
8
|
+
// branch stands ahead of main, merged once its commit is an ancestor of
|
|
9
|
+
// main, rejected once the branch is gone — and offers merge / reject, which
|
|
10
|
+
// are a merge commit into main and a branch delete. There is no pull
|
|
11
|
+
// request, no forge API, no token: a repo with no remote works identically,
|
|
12
|
+
// and one with a remote gets the branch and main pushed.
|
|
13
|
+
//
|
|
14
|
+
// The status entries appended here (`fix-status`) are the audit trail; the
|
|
15
|
+
// live truth is git, and `listFixes` prefers it.
|
|
16
|
+
import { existsSync, readFileSync, appendFileSync, writeFileSync, mkdirSync } from 'node:fs';
|
|
17
|
+
import { join, relative, isAbsolute } from 'node:path';
|
|
18
|
+
import { execFileSync } from 'node:child_process';
|
|
19
|
+
|
|
20
|
+
export const NOT_A_REPO = "this project isn't a git repo — run `git init` (and commit) before sloptimize can propose or merge fixes";
|
|
21
|
+
export const AUTOMATION_LEVELS = ['propose', 'merge'];
|
|
22
|
+
|
|
23
|
+
function git(cwd, args, opts = {}) {
|
|
24
|
+
return execFileSync('git', args, { cwd, stdio: ['ignore', 'pipe', 'pipe'], ...opts }).toString().trim();
|
|
25
|
+
}
|
|
26
|
+
function tryGit(cwd, args) { try { return git(cwd, args); } catch { return null; } }
|
|
27
|
+
|
|
28
|
+
export function isGitRepo(repoDir) {
|
|
29
|
+
return tryGit(repoDir, ['rev-parse', '--is-inside-work-tree']) === 'true';
|
|
30
|
+
}
|
|
31
|
+
function requireRepo(repoDir) { if (!isGitRepo(repoDir)) throw new Error(NOT_A_REPO); }
|
|
32
|
+
|
|
33
|
+
/** The integration branch: `main` if it exists, else `master`, else the
|
|
34
|
+
* current branch. */
|
|
35
|
+
export function mainBranch(repoDir) {
|
|
36
|
+
for (const b of ['main', 'master']) if (tryGit(repoDir, ['rev-parse', '--verify', '-q', `refs/heads/${b}`])) return b;
|
|
37
|
+
return git(repoDir, ['branch', '--show-current']);
|
|
38
|
+
}
|
|
39
|
+
function hasRemote(repoDir) { return (tryGit(repoDir, ['remote']) ?? '') !== ''; }
|
|
40
|
+
const short = (sha) => sha.slice(0, 12);
|
|
41
|
+
|
|
42
|
+
export function slugOf(title) {
|
|
43
|
+
return String(title).toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 48) || 'fix';
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// ── ledger ──────────────────────────────────────────────────────────────────
|
|
47
|
+
function readJsonl(path) {
|
|
48
|
+
if (!existsSync(path)) return [];
|
|
49
|
+
return readFileSync(path, 'utf8').split('\n').filter(Boolean).map((l) => { try { return JSON.parse(l); } catch { return null; } }).filter(Boolean);
|
|
50
|
+
}
|
|
51
|
+
function append(dir, rec) {
|
|
52
|
+
mkdirSync(dir, { recursive: true });
|
|
53
|
+
appendFileSync(join(dir, 'fixes.jsonl'), JSON.stringify(rec) + '\n');
|
|
54
|
+
}
|
|
55
|
+
/** Fold fix entries and their status lines into one record per fix. */
|
|
56
|
+
function foldFixes(dir) {
|
|
57
|
+
const byId = new Map();
|
|
58
|
+
for (const r of readJsonl(join(dir, 'fixes.jsonl'))) {
|
|
59
|
+
// A record-only entry (`sloptimize fix --title …`, or an older ledger)
|
|
60
|
+
// has no id: derive a stable one so it lists beside the proposals.
|
|
61
|
+
if (r.type === 'fix') { const id = r.id ?? `rec-${(r.at ?? '').replace(/[^0-9]/g, '')}-${slugOf(r.title ?? '').slice(0, 24)}`; byId.set(id, { ...r, id }); }
|
|
62
|
+
else if (r.type === 'fix-status' && byId.has(r.id)) Object.assign(byId.get(r.id), { status: r.status, statusAt: r.at, ...(r.mergeCommit ? { mergeCommit: r.mergeCommit } : {}) });
|
|
63
|
+
}
|
|
64
|
+
return [...byId.values()];
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// ── settings ────────────────────────────────────────────────────────────────
|
|
68
|
+
export function readSettings(dir) {
|
|
69
|
+
try {
|
|
70
|
+
const s = JSON.parse(readFileSync(join(dir, 'settings.json'), 'utf8'));
|
|
71
|
+
return { automation: AUTOMATION_LEVELS.includes(s.automation) ? s.automation : 'propose' };
|
|
72
|
+
} catch { return { automation: 'propose' }; }
|
|
73
|
+
}
|
|
74
|
+
export function writeSettings(dir, settings) {
|
|
75
|
+
if (!AUTOMATION_LEVELS.includes(settings.automation)) throw new Error(`automation must be one of ${AUTOMATION_LEVELS.join(' | ')}`);
|
|
76
|
+
mkdirSync(dir, { recursive: true });
|
|
77
|
+
const next = { automation: settings.automation };
|
|
78
|
+
writeFileSync(join(dir, 'settings.json'), JSON.stringify(next, null, 2) + '\n');
|
|
79
|
+
return next;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// ── propose ─────────────────────────────────────────────────────────────────
|
|
83
|
+
/**
|
|
84
|
+
* Turn the working tree's changes (or the current branch's unmerged commits)
|
|
85
|
+
* into a proposal. `measure(records)` is optional: given, it returns the
|
|
86
|
+
* measured before/after windows (history.buildFix); absent or throwing, the
|
|
87
|
+
* proposal carries `before: null, after: null` — a fix may be proposed
|
|
88
|
+
* before its after-data exists, and the numbers arrive when it is played.
|
|
89
|
+
*/
|
|
90
|
+
export function proposeFix(repoDir, dir, opts) {
|
|
91
|
+
requireRepo(repoDir);
|
|
92
|
+
if (!opts?.title) throw new Error('a proposal needs a title');
|
|
93
|
+
const main = mainBranch(repoDir);
|
|
94
|
+
// Dirty = anything outside the ledger dir itself. Match on the directory
|
|
95
|
+
// boundary: a sibling file whose NAME starts with the dir's (".sloptimize-x")
|
|
96
|
+
// is a change to propose, not the ledger.
|
|
97
|
+
const ledgerRel = relative(repoDir, dir);
|
|
98
|
+
const inLedger = (path) => !!ledgerRel && !ledgerRel.startsWith('..') && (path === ledgerRel || path.startsWith(ledgerRel + '/'));
|
|
99
|
+
const dirty = git(repoDir, ['status', '--porcelain', '--untracked-files=all']).split('\n').filter(Boolean)
|
|
100
|
+
.some((l) => !inLedger(l.slice(3).replace(/^"|"$/g, '')));
|
|
101
|
+
const current = git(repoDir, ['branch', '--show-current']);
|
|
102
|
+
let branch = opts.branch ?? (current.startsWith('sloptimize/') ? current : `sloptimize/${slugOf(opts.title)}`);
|
|
103
|
+
if (!dirty) {
|
|
104
|
+
const ahead = Number(tryGit(repoDir, ['rev-list', '--count', `${main}..HEAD`]) ?? 0);
|
|
105
|
+
if (ahead === 0 || current === main) throw new Error('nothing to propose: the working tree is clean and HEAD is not ahead of ' + main);
|
|
106
|
+
branch = opts.branch ?? current; // already committed on a branch: propose that branch as it stands
|
|
107
|
+
} else {
|
|
108
|
+
if (current !== branch) {
|
|
109
|
+
if (tryGit(repoDir, ['rev-parse', '--verify', '-q', `refs/heads/${branch}`])) git(repoDir, ['checkout', '-q', branch]);
|
|
110
|
+
else git(repoDir, ['checkout', '-q', '-b', branch]);
|
|
111
|
+
}
|
|
112
|
+
// Never stage the ledger itself, wherever it lives relative to the repo:
|
|
113
|
+
// stage everything, then unstage the ledger dir (a no-op when, as in the
|
|
114
|
+
// reference deployment, it is gitignored — an exclude pathspec on an
|
|
115
|
+
// ignored path makes git refuse instead).
|
|
116
|
+
git(repoDir, ['add', '-A']);
|
|
117
|
+
const rel = relative(repoDir, dir);
|
|
118
|
+
if (rel && !rel.startsWith('..') && !isAbsolute(rel)) tryGit(repoDir, ['reset', '-q', '--', rel]);
|
|
119
|
+
git(repoDir, ['commit', '-q', '-m', opts.message ?? opts.title]);
|
|
120
|
+
}
|
|
121
|
+
const commit = short(git(repoDir, ['rev-parse', branch]));
|
|
122
|
+
const base = short(git(repoDir, ['merge-base', main, branch]));
|
|
123
|
+
// The proposal lives on its branch; the checkout goes back to where the
|
|
124
|
+
// agent was working. Its change has MOVED to the branch, so the tree is
|
|
125
|
+
// clean on return — a session mid-ticket is not left parked on a
|
|
126
|
+
// sloptimize/ branch it never asked for.
|
|
127
|
+
if (dirty && current !== branch) git(repoDir, ['checkout', '-q', current]);
|
|
128
|
+
let pushed = false;
|
|
129
|
+
if (hasRemote(repoDir) && opts.push !== false) {
|
|
130
|
+
try { git(repoDir, ['push', '-q', '-u', 'origin', branch]); pushed = true; } catch { pushed = false; }
|
|
131
|
+
}
|
|
132
|
+
let before = null, after = null;
|
|
133
|
+
if (opts.measure) { try { ({ before, after } = opts.measure()); } catch { /* no evidence yet */ } }
|
|
134
|
+
const fix = {
|
|
135
|
+
type: 'fix', id: `${Date.now().toString(36)}-${slugOf(opts.title).slice(0, 24)}`,
|
|
136
|
+
at: new Date().toISOString(), title: opts.title,
|
|
137
|
+
...(opts.issue ? { issue: opts.issue } : {}), ...(opts.solution ? { solution: opts.solution } : {}),
|
|
138
|
+
...(opts.files ? { files: opts.files } : {}),
|
|
139
|
+
...(opts.footprints?.length ? { footprints: opts.footprints } : {}),
|
|
140
|
+
branch, commit, base, main, from: current, pushed, status: 'proposed', before, after,
|
|
141
|
+
};
|
|
142
|
+
append(dir, fix);
|
|
143
|
+
return fix;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// ── pull requests, from git alone ───────────────────────────────────────────
|
|
147
|
+
// GitHub publishes every PR's head as `refs/pull/<n>/head`, readable with a
|
|
148
|
+
// plain `git ls-remote` and no token. A fix whose branch head (or recorded
|
|
149
|
+
// commit) is one of those heads IS that PR; the panel gets a link. Any other
|
|
150
|
+
// forge, or no match: no `pr`, nothing else changes.
|
|
151
|
+
|
|
152
|
+
/** {owner, repo, url} when origin is on github.com, else null. */
|
|
153
|
+
export function githubOrigin(remoteUrl) {
|
|
154
|
+
const m = /github\.com[:/]([^/\s]+)\/([^/\s]+?)(?:\.git)?\/?$/.exec(remoteUrl ?? '');
|
|
155
|
+
return m ? { owner: m[1], repo: m[2], url: `https://github.com/${m[1]}/${m[2]}` } : null;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/** Parse `git ls-remote` output into [{number, sha}] for pull heads. */
|
|
159
|
+
export function parsePullRefs(text) {
|
|
160
|
+
const out = [];
|
|
161
|
+
for (const line of String(text ?? '').split('\n')) {
|
|
162
|
+
const m = /^([0-9a-f]{40})\s+refs\/pull\/(\d+)\/head$/.exec(line.trim());
|
|
163
|
+
if (m) out.push({ number: Number(m[2]), sha: m[1] });
|
|
164
|
+
}
|
|
165
|
+
return out;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/** The PR a fix corresponds to — by its branch head, else its recorded
|
|
169
|
+
* commit (a short sha is a prefix of the head). Highest number wins when a
|
|
170
|
+
* head was reused. */
|
|
171
|
+
export function matchPR(fix, refs, origin) {
|
|
172
|
+
if (!origin || !refs?.length) return null;
|
|
173
|
+
const heads = [fix.head, fix.commit].filter(Boolean);
|
|
174
|
+
let best = null;
|
|
175
|
+
for (const r of refs) {
|
|
176
|
+
if (heads.some((h) => r.sha.startsWith(h) || h.startsWith(r.sha))) { if (!best || r.number > best.number) best = r; }
|
|
177
|
+
}
|
|
178
|
+
return best ? { number: best.number, url: `${origin.url}/pull/${best.number}` } : null;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
const PULL_REFS_TTL_MS = 60_000;
|
|
182
|
+
const _pullRefsCache = new Map(); // repoDir → { at, refs, origin }
|
|
183
|
+
function pullRefs(repoDir) {
|
|
184
|
+
const hit = _pullRefsCache.get(repoDir);
|
|
185
|
+
if (hit && Date.now() - hit.at < PULL_REFS_TTL_MS) return hit;
|
|
186
|
+
const origin = githubOrigin(tryGit(repoDir, ['remote', 'get-url', 'origin']));
|
|
187
|
+
let refs = [];
|
|
188
|
+
if (origin) {
|
|
189
|
+
try { refs = parsePullRefs(execFileSync('git', ['ls-remote', 'origin', 'refs/pull/*/head'], { cwd: repoDir, stdio: ['ignore', 'pipe', 'ignore'], timeout: 8_000 }).toString()); }
|
|
190
|
+
catch { refs = []; }
|
|
191
|
+
}
|
|
192
|
+
const entry = { at: Date.now(), refs, origin };
|
|
193
|
+
_pullRefsCache.set(repoDir, entry);
|
|
194
|
+
return entry;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
// ── list ────────────────────────────────────────────────────────────────────
|
|
198
|
+
/** Every proposal with its status as git sees it now. */
|
|
199
|
+
export function listFixes(repoDir, dir) {
|
|
200
|
+
if (!isGitRepo(repoDir)) return { repo: false, error: NOT_A_REPO, fixes: [] };
|
|
201
|
+
const main = mainBranch(repoDir);
|
|
202
|
+
const mainHead = git(repoDir, ['rev-parse', main]);
|
|
203
|
+
const { refs, origin } = pullRefs(repoDir);
|
|
204
|
+
const withPR = (f) => { const pr = matchPR(f, refs, origin); return pr ? { ...f, pr } : f; };
|
|
205
|
+
const fixes = foldFixes(dir).map((f) => {
|
|
206
|
+
if (!f.branch || !f.commit) return withPR({ ...f, status: f.status ?? 'recorded' });
|
|
207
|
+
const branchHead = tryGit(repoDir, ['rev-parse', '--verify', '-q', `refs/heads/${f.branch}`]);
|
|
208
|
+
const merged = tryGit(repoDir, ['merge-base', '--is-ancestor', f.commit, main]) !== null;
|
|
209
|
+
let status = f.status;
|
|
210
|
+
if (merged) status = 'merged';
|
|
211
|
+
else if (!branchHead) status = f.status === 'rejected' ? 'rejected' : 'orphaned';
|
|
212
|
+
else status = 'proposed';
|
|
213
|
+
const mergeBase = branchHead ? tryGit(repoDir, ['merge-base', main, branchHead]) : null;
|
|
214
|
+
const ahead = branchHead ? Number(tryGit(repoDir, ['rev-list', '--count', `${main}..${branchHead}`]) ?? 0) : 0;
|
|
215
|
+
return withPR({ ...f, status, ahead, upToDate: mergeBase !== null && mergeBase === mainHead, head: branchHead ? short(branchHead) : null });
|
|
216
|
+
}).sort((a, b) => Date.parse(b.at) - Date.parse(a.at));
|
|
217
|
+
return { repo: true, main, ...(origin ? { origin: origin.url } : {}), fixes };
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
// ── merge / reject ──────────────────────────────────────────────────────────
|
|
221
|
+
function findFix(dir, id) {
|
|
222
|
+
const f = foldFixes(dir).find((x) => x.id === id);
|
|
223
|
+
if (!f) throw new Error(`no proposal with id ${id}`);
|
|
224
|
+
return f;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/** Merge a proposal into main with a merge commit; push main if a remote
|
|
228
|
+
* exists. Refuses anything that is not a plain, current, unmerged proposal:
|
|
229
|
+
* the direct-push-to-main class of accident is exactly what this must not
|
|
230
|
+
* be a new door for. */
|
|
231
|
+
export function mergeFix(repoDir, dir, id) {
|
|
232
|
+
requireRepo(repoDir);
|
|
233
|
+
const f = findFix(dir, id);
|
|
234
|
+
const main = mainBranch(repoDir);
|
|
235
|
+
if (tryGit(repoDir, ['merge-base', '--is-ancestor', f.commit, main]) !== null) throw new Error(`already merged: ${f.title}`);
|
|
236
|
+
const current = git(repoDir, ['branch', '--show-current']);
|
|
237
|
+
if (current !== main) throw new Error(`checkout is on '${current}', not '${main}' — merge from the ${main} checkout`);
|
|
238
|
+
if (git(repoDir, ['status', '--porcelain', '--untracked-files=no']) !== '') throw new Error(`the ${main} checkout has uncommitted changes — commit or discard them first`);
|
|
239
|
+
const branchHead = tryGit(repoDir, ['rev-parse', '--verify', '-q', `refs/heads/${f.branch}`]);
|
|
240
|
+
if (!branchHead) throw new Error(`branch ${f.branch} no longer exists`);
|
|
241
|
+
if (hasRemote(repoDir)) { tryGit(repoDir, ['fetch', '-q', 'origin', main]); }
|
|
242
|
+
const mainHead = git(repoDir, ['rev-parse', main]);
|
|
243
|
+
const remoteMain = hasRemote(repoDir) ? tryGit(repoDir, ['rev-parse', `origin/${main}`]) : null;
|
|
244
|
+
if (remoteMain && remoteMain !== mainHead) throw new Error(`local ${main} is not at origin/${main} — pull first`);
|
|
245
|
+
if (git(repoDir, ['merge-base', main, branchHead]) !== mainHead) throw new Error(`proposal is not based on current ${main} — rebase ${f.branch} first`);
|
|
246
|
+
git(repoDir, ['merge', '-q', '--no-ff', '-m', `Merge sloptimize fix: ${f.title}`, branchHead]);
|
|
247
|
+
const mergeCommit = short(git(repoDir, ['rev-parse', 'HEAD']));
|
|
248
|
+
let pushed = false;
|
|
249
|
+
if (hasRemote(repoDir)) { try { git(repoDir, ['push', '-q', 'origin', main]); pushed = true; } catch { pushed = false; } }
|
|
250
|
+
const status = { type: 'fix-status', id, at: new Date().toISOString(), status: 'merged', mergeCommit, pushed };
|
|
251
|
+
append(dir, status);
|
|
252
|
+
return status;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/** Delete a proposal's branch (locally, and on the remote if pushed). */
|
|
256
|
+
export function rejectFix(repoDir, dir, id) {
|
|
257
|
+
requireRepo(repoDir);
|
|
258
|
+
const f = findFix(dir, id);
|
|
259
|
+
const main = mainBranch(repoDir);
|
|
260
|
+
if (tryGit(repoDir, ['merge-base', '--is-ancestor', f.commit, main]) !== null) throw new Error(`already merged: ${f.title}`);
|
|
261
|
+
const current = git(repoDir, ['branch', '--show-current']);
|
|
262
|
+
if (current === f.branch) git(repoDir, ['checkout', '-q', f.from && f.from !== f.branch ? f.from : main]);
|
|
263
|
+
tryGit(repoDir, ['branch', '-D', f.branch]);
|
|
264
|
+
if (hasRemote(repoDir)) tryGit(repoDir, ['push', '-q', 'origin', '--delete', f.branch]);
|
|
265
|
+
const status = { type: 'fix-status', id, at: new Date().toISOString(), status: 'rejected' };
|
|
266
|
+
append(dir, status);
|
|
267
|
+
return status;
|
|
268
|
+
}
|
package/src/recorder.js
ADDED
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
// ============================================================
|
|
2
|
+
// recorder.js — the flight recorder (SPEC §3, plus §3.5 usermark)
|
|
3
|
+
// ============================================================
|
|
4
|
+
// Framework-agnostic: the host calls `frame(sample)` once per render with
|
|
5
|
+
// numbers it already has (three's renderer.info + two clock reads). The
|
|
6
|
+
// recorder owns the ring, the hitch math, the rate limits and the summaries;
|
|
7
|
+
// it allocates nothing on the steady path (pre-allocated ring, records only
|
|
8
|
+
// when a hitch or a usermark actually happens).
|
|
9
|
+
//
|
|
10
|
+
// The host decides transport: `drainRecords()` hands back and clears whatever
|
|
11
|
+
// accumulated; the caller ships them wherever its sink lives. The recorder
|
|
12
|
+
// must never be the hitch it reports — no JSON, no strings, no closures per
|
|
13
|
+
// frame.
|
|
14
|
+
|
|
15
|
+
import { classifyHitch } from './classify.js';
|
|
16
|
+
|
|
17
|
+
const RING = 600; // ~10s at 60fps (SPEC §3.1)
|
|
18
|
+
const MAX_RECORDS_PER_SESSION = 500;
|
|
19
|
+
const MIN_RECORD_GAP_MS = 1000; // at most 1 hitch record per second
|
|
20
|
+
|
|
21
|
+
const FIELDS = ['frameMs', 'insideRenderMs', 'calls', 'triangles', 'programs',
|
|
22
|
+
'textures', 'geometries', 'spawned'];
|
|
23
|
+
|
|
24
|
+
export function createRecorder(opts = {}) {
|
|
25
|
+
const now = opts.now ?? (() => (typeof performance !== 'undefined' ? performance.now() : Date.now()));
|
|
26
|
+
const budgetFrameMs = opts.budgetFrameMs ?? 16.7;
|
|
27
|
+
|
|
28
|
+
// The ring: one Float64Array lane per field plus paused/timestamps lanes.
|
|
29
|
+
const lanes = Object.fromEntries(FIELDS.map((f) => [f, new Float64Array(RING)]));
|
|
30
|
+
const pausedLane = new Uint8Array(RING);
|
|
31
|
+
const atLane = new Float64Array(RING);
|
|
32
|
+
let head = 0; // next write index
|
|
33
|
+
let count = 0; // filled slots (≤ RING)
|
|
34
|
+
let frameNo = 0;
|
|
35
|
+
|
|
36
|
+
// Rolling median over the last window of NON-paused frames, recomputed
|
|
37
|
+
// lazily at a coarse cadence — a per-frame exact median would sort 600
|
|
38
|
+
// numbers every frame for a threshold that moves slowly.
|
|
39
|
+
let cachedMedian = budgetFrameMs;
|
|
40
|
+
let medianStale = 60;
|
|
41
|
+
|
|
42
|
+
let records = [];
|
|
43
|
+
let sessionRecords = 0;
|
|
44
|
+
let droppedSinceLast = 0;
|
|
45
|
+
let lastRecordAt = -Infinity;
|
|
46
|
+
|
|
47
|
+
function sortedNonPaused(field, sinceIdx = 0) {
|
|
48
|
+
const vals = [];
|
|
49
|
+
for (let i = 0; i < count; i++) {
|
|
50
|
+
const idx = (head - 1 - i + RING * 2) % RING;
|
|
51
|
+
if (pausedLane[idx]) continue;
|
|
52
|
+
vals.push(lanes[field][idx]);
|
|
53
|
+
}
|
|
54
|
+
vals.sort((a, b) => a - b);
|
|
55
|
+
return vals;
|
|
56
|
+
}
|
|
57
|
+
function pct(sorted, p) {
|
|
58
|
+
if (sorted.length === 0) return null;
|
|
59
|
+
return sorted[Math.min(sorted.length - 1, Math.floor(sorted.length * p))];
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function rollingMedian() {
|
|
63
|
+
if (--medianStale <= 0 || cachedMedian === null) {
|
|
64
|
+
const s = sortedNonPaused('frameMs');
|
|
65
|
+
cachedMedian = pct(s, 0.5) ?? budgetFrameMs;
|
|
66
|
+
medianStale = 60;
|
|
67
|
+
}
|
|
68
|
+
return cachedMedian;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function prevIdx(back = 1) { return (head - 1 - back + RING * 2) % RING; }
|
|
72
|
+
|
|
73
|
+
return {
|
|
74
|
+
/** One frame's numbers. Zero-allocation on the steady path. */
|
|
75
|
+
frame(s) {
|
|
76
|
+
const idx = head;
|
|
77
|
+
for (const f of FIELDS) lanes[f][idx] = s[f] ?? 0;
|
|
78
|
+
pausedLane[idx] = s.paused ? 1 : 0;
|
|
79
|
+
atLane[idx] = now();
|
|
80
|
+
head = (head + 1) % RING;
|
|
81
|
+
if (count < RING) count++;
|
|
82
|
+
frameNo++;
|
|
83
|
+
|
|
84
|
+
if (s.paused) return;
|
|
85
|
+
const median = rollingMedian();
|
|
86
|
+
const threshold = Math.max(2 * median, budgetFrameMs * 1.5);
|
|
87
|
+
if (s.frameMs <= threshold || count < 30) return;
|
|
88
|
+
|
|
89
|
+
// A hitch. Rate limits first (SPEC §3.3): silence must mean nothing
|
|
90
|
+
// was dropped, so the drops are counted and reported on the NEXT record.
|
|
91
|
+
const t = now();
|
|
92
|
+
if (t - lastRecordAt < MIN_RECORD_GAP_MS || sessionRecords >= MAX_RECORDS_PER_SESSION) {
|
|
93
|
+
droppedSinceLast++;
|
|
94
|
+
return;
|
|
95
|
+
}
|
|
96
|
+
lastRecordAt = t;
|
|
97
|
+
sessionRecords++;
|
|
98
|
+
|
|
99
|
+
const prev = prevIdx(1);
|
|
100
|
+
const delta = {};
|
|
101
|
+
for (const f of ['calls', 'triangles', 'programs', 'textures', 'geometries']) {
|
|
102
|
+
delta[f] = count > 1 ? lanes[f][idx] - lanes[f][prev] : 0;
|
|
103
|
+
}
|
|
104
|
+
const rec = {
|
|
105
|
+
type: 'hitch',
|
|
106
|
+
at: new Date().toISOString(),
|
|
107
|
+
frame: frameNo,
|
|
108
|
+
frameMs: s.frameMs,
|
|
109
|
+
medianMs: +median.toFixed(2),
|
|
110
|
+
insideRenderMs: s.insideRenderMs ?? 0,
|
|
111
|
+
delta,
|
|
112
|
+
classification: classifyHitch({
|
|
113
|
+
frameMs: s.frameMs, medianMs: median, insideRenderMs: s.insideRenderMs ?? 0,
|
|
114
|
+
delta, spawned: s.spawned ?? 0, memorySampled: !!s.memorySampled,
|
|
115
|
+
}),
|
|
116
|
+
};
|
|
117
|
+
if (s.world) rec.world = s.world;
|
|
118
|
+
// Phase is a string the HOST passes per frame (menu/boot/launch/match…)
|
|
119
|
+
// — stamped at mint time so the record names the moment the hitch
|
|
120
|
+
// happened, not the moment it was drained/posted (drains run on a 2s
|
|
121
|
+
// cadence, and a launch is over in less).
|
|
122
|
+
if (s.phase) rec.phase = s.phase;
|
|
123
|
+
// The host's situation facets (SPEC §3.7), a canonical string the host
|
|
124
|
+
// refreshes on its own cadence — stamped at mint like the phase.
|
|
125
|
+
if (s.ctx) rec.ctx = s.ctx;
|
|
126
|
+
if (droppedSinceLast > 0) { rec.droppedSinceLast = droppedSinceLast; droppedSinceLast = 0; }
|
|
127
|
+
records.push(rec);
|
|
128
|
+
},
|
|
129
|
+
|
|
130
|
+
/** SPEC §3.2 — the rolling summary. Absent fields stay absent. */
|
|
131
|
+
summary() {
|
|
132
|
+
const sortedMs = sortedNonPaused('frameMs');
|
|
133
|
+
const seconds = count > 1
|
|
134
|
+
? (atLane[prevIdx(0)] - atLane[prevIdx(count - 1)]) / 1000
|
|
135
|
+
: 0;
|
|
136
|
+
const last = prevIdx(0);
|
|
137
|
+
const median = pct(sortedMs, 0.5);
|
|
138
|
+
const s = {
|
|
139
|
+
at: new Date().toISOString(),
|
|
140
|
+
window: { frames: sortedMs.length, seconds: +Math.max(0, seconds).toFixed(1) },
|
|
141
|
+
frame: {},
|
|
142
|
+
render: {}, memory: {},
|
|
143
|
+
paused: count > 0 ? pausedLane[last] === 1 : false,
|
|
144
|
+
};
|
|
145
|
+
if (median !== null) {
|
|
146
|
+
s.frame.medianMs = +median.toFixed(2);
|
|
147
|
+
s.frame.p95Ms = +pct(sortedMs, 0.95).toFixed(2);
|
|
148
|
+
s.frame.fps = median > 0 ? Math.round(1000 / median) : 0;
|
|
149
|
+
const inside = sortedNonPaused('insideRenderMs');
|
|
150
|
+
s.frame.insideRenderMs = +pct(inside, 0.5).toFixed(2);
|
|
151
|
+
}
|
|
152
|
+
if (count > 0) {
|
|
153
|
+
s.render.calls = lanes.calls[last];
|
|
154
|
+
s.render.triangles = lanes.triangles[last];
|
|
155
|
+
s.memory.geometries = lanes.geometries[last];
|
|
156
|
+
s.memory.textures = lanes.textures[last];
|
|
157
|
+
s.memory.programs = lanes.programs[last];
|
|
158
|
+
}
|
|
159
|
+
return s;
|
|
160
|
+
},
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* §3.5 — USERMARK. The human's half of hitch detection: they FELT it, so
|
|
164
|
+
* they press the key and the recorder freezes the evidence — the trailing
|
|
165
|
+
* `windowMs` of ring samples, summarized, with the worst frames ranked
|
|
166
|
+
* and each classified exactly like an automatic hitch. Exists because the
|
|
167
|
+
* automatic threshold cannot see "it feels wrong" (steady-but-low fps,
|
|
168
|
+
* micro-stutter under the hitch bar), and because a human timestamp turns
|
|
169
|
+
* ten seconds of ring into a labeled training example.
|
|
170
|
+
*/
|
|
171
|
+
usermark(meta = {}) {
|
|
172
|
+
const windowMs = meta.windowMs ?? 5000;
|
|
173
|
+
const tNow = now();
|
|
174
|
+
const idxs = [];
|
|
175
|
+
for (let i = 0; i < count; i++) {
|
|
176
|
+
const idx = prevIdx(i);
|
|
177
|
+
if (tNow - atLane[idx] > windowMs) break;
|
|
178
|
+
idxs.push(idx);
|
|
179
|
+
}
|
|
180
|
+
const ms = idxs.filter((i) => !pausedLane[i]).map((i) => lanes.frameMs[i]).sort((a, b) => a - b);
|
|
181
|
+
const worst = [...idxs]
|
|
182
|
+
.filter((i) => !pausedLane[i])
|
|
183
|
+
.sort((a, b) => lanes.frameMs[b] - lanes.frameMs[a])
|
|
184
|
+
.slice(0, 5)
|
|
185
|
+
.map((i) => {
|
|
186
|
+
const prev = (i - 1 + RING) % RING;
|
|
187
|
+
const delta = {};
|
|
188
|
+
for (const f of ['calls', 'triangles', 'programs', 'textures', 'geometries']) {
|
|
189
|
+
delta[f] = lanes[f][i] - lanes[f][prev];
|
|
190
|
+
}
|
|
191
|
+
const median = pct(ms, 0.5) ?? 0;
|
|
192
|
+
// A worst frame UNDER the hitch bar is a healthy window, and saying
|
|
193
|
+
// so beats forcing the classifier to name a culprit for a 17.8ms
|
|
194
|
+
// frame (field capture: a perfect 300-frame window labeled
|
|
195
|
+
// long-script — a guess with no incident under it).
|
|
196
|
+
const nominal = lanes.frameMs[i] <= Math.max(2 * median, 25);
|
|
197
|
+
return {
|
|
198
|
+
agoMs: Math.round(tNow - atLane[i]),
|
|
199
|
+
frameMs: +lanes.frameMs[i].toFixed(1),
|
|
200
|
+
insideRenderMs: +lanes.insideRenderMs[i].toFixed(1),
|
|
201
|
+
delta,
|
|
202
|
+
classification: nominal
|
|
203
|
+
? [{ guess: 'nominal', confidence: 'high', evidence: `worst frame ${lanes.frameMs[i].toFixed(1)}ms is inside the hitch bar — a healthy window` }]
|
|
204
|
+
: classifyHitch({
|
|
205
|
+
frameMs: lanes.frameMs[i], medianMs: median,
|
|
206
|
+
insideRenderMs: lanes.insideRenderMs[i], delta,
|
|
207
|
+
spawned: lanes.spawned[i],
|
|
208
|
+
}),
|
|
209
|
+
};
|
|
210
|
+
});
|
|
211
|
+
const mark = {
|
|
212
|
+
type: 'usermark',
|
|
213
|
+
at: new Date().toISOString(),
|
|
214
|
+
frame: frameNo,
|
|
215
|
+
window: {
|
|
216
|
+
ms: windowMs,
|
|
217
|
+
frames: idxs.length,
|
|
218
|
+
medianMs: pct(ms, 0.5) !== null ? +pct(ms, 0.5).toFixed(2) : undefined,
|
|
219
|
+
p95Ms: pct(ms, 0.95) !== null ? +pct(ms, 0.95).toFixed(2) : undefined,
|
|
220
|
+
},
|
|
221
|
+
worstFrames: worst,
|
|
222
|
+
};
|
|
223
|
+
if (meta.note) mark.note = meta.note;
|
|
224
|
+
if (meta.phase) mark.phase = meta.phase;
|
|
225
|
+
if (meta.ctx) mark.ctx = meta.ctx;
|
|
226
|
+
if (meta.inputsHeld) mark.inputsHeld = meta.inputsHeld;
|
|
227
|
+
if (meta.world) mark.world = meta.world;
|
|
228
|
+
records.push(mark);
|
|
229
|
+
return mark;
|
|
230
|
+
},
|
|
231
|
+
|
|
232
|
+
/** Hand back accumulated records and clear — the host owns transport. */
|
|
233
|
+
drainRecords() { const r = records; records = []; return r; },
|
|
234
|
+
};
|
|
235
|
+
}
|