imprint-mcp 0.2.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.
Files changed (97) hide show
  1. package/CHANGELOG.md +168 -0
  2. package/LICENSE +21 -0
  3. package/README.md +322 -0
  4. package/examples/discoverandgo/README.md +57 -0
  5. package/examples/discoverandgo/book_discoverandgo_museum_pass/cron.json +8 -0
  6. package/examples/discoverandgo/book_discoverandgo_museum_pass/index.ts +89 -0
  7. package/examples/discoverandgo/book_discoverandgo_museum_pass/workflow.json +39 -0
  8. package/examples/echo/README.md +37 -0
  9. package/examples/echo/echo_test/index.ts +31 -0
  10. package/examples/google-flights/search_google_flights/index.ts +101 -0
  11. package/examples/google-flights/search_google_flights/parser.test.ts +140 -0
  12. package/examples/google-flights/search_google_flights/parser.ts +189 -0
  13. package/examples/google-flights/search_google_flights/playbook.yaml +130 -0
  14. package/examples/google-flights/search_google_flights/workflow.json +48 -0
  15. package/examples/google-hotels/search_google_hotels/index.ts +194 -0
  16. package/examples/google-hotels/search_google_hotels/parser.test.ts +168 -0
  17. package/examples/google-hotels/search_google_hotels/parser.ts +330 -0
  18. package/examples/google-hotels/search_google_hotels/playbook.yaml +125 -0
  19. package/examples/google-hotels/search_google_hotels/workflow.json +111 -0
  20. package/examples/namecheap-domains/search_namecheap_domains/index.ts +144 -0
  21. package/examples/namecheap-domains/search_namecheap_domains/parser.ts +380 -0
  22. package/examples/namecheap-domains/search_namecheap_domains/playbook.yaml +50 -0
  23. package/examples/namecheap-domains/search_namecheap_domains/request-transform.ts +136 -0
  24. package/examples/namecheap-domains/search_namecheap_domains/workflow.json +97 -0
  25. package/examples/southwest/README.md +81 -0
  26. package/examples/southwest/search_southwest_flights/backends.json +23 -0
  27. package/examples/southwest/search_southwest_flights/cron.json +19 -0
  28. package/examples/southwest/search_southwest_flights/index.ts +110 -0
  29. package/examples/southwest/search_southwest_flights/playbook.yaml +46 -0
  30. package/examples/southwest/search_southwest_flights/workflow.json +54 -0
  31. package/package.json +78 -0
  32. package/prompts/compile-agent.md +580 -0
  33. package/prompts/intent-detection.md +198 -0
  34. package/prompts/playbook-compilation.md +279 -0
  35. package/prompts/request-triage.md +74 -0
  36. package/prompts/tool-candidate-detection.md +104 -0
  37. package/src/cli.ts +1287 -0
  38. package/src/imprint/agent.ts +468 -0
  39. package/src/imprint/app-api-hosts.ts +53 -0
  40. package/src/imprint/backend-ladder.ts +568 -0
  41. package/src/imprint/check.ts +136 -0
  42. package/src/imprint/chromium.ts +211 -0
  43. package/src/imprint/claude-cli-compile.ts +640 -0
  44. package/src/imprint/cli-credential.ts +394 -0
  45. package/src/imprint/codex-cli-compile.ts +712 -0
  46. package/src/imprint/compile-agent-types.ts +40 -0
  47. package/src/imprint/compile-agent.ts +404 -0
  48. package/src/imprint/compile-tools.ts +1389 -0
  49. package/src/imprint/compile.ts +720 -0
  50. package/src/imprint/cookie-jar.ts +246 -0
  51. package/src/imprint/credential-bundle.ts +195 -0
  52. package/src/imprint/credential-extract.ts +290 -0
  53. package/src/imprint/credential-store.ts +707 -0
  54. package/src/imprint/cron.ts +312 -0
  55. package/src/imprint/doctor.ts +223 -0
  56. package/src/imprint/emit.ts +154 -0
  57. package/src/imprint/etld.ts +134 -0
  58. package/src/imprint/freeform-redact.ts +216 -0
  59. package/src/imprint/inject-listener.ts +137 -0
  60. package/src/imprint/install.ts +795 -0
  61. package/src/imprint/integrations.ts +385 -0
  62. package/src/imprint/is-compiled.ts +2 -0
  63. package/src/imprint/json-path.ts +100 -0
  64. package/src/imprint/llm.ts +998 -0
  65. package/src/imprint/load-json.ts +54 -0
  66. package/src/imprint/log.ts +33 -0
  67. package/src/imprint/login.ts +166 -0
  68. package/src/imprint/mcp-compile-server.ts +282 -0
  69. package/src/imprint/mcp-maintenance.ts +1790 -0
  70. package/src/imprint/mcp-server.ts +350 -0
  71. package/src/imprint/multi-progress.ts +69 -0
  72. package/src/imprint/notify.ts +155 -0
  73. package/src/imprint/paths.ts +64 -0
  74. package/src/imprint/playbook-parser.ts +21 -0
  75. package/src/imprint/playbook-runner.ts +465 -0
  76. package/src/imprint/probe-backends.ts +251 -0
  77. package/src/imprint/progress.ts +28 -0
  78. package/src/imprint/record.ts +470 -0
  79. package/src/imprint/redact.ts +550 -0
  80. package/src/imprint/replay-capture.ts +387 -0
  81. package/src/imprint/request-context.ts +66 -0
  82. package/src/imprint/runtime-link.ts +73 -0
  83. package/src/imprint/runtime.ts +942 -0
  84. package/src/imprint/sensitive-keys.ts +156 -0
  85. package/src/imprint/session-diff.ts +409 -0
  86. package/src/imprint/session-merge.ts +198 -0
  87. package/src/imprint/session-writer.ts +149 -0
  88. package/src/imprint/sites.ts +27 -0
  89. package/src/imprint/stealth-fetch.ts +434 -0
  90. package/src/imprint/teach-state.ts +235 -0
  91. package/src/imprint/teach.ts +2120 -0
  92. package/src/imprint/tool-candidates.ts +423 -0
  93. package/src/imprint/tool-loader.ts +186 -0
  94. package/src/imprint/tool-selection.ts +70 -0
  95. package/src/imprint/tracing.ts +508 -0
  96. package/src/imprint/types.ts +472 -0
  97. package/src/imprint/version.ts +21 -0
@@ -0,0 +1,470 @@
1
+ /**
2
+ * `imprint record` — capture a teaching session via CDP. Streams network
3
+ * requests, DOM events, and stdin narration to JSONL; assembles session.json
4
+ * on clean shutdown (Ctrl+C, /done, or external AbortSignal).
5
+ */
6
+
7
+ import { mkdirSync } from 'node:fs';
8
+ import { join as pathJoin, resolve as pathResolve } from 'node:path';
9
+ import { createInterface } from 'node:readline';
10
+ import { setTimeout as sleep } from 'node:timers/promises';
11
+ import CDP from 'chrome-remote-interface';
12
+ import envPaths from 'env-paths';
13
+ import { launchChromium } from './chromium.ts';
14
+ import { IMPRINT_SENTINEL, INJECTED_LISTENER_SOURCE } from './inject-listener.ts';
15
+ import { isDebug } from './log.ts';
16
+ import { defaultSessionJsonlPath } from './paths.ts';
17
+ import { createSessionWriter } from './session-writer.ts';
18
+ import type { CapturedEvent, CapturedRequest, CookieSnapshot, StorageSnapshot } from './types.ts';
19
+ import { VERSION } from './version.ts';
20
+
21
+ const PATHS = envPaths('imprint', { suffix: '' });
22
+
23
+ interface RecordOptions {
24
+ /** Site label, e.g. "southwest". Determines output path. */
25
+ site: string;
26
+ /** Starting URL. If omitted, opens about:blank — user navigates manually. */
27
+ url?: string;
28
+ /** Output path for session.jsonl. Defaults to ~/.imprint/<site>/sessions/<timestamp>.jsonl */
29
+ outPath?: string;
30
+ /** Persist a stable profile at $IMPRINT_DATA/profiles/<site> so cookies + login
31
+ * survive between captures. Useful for re-recording an authed site. Default false. */
32
+ persistProfile?: boolean;
33
+ /** Stop signal. CLI wires this to SIGINT. */
34
+ signal?: AbortSignal;
35
+ /** Skip the interactive stdin narration loop (tests). */
36
+ noNarration?: boolean;
37
+ }
38
+
39
+ interface RecordResult {
40
+ jsonlPath: string;
41
+ sessionPath: string;
42
+ /** Number of records written (requests + events + narration). */
43
+ count: number;
44
+ }
45
+
46
+ interface PendingRequest {
47
+ seq: number;
48
+ timestamp: number;
49
+ method: string;
50
+ url: string;
51
+ headers: Record<string, string>;
52
+ body?: string;
53
+ resourceType: string;
54
+ }
55
+
56
+ export async function record(opts: RecordOptions): Promise<RecordResult> {
57
+ const startedAt = new Date();
58
+ const sessionTs = startedAt.toISOString().replace(/[:.]/g, '-');
59
+
60
+ const outPath = opts.outPath
61
+ ? pathResolve(opts.outPath)
62
+ : defaultSessionJsonlPath(opts.site, sessionTs);
63
+
64
+ mkdirSync(pathJoin(outPath, '..'), { recursive: true });
65
+
66
+ console.log(`[imprint] recording → ${outPath}`);
67
+ console.log('[imprint] launching chromium...');
68
+
69
+ // Launch with about:blank so we attach CDP + enable Network BEFORE the
70
+ // first real request fires. Passing the target URL up front loses events.
71
+ const userDataDir = opts.persistProfile ? pathJoin(PATHS.data, 'profiles', opts.site) : undefined;
72
+ if (userDataDir) {
73
+ mkdirSync(userDataDir, { recursive: true });
74
+ console.log(`[imprint] using persistent profile at ${userDataDir}`);
75
+ }
76
+ const chromium = await launchChromium({
77
+ url: 'about:blank',
78
+ headless: false,
79
+ userDataDir,
80
+ });
81
+
82
+ try {
83
+ await chromium.ready;
84
+ } catch (err) {
85
+ await chromium.close();
86
+ throw err;
87
+ }
88
+ console.log(`[imprint] chromium up on CDP port ${chromium.port}`);
89
+
90
+ // Wait for Chromium to publish the target list, then attach to the first
91
+ // real page tab (skip chrome-extension://). The callback must return a
92
+ // number index — never undefined.
93
+ await sleep(250);
94
+ const client = await CDP({
95
+ port: chromium.port,
96
+ target: (targets) => {
97
+ const idx = targets.findIndex(
98
+ (t) => t.type === 'page' && !t.url.startsWith('chrome-extension://'),
99
+ );
100
+ return idx >= 0 ? idx : 0;
101
+ },
102
+ });
103
+ const { Network, Page, Runtime } = client;
104
+
105
+ await Promise.all([Network.enable(), Page.enable(), Runtime.enable()]);
106
+
107
+ // Passive DOM listener emits sentinel-prefixed console.log lines we parse
108
+ // via Runtime.consoleAPICalled below.
109
+ await Page.addScriptToEvaluateOnNewDocument({ source: INJECTED_LISTENER_SOURCE });
110
+
111
+ const writer = createSessionWriter(outPath, {
112
+ site: opts.site,
113
+ url: opts.url ?? 'about:blank',
114
+ imprintVersion: VERSION,
115
+ startedAt: startedAt.toISOString(),
116
+ });
117
+
118
+ const t0 = Date.now();
119
+ const elapsed = (): number => Date.now() - t0;
120
+
121
+ let seq = 0;
122
+ const nextSeq = (): number => seq++;
123
+
124
+ // CDP order: requestWillBeSent → responseReceived → loadingFinished.
125
+ // We write the request record on responseReceived. The body fetch waits
126
+ // for loadingFinished (with a 30s safety timeout) before calling
127
+ // getResponseBody — large bodies aren't ready immediately and the older
128
+ // sleep(100) heuristic dropped flight-search payloads silently.
129
+ const pending = new Map<string, PendingRequest>();
130
+ const inflight = new Set<Promise<void>>();
131
+ const bodyReady = new Map<string, ReturnType<typeof Promise.withResolvers<void>>>();
132
+
133
+ Network.requestWillBeSent((params) => {
134
+ const { request, requestId, type } = params;
135
+ if (isDebug()) {
136
+ console.error(`[debug] requestWillBeSent ${requestId} ${request.method} ${request.url}`);
137
+ }
138
+ pending.set(requestId, {
139
+ seq: nextSeq(),
140
+ timestamp: elapsed(),
141
+ method: request.method,
142
+ url: request.url,
143
+ headers: request.headers as Record<string, string>,
144
+ body: typeof request.postData === 'string' ? request.postData : undefined,
145
+ resourceType: type ?? 'Other',
146
+ });
147
+ bodyReady.set(requestId, Promise.withResolvers<void>());
148
+ });
149
+
150
+ Network.responseReceived((params) => {
151
+ const { requestId, response } = params;
152
+ const reqInfo = pending.get(requestId);
153
+ if (!reqInfo) return;
154
+ pending.delete(requestId);
155
+
156
+ if (isDebug()) {
157
+ console.error(
158
+ `[debug] responseReceived ${requestId} status=${response.status} ${reqInfo.url}`,
159
+ );
160
+ }
161
+
162
+ const captured: CapturedRequest = {
163
+ seq: reqInfo.seq,
164
+ timestamp: reqInfo.timestamp,
165
+ method: reqInfo.method,
166
+ url: reqInfo.url,
167
+ headers: reqInfo.headers,
168
+ body: reqInfo.body,
169
+ resourceType: reqInfo.resourceType,
170
+ response: {
171
+ status: response.status,
172
+ headers: response.headers as Record<string, string>,
173
+ mimeType: response.mimeType,
174
+ // body filled in by the loadingFinished handler if it fires
175
+ },
176
+ };
177
+ writer.request(captured);
178
+
179
+ const bodyWork = (async () => {
180
+ const ready = bodyReady.get(requestId);
181
+ if (ready) {
182
+ await Promise.race([ready.promise, sleep(30_000)]);
183
+ }
184
+ bodyReady.delete(requestId);
185
+ try {
186
+ const bodyResp = await Network.getResponseBody({ requestId });
187
+ const body = bodyResp.base64Encoded
188
+ ? Buffer.from(bodyResp.body, 'base64').toString('utf8')
189
+ : bodyResp.body;
190
+ const MAX = 256 * 1024;
191
+ const truncated = body.length > MAX ? `${body.slice(0, MAX)}\n[…truncated…]` : body;
192
+ writer.requestBody(captured.seq, truncated);
193
+ } catch (err) {
194
+ if (isDebug()) {
195
+ console.error(`[debug] body unavailable seq=${captured.seq} ${reqInfo.url}: ${err}`);
196
+ }
197
+ }
198
+ })();
199
+ inflight.add(bodyWork);
200
+ bodyWork.finally(() => inflight.delete(bodyWork));
201
+ });
202
+
203
+ Network.loadingFinished((params) => {
204
+ bodyReady.get(params.requestId)?.resolve();
205
+ });
206
+
207
+ Network.loadingFailed((params) => {
208
+ if (isDebug()) {
209
+ console.error(`[debug] loadingFailed ${params.requestId} ${params.errorText}`);
210
+ }
211
+ bodyReady.get(params.requestId)?.resolve();
212
+ bodyReady.delete(params.requestId);
213
+ pending.delete(params.requestId);
214
+ });
215
+
216
+ // Network is wired — safe to drive Chromium to the target URL.
217
+ if (opts.url && opts.url !== 'about:blank') {
218
+ if (isDebug()) {
219
+ console.error(`[debug] navigating to ${opts.url}`);
220
+ }
221
+ const navResult = await Page.navigate({ url: opts.url });
222
+ if (isDebug()) {
223
+ console.error(`[debug] navigate returned: ${JSON.stringify(navResult)}`);
224
+ }
225
+ }
226
+
227
+ // ── Page navigation events ────────────────────────────────────────────────
228
+ Page.frameNavigated((params) => {
229
+ if (params.frame.parentId) return; // only top-level frames
230
+ const ev: CapturedEvent = {
231
+ seq: nextSeq(),
232
+ timestamp: elapsed(),
233
+ type: 'navigation',
234
+ detail: params.frame.url,
235
+ };
236
+ writer.event(ev);
237
+ });
238
+
239
+ // ── DOM event capture (via injected console.log sentinel) ────────────────
240
+ // The injector posts lines like: [IMPRINT] click {"tag":"button","id":...,"selector":...}
241
+ Runtime.consoleAPICalled((params) => {
242
+ try {
243
+ if (params.type !== 'log' || !params.args || params.args.length < 2) return;
244
+ const first = params.args[0];
245
+ if (!first || first.type !== 'string' || first.value !== IMPRINT_SENTINEL) return;
246
+ const second = params.args[1];
247
+ const third = params.args[2];
248
+ const eventType = second?.type === 'string' ? second.value : null;
249
+ const payload = third?.type === 'string' ? third.value : null;
250
+ if (!eventType || !payload) return;
251
+ // Map injector's event names to our CapturedEvent type union.
252
+ const allowed: CapturedEvent['type'][] = ['click', 'input', 'change', 'submit'];
253
+ if (!allowed.includes(eventType as CapturedEvent['type'])) return;
254
+ writer.event({
255
+ seq: nextSeq(),
256
+ timestamp: elapsed(),
257
+ type: eventType as CapturedEvent['type'],
258
+ detail: payload,
259
+ });
260
+ } catch {
261
+ // Never let a single bad console line break the recorder.
262
+ }
263
+ });
264
+
265
+ // ── WebSocket frames (sent + received, payload truncated to 1KB) ─────────
266
+ const wsUrls = new Map<string, string>();
267
+ Network.webSocketCreated((params) => {
268
+ wsUrls.set(params.requestId, params.url);
269
+ });
270
+ Network.webSocketFrameSent((params) => {
271
+ const url = wsUrls.get(params.requestId) ?? '';
272
+ const payload = params.response.payloadData ?? '';
273
+ writer.event({
274
+ seq: nextSeq(),
275
+ timestamp: elapsed(),
276
+ type: 'ws-sent',
277
+ detail: JSON.stringify({
278
+ url,
279
+ opcode: params.response.opcode,
280
+ payloadDataPreview: payload.slice(0, 1024),
281
+ }),
282
+ });
283
+ });
284
+ Network.webSocketFrameReceived((params) => {
285
+ const url = wsUrls.get(params.requestId) ?? '';
286
+ const payload = params.response.payloadData ?? '';
287
+ writer.event({
288
+ seq: nextSeq(),
289
+ timestamp: elapsed(),
290
+ type: 'ws-received',
291
+ detail: JSON.stringify({
292
+ url,
293
+ opcode: params.response.opcode,
294
+ payloadDataPreview: payload.slice(0, 1024),
295
+ }),
296
+ });
297
+ });
298
+ Network.webSocketClosed((params) => {
299
+ wsUrls.delete(params.requestId);
300
+ });
301
+
302
+ // ── Cookie snapshots: start (initial auth) + end (e.g. confirmation cookies) ─
303
+ const snapshotCookies = async (label: CookieSnapshot['label']): Promise<void> => {
304
+ try {
305
+ const all = await Network.getAllCookies();
306
+ writer.cookies({
307
+ takenAt: new Date().toISOString(),
308
+ timestamp: elapsed(),
309
+ label,
310
+ cookies: all.cookies.map((c) => ({
311
+ name: c.name,
312
+ value: c.value,
313
+ domain: c.domain,
314
+ path: c.path,
315
+ expires: c.expires,
316
+ httpOnly: c.httpOnly,
317
+ secure: c.secure,
318
+ sameSite: c.sameSite,
319
+ })),
320
+ });
321
+ } catch (err) {
322
+ if (isDebug()) {
323
+ console.error(`[debug] cookie snapshot ${label} failed: ${String(err)}`);
324
+ }
325
+ }
326
+ };
327
+ await snapshotCookies('start');
328
+
329
+ const snapshotStorage = async (label: StorageSnapshot['label']): Promise<void> => {
330
+ try {
331
+ const result = await Runtime.evaluate({
332
+ expression: `(() => {
333
+ const local = {};
334
+ const session = {};
335
+ try {
336
+ for (let i = 0; i < localStorage.length; i++) {
337
+ const k = localStorage.key(i);
338
+ if (k) local[k] = localStorage.getItem(k) ?? '';
339
+ }
340
+ } catch {}
341
+ try {
342
+ for (let i = 0; i < sessionStorage.length; i++) {
343
+ const k = sessionStorage.key(i);
344
+ if (k) session[k] = sessionStorage.getItem(k) ?? '';
345
+ }
346
+ } catch {}
347
+ return { origin: location.origin, localStorage: local, sessionStorage: session };
348
+ })()`,
349
+ returnByValue: true,
350
+ });
351
+ const value = result.result.value as
352
+ | {
353
+ origin?: string;
354
+ localStorage?: Record<string, string>;
355
+ sessionStorage?: Record<string, string>;
356
+ }
357
+ | undefined;
358
+ if (!value?.origin || value.origin === 'null') return;
359
+ writer.storage({
360
+ takenAt: new Date().toISOString(),
361
+ timestamp: elapsed(),
362
+ label,
363
+ origin: value.origin,
364
+ localStorage: value.localStorage ?? {},
365
+ sessionStorage: value.sessionStorage ?? {},
366
+ });
367
+ } catch (err) {
368
+ if (isDebug()) {
369
+ console.error(`[debug] storage snapshot ${label} failed: ${String(err)}`);
370
+ }
371
+ }
372
+ };
373
+ await snapshotStorage('start');
374
+
375
+ // ── Narration loop ────────────────────────────────────────────────────────
376
+ let narrationOpen = !opts.noNarration;
377
+ let rl: ReturnType<typeof createInterface> | null = null;
378
+
379
+ const formatPrompt = (): string => {
380
+ const secs = Math.floor(elapsed() / 1000);
381
+ const mm = Math.floor(secs / 60);
382
+ const ss = String(secs % 60).padStart(2, '0');
383
+ return `[${mm}:${ss} • ${seq} captured] narrate (or /done): `;
384
+ };
385
+
386
+ const narrationLoop: Promise<void> = (async () => {
387
+ if (opts.noNarration) return;
388
+ rl = createInterface({ input: process.stdin, output: process.stdout, terminal: true });
389
+ console.log('');
390
+ console.log('[imprint] recording. drive the browser, narrate as you go.');
391
+ console.log('[imprint] blank line = skip without recording narration');
392
+ console.log('[imprint] /done = stop recording cleanly');
393
+ console.log('[imprint] Ctrl+C = same as /done');
394
+ console.log('');
395
+ while (narrationOpen) {
396
+ const reader = rl;
397
+ if (!reader) break;
398
+ const line: string = await new Promise((resolve) => {
399
+ reader.question(formatPrompt(), (answer) => resolve(answer));
400
+ });
401
+ if (!narrationOpen) break;
402
+ const trimmed = line.trim();
403
+ if (trimmed.length === 0) continue;
404
+ if (trimmed === '/done' || trimmed === '/quit' || trimmed === '/q') {
405
+ narrationOpen = false;
406
+ break;
407
+ }
408
+ writer.narration({ seq: nextSeq(), timestamp: elapsed(), text: trimmed });
409
+ }
410
+ })();
411
+
412
+ // ── Shutdown handling ─────────────────────────────────────────────────────
413
+ let shuttingDown = false;
414
+ let resolveStopped: () => void = () => {};
415
+ const stopped = new Promise<void>((resolve) => {
416
+ resolveStopped = resolve;
417
+ });
418
+
419
+ const shutdown = async (): Promise<RecordResult> => {
420
+ if (shuttingDown) {
421
+ await stopped;
422
+ const { jsonlPath: jp, sessionPath: sp } = await writer.close();
423
+ return { jsonlPath: jp, sessionPath: sp, count: seq };
424
+ }
425
+ shuttingDown = true;
426
+ narrationOpen = false;
427
+ rl?.close();
428
+
429
+ // Drain in-flight body fetches before closing the JSONL stream — CDP
430
+ // body fetch is async, late arrivals would be silently dropped.
431
+ if (inflight.size > 0) {
432
+ if (isDebug()) {
433
+ console.error(`[debug] draining ${inflight.size} inflight handlers`);
434
+ }
435
+ await Promise.allSettled(Array.from(inflight));
436
+ }
437
+
438
+ // Final cookie snapshot before CDP teardown — confirmation pages
439
+ // sometimes set fresh session cookies the replay needs.
440
+ await snapshotCookies('end');
441
+ await snapshotStorage('end');
442
+
443
+ try {
444
+ await client.close();
445
+ } catch {
446
+ // ignore
447
+ }
448
+ await chromium.close();
449
+ const { jsonlPath, sessionPath } = await writer.close();
450
+ resolveStopped();
451
+ console.log('');
452
+ console.log(`[imprint] saved ${jsonlPath}`);
453
+ console.log(`[imprint] assembled ${sessionPath}`);
454
+ console.log(`[imprint] ${seq} captured records`);
455
+ console.log('');
456
+ console.log('next step:');
457
+ console.log(` imprint redact ${sessionPath} # scrub credentials before LLM analysis`);
458
+ return { jsonlPath, sessionPath, count: seq };
459
+ };
460
+
461
+ if (opts.signal) {
462
+ if (opts.signal.aborted) return shutdown();
463
+ opts.signal.addEventListener('abort', () => void shutdown());
464
+ }
465
+ // If the user closes the window, wind down.
466
+ chromium.process.once('exit', () => void shutdown());
467
+
468
+ await narrationLoop;
469
+ return shutdown();
470
+ }