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,423 @@
1
+ /**
2
+ * Candidate-tool detection for `imprint teach`.
3
+ *
4
+ * One browser recording can exercise multiple user-facing intents. This pass
5
+ * runs after redaction and before compile so teach can fan out the shared
6
+ * session into one generated tool per selected candidate.
7
+ */
8
+
9
+ import { existsSync, readFileSync } from 'node:fs';
10
+ import { join as pathJoin } from 'node:path';
11
+ import { z } from 'zod';
12
+ import { inferAppApiHosts } from './app-api-hosts.ts';
13
+ import { isSameRegistrableDomain, registrableDomain } from './etld.ts';
14
+ import { type LLMOptions, extractJsonObject, resolveProvider } from './llm.ts';
15
+ import { createLog } from './log.ts';
16
+ import { compactRequestContexts, requestContextDigest } from './request-context.ts';
17
+ import { setSpanAttributes, traced } from './tracing.ts';
18
+ import type { CapturedRequest, Session } from './types.ts';
19
+
20
+ const PROMPTS_DIR = pathJoin(import.meta.dir, '..', '..', 'prompts');
21
+ const BODY_LIMIT = 800;
22
+ const RESPONSE_PREVIEW_LIMIT = 500;
23
+ const HEADER_LIMIT = 600;
24
+ const log = createLog('candidates');
25
+
26
+ function normalizeCandidateParamType(value: unknown): unknown {
27
+ if (typeof value !== 'string') {
28
+ return value;
29
+ }
30
+
31
+ const normalized = value
32
+ .trim()
33
+ .toLowerCase()
34
+ .replace(/[\s_-]+/g, '');
35
+ if (normalized.length === 0) {
36
+ return undefined;
37
+ }
38
+
39
+ if (
40
+ normalized === 'string' ||
41
+ normalized === 'str' ||
42
+ normalized === 'text' ||
43
+ normalized === 'array' ||
44
+ normalized === 'list' ||
45
+ normalized === 'string[]' ||
46
+ normalized === 'array<string>' ||
47
+ normalized === 'stringarray' ||
48
+ normalized === 'stringlist'
49
+ ) {
50
+ return 'string';
51
+ }
52
+
53
+ if (
54
+ normalized === 'number' ||
55
+ normalized === 'integer' ||
56
+ normalized === 'int' ||
57
+ normalized === 'float' ||
58
+ normalized === 'numeric' ||
59
+ normalized === 'number[]' ||
60
+ normalized === 'array<number>' ||
61
+ normalized === 'numberarray' ||
62
+ normalized === 'numberlist'
63
+ ) {
64
+ return 'number';
65
+ }
66
+
67
+ if (
68
+ normalized === 'boolean' ||
69
+ normalized === 'bool' ||
70
+ normalized === 'boolean[]' ||
71
+ normalized === 'bool[]' ||
72
+ normalized === 'array<boolean>' ||
73
+ normalized === 'booleanarray' ||
74
+ normalized === 'booleanlist'
75
+ ) {
76
+ return 'boolean';
77
+ }
78
+
79
+ return undefined;
80
+ }
81
+
82
+ const CandidateParamSchema = z.object({
83
+ name: z.string(),
84
+ type: z.preprocess(
85
+ normalizeCandidateParamType,
86
+ z.enum(['string', 'number', 'boolean']).optional(),
87
+ ),
88
+ description: z.string().optional(),
89
+ });
90
+
91
+ export const SharedCompileContextSchema = z.object({
92
+ loginRequestSeqs: z.array(z.number().int().nonnegative()).default([]),
93
+ credentialNames: z.array(z.string()).default([]),
94
+ tokenExtractionNotes: z.string().default(''),
95
+ sharedHelperNotes: z.string().default(''),
96
+ });
97
+ export type SharedCompileContext = z.infer<typeof SharedCompileContextSchema>;
98
+
99
+ export const ToolCandidateSchema = z.object({
100
+ toolName: z.string().regex(/^[a-z][a-z0-9_]*$/),
101
+ description: z.string().min(1),
102
+ rationale: z.string().min(1),
103
+ confidence: z.number().min(0).max(1),
104
+ primary: z.boolean(),
105
+ requestSeqs: z.array(z.number().int().nonnegative()).default([]),
106
+ representativeSeqs: z.array(z.number().int().nonnegative()).default([]),
107
+ eventSeqs: z.array(z.number().int().nonnegative()).default([]),
108
+ eventTimeRange: z
109
+ .object({
110
+ startTimestamp: z.number(),
111
+ endTimestamp: z.number(),
112
+ })
113
+ .optional(),
114
+ expectedOutput: z.string().default(''),
115
+ likelyParams: z.array(CandidateParamSchema).default([]),
116
+ dependencySeqs: z.array(z.number().int().nonnegative()).default([]),
117
+ });
118
+ export type ToolCandidate = z.infer<typeof ToolCandidateSchema>;
119
+
120
+ const ToolCandidateDetectionSchema = z
121
+ .object({
122
+ sharedContext: SharedCompileContextSchema.default({}),
123
+ candidates: z.array(ToolCandidateSchema).min(1),
124
+ })
125
+ .superRefine((value, ctx) => {
126
+ const primaryCount = value.candidates.filter((c) => c.primary).length;
127
+ if (primaryCount !== 1) {
128
+ ctx.addIssue({
129
+ code: z.ZodIssueCode.custom,
130
+ path: ['candidates'],
131
+ message: `expected exactly one primary candidate, got ${primaryCount}`,
132
+ });
133
+ }
134
+ const names = new Set<string>();
135
+ for (const [i, candidate] of value.candidates.entries()) {
136
+ if (names.has(candidate.toolName)) {
137
+ ctx.addIssue({
138
+ code: z.ZodIssueCode.custom,
139
+ path: ['candidates', i, 'toolName'],
140
+ message: `duplicate toolName "${candidate.toolName}"`,
141
+ });
142
+ }
143
+ names.add(candidate.toolName);
144
+ }
145
+ });
146
+ type ToolCandidateDetection = z.infer<typeof ToolCandidateDetectionSchema>;
147
+
148
+ interface DetectToolCandidatesResult extends ToolCandidateDetection {
149
+ inputTokens: number | null;
150
+ outputTokens: number | null;
151
+ durationMs: number;
152
+ }
153
+
154
+ export async function detectToolCandidates(
155
+ session: Session,
156
+ llmConfig?: LLMOptions,
157
+ ): Promise<DetectToolCandidatesResult> {
158
+ return await traced(
159
+ 'teach.detect_tool_candidates',
160
+ 'AGENT',
161
+ {
162
+ 'imprint.site': session.site,
163
+ 'imprint.session_url': session.url,
164
+ 'imprint.provider': llmConfig?.provider ?? 'auto',
165
+ },
166
+ async (span) => {
167
+ const promptPath = pathJoin(PROMPTS_DIR, 'tool-candidate-detection.md');
168
+ if (!existsSync(promptPath)) {
169
+ throw new Error(
170
+ `Candidate detection prompt not found at ${promptPath}\n→ this is an Imprint installation problem.`,
171
+ );
172
+ }
173
+ const systemPrompt = readFileSync(promptPath, 'utf8');
174
+ const payload = buildToolCandidatePayload(session);
175
+
176
+ setSpanAttributes(span, {
177
+ 'imprint.events_considered': payload.events.length,
178
+ 'imprint.requests_considered': payload.requests.length,
179
+ });
180
+
181
+ log(
182
+ `detecting candidate tools from ${payload.events.length} event(s), ${payload.requests.length} request(s)…`,
183
+ );
184
+ const llm = resolveProvider(llmConfig ?? {});
185
+ const result = await llm.analyze(systemPrompt, payload);
186
+ const objectText = extractJsonObject(result.text);
187
+ if (!objectText) {
188
+ throw new Error(
189
+ `Candidate detector did not return a JSON object.\nRaw response:\n${result.text.slice(0, 1000)}`,
190
+ );
191
+ }
192
+
193
+ let parsed: unknown;
194
+ try {
195
+ parsed = JSON.parse(objectText);
196
+ } catch (err) {
197
+ throw new Error(
198
+ `Candidate detector response was not valid JSON: ${err instanceof Error ? err.message : String(err)}\nExtracted:\n${objectText.slice(0, 1000)}`,
199
+ );
200
+ }
201
+
202
+ const detection = validateToolCandidateDetection(parsed);
203
+ setSpanAttributes(span, {
204
+ 'imprint.candidate_count': detection.candidates.length,
205
+ 'imprint.primary_tool_name': detection.candidates.find((c) => c.primary)?.toolName,
206
+ 'imprint.detect.duration_ms': result.durationMs,
207
+ 'imprint.detect.input_tokens': result.inputTokens,
208
+ 'imprint.detect.output_tokens': result.outputTokens,
209
+ });
210
+ return {
211
+ ...detection,
212
+ inputTokens: result.inputTokens,
213
+ outputTokens: result.outputTokens,
214
+ durationMs: result.durationMs,
215
+ };
216
+ },
217
+ );
218
+ }
219
+
220
+ export function validateToolCandidateDetection(input: unknown): ToolCandidateDetection {
221
+ const raw = ToolCandidateDetectionSchema.parse(input);
222
+ const before = raw.candidates.length;
223
+ raw.candidates = raw.candidates.filter((c) => c.requestSeqs.length > 0);
224
+ if (raw.candidates.length === 0) {
225
+ throw new Error(
226
+ `All ${before} candidate(s) had empty requestSeqs — cannot compile tools without backing requests.`,
227
+ );
228
+ }
229
+ if (raw.candidates.length < before) {
230
+ log(
231
+ `dropped ${before - raw.candidates.length} candidate(s) with empty requestSeqs (${raw.candidates.length} remaining)`,
232
+ );
233
+ }
234
+ if (!raw.candidates.some((c) => c.primary)) {
235
+ const first = raw.candidates[0];
236
+ if (first) first.primary = true;
237
+ }
238
+ return raw;
239
+ }
240
+
241
+ export function primaryToolCandidate(detection: ToolCandidateDetection): ToolCandidate {
242
+ const primary = detection.candidates.find((c) => c.primary);
243
+ if (!primary) {
244
+ throw new Error('candidate detection has no primary candidate');
245
+ }
246
+ return primary;
247
+ }
248
+
249
+ export function buildSharedCompileContext(
250
+ detection: ToolCandidateDetection,
251
+ _selected: ToolCandidate[],
252
+ ): SharedCompileContext {
253
+ return {
254
+ ...detection.sharedContext,
255
+ loginRequestSeqs: [...new Set(detection.sharedContext.loginRequestSeqs)].sort((a, b) => a - b),
256
+ };
257
+ }
258
+
259
+ interface CandidateRequestPayload {
260
+ seq: number;
261
+ timestamp: number;
262
+ method: string;
263
+ url: string;
264
+ resourceType: string;
265
+ status?: number;
266
+ mimeType?: string;
267
+ headers: string;
268
+ body?: string;
269
+ bodyDigest?: string;
270
+ bodyLength?: number;
271
+ responsePreview?: string;
272
+ responseBodyDigest?: string;
273
+ responseBodyLength?: number;
274
+ credentialPlaceholders: string[];
275
+ likelyLoginOrAuth: boolean;
276
+ repeatCount?: number;
277
+ repeatedSeqs?: number[];
278
+ lastTimestamp?: number;
279
+ }
280
+
281
+ interface ToolCandidatePayload {
282
+ site: string;
283
+ url: string;
284
+ narration: Array<{ seq: number; timestamp: number; text: string }>;
285
+ events: Array<{ seq: number; timestamp: number; type: string; detail: string }>;
286
+ requests: CandidateRequestPayload[];
287
+ }
288
+
289
+ export function buildToolCandidatePayload(session: Session): ToolCandidatePayload {
290
+ const startRoot = candidateStartRoot(session);
291
+ const appApiHosts = inferAppApiHosts(session, startRoot);
292
+ const requests = compactRequestContexts(
293
+ session.requests
294
+ .filter((request) => isCandidateRequest(request, startRoot, appApiHosts))
295
+ .map((request) => {
296
+ const body = truncate(request.body, BODY_LIMIT);
297
+ const responsePreview = truncate(request.response?.body, RESPONSE_PREVIEW_LIMIT);
298
+ const placeholderText = `${request.url}\n${JSON.stringify(request.headers)}\n${request.body ?? ''}`;
299
+ return {
300
+ seq: request.seq,
301
+ timestamp: request.timestamp,
302
+ method: request.method,
303
+ url: request.url,
304
+ resourceType: request.resourceType,
305
+ status: request.response?.status,
306
+ mimeType: request.response?.mimeType,
307
+ headers: truncate(JSON.stringify(request.headers), HEADER_LIMIT) ?? '{}',
308
+ body,
309
+ bodyDigest: requestContextDigest(request.body),
310
+ bodyLength: request.body?.length,
311
+ responsePreview,
312
+ responseBodyDigest: requestContextDigest(request.response?.body),
313
+ responseBodyLength: request.response?.body?.length,
314
+ credentialPlaceholders: credentialPlaceholders(placeholderText),
315
+ likelyLoginOrAuth: likelyLoginOrAuth(request),
316
+ };
317
+ }),
318
+ candidateRequestGroupKey,
319
+ );
320
+
321
+ return {
322
+ site: session.site,
323
+ url: session.url,
324
+ narration: session.narration.map((n) => ({
325
+ seq: n.seq,
326
+ timestamp: n.timestamp,
327
+ text: n.text,
328
+ })),
329
+ events: session.events.map((e) => ({
330
+ seq: e.seq,
331
+ timestamp: e.timestamp,
332
+ type: e.type,
333
+ detail: truncate(e.detail, 1000) ?? '',
334
+ })),
335
+ requests,
336
+ };
337
+ }
338
+
339
+ function candidateStartRoot(session: Session): string | null {
340
+ for (const value of [
341
+ session.url,
342
+ ...session.events.filter((event) => event.type === 'navigation').map((event) => event.detail),
343
+ ...session.requests
344
+ .filter((request) => request.resourceType === 'Document')
345
+ .map((request) => request.url),
346
+ ]) {
347
+ const root = rootFromHttpUrl(value);
348
+ if (root) return root;
349
+ }
350
+ return null;
351
+ }
352
+
353
+ function rootFromHttpUrl(value: string): string | null {
354
+ const url = safeUrl(value);
355
+ if (!url || !url.hostname) return null;
356
+ if (url.protocol !== 'http:' && url.protocol !== 'https:') return null;
357
+ return registrableDomain(url.hostname) || null;
358
+ }
359
+
360
+ function candidateRequestGroupKey(request: CandidateRequestPayload): unknown[] {
361
+ return [
362
+ request.method,
363
+ request.url,
364
+ request.bodyDigest,
365
+ request.bodyLength,
366
+ request.status,
367
+ request.mimeType,
368
+ request.responseBodyDigest,
369
+ request.responseBodyLength,
370
+ request.credentialPlaceholders,
371
+ request.likelyLoginOrAuth,
372
+ ];
373
+ }
374
+
375
+ function isCandidateRequest(
376
+ request: CapturedRequest,
377
+ startRoot: string | null,
378
+ appApiHosts: Set<string>,
379
+ ): boolean {
380
+ if (request.resourceType !== 'XHR' && request.resourceType !== 'Fetch') return false;
381
+ const url = safeUrl(request.url);
382
+ if (!url) return false;
383
+ if (startRoot && !isSameRegistrableDomain(url.hostname, startRoot)) {
384
+ return appApiHosts.has(url.hostname);
385
+ }
386
+ return true;
387
+ }
388
+
389
+ function likelyLoginOrAuth(request: CapturedRequest): boolean {
390
+ const url = safeUrl(request.url);
391
+ const endpointText =
392
+ `${request.method} ${url ? `${url.pathname} ${url.search}` : request.url} ${request.body ?? ''}`.toLowerCase();
393
+ const headerText = JSON.stringify(request.headers ?? {}).toLowerCase();
394
+ if (/\$\{credential\.[^}]+\}/.test(`${endpointText} ${headerText}`)) return true;
395
+
396
+ // Data requests often carry CSRF headers. Treat endpoint/body semantics as the
397
+ // signal so normal authenticated API calls do not get mislabeled as auth setup.
398
+ return /login|signin|sign-in|authenticate|authentication|oauth|session|password|csrf|token/.test(
399
+ endpointText,
400
+ );
401
+ }
402
+
403
+ function credentialPlaceholders(s: string): string[] {
404
+ const names = new Set<string>();
405
+ for (const match of s.matchAll(/\$\{credential\.([^}]+)\}/g)) {
406
+ if (match[1]) names.add(match[1]);
407
+ }
408
+ return [...names];
409
+ }
410
+
411
+ function truncate(s: string | undefined, limit: number): string | undefined {
412
+ if (!s) return undefined;
413
+ if (s.length <= limit) return s;
414
+ return `${s.slice(0, limit)}…(truncated, original length ${s.length})`;
415
+ }
416
+
417
+ function safeUrl(s: string): URL | null {
418
+ try {
419
+ return new URL(s);
420
+ } catch {
421
+ return null;
422
+ }
423
+ }
@@ -0,0 +1,186 @@
1
+ /** Discover + load generated tools from <assetRoot>/<site>/<toolName>/index.ts. Used
2
+ * by mcp-server, cron, and probe-backends. */
3
+
4
+ import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs';
5
+ import { dirname, join as pathJoin, resolve as pathResolve } from 'node:path';
6
+ import { pathToFileURL } from 'node:url';
7
+ import { z } from 'zod';
8
+ import { ensureImprintRuntimeLink } from './runtime-link.ts';
9
+ import type { ToolResult, Workflow, WorkflowParameter } from './types.ts';
10
+
11
+ type GeneratedToolFn = (
12
+ input: Record<string, unknown>,
13
+ opts?: Record<string, unknown>,
14
+ ) => Promise<ToolResult>;
15
+
16
+ interface GeneratedModule {
17
+ WORKFLOW: Workflow;
18
+ [exportName: string]: unknown;
19
+ }
20
+
21
+ export interface ResolvedTool {
22
+ /** Directory name under the asset root, e.g. "discoverandgo". */
23
+ site: string;
24
+ /** Absolute path to the directory containing workflow.json, playbook.yaml, etc. */
25
+ dir: string;
26
+ workflow: Workflow;
27
+ toolFn: GeneratedToolFn;
28
+ }
29
+
30
+ /** Scan the generated asset root, dynamically import each nested tool index.ts. Per-entry
31
+ * errors go to stderr and the entry is skipped — discovery never throws. */
32
+ export async function discoverTools(
33
+ assetRoot: string,
34
+ only?: string,
35
+ logPrefix = '[imprint]',
36
+ ): Promise<ResolvedTool[]> {
37
+ if (!existsSync(assetRoot)) return [];
38
+ // Self-heal the node_modules/imprint symlink so generated tools' import
39
+ // of `imprint/runtime` resolves even when the original codegen-time
40
+ // repo path has moved or vanished (e.g. ephemeral Conductor workspace).
41
+ ensureImprintRuntimeLink(assetRoot);
42
+ const entries = readdirSync(assetRoot);
43
+ const out: ResolvedTool[] = [];
44
+ for (const entry of entries) {
45
+ if (only && entry !== only) continue;
46
+ const dir = pathResolve(assetRoot, entry);
47
+ let isDir = false;
48
+ try {
49
+ isDir = statSync(dir).isDirectory();
50
+ } catch {
51
+ continue;
52
+ }
53
+ if (!isDir) continue;
54
+
55
+ // Tool layout: <assetRoot>/<site>/<toolName>/index.ts
56
+ for (const sub of readdirSync(dir)) {
57
+ const subDir = pathResolve(dir, sub);
58
+ try {
59
+ if (!statSync(subDir).isDirectory()) continue;
60
+ } catch {
61
+ continue;
62
+ }
63
+ const subModule = pathResolve(subDir, 'index.ts');
64
+ if (!existsSync(subModule)) continue;
65
+ const tool = await tryLoadTool(subModule, entry, logPrefix);
66
+ if (tool) out.push(tool);
67
+ }
68
+ }
69
+ return out;
70
+ }
71
+
72
+ async function tryLoadTool(
73
+ modulePath: string,
74
+ site: string,
75
+ logPrefix: string,
76
+ ): Promise<ResolvedTool | null> {
77
+ let mod: GeneratedModule;
78
+ if (hasStaleRuntimeImport(modulePath)) {
79
+ await tryRepairGeneratedModule(modulePath, logPrefix);
80
+ }
81
+ try {
82
+ mod = (await import(modulePath)) as GeneratedModule;
83
+ } catch (err) {
84
+ if (
85
+ canRepairStaleRuntimeImport(err) &&
86
+ (await tryRepairGeneratedModule(modulePath, logPrefix))
87
+ ) {
88
+ try {
89
+ const repairedUrl = `${pathToFileURL(modulePath).href}?imprintRepair=${Date.now()}`;
90
+ mod = (await import(repairedUrl)) as GeneratedModule;
91
+ } catch (repairErr) {
92
+ process.stderr.write(
93
+ `${logPrefix} skipping ${modulePath}: failed to load after repair (${repairErr instanceof Error ? repairErr.message : String(repairErr)})\n`,
94
+ );
95
+ return null;
96
+ }
97
+ } else {
98
+ process.stderr.write(
99
+ `${logPrefix} skipping ${modulePath}: failed to load (${err instanceof Error ? err.message : String(err)})\n`,
100
+ );
101
+ return null;
102
+ }
103
+ }
104
+ if (!mod.WORKFLOW) {
105
+ process.stderr.write(`${logPrefix} skipping ${modulePath}: missing WORKFLOW export\n`);
106
+ return null;
107
+ }
108
+ const fn = findToolFunction(mod);
109
+ if (!fn) {
110
+ process.stderr.write(
111
+ `${logPrefix} skipping ${modulePath}: missing exported function for "${mod.WORKFLOW.toolName}"\n`,
112
+ );
113
+ return null;
114
+ }
115
+ return { site, dir: dirname(modulePath), workflow: mod.WORKFLOW, toolFn: fn };
116
+ }
117
+
118
+ function hasStaleRuntimeImport(modulePath: string): boolean {
119
+ try {
120
+ const source = readFileSync(modulePath, 'utf8');
121
+ return /from\s+['"][^'"]*\/src\/imprint\/runtime\.ts['"]/.test(source);
122
+ } catch {
123
+ return false;
124
+ }
125
+ }
126
+
127
+ function canRepairStaleRuntimeImport(err: unknown): boolean {
128
+ const message = err instanceof Error ? err.message : String(err);
129
+ return message.includes('Cannot find module') && message.includes('/src/imprint/runtime.ts');
130
+ }
131
+
132
+ async function tryRepairGeneratedModule(modulePath: string, logPrefix: string): Promise<boolean> {
133
+ const toolDir = dirname(modulePath);
134
+ const workflowPath = pathJoin(toolDir, 'workflow.json');
135
+ try {
136
+ const { emit } = await import('./emit.ts');
137
+ emit({ workflowPath, outDir: toolDir, force: true });
138
+ process.stderr.write(`${logPrefix} repaired stale generated wrapper at ${modulePath}\n`);
139
+ return true;
140
+ } catch (err) {
141
+ process.stderr.write(
142
+ `${logPrefix} could not repair stale generated wrapper at ${modulePath}: ${err instanceof Error ? err.message : String(err)}\n`,
143
+ );
144
+ return false;
145
+ }
146
+ }
147
+
148
+ /** Tool fn export is the camelCase of toolName: book_x_y → bookXY. */
149
+ export function findToolFunction(mod: GeneratedModule): GeneratedToolFn | null {
150
+ const camelName = toCamelCase(mod.WORKFLOW.toolName);
151
+ const fn = mod[camelName];
152
+ return typeof fn === 'function' ? (fn as GeneratedToolFn) : null;
153
+ }
154
+
155
+ export function toCamelCase(snake: string): string {
156
+ return snake
157
+ .split('_')
158
+ .map((p, i) =>
159
+ i === 0 ? p.toLowerCase() : p.charAt(0).toUpperCase() + p.slice(1).toLowerCase(),
160
+ )
161
+ .join('');
162
+ }
163
+
164
+ /** Zod validator from workflow parameters — enforces the same contract
165
+ * for MCP args (from the LLM) and cron.json params. */
166
+ export function buildZodValidator(parameters: WorkflowParameter[]): z.ZodObject<z.ZodRawShape> {
167
+ const shape: z.ZodRawShape = {};
168
+ for (const p of parameters) {
169
+ let field: z.ZodType;
170
+ switch (p.type) {
171
+ case 'string':
172
+ field = z.string();
173
+ break;
174
+ case 'number':
175
+ field = z.number();
176
+ break;
177
+ case 'boolean':
178
+ field = z.boolean();
179
+ break;
180
+ }
181
+ field = field.describe(p.description);
182
+ if (p.default !== undefined) field = field.optional();
183
+ shape[p.name] = field;
184
+ }
185
+ return z.object(shape);
186
+ }
@@ -0,0 +1,70 @@
1
+ import {
2
+ basename,
3
+ isAbsolute as pathIsAbsolute,
4
+ relative as pathRelative,
5
+ resolve as pathResolve,
6
+ } from 'node:path';
7
+ import type { ResolvedTool } from './tool-loader.ts';
8
+
9
+ interface SelectGeneratedToolOptions {
10
+ site: string;
11
+ tools: ResolvedTool[];
12
+ purpose: 'cron' | 'probe';
13
+ toolName?: string;
14
+ pathHint?: string;
15
+ pathHintLabel?: string;
16
+ }
17
+
18
+ export function selectGeneratedTool(opts: SelectGeneratedToolOptions): ResolvedTool | null {
19
+ const { site, tools, purpose, toolName, pathHint, pathHintLabel } = opts;
20
+ if (tools.length === 0) return null;
21
+
22
+ const byName = toolName ? findToolByName(tools, toolName) : null;
23
+ if (toolName && !byName) {
24
+ throw new Error(
25
+ [
26
+ `No generated tool named "${toolName}" for site "${site}".`,
27
+ `Available tools: ${tools.map(displayToolName).join(', ')}`,
28
+ ].join('\n'),
29
+ );
30
+ }
31
+
32
+ const byPath = pathHint ? findToolByPath(tools, pathHint) : null;
33
+ if (byName && byPath && byName.dir !== byPath.dir) {
34
+ throw new Error(
35
+ `${pathHintLabel ?? 'path'} belongs to "${displayToolName(byPath)}", but --tool selected "${displayToolName(byName)}".`,
36
+ );
37
+ }
38
+ if (byName) return byName;
39
+ if (byPath) return byPath;
40
+ if (tools.length === 1) return tools[0] ?? null;
41
+
42
+ throw new Error(
43
+ [
44
+ `Site "${site}" has ${tools.length} generated tools; choose one for ${purpose}.`,
45
+ `Available tools:\n${tools.map((tool) => ` --tool ${displayToolName(tool)} (${tool.dir})`).join('\n')}`,
46
+ ].join('\n'),
47
+ );
48
+ }
49
+
50
+ function findToolByName(tools: ResolvedTool[], name: string): ResolvedTool | null {
51
+ return (
52
+ tools.find((tool) => tool.workflow.toolName === name || basename(tool.dir) === name) ?? null
53
+ );
54
+ }
55
+
56
+ function findToolByPath(tools: ResolvedTool[], pathHint: string): ResolvedTool | null {
57
+ const absolutePath = pathResolve(pathHint);
58
+ for (const tool of tools) {
59
+ const dir = pathResolve(tool.dir);
60
+ const relative = pathRelative(dir, absolutePath);
61
+ if (relative === '' || (!relative.startsWith('..') && !pathIsAbsolute(relative))) {
62
+ return tool;
63
+ }
64
+ }
65
+ return null;
66
+ }
67
+
68
+ function displayToolName(tool: ResolvedTool): string {
69
+ return tool.workflow.toolName || basename(tool.dir);
70
+ }