@broberg/lens-daemon 0.0.0-stage → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/record.ts ADDED
@@ -0,0 +1,387 @@
1
+ // F312.1 — record a live surface ON DEMAND, with nothing to script.
2
+ //
3
+ // `record: true` has existed on a flow since F255.5, and it is unreachable for
4
+ // this purpose: a flow REQUIRES steps, and you must know what to drive before you
5
+ // know what is wrong. The defect that motivated F312 only appears while an agent
6
+ // writes into the stream — the reproduction is literally "open it and sit still
7
+ // while something else happens". So the verb is: open, watch, stop.
8
+ import { mkdtemp, readFile, rm } from 'node:fs/promises';
9
+ import { tmpdir } from 'node:os';
10
+ import { join } from 'node:path';
11
+ import type { BrowserContext, Page } from 'playwright';
12
+ import { withPage } from './browser.ts';
13
+ import {
14
+ PROBE_READ,
15
+ type ScrollNote,
16
+ findUncausedMoves,
17
+ probeSource,
18
+ rankFindings,
19
+ type FindOptions,
20
+ type MotionFinding,
21
+ type MotionSample,
22
+ type ProbeConfig,
23
+ } from './motion-probe.ts';
24
+
25
+ /**
26
+ * F312.11 — put the view back where a reader would keep it, and STAMP IT AS OURS.
27
+ *
28
+ * The driver stamp is the load-bearing half. Without it the re-scroll is itself
29
+ * an uncaused move and the run reports its own instrument; with it, the seconds
30
+ * that follow are a genuine "he is sitting still, high up, while content
31
+ * arrives" — which is the exact situation being complained about and the one a
32
+ * bottom-anchored recording can never observe.
33
+ */
34
+ export async function scrollUpBy(page: Page, selector: string | null, px: number): Promise<void> {
35
+ // STAMP BEFORE, NOT ONLY AFTER. The scroll and the stamp are two round-trips
36
+ // to the page and the probe samples every 50ms, so a stamp that only follows
37
+ // the scroll can arrive after the move has already been sampled — measured 6
38
+ // Sept: 2 of 17 driver re-scrolls were reported as uncaused moves, i.e. the
39
+ // instrument filing its own action as the defect. Opening the grace window
40
+ // first closes the race in the safe direction.
41
+ await page.evaluate('window.__lensDriverActed && window.__lensDriverActed()').catch(() => {});
42
+ await page.evaluate(
43
+ ([s, n]) => {
44
+ const el = s ? document.querySelector(s as string) : null;
45
+ if (el) (el as HTMLElement).scrollTop = Math.max(0, (el as HTMLElement).scrollTop - (n as number));
46
+ },
47
+ [selector, px] as const,
48
+ );
49
+ await page.evaluate('window.__lensDriverActed && window.__lensDriverActed()').catch(() => {});
50
+ }
51
+
52
+ /** F312.21 — what the tab seeding did, or why it could not. Never inferred from
53
+ * a later click failing: "the tab was not there" and "the click missed" are
54
+ * different faults with different fixes. */
55
+ /**
56
+ * F312.17 — wait until the surface has stopped ARRIVING, not merely stopped
57
+ * growing for a moment. Three consecutive equal readings of height AND row
58
+ * count; a transcript renders a few rows, pauses while it fetches, then jumps,
59
+ * so one quiet moment passes for finished.
60
+ *
61
+ * Extracted because I reintroduced the very bug this fixes, one function over:
62
+ * F312.21's tab seeding reloads the page and waited a FIXED 2500ms, so a run
63
+ * began with 56 rows on screen and ended with 133. A fix that lives in one call
64
+ * site is a fix the next call site does not get.
65
+ */
66
+ async function waitForSettled(page: Page, selector: string | null, rowAttr: string): Promise<void> {
67
+ let last = '';
68
+ let stable = 0;
69
+ const deadline = Date.now() + 25_000;
70
+ while (Date.now() < deadline) {
71
+ const sig = (await page
72
+ .evaluate(
73
+ ([s2, ra]) => {
74
+ const el = s2 ? document.querySelector(s2 as string) : null;
75
+ if (!el) return '';
76
+ const e = el as HTMLElement;
77
+ return `${e.scrollHeight}:${e.querySelectorAll('[' + ra + ']').length}`;
78
+ },
79
+ [selector, rowAttr] as const,
80
+ )
81
+ .catch(() => '')) as string;
82
+ stable = sig !== '' && sig === last ? stable + 1 : 0;
83
+ last = sig;
84
+ if (stable >= 2) break;
85
+ await page.waitForTimeout(700);
86
+ }
87
+ }
88
+
89
+ export type SeedResult = { ok: true; edge: string; tabs: string[] } | { ok: false; reason: string } | null;
90
+
91
+ export interface RecordOptions extends ProbeConfig, FindOptions {
92
+ url: string;
93
+ /**
94
+ * F312.10 — SCROLL UP BY THIS MANY PIXELS BEFORE WATCHING.
95
+ *
96
+ * The experiment was measuring the wrong situation. Parked at the live bottom,
97
+ * the browser keeps you pinned there as content arrives — every "move" tracks
98
+ * the growing maximum and nothing is wrong. His complaint is the opposite: he
99
+ * has scrolled UP to read, and THEN it moves. A recording that never leaves the
100
+ * bottom cannot show it.
101
+ */
102
+ scrollUpPx?: number;
103
+ /**
104
+ * F312.21 — SEED THE OPEN TABS before measuring a tab switch.
105
+ *
106
+ * Lens runs a fresh browser profile, so the chat's tab strip is empty and there
107
+ * is nothing to switch BETWEEN. Measured: a three-step switch sequence reported
108
+ * `pressed: false` on its first press, and the frame confirmed the picker was
109
+ * not open — the experiment had simply never happened.
110
+ *
111
+ * The edge is NOT guessed. It is read back from the tab the app itself wrote
112
+ * once it had picked a session, so the seeded tabs carry the same edge the
113
+ * running app uses. A guessed edge produces a tab that renders and cannot be
114
+ * switched to, which is worse than no tab at all: it looks like the scenario ran.
115
+ */
116
+ seedTabs?: { session: string; channel?: string; label?: string }[];
117
+ /** How long to sit and watch. */
118
+ seconds: number;
119
+ viewport?: { width: number; height: number };
120
+ engine?: 'chromium' | 'webkit' | 'firefox';
121
+ locale?: string;
122
+ timezone?: string;
123
+ reducedMotion?: 'reduce' | 'no-preference';
124
+ prepareContext?: (context: BrowserContext) => Promise<void>;
125
+ /** Optional — the point of this verb is that it works with none. Each entry is
126
+ * run before the watch begins, and each stamps the driver clock so its own
127
+ * scrolling is never reported as a defect. */
128
+ before?: (page: Page) => Promise<void>;
129
+ /** F312.5 — run WHILE the probe watches. This is how the typing case is
130
+ * reproduced: a browser sitting still cannot show a defect that only appears
131
+ * when the caret is in a field. Its keystrokes deliberately do NOT stamp the
132
+ * driver clock — typing is not a request to move the view, so a move during it
133
+ * must stay reportable. */
134
+ during?: (page: Page, seconds: number) => Promise<void>;
135
+ }
136
+
137
+ export interface RecordResult {
138
+ video: Buffer | null;
139
+ durationMs: number;
140
+ /** The scroller the probe actually watched — never assumed, always reported, so
141
+ * "no findings" can be told apart from "watched the wrong element". */
142
+ watched: string | null;
143
+ samples: MotionSample[];
144
+ findings: MotionFinding[];
145
+ /** F312.12 — the page's own record of who moved the view, on the probe's clock.
146
+ * Empty on a surface that keeps no ledger. */
147
+ ledger: ScrollNote[] | null;
148
+ /** F312.16 — marker → ms after probe start when it first appeared on screen. */
149
+ markers: Record<string, number>;
150
+ /** UTC epoch ms at which the probe started, so a marker's arrival on the probe
151
+ * clock can be turned back into a wall-clock time and compared with when the
152
+ * message was actually sent. Without it `landed at 4213ms` cannot be paired
153
+ * with anything. */
154
+ probeStartedAt: number;
155
+ /**
156
+ * F312.18 — did the probe DEMONSTRABLY see a movement it was given on purpose?
157
+ *
158
+ * False means the run proves nothing: zero findings from a blind camera and
159
+ * zero from a fixed product are the same number, and only this tells them
160
+ * apart. Never inferred from the absence of findings — that is the very
161
+ * confusion it exists to remove.
162
+ */
163
+ detectorAlive: boolean;
164
+ /** F312.21 — the outcome of seeding the open-tab set, or null when not asked. */
165
+ seed: SeedResult;
166
+ /** F312.18 — the working behind `detectorAlive`, so a false can be attributed
167
+ * instead of merely believed. */
168
+ selfTest: { from: number; sampledBefore3s: number; maxDeviation: number | null };
169
+ probeError: string | null;
170
+ }
171
+
172
+ /**
173
+ * Open `url`, attach the motion probe, record for `seconds`, and return the video
174
+ * with a measured timeline.
175
+ *
176
+ * A MISSING RECORDING NEVER FAILS THE RUN (F255.5's rule, inherited on purpose):
177
+ * the probe data is the primary result, and it survives a video that did not
178
+ * finalise.
179
+ */
180
+ export async function recordSurface(opts: RecordOptions): Promise<RecordResult> {
181
+ const videoDir = await mkdtemp(join(tmpdir(), 'lens-record-'));
182
+ let videoPage: Page | null = null;
183
+ const startedAt = Date.now();
184
+ let samples: MotionSample[] = [];
185
+ let ledger: ScrollNote[] | null = null;
186
+ let markers: Record<string, number> = {};
187
+ let probeStartedAt = 0;
188
+ let selfTestFrom = -1;
189
+ let seedResult: SeedResult = null;
190
+ let probeError: string | null = null;
191
+ let watched: string | null = null;
192
+
193
+ try {
194
+ await withPage(
195
+ {
196
+ recordVideoDir: videoDir,
197
+ ...(opts.viewport ? { viewport: opts.viewport } : {}),
198
+ ...(opts.engine ? { engine: opts.engine } : {}),
199
+ ...(opts.locale ? { locale: opts.locale } : {}),
200
+ ...(opts.timezone ? { timezone: opts.timezone } : {}),
201
+ ...(opts.reducedMotion ? { reducedMotion: opts.reducedMotion } : {}),
202
+ ...(opts.prepareContext ? { prepareContext: opts.prepareContext } : {}),
203
+ },
204
+ async (page) => {
205
+ videoPage = page;
206
+ await page.goto(opts.url, { waitUntil: 'domcontentloaded', timeout: 30_000 });
207
+ // Let the surface settle BEFORE the probe starts, or the first-load
208
+ // landing — which is not a defect — becomes finding number one.
209
+ //
210
+ // F312.17 — WAIT FOR IT TO STOP GROWING, don't guess a number. Measured 6
211
+ // Sept: a run began with 6 rows on screen and ended with 131, so the
212
+ // reading position was taken against a transcript that had not loaded and
213
+ // the whole recording measured the load instead of the defect. A fixed
214
+ // 2500ms is a guess about someone else's network; two consecutive equal
215
+ // heights is the actual question.
216
+ await page.waitForTimeout(1200);
217
+ await waitForSettled(page, opts.selector ?? null, opts.rowAttr ?? 'data-ev-uuid');
218
+
219
+ // F312.21 — seed the open-tab set, then reload so the strip renders it.
220
+ // The edge comes from the tab the APP wrote after it picked a session;
221
+ // guessing it would produce a tab that renders and cannot be switched to,
222
+ // which looks like the scenario ran.
223
+ if (opts.seedTabs && opts.seedTabs.length > 0) {
224
+ const seeded = await page
225
+ .evaluate((want) => {
226
+ const KEY = 'cardmem.chat.tabs';
227
+ let cur: { edge?: string; session?: string; channel?: string; label?: string }[] = [];
228
+ try {
229
+ const raw = localStorage.getItem(KEY);
230
+ if (raw) cur = JSON.parse(raw);
231
+ } catch {
232
+ cur = [];
233
+ }
234
+ const edge = Array.isArray(cur) && cur[0]?.edge ? String(cur[0].edge) : null;
235
+ if (!edge) return { ok: false, reason: 'no edge — the app has not picked a session yet' };
236
+ const have = new Set((cur || []).map((t) => String(t.channel ?? t.session)));
237
+ for (const w of want as { session: string; channel?: string; label?: string }[]) {
238
+ const channel = w.channel ?? w.session;
239
+ if (have.has(channel)) continue;
240
+ cur.push({ edge, session: w.session, channel, label: w.label ?? channel });
241
+ }
242
+ localStorage.setItem(KEY, JSON.stringify(cur));
243
+ return { ok: true, edge, tabs: cur.map((t) => String(t.channel ?? t.session)) };
244
+ }, opts.seedTabs)
245
+ .catch(() => ({ ok: false, reason: 'evaluate failed' }));
246
+ seedResult = seeded as SeedResult;
247
+ if ((seeded as { ok?: boolean }).ok) {
248
+ await page.reload({ waitUntil: 'domcontentloaded', timeout: 30_000 });
249
+ await page.waitForTimeout(1200);
250
+ await waitForSettled(page, opts.selector ?? null, opts.rowAttr ?? 'data-ev-uuid');
251
+ }
252
+ }
253
+
254
+ // F312.10 — take up the reading position BEFORE the probe starts, so the
255
+ // deliberate scroll is not itself the first finding.
256
+ if (opts.scrollUpPx && opts.scrollUpPx > 0) {
257
+ await scrollUpBy(page, opts.selector ?? null, opts.scrollUpPx);
258
+ await page.waitForTimeout(1200);
259
+ }
260
+
261
+ if (opts.before) {
262
+ await opts.before(page);
263
+ // Whatever that did was OURS. Stamp it so the grace window covers it.
264
+ await page.evaluate('window.__lensDriverActed && window.__lensDriverActed()').catch(() => {});
265
+ }
266
+
267
+ const started = await page.evaluate(
268
+ probeSource({
269
+ ...(opts.selector ? { selector: opts.selector } : {}),
270
+ ...(opts.intervalMs ? { intervalMs: opts.intervalMs } : {}),
271
+ ...(opts.rowAttr ? { rowAttr: opts.rowAttr } : {}),
272
+ }),
273
+ );
274
+ probeStartedAt = Date.now();
275
+ if (started === 'no-scroller') probeError = 'no scrollable element found';
276
+
277
+ // F312.18 — DOES THE DETECTOR STILL WORK? A deliberate, stamped nudge a
278
+ // moment after the probe starts, whose only job is to prove the camera is
279
+ // alive. Raised by the trail session from two failures of exactly this
280
+ // shape in their own day: a coverage metric that only counted the chunks
281
+ // that EXISTED, and an indexing job whose `WHERE enabled = 1` selected
282
+ // zero rows — so it neither failed nor reported. Both read as success.
283
+ //
284
+ // The same hole was open here. `exercised` answers "did the surface
285
+ // change"; nothing answered "would this probe have SEEN a change". A run
286
+ // whose probe never attached, whose selector matched a static element, or
287
+ // whose sampling died reports zero findings — identical to a fixed
288
+ // product. The nudge is classified `driver`, so it is never a finding;
289
+ // its absence from the samples is the alarm.
290
+ await page.waitForTimeout(600);
291
+ const before = (await page
292
+ .evaluate((s2) => {
293
+ const el = s2 ? document.querySelector(s2 as string) : null;
294
+ return el ? (el as HTMLElement).scrollTop : -1;
295
+ }, opts.selector ?? null)
296
+ .catch(() => -1)) as number;
297
+ await scrollUpBy(page, opts.selector ?? null, 140);
298
+ await page.waitForTimeout(250);
299
+ await scrollUpBy(page, opts.selector ?? null, -140);
300
+ await page.waitForTimeout(250);
301
+ selfTestFrom = before;
302
+
303
+ const secs = Math.max(1, opts.seconds);
304
+ if (opts.during) await opts.during(page, secs);
305
+ else await page.waitForTimeout(secs * 1000);
306
+
307
+ const read = (await page.evaluate(PROBE_READ)) as {
308
+ error?: string | null;
309
+ selector?: string | null;
310
+ samples?: MotionSample[];
311
+ ledger?: ScrollNote[] | null;
312
+ markers?: Record<string, number>;
313
+ };
314
+ samples = read?.samples ?? [];
315
+ ledger = read?.ledger ?? null;
316
+ markers = read?.markers ?? {};
317
+ watched = read?.selector ?? null;
318
+ if (read?.error) probeError = read.error;
319
+ },
320
+ );
321
+ } finally {
322
+ // Playwright only finalises the .webm when the context closes, so this read
323
+ // has to happen AFTER withPage resolves.
324
+ }
325
+
326
+ let video: Buffer | null = null;
327
+ try {
328
+ // `saveAs`, NOT `path()`. Measured: path() resolves immediately and the file
329
+ // is still 0 bytes — Playwright flushes the webm asynchronously after the
330
+ // context closes, so reading the path is a race that loses quietly. saveAs
331
+ // WAITS for the write, which is the difference between a video and an empty
332
+ // file that ffmpeg then rejects with an unhelpful exit code.
333
+ const out = join(videoDir, 'run.webm');
334
+ const v = (videoPage as Page | null)?.video();
335
+ if (v) {
336
+ await v.saveAs(out);
337
+ video = await readFile(out);
338
+ }
339
+ } catch {
340
+ video = null; // a missing recording must never fail an otherwise-good run
341
+ } finally {
342
+ await rm(videoDir, { recursive: true, force: true }).catch(() => {});
343
+ }
344
+
345
+ const findings = rankFindings(
346
+ findUncausedMoves(samples, {
347
+ ...(opts.minDeltaPx != null ? { minDeltaPx: opts.minDeltaPx } : {}),
348
+ ...(opts.inputGraceMs != null ? { inputGraceMs: opts.inputGraceMs } : {}),
349
+ ...(opts.driverGraceMs != null ? { driverGraceMs: opts.driverGraceMs } : {}),
350
+ }),
351
+ );
352
+
353
+ return {
354
+ video,
355
+ durationMs: Date.now() - startedAt,
356
+ watched,
357
+ samples,
358
+ findings,
359
+ ledger,
360
+ markers,
361
+ probeStartedAt,
362
+ // The nudge moved the position by 140px and back within the first second, so
363
+ // a probe that was watching MUST have sampled something other than its
364
+ // starting point. Judged on the samples, not on whether the scroll call
365
+ // resolved — a resolved call proves the message was sent, not that anyone
366
+ // was listening.
367
+ seed: seedResult,
368
+ detectorAlive:
369
+ selfTestFrom >= 0 && samples.some((x) => x.t < 3000 && Math.abs(x.top - selfTestFrom) > 40),
370
+ // F312.18 — WHY it says what it says. A bare false is the same shape as the
371
+ // silence it exists to detect: it could mean the probe was blind, or that the
372
+ // nudge never moved anything, or that the position could not be read at all.
373
+ // Measured twice today, and I could not tell those apart from the field alone
374
+ // — so the working is reported beside the verdict.
375
+ selfTest: {
376
+ from: selfTestFrom,
377
+ sampledBefore3s: samples.filter((x) => x.t < 3000).length,
378
+ maxDeviation:
379
+ selfTestFrom < 0
380
+ ? null
381
+ : samples
382
+ .filter((x) => x.t < 3000)
383
+ .reduce((m, x) => Math.max(m, Math.abs(x.top - selfTestFrom)), 0),
384
+ },
385
+ probeError,
386
+ };
387
+ }
package/src/sandbox.ts ADDED
@@ -0,0 +1,22 @@
1
+ // lens F001.5 — the daemon's sandbox, INJECTED, never copied.
2
+ //
3
+ // resolveSandboxedPath is the cardmem daemon's security boundary (it also guards
4
+ // non-Lens routes there). A second copy inside this package would be two
5
+ // security checks that can drift apart, so the package holds no implementation:
6
+ // the daemon hands its own function in through LensDeps when it mounts the
7
+ // router. Until then every call refuses — fail closed, never "allow".
8
+
9
+ export type SandboxResolver = (path: string, sandboxRoots: string[]) => string;
10
+
11
+ let resolver: SandboxResolver | null = null;
12
+
13
+ export function setSandboxResolver(fn: SandboxResolver): void {
14
+ resolver = fn;
15
+ }
16
+
17
+ export function resolveSandboxedPath(path: string, sandboxRoots: string[]): string {
18
+ if (!resolver) {
19
+ throw new Error('lens-daemon: no sandbox resolver installed — the daemon must pass resolveSandboxedPath in LensDeps');
20
+ }
21
+ return resolver(path, sandboxRoots);
22
+ }