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.
- package/CHANGELOG.md +168 -0
- package/LICENSE +21 -0
- package/README.md +322 -0
- package/examples/discoverandgo/README.md +57 -0
- package/examples/discoverandgo/book_discoverandgo_museum_pass/cron.json +8 -0
- package/examples/discoverandgo/book_discoverandgo_museum_pass/index.ts +89 -0
- package/examples/discoverandgo/book_discoverandgo_museum_pass/workflow.json +39 -0
- package/examples/echo/README.md +37 -0
- package/examples/echo/echo_test/index.ts +31 -0
- package/examples/google-flights/search_google_flights/index.ts +101 -0
- package/examples/google-flights/search_google_flights/parser.test.ts +140 -0
- package/examples/google-flights/search_google_flights/parser.ts +189 -0
- package/examples/google-flights/search_google_flights/playbook.yaml +130 -0
- package/examples/google-flights/search_google_flights/workflow.json +48 -0
- package/examples/google-hotels/search_google_hotels/index.ts +194 -0
- package/examples/google-hotels/search_google_hotels/parser.test.ts +168 -0
- package/examples/google-hotels/search_google_hotels/parser.ts +330 -0
- package/examples/google-hotels/search_google_hotels/playbook.yaml +125 -0
- package/examples/google-hotels/search_google_hotels/workflow.json +111 -0
- package/examples/namecheap-domains/search_namecheap_domains/index.ts +144 -0
- package/examples/namecheap-domains/search_namecheap_domains/parser.ts +380 -0
- package/examples/namecheap-domains/search_namecheap_domains/playbook.yaml +50 -0
- package/examples/namecheap-domains/search_namecheap_domains/request-transform.ts +136 -0
- package/examples/namecheap-domains/search_namecheap_domains/workflow.json +97 -0
- package/examples/southwest/README.md +81 -0
- package/examples/southwest/search_southwest_flights/backends.json +23 -0
- package/examples/southwest/search_southwest_flights/cron.json +19 -0
- package/examples/southwest/search_southwest_flights/index.ts +110 -0
- package/examples/southwest/search_southwest_flights/playbook.yaml +46 -0
- package/examples/southwest/search_southwest_flights/workflow.json +54 -0
- package/package.json +78 -0
- package/prompts/compile-agent.md +580 -0
- package/prompts/intent-detection.md +198 -0
- package/prompts/playbook-compilation.md +279 -0
- package/prompts/request-triage.md +74 -0
- package/prompts/tool-candidate-detection.md +104 -0
- package/src/cli.ts +1287 -0
- package/src/imprint/agent.ts +468 -0
- package/src/imprint/app-api-hosts.ts +53 -0
- package/src/imprint/backend-ladder.ts +568 -0
- package/src/imprint/check.ts +136 -0
- package/src/imprint/chromium.ts +211 -0
- package/src/imprint/claude-cli-compile.ts +640 -0
- package/src/imprint/cli-credential.ts +394 -0
- package/src/imprint/codex-cli-compile.ts +712 -0
- package/src/imprint/compile-agent-types.ts +40 -0
- package/src/imprint/compile-agent.ts +404 -0
- package/src/imprint/compile-tools.ts +1389 -0
- package/src/imprint/compile.ts +720 -0
- package/src/imprint/cookie-jar.ts +246 -0
- package/src/imprint/credential-bundle.ts +195 -0
- package/src/imprint/credential-extract.ts +290 -0
- package/src/imprint/credential-store.ts +707 -0
- package/src/imprint/cron.ts +312 -0
- package/src/imprint/doctor.ts +223 -0
- package/src/imprint/emit.ts +154 -0
- package/src/imprint/etld.ts +134 -0
- package/src/imprint/freeform-redact.ts +216 -0
- package/src/imprint/inject-listener.ts +137 -0
- package/src/imprint/install.ts +795 -0
- package/src/imprint/integrations.ts +385 -0
- package/src/imprint/is-compiled.ts +2 -0
- package/src/imprint/json-path.ts +100 -0
- package/src/imprint/llm.ts +998 -0
- package/src/imprint/load-json.ts +54 -0
- package/src/imprint/log.ts +33 -0
- package/src/imprint/login.ts +166 -0
- package/src/imprint/mcp-compile-server.ts +282 -0
- package/src/imprint/mcp-maintenance.ts +1790 -0
- package/src/imprint/mcp-server.ts +350 -0
- package/src/imprint/multi-progress.ts +69 -0
- package/src/imprint/notify.ts +155 -0
- package/src/imprint/paths.ts +64 -0
- package/src/imprint/playbook-parser.ts +21 -0
- package/src/imprint/playbook-runner.ts +465 -0
- package/src/imprint/probe-backends.ts +251 -0
- package/src/imprint/progress.ts +28 -0
- package/src/imprint/record.ts +470 -0
- package/src/imprint/redact.ts +550 -0
- package/src/imprint/replay-capture.ts +387 -0
- package/src/imprint/request-context.ts +66 -0
- package/src/imprint/runtime-link.ts +73 -0
- package/src/imprint/runtime.ts +942 -0
- package/src/imprint/sensitive-keys.ts +156 -0
- package/src/imprint/session-diff.ts +409 -0
- package/src/imprint/session-merge.ts +198 -0
- package/src/imprint/session-writer.ts +149 -0
- package/src/imprint/sites.ts +27 -0
- package/src/imprint/stealth-fetch.ts +434 -0
- package/src/imprint/teach-state.ts +235 -0
- package/src/imprint/teach.ts +2120 -0
- package/src/imprint/tool-candidates.ts +423 -0
- package/src/imprint/tool-loader.ts +186 -0
- package/src/imprint/tool-selection.ts +70 -0
- package/src/imprint/tracing.ts +508 -0
- package/src/imprint/types.ts +472 -0
- package/src/imprint/version.ts +21 -0
|
@@ -0,0 +1,387 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Replay a session in a fresh browser while capturing full request/response
|
|
3
|
+
* data for dual-pass diff analysis.
|
|
4
|
+
*
|
|
5
|
+
* Two replay strategies:
|
|
6
|
+
* 1. replayRawSession() — replays raw DOM events (click/type/navigate) from
|
|
7
|
+
* the original recording. Used for the site-level dual-pass before triage.
|
|
8
|
+
* 2. replayAndCapture() — replays a compiled playbook via playbook-runner.ts.
|
|
9
|
+
* Kept for potential future use but no longer part of the teach pipeline.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { appendFileSync, writeFileSync } from 'node:fs';
|
|
13
|
+
import { tmpdir } from 'node:os';
|
|
14
|
+
import { join as pathJoin } from 'node:path';
|
|
15
|
+
import type { Browser, BrowserContext, Locator, Page } from 'playwright';
|
|
16
|
+
import { createLog } from './log.ts';
|
|
17
|
+
import type { CapturedReplayRequest } from './session-diff.ts';
|
|
18
|
+
import type { CapturedEvent, Session } from './types.ts';
|
|
19
|
+
|
|
20
|
+
const log = createLog('replay-capture');
|
|
21
|
+
|
|
22
|
+
const isReplayDebug = (): boolean => process.env.IMPRINT_REPLAY_DEBUG === '1';
|
|
23
|
+
let replayDebugPath: string | null = null;
|
|
24
|
+
|
|
25
|
+
function replayLog(msg: string): void {
|
|
26
|
+
if (!isReplayDebug()) return;
|
|
27
|
+
if (!replayDebugPath) {
|
|
28
|
+
replayDebugPath = pathJoin(tmpdir(), `imprint-replay-debug-${Date.now()}.log`);
|
|
29
|
+
writeFileSync(
|
|
30
|
+
replayDebugPath,
|
|
31
|
+
`[imprint replay-debug] started at ${new Date().toISOString()}\n`,
|
|
32
|
+
);
|
|
33
|
+
log(`replay debug log: ${replayDebugPath}`);
|
|
34
|
+
}
|
|
35
|
+
const line = `[${new Date().toISOString()}] ${msg}\n`;
|
|
36
|
+
appendFileSync(replayDebugPath, line);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
interface ReplayCaptureResult {
|
|
40
|
+
ok: boolean;
|
|
41
|
+
requests: CapturedReplayRequest[];
|
|
42
|
+
error?: string;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// ─── Raw session replay ─────────────────────────────────────────────────────
|
|
46
|
+
|
|
47
|
+
interface RawReplayOptions {
|
|
48
|
+
session: Session;
|
|
49
|
+
site: string;
|
|
50
|
+
headed?: boolean;
|
|
51
|
+
onProgress?: (current: number, total: number, captured: number) => void;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Replay the raw DOM events from an original recording in a fresh browser,
|
|
56
|
+
* capturing all network requests. This is the site-level dual-pass strategy:
|
|
57
|
+
* one replay of the entire session, not per-tool.
|
|
58
|
+
*/
|
|
59
|
+
export async function replayRawSession(opts: RawReplayOptions): Promise<ReplayCaptureResult> {
|
|
60
|
+
let browser: Browser | undefined;
|
|
61
|
+
let context: BrowserContext | undefined;
|
|
62
|
+
|
|
63
|
+
let chromium: typeof import('playwright').chromium;
|
|
64
|
+
try {
|
|
65
|
+
const pwExtra = await import('playwright-extra');
|
|
66
|
+
const stealthMod = await import('puppeteer-extra-plugin-stealth');
|
|
67
|
+
const stealthFactory =
|
|
68
|
+
(stealthMod as { default?: () => unknown }).default ??
|
|
69
|
+
(stealthMod as unknown as () => unknown);
|
|
70
|
+
pwExtra.chromium.use(stealthFactory() as never);
|
|
71
|
+
chromium = pwExtra.chromium as unknown as typeof import('playwright').chromium;
|
|
72
|
+
} catch {
|
|
73
|
+
try {
|
|
74
|
+
const pw = await import('playwright');
|
|
75
|
+
chromium = pw.chromium;
|
|
76
|
+
} catch (innerErr) {
|
|
77
|
+
return { ok: false, requests: [], error: `Playwright not available: ${errMsg(innerErr)}` };
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
try {
|
|
82
|
+
replayLog(`launching browser (headed=${!!opts.headed})`);
|
|
83
|
+
browser = await chromium.launch({ headless: !opts.headed });
|
|
84
|
+
} catch (err) {
|
|
85
|
+
replayLog(`browser launch failed: ${errMsg(err)}`);
|
|
86
|
+
return { ok: false, requests: [], error: `Could not launch Chromium: ${errMsg(err)}` };
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const captured: CapturedReplayRequest[] = [];
|
|
90
|
+
let seq = 0;
|
|
91
|
+
const startTime = Date.now();
|
|
92
|
+
|
|
93
|
+
try {
|
|
94
|
+
context = await browser.newContext();
|
|
95
|
+
const page = await context.newPage();
|
|
96
|
+
replayLog('browser context + page created');
|
|
97
|
+
|
|
98
|
+
// Inject credentials if available
|
|
99
|
+
try {
|
|
100
|
+
const { loadSiteCredentials } = await import('./credential-store.ts');
|
|
101
|
+
const view = await loadSiteCredentials(opts.site);
|
|
102
|
+
const playwrightCookies = view.cookies
|
|
103
|
+
.map((c) => ({ name: c.name, value: c.value, domain: c.domain, path: c.path }))
|
|
104
|
+
.filter((c) => c.name && c.value);
|
|
105
|
+
if (playwrightCookies.length > 0) {
|
|
106
|
+
await context.addCookies(playwrightCookies);
|
|
107
|
+
replayLog(`injected ${playwrightCookies.length} cookies`);
|
|
108
|
+
log(`injected ${playwrightCookies.length} cookies`);
|
|
109
|
+
}
|
|
110
|
+
} catch {
|
|
111
|
+
// No credentials — fine for unauthenticated flows
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// Hook request/response capture (same as replayAndCapture)
|
|
115
|
+
let reqId = 0;
|
|
116
|
+
const requestMeta = new Map<
|
|
117
|
+
string,
|
|
118
|
+
{ method: string; url: string; headers: Record<string, string>; body?: string }
|
|
119
|
+
>();
|
|
120
|
+
page.on('request', (req) => {
|
|
121
|
+
const id = `${reqId++}`;
|
|
122
|
+
requestMeta.set(id, {
|
|
123
|
+
method: req.method(),
|
|
124
|
+
url: req.url(),
|
|
125
|
+
headers: req.headers(),
|
|
126
|
+
body: req.postData() ?? undefined,
|
|
127
|
+
});
|
|
128
|
+
(req as unknown as Record<string, string>).__replayCaptureId = id;
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
const pendingReads: Promise<void>[] = [];
|
|
132
|
+
page.on('response', (resp) => {
|
|
133
|
+
const req = resp.request();
|
|
134
|
+
const id = (req as unknown as Record<string, string>).__replayCaptureId;
|
|
135
|
+
const meta = id ? requestMeta.get(id) : undefined;
|
|
136
|
+
const currentSeq = seq++;
|
|
137
|
+
const method = meta?.method ?? req.method();
|
|
138
|
+
const url = meta?.url ?? resp.url();
|
|
139
|
+
const headers = meta?.headers ?? req.headers();
|
|
140
|
+
const body = meta?.body;
|
|
141
|
+
const readP = resp
|
|
142
|
+
.text()
|
|
143
|
+
.then((respBody) => {
|
|
144
|
+
captured.push({
|
|
145
|
+
seq: currentSeq,
|
|
146
|
+
timestamp: Date.now() - startTime,
|
|
147
|
+
method,
|
|
148
|
+
url,
|
|
149
|
+
headers,
|
|
150
|
+
body,
|
|
151
|
+
resourceType: req.resourceType(),
|
|
152
|
+
response: {
|
|
153
|
+
status: resp.status(),
|
|
154
|
+
headers: resp.headers(),
|
|
155
|
+
body: respBody,
|
|
156
|
+
mimeType: resp.headers()['content-type']?.split(';')[0]?.trim(),
|
|
157
|
+
},
|
|
158
|
+
});
|
|
159
|
+
})
|
|
160
|
+
.catch(() => {
|
|
161
|
+
captured.push({
|
|
162
|
+
seq: currentSeq,
|
|
163
|
+
timestamp: Date.now() - startTime,
|
|
164
|
+
method,
|
|
165
|
+
url,
|
|
166
|
+
headers,
|
|
167
|
+
body,
|
|
168
|
+
resourceType: req.resourceType(),
|
|
169
|
+
response: {
|
|
170
|
+
status: resp.status(),
|
|
171
|
+
headers: resp.headers(),
|
|
172
|
+
body: undefined,
|
|
173
|
+
},
|
|
174
|
+
});
|
|
175
|
+
});
|
|
176
|
+
pendingReads.push(readP);
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
// Replay each DOM event from the original recording
|
|
180
|
+
const replayableEvents = opts.session.events.filter(
|
|
181
|
+
(e) =>
|
|
182
|
+
e.type === 'navigation' ||
|
|
183
|
+
e.type === 'click' ||
|
|
184
|
+
e.type === 'input' ||
|
|
185
|
+
e.type === 'change' ||
|
|
186
|
+
e.type === 'submit',
|
|
187
|
+
);
|
|
188
|
+
|
|
189
|
+
replayLog(
|
|
190
|
+
`total session events: ${opts.session.events.length}, replayable: ${replayableEvents.length}`,
|
|
191
|
+
);
|
|
192
|
+
let prevTimestamp = replayableEvents[0]?.timestamp ?? 0;
|
|
193
|
+
|
|
194
|
+
for (let i = 0; i < replayableEvents.length; i++) {
|
|
195
|
+
// biome-ignore lint/style/noNonNullAssertion: bounded by loop condition
|
|
196
|
+
const event = replayableEvents[i]!;
|
|
197
|
+
|
|
198
|
+
// Use timestamp delta as minimum delay between events
|
|
199
|
+
const delta = Math.max(0, event.timestamp - prevTimestamp);
|
|
200
|
+
if (delta > 100 && i > 0) {
|
|
201
|
+
const wait = Math.min(delta, 3000);
|
|
202
|
+
replayLog(` waiting ${wait}ms (original delta ${delta}ms)`);
|
|
203
|
+
await page.waitForTimeout(wait);
|
|
204
|
+
}
|
|
205
|
+
prevTimestamp = event.timestamp;
|
|
206
|
+
|
|
207
|
+
const detail = typeof event.detail === 'string' ? event.detail : JSON.stringify(event.detail);
|
|
208
|
+
const detailPreview = detail.length > 200 ? `${detail.slice(0, 200)}...` : detail;
|
|
209
|
+
replayLog(
|
|
210
|
+
`event ${i + 1}/${replayableEvents.length}: type=${event.type} seq=${event.seq} detail=${detailPreview}`,
|
|
211
|
+
);
|
|
212
|
+
|
|
213
|
+
await replayEvent(page, event);
|
|
214
|
+
replayLog(` event ${i + 1} done (captured ${captured.length} requests so far)`);
|
|
215
|
+
opts.onProgress?.(i + 1, replayableEvents.length, captured.length);
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
// Allow final network requests to settle
|
|
219
|
+
replayLog('waiting for networkidle...');
|
|
220
|
+
await page.waitForLoadState('networkidle').catch(() => {});
|
|
221
|
+
await page.waitForTimeout(1000);
|
|
222
|
+
await Promise.allSettled(pendingReads);
|
|
223
|
+
captured.sort((a, b) => a.seq - b.seq);
|
|
224
|
+
|
|
225
|
+
replayLog(`replay complete: captured ${captured.length} requests total`);
|
|
226
|
+
log(`captured ${captured.length} requests during raw session replay`);
|
|
227
|
+
return { ok: true, requests: captured };
|
|
228
|
+
} catch (err) {
|
|
229
|
+
replayLog(`replay threw: ${errMsg(err)}`);
|
|
230
|
+
return { ok: false, requests: captured, error: errMsg(err) };
|
|
231
|
+
} finally {
|
|
232
|
+
await context?.close().catch(() => {});
|
|
233
|
+
await browser?.close().catch(() => {});
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
async function replayEvent(page: Page, event: CapturedEvent): Promise<void> {
|
|
238
|
+
if (event.type === 'navigation') {
|
|
239
|
+
const url = typeof event.detail === 'string' ? event.detail : String(event.detail);
|
|
240
|
+
if (!url.startsWith('http')) {
|
|
241
|
+
replayLog(` skip non-http navigation: ${url.slice(0, 80)}`);
|
|
242
|
+
return;
|
|
243
|
+
}
|
|
244
|
+
replayLog(` navigating to ${url.slice(0, 120)}`);
|
|
245
|
+
await page.goto(url, { waitUntil: 'domcontentloaded', timeout: 30_000 });
|
|
246
|
+
replayLog(' navigation complete');
|
|
247
|
+
return;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
const d = parseEventDetail(event.detail);
|
|
251
|
+
if (!d) {
|
|
252
|
+
replayLog(' skip: could not parse event detail');
|
|
253
|
+
return;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
if (event.type === 'click') {
|
|
257
|
+
const loc = buildLocatorFromEvent(page, d);
|
|
258
|
+
if (!loc) {
|
|
259
|
+
replayLog(
|
|
260
|
+
` skip click: no locator for id=${d.id} name=${d.name} text=${(d.text ?? '').slice(0, 40)} selector=${(d.selector ?? '').slice(0, 60)}`,
|
|
261
|
+
);
|
|
262
|
+
return;
|
|
263
|
+
}
|
|
264
|
+
replayLog(` clicking: id=${d.id} name=${d.name} text=${(d.text ?? '').slice(0, 40)}`);
|
|
265
|
+
try {
|
|
266
|
+
// Fast visibility check — don't wait 10s for elements that aren't there
|
|
267
|
+
const visible = await loc.isVisible().catch(() => false);
|
|
268
|
+
if (!visible) {
|
|
269
|
+
replayLog(' skip click: element not visible');
|
|
270
|
+
return;
|
|
271
|
+
}
|
|
272
|
+
await loc.click({ timeout: 3_000, force: false });
|
|
273
|
+
replayLog(' click succeeded');
|
|
274
|
+
} catch (e1) {
|
|
275
|
+
replayLog(` click failed (${errMsg(e1).split('\n')[0]}), retrying with force`);
|
|
276
|
+
try {
|
|
277
|
+
await loc.click({ timeout: 2_000, force: true });
|
|
278
|
+
replayLog(' force-click succeeded');
|
|
279
|
+
} catch (e2) {
|
|
280
|
+
replayLog(` force-click also failed: ${errMsg(e2).split('\n')[0]}`);
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
return;
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
if (event.type === 'input' || event.type === 'change') {
|
|
287
|
+
const loc = buildLocatorFromEvent(page, d);
|
|
288
|
+
if (!loc || !d.value) {
|
|
289
|
+
replayLog(` skip ${event.type}: no locator or no value`);
|
|
290
|
+
return;
|
|
291
|
+
}
|
|
292
|
+
const tag = (d.tag ?? '').toLowerCase();
|
|
293
|
+
const type = (d.type ?? '').toLowerCase();
|
|
294
|
+
replayLog(
|
|
295
|
+
` ${event.type}: tag=${tag} type=${type} name=${d.name} value=${(d.value ?? '').slice(0, 40)}`,
|
|
296
|
+
);
|
|
297
|
+
try {
|
|
298
|
+
const visible = await loc.isVisible().catch(() => false);
|
|
299
|
+
if (!visible) {
|
|
300
|
+
replayLog(` skip ${event.type}: element not visible`);
|
|
301
|
+
return;
|
|
302
|
+
}
|
|
303
|
+
if (tag === 'select' || type === 'select-one') {
|
|
304
|
+
await loc.selectOption(d.value, { timeout: 3_000 });
|
|
305
|
+
} else {
|
|
306
|
+
await loc.fill(d.value, { timeout: 3_000 });
|
|
307
|
+
}
|
|
308
|
+
replayLog(` ${event.type} succeeded`);
|
|
309
|
+
} catch (err) {
|
|
310
|
+
replayLog(` ${event.type} failed: ${errMsg(err).split('\n')[0]}`);
|
|
311
|
+
}
|
|
312
|
+
return;
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
if (event.type === 'submit') {
|
|
316
|
+
replayLog(` submit: selector=${(d.selector ?? '').slice(0, 60)}`);
|
|
317
|
+
const loc = d.selector ? page.locator(d.selector) : null;
|
|
318
|
+
if (loc) {
|
|
319
|
+
try {
|
|
320
|
+
const visible = await loc.isVisible().catch(() => false);
|
|
321
|
+
if (!visible) {
|
|
322
|
+
replayLog(' skip submit: form not visible');
|
|
323
|
+
return;
|
|
324
|
+
}
|
|
325
|
+
await loc.press('Enter', { timeout: 3_000 });
|
|
326
|
+
replayLog(' submit succeeded');
|
|
327
|
+
} catch (err) {
|
|
328
|
+
replayLog(` submit failed: ${errMsg(err).split('\n')[0]}`);
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
return;
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
interface EventDetail {
|
|
336
|
+
tag?: string;
|
|
337
|
+
id?: string;
|
|
338
|
+
name?: string;
|
|
339
|
+
type?: string;
|
|
340
|
+
text?: string;
|
|
341
|
+
ariaLabel?: string;
|
|
342
|
+
href?: string;
|
|
343
|
+
selector?: string;
|
|
344
|
+
value?: string;
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
function parseEventDetail(detail: string | unknown): EventDetail | null {
|
|
348
|
+
if (typeof detail === 'string') {
|
|
349
|
+
try {
|
|
350
|
+
return JSON.parse(detail);
|
|
351
|
+
} catch {
|
|
352
|
+
return null;
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
if (typeof detail === 'object' && detail !== null) {
|
|
356
|
+
return detail as EventDetail;
|
|
357
|
+
}
|
|
358
|
+
return null;
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
function buildLocatorFromEvent(page: Page, d: EventDetail): Locator | null {
|
|
362
|
+
// Priority chain: id → name → ariaLabel → text → css selector
|
|
363
|
+
if (d.id) {
|
|
364
|
+
return page.locator(`[id="${d.id}"]`).first();
|
|
365
|
+
}
|
|
366
|
+
if (d.name) {
|
|
367
|
+
return page.locator(`[name="${d.name}"]`).first();
|
|
368
|
+
}
|
|
369
|
+
if (d.ariaLabel) {
|
|
370
|
+
return page.getByLabel(d.ariaLabel).first();
|
|
371
|
+
}
|
|
372
|
+
if (d.text && d.tag) {
|
|
373
|
+
const tag = d.tag.toLowerCase();
|
|
374
|
+
// For buttons/links, use text-based locator
|
|
375
|
+
if (tag === 'button' || tag === 'a') {
|
|
376
|
+
return page.getByRole(tag === 'button' ? 'button' : 'link', { name: d.text }).first();
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
if (d.selector) {
|
|
380
|
+
return page.locator(d.selector).first();
|
|
381
|
+
}
|
|
382
|
+
return null;
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
function errMsg(e: unknown): string {
|
|
386
|
+
return e instanceof Error ? e.message : String(e);
|
|
387
|
+
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared helpers for compacting repeated request metadata before it is handed
|
|
3
|
+
* to an LLM. Full request/response bodies remain available through explicit
|
|
4
|
+
* read tools; these helpers only shrink overview payloads.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { createHash } from 'node:crypto';
|
|
8
|
+
|
|
9
|
+
interface CompactRequestContext {
|
|
10
|
+
seq: number;
|
|
11
|
+
timestamp: number;
|
|
12
|
+
repeatCount?: number;
|
|
13
|
+
repeatedSeqs?: number[];
|
|
14
|
+
lastTimestamp?: number;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
interface CompactRequestContextsOptions {
|
|
18
|
+
/**
|
|
19
|
+
* Seqs that must remain as their own rows, even if they look identical to a
|
|
20
|
+
* neighboring request. Candidate-scoped requests use this so selected tool
|
|
21
|
+
* traffic is never hidden inside an unrelated representative row.
|
|
22
|
+
*/
|
|
23
|
+
preserveSeqs?: Iterable<number>;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function compactRequestContexts<T extends CompactRequestContext>(
|
|
27
|
+
requests: T[],
|
|
28
|
+
groupKey: (request: T) => unknown,
|
|
29
|
+
opts: CompactRequestContextsOptions = {},
|
|
30
|
+
): T[] {
|
|
31
|
+
const out: T[] = [];
|
|
32
|
+
const seen = new Map<string, T>();
|
|
33
|
+
const preserveSeqs = new Set(opts.preserveSeqs ?? []);
|
|
34
|
+
|
|
35
|
+
for (const request of requests) {
|
|
36
|
+
if (preserveSeqs.has(request.seq)) {
|
|
37
|
+
request.repeatCount = request.repeatCount ?? 1;
|
|
38
|
+
out.push(request);
|
|
39
|
+
continue;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const key = stableRequestContextKey(groupKey(request));
|
|
43
|
+
const existing = seen.get(key);
|
|
44
|
+
if (!existing) {
|
|
45
|
+
request.repeatCount = request.repeatCount ?? 1;
|
|
46
|
+
seen.set(key, request);
|
|
47
|
+
out.push(request);
|
|
48
|
+
continue;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
existing.repeatCount = (existing.repeatCount ?? 1) + 1;
|
|
52
|
+
existing.repeatedSeqs = [...(existing.repeatedSeqs ?? [existing.seq]), request.seq];
|
|
53
|
+
existing.lastTimestamp = request.timestamp;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
return out;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function stableRequestContextKey(parts: unknown): string {
|
|
60
|
+
return typeof parts === 'string' ? parts : JSON.stringify(parts);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export function requestContextDigest(value: string | undefined): string | undefined {
|
|
64
|
+
if (value === undefined) return undefined;
|
|
65
|
+
return createHash('sha256').update(value).digest('hex');
|
|
66
|
+
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Maintains the `<imprintHome>/node_modules/imprint` symlink that lets
|
|
3
|
+
* generated tool files (`~/.imprint/<site>/<tool>/index.ts`) resolve
|
|
4
|
+
* `import { ... } from 'imprint/runtime'` via standard Bun module
|
|
5
|
+
* resolution.
|
|
6
|
+
*
|
|
7
|
+
* Self-heals dangling links — a Conductor or git-worktree workspace can
|
|
8
|
+
* vanish out from under the symlink, and re-running `imprint emit` from
|
|
9
|
+
* the new repo location wouldn't fix it (existsSync follows the link, so
|
|
10
|
+
* dangling links report as "not present" and the replace branch was
|
|
11
|
+
* silently skipped). We call this from `discoverTools` so every entry
|
|
12
|
+
* point — mcp-server, cron, probe-backends — repairs the link before
|
|
13
|
+
* trying to import any tool module.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { lstatSync, mkdirSync, readlinkSync, symlinkSync, unlinkSync } from 'node:fs';
|
|
17
|
+
import { dirname, join as pathJoin, resolve as pathResolve } from 'node:path';
|
|
18
|
+
|
|
19
|
+
/** Repo root for the currently-running imprint install. */
|
|
20
|
+
function imprintRepoRoot(): string {
|
|
21
|
+
return pathResolve(import.meta.dir, '..', '..');
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Ensure `<imprintHome>/node_modules/imprint` is a symlink pointing at
|
|
26
|
+
* the running imprint repo. Idempotent: no-op when the link is already
|
|
27
|
+
* correct. Repairs dangling links and links pointing to a different
|
|
28
|
+
* repo path.
|
|
29
|
+
*
|
|
30
|
+
* Refuses to overwrite anything that is not a symlink (so a user who
|
|
31
|
+
* actually `npm i imprint` into their home is left alone).
|
|
32
|
+
*
|
|
33
|
+
* Failures are non-fatal — if we can't write the link, the caller's
|
|
34
|
+
* import will fail with the original ResolveMessage and the user gets
|
|
35
|
+
* the standard error path.
|
|
36
|
+
*/
|
|
37
|
+
export function ensureImprintRuntimeLink(imprintHome: string): void {
|
|
38
|
+
const repoRoot = imprintRepoRoot();
|
|
39
|
+
const nodeModulesDir = pathJoin(imprintHome, 'node_modules');
|
|
40
|
+
const linkPath = pathJoin(nodeModulesDir, 'imprint');
|
|
41
|
+
|
|
42
|
+
try {
|
|
43
|
+
let existing: ReturnType<typeof lstatSync> | null = null;
|
|
44
|
+
try {
|
|
45
|
+
existing = lstatSync(linkPath);
|
|
46
|
+
} catch {
|
|
47
|
+
// ENOENT — fall through to create.
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
if (existing) {
|
|
51
|
+
if (!existing.isSymbolicLink()) {
|
|
52
|
+
// Real file or directory at this path — likely an actual install.
|
|
53
|
+
// Don't touch it.
|
|
54
|
+
return;
|
|
55
|
+
}
|
|
56
|
+
let currentTarget: string;
|
|
57
|
+
try {
|
|
58
|
+
currentTarget = readlinkSync(linkPath);
|
|
59
|
+
} catch {
|
|
60
|
+
currentTarget = '';
|
|
61
|
+
}
|
|
62
|
+
// readlink may return a relative path; resolve it the same way Bun would.
|
|
63
|
+
const resolvedTarget = pathResolve(dirname(linkPath), currentTarget);
|
|
64
|
+
if (resolvedTarget === repoRoot) return; // already correct
|
|
65
|
+
unlinkSync(linkPath);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
mkdirSync(nodeModulesDir, { recursive: true });
|
|
69
|
+
symlinkSync(repoRoot, linkPath, 'dir');
|
|
70
|
+
} catch {
|
|
71
|
+
// Non-fatal — see docstring.
|
|
72
|
+
}
|
|
73
|
+
}
|