imprint-mcp 0.2.0 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +165 -201
- package/examples/discoverandgo/README.md +1 -1
- package/examples/echo/README.md +1 -1
- package/examples/google-flights/README.md +28 -0
- package/examples/google-flights/_shared/batchexecute.ts +63 -0
- package/examples/google-flights/_shared/flights_request.ts +95 -0
- package/examples/google-flights/_shared/package.json +9 -0
- package/examples/google-flights/get_flight_booking_details/index.ts +159 -0
- package/examples/google-flights/get_flight_booking_details/package.json +9 -0
- package/examples/google-flights/get_flight_booking_details/parser.ts +182 -0
- package/examples/google-flights/get_flight_booking_details/playbook.yaml +138 -0
- package/examples/google-flights/get_flight_booking_details/request-transform.ts +86 -0
- package/examples/google-flights/get_flight_booking_details/workflow.json +98 -0
- package/examples/google-flights/get_flight_calendar_prices/index.ts +131 -0
- package/examples/google-flights/get_flight_calendar_prices/package.json +9 -0
- package/examples/google-flights/get_flight_calendar_prices/parser.ts +86 -0
- package/examples/google-flights/get_flight_calendar_prices/playbook.yaml +97 -0
- package/examples/google-flights/get_flight_calendar_prices/request-transform.ts +31 -0
- package/examples/google-flights/get_flight_calendar_prices/workflow.json +76 -0
- package/examples/google-flights/lookup_airport/index.ts +101 -0
- package/examples/google-flights/lookup_airport/package.json +9 -0
- package/examples/google-flights/lookup_airport/parser.ts +66 -0
- package/examples/google-flights/lookup_airport/playbook.yaml +47 -0
- package/examples/google-flights/lookup_airport/request-transform.ts +20 -0
- package/examples/google-flights/lookup_airport/workflow.json +57 -0
- package/examples/google-flights/search_flights/index.ts +219 -0
- package/examples/google-flights/search_flights/package.json +9 -0
- package/examples/google-flights/search_flights/parser.ts +169 -0
- package/examples/google-flights/search_flights/playbook.yaml +184 -0
- package/examples/google-flights/search_flights/request-transform.ts +119 -0
- package/examples/google-flights/search_flights/workflow.json +143 -0
- package/examples/google-hotels/README.md +29 -0
- package/examples/google-hotels/_shared/batchexecute.ts +73 -0
- package/examples/google-hotels/_shared/freq.ts +158 -0
- package/examples/google-hotels/_shared/package.json +9 -0
- package/examples/google-hotels/autocomplete_hotel_location/index.ts +80 -0
- package/examples/google-hotels/autocomplete_hotel_location/package.json +9 -0
- package/examples/google-hotels/autocomplete_hotel_location/parser.ts +71 -0
- package/examples/google-hotels/autocomplete_hotel_location/playbook.yaml +36 -0
- package/examples/google-hotels/autocomplete_hotel_location/request-transform.ts +37 -0
- package/examples/google-hotels/autocomplete_hotel_location/workflow.json +36 -0
- package/examples/google-hotels/get_hotel_booking_options/index.ts +143 -0
- package/examples/google-hotels/get_hotel_booking_options/package.json +9 -0
- package/examples/google-hotels/get_hotel_booking_options/parser.ts +271 -0
- package/examples/google-hotels/get_hotel_booking_options/playbook.yaml +154 -0
- package/examples/google-hotels/get_hotel_booking_options/request-transform.ts +154 -0
- package/examples/google-hotels/get_hotel_booking_options/workflow.json +84 -0
- package/examples/google-hotels/get_hotel_reviews/index.ts +81 -0
- package/examples/google-hotels/get_hotel_reviews/package.json +9 -0
- package/examples/google-hotels/get_hotel_reviews/parser.ts +128 -0
- package/examples/google-hotels/get_hotel_reviews/playbook.yaml +64 -0
- package/examples/google-hotels/get_hotel_reviews/request-transform.ts +42 -0
- package/examples/google-hotels/get_hotel_reviews/workflow.json +37 -0
- package/examples/google-hotels/search_hotels/index.ts +207 -0
- package/examples/google-hotels/search_hotels/package.json +9 -0
- package/examples/google-hotels/search_hotels/parser.ts +260 -0
- package/examples/google-hotels/search_hotels/playbook.yaml +87 -0
- package/examples/google-hotels/search_hotels/request-transform.ts +197 -0
- package/examples/google-hotels/search_hotels/workflow.json +127 -0
- package/package.json +3 -2
- package/prompts/audit-agent.md +71 -0
- package/prompts/build-planning.md +74 -0
- package/prompts/compile-agent.md +132 -28
- package/prompts/prereq-builder.md +64 -0
- package/prompts/prereq-planner.md +34 -0
- package/prompts/tool-planning.md +39 -0
- package/src/cli.ts +111 -4
- package/src/imprint/agent.ts +5 -0
- package/src/imprint/audit.ts +996 -0
- package/src/imprint/backend-ladder.ts +1214 -184
- package/src/imprint/build-plan.ts +1051 -0
- package/src/imprint/cdp-browser-fetch.ts +589 -0
- package/src/imprint/cdp-jar-cache.ts +320 -0
- package/src/imprint/chromium.ts +135 -0
- package/src/imprint/claude-cli-compile.ts +125 -25
- package/src/imprint/codex-cli-compile.ts +26 -23
- package/src/imprint/compile-agent-types.ts +38 -0
- package/src/imprint/compile-agent.ts +65 -27
- package/src/imprint/compile-tools.ts +1656 -64
- package/src/imprint/compile.ts +14 -2
- package/src/imprint/concurrency.ts +87 -0
- package/src/imprint/credential-extract.ts +174 -25
- package/src/imprint/cron.ts +1 -0
- package/src/imprint/doctor.ts +39 -0
- package/src/imprint/emit.ts +85 -0
- package/src/imprint/freeform-redact.ts +5 -4
- package/src/imprint/integrations.ts +2 -2
- package/src/imprint/llm.ts +56 -8
- package/src/imprint/mcp-compile-server.ts +43 -10
- package/src/imprint/mcp-maintenance.ts +9 -101
- package/src/imprint/mcp-server.ts +73 -7
- package/src/imprint/multi-progress.ts +7 -2
- package/src/imprint/param-grounding.ts +367 -0
- package/src/imprint/paths.ts +29 -0
- package/src/imprint/playbook-runner.ts +101 -40
- package/src/imprint/prereq-builder.ts +651 -0
- package/src/imprint/probe-backends.ts +6 -3
- package/src/imprint/record.ts +10 -1
- package/src/imprint/redact.ts +30 -2
- package/src/imprint/replay-capture.ts +19 -18
- package/src/imprint/runtime.ts +19 -10
- package/src/imprint/sensitive-keys.ts +141 -7
- package/src/imprint/session-diff.ts +79 -2
- package/src/imprint/session-merge.ts +9 -5
- package/src/imprint/stealth-chromium.ts +81 -0
- package/src/imprint/stealth-fetch.ts +309 -29
- package/src/imprint/stealth-token-cache.ts +88 -0
- package/src/imprint/teach-plan.ts +251 -0
- package/src/imprint/teach-state.ts +17 -0
- package/src/imprint/teach.ts +582 -147
- package/src/imprint/tool-candidates.ts +72 -14
- package/src/imprint/tool-plan.ts +313 -0
- package/src/imprint/tracing.ts +135 -6
- package/src/imprint/types.ts +61 -3
- package/examples/google-flights/search_google_flights/index.ts +0 -101
- package/examples/google-flights/search_google_flights/parser.test.ts +0 -140
- package/examples/google-flights/search_google_flights/parser.ts +0 -189
- package/examples/google-flights/search_google_flights/playbook.yaml +0 -130
- package/examples/google-flights/search_google_flights/workflow.json +0 -48
- package/examples/google-hotels/search_google_hotels/index.ts +0 -194
- package/examples/google-hotels/search_google_hotels/parser.test.ts +0 -168
- package/examples/google-hotels/search_google_hotels/parser.ts +0 -330
- package/examples/google-hotels/search_google_hotels/playbook.yaml +0 -125
- package/examples/google-hotels/search_google_hotels/workflow.json +0 -111
- package/examples/namecheap-domains/search_namecheap_domains/index.ts +0 -144
- package/examples/namecheap-domains/search_namecheap_domains/parser.ts +0 -380
- package/examples/namecheap-domains/search_namecheap_domains/playbook.yaml +0 -50
- package/examples/namecheap-domains/search_namecheap_domains/request-transform.ts +0 -136
- package/examples/namecheap-domains/search_namecheap_domains/workflow.json +0 -97
package/src/imprint/teach.ts
CHANGED
|
@@ -9,9 +9,20 @@
|
|
|
9
9
|
|
|
10
10
|
import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
|
11
11
|
import { homedir } from 'node:os';
|
|
12
|
-
import {
|
|
12
|
+
import {
|
|
13
|
+
basename as pathBasename,
|
|
14
|
+
dirname as pathDirname,
|
|
15
|
+
join as pathJoin,
|
|
16
|
+
resolve as pathResolve,
|
|
17
|
+
} from 'node:path';
|
|
13
18
|
import * as p from '@clack/prompts';
|
|
14
19
|
import type { OnDeadlineReached } from './agent.ts';
|
|
20
|
+
import {
|
|
21
|
+
type SharedModuleManifestEntry,
|
|
22
|
+
buildPlanSidecarPath,
|
|
23
|
+
readBuildPlanFile,
|
|
24
|
+
topoLevelsForTools,
|
|
25
|
+
} from './build-plan.ts';
|
|
15
26
|
import {
|
|
16
27
|
type CompileAgentProgress,
|
|
17
28
|
type TriageResult,
|
|
@@ -19,6 +30,7 @@ import {
|
|
|
19
30
|
generate,
|
|
20
31
|
triageRequests,
|
|
21
32
|
} from './compile.ts';
|
|
33
|
+
import { mapLimit, mapLimitSettled } from './concurrency.ts';
|
|
22
34
|
import {
|
|
23
35
|
type CredentialFinding,
|
|
24
36
|
type Replacement,
|
|
@@ -41,15 +53,23 @@ import {
|
|
|
41
53
|
isTeachCompatibleProvider,
|
|
42
54
|
} from './llm.ts';
|
|
43
55
|
import { loadJsonFile } from './load-json.ts';
|
|
44
|
-
import { muteLog, unmuteLog } from './log.ts';
|
|
56
|
+
import { createLog, muteLog, unmuteLog } from './log.ts';
|
|
45
57
|
import { MultiProgress } from './multi-progress.ts';
|
|
46
58
|
import { localSiteDir, localToolDir } from './paths.ts';
|
|
47
59
|
import { describeAgentActivity, formatElapsed } from './progress.ts';
|
|
48
60
|
import { record } from './record.ts';
|
|
49
61
|
import { detectPageMintedHeaders, redactSession } from './redact.ts';
|
|
50
62
|
import { loadCredentialStore } from './runtime.ts';
|
|
63
|
+
import { isSensitiveCredentialKey, passwordLikeTokens } from './sensitive-keys.ts';
|
|
51
64
|
import type { ClassifiedValue } from './session-diff.ts';
|
|
52
|
-
import {
|
|
65
|
+
import {
|
|
66
|
+
listSessionsInDir,
|
|
67
|
+
listSiteSessions,
|
|
68
|
+
mergeSessions,
|
|
69
|
+
writeCombinedSession,
|
|
70
|
+
} from './session-merge.ts';
|
|
71
|
+
import { clearCachedToken } from './stealth-token-cache.ts';
|
|
72
|
+
import { planAndBuildPrereqs } from './teach-plan.ts';
|
|
53
73
|
import {
|
|
54
74
|
TEACH_STEPS as STEPS,
|
|
55
75
|
type TeachStep as Step,
|
|
@@ -73,11 +93,25 @@ import {
|
|
|
73
93
|
detectToolCandidates,
|
|
74
94
|
primaryToolCandidate,
|
|
75
95
|
} from './tool-candidates.ts';
|
|
96
|
+
import { planToolCompile } from './tool-plan.ts';
|
|
97
|
+
import { setSpanAttributes, traced } from './tracing.ts';
|
|
76
98
|
import { CronConfigSchema, SessionSchema, WorkflowSchema } from './types.ts';
|
|
77
99
|
import type { CronConfig, Playbook, Session, Workflow } from './types.ts';
|
|
78
100
|
|
|
79
101
|
export { buildTeachStateFromSession, resolveTeachStatePath } from './teach-state.ts';
|
|
80
102
|
|
|
103
|
+
/**
|
|
104
|
+
* How many compile agents run in parallel when more than one tool is selected.
|
|
105
|
+
* Kept at 2 (not 3): bursts of near-identical reverse-engineering requests in a
|
|
106
|
+
* short window raise the model's usage-policy safety-filter false-positive rate,
|
|
107
|
+
* so we trade a little wall-clock for fewer spurious refusals. Single-tool runs
|
|
108
|
+
* still use concurrency 1.
|
|
109
|
+
*/
|
|
110
|
+
const COMPILE_CONCURRENCY = 2;
|
|
111
|
+
|
|
112
|
+
/** Module logger — suppressed during teach's spinner phases via muteLog(). */
|
|
113
|
+
const log = createLog('teach');
|
|
114
|
+
|
|
81
115
|
// ─── Types ──────────────────────────────────────────────────────────────────
|
|
82
116
|
|
|
83
117
|
interface TeachOptions {
|
|
@@ -89,7 +123,7 @@ interface TeachOptions {
|
|
|
89
123
|
provider?: ProviderName;
|
|
90
124
|
/** Override the compile model (otherwise prompted or auto-detected). */
|
|
91
125
|
model?: string;
|
|
92
|
-
/** Per-tool compile timeout in ms. Default
|
|
126
|
+
/** Per-tool compile timeout in ms. Default 20 minutes. */
|
|
93
127
|
maxDurationMs?: number;
|
|
94
128
|
fromSession?: string;
|
|
95
129
|
/** Retain parser.test.ts after successful compile-agent verification. */
|
|
@@ -317,7 +351,7 @@ export async function promptForTeachProvider(
|
|
|
317
351
|
async function promptForModel(provider: ProviderName): Promise<string> {
|
|
318
352
|
const { availableModelsForProvider } = await import('./llm.ts');
|
|
319
353
|
const models = availableModelsForProvider(provider);
|
|
320
|
-
if (models.length <= 1) return models[0]?.model ?? 'claude-opus-4-
|
|
354
|
+
if (models.length <= 1) return models[0]?.model ?? 'claude-opus-4-8';
|
|
321
355
|
|
|
322
356
|
const choice = await p.select({
|
|
323
357
|
message: 'Which model should compile this workflow?',
|
|
@@ -500,16 +534,25 @@ export async function teach(opts: TeachOptions): Promise<TeachResult> {
|
|
|
500
534
|
if (startIdx <= STEPS.indexOf('record')) {
|
|
501
535
|
const startUrl = await resolveStartUrl(opts);
|
|
502
536
|
|
|
503
|
-
spinner.start('Recording
|
|
537
|
+
spinner.start('Recording');
|
|
504
538
|
spinner.stop('Ready to record.');
|
|
505
539
|
console.log('');
|
|
506
540
|
|
|
507
|
-
const recordResult = await
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
541
|
+
const recordResult = await traced(
|
|
542
|
+
'teach.record',
|
|
543
|
+
'CHAIN',
|
|
544
|
+
{ 'imprint.site': site, 'imprint.url': startUrl },
|
|
545
|
+
async (span) => {
|
|
546
|
+
const res = await record({
|
|
547
|
+
site: site,
|
|
548
|
+
url: startUrl,
|
|
549
|
+
persistProfile: opts.persistProfile,
|
|
550
|
+
signal: opts.signal,
|
|
551
|
+
});
|
|
552
|
+
setSpanAttributes(span, { 'imprint.record.event_count': res.count });
|
|
553
|
+
return res;
|
|
554
|
+
},
|
|
555
|
+
);
|
|
513
556
|
sessionPath = recordResult.sessionPath;
|
|
514
557
|
|
|
515
558
|
checkpoint(site, state, workflowKey, {
|
|
@@ -518,21 +561,30 @@ export async function teach(opts: TeachOptions): Promise<TeachResult> {
|
|
|
518
561
|
startedAt: new Date().toISOString(),
|
|
519
562
|
updatedAt: new Date().toISOString(),
|
|
520
563
|
});
|
|
564
|
+
}
|
|
521
565
|
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
566
|
+
// ── 1b. Combine with past sessions (optional) ──────────────────────
|
|
567
|
+
// Runs after recording OR when --from-session is provided. Skipped when
|
|
568
|
+
// resuming from a checkpoint (the checkpoint already stores the final
|
|
569
|
+
// session path, possibly combined from a previous run).
|
|
570
|
+
if (sessionPath && (startIdx <= STEPS.indexOf('record') || usingFromSession)) {
|
|
571
|
+
const isCombinedSession = pathBasename(sessionPath).startsWith('combined-');
|
|
572
|
+
if (!isCombinedSession) {
|
|
573
|
+
const originalSessionPath = sessionPath;
|
|
574
|
+
sessionPath = await combineAvailableSessions({
|
|
575
|
+
site,
|
|
576
|
+
currentSessionPath: sessionPath,
|
|
577
|
+
noInteractive: opts.noInteractive ?? false,
|
|
578
|
+
fromSession: usingFromSession,
|
|
535
579
|
});
|
|
580
|
+
if (sessionPath !== originalSessionPath) {
|
|
581
|
+
checkpoint(site, state, workflowKey, {
|
|
582
|
+
sessionPath: toRelative(site, sessionPath),
|
|
583
|
+
completedSteps: ['record'],
|
|
584
|
+
startedAt: new Date().toISOString(),
|
|
585
|
+
updatedAt: new Date().toISOString(),
|
|
586
|
+
});
|
|
587
|
+
}
|
|
536
588
|
}
|
|
537
589
|
}
|
|
538
590
|
|
|
@@ -581,14 +633,33 @@ export async function teach(opts: TeachOptions): Promise<TeachResult> {
|
|
|
581
633
|
}
|
|
582
634
|
}
|
|
583
635
|
|
|
584
|
-
spinner.start('Redacting credentials
|
|
585
|
-
const pageMintedHeaders = detectPageMintedHeaders(session);
|
|
586
|
-
const { session: scrubbed, stats } = redactSession(session, {
|
|
587
|
-
replacements: confirmedReplacements,
|
|
588
|
-
keepHeaders: pageMintedHeaders,
|
|
589
|
-
});
|
|
636
|
+
spinner.start('Redacting credentials');
|
|
590
637
|
redactedPath = sessionPath.replace(/\.json$/, '.redacted.json');
|
|
591
|
-
|
|
638
|
+
const { stats } = await traced(
|
|
639
|
+
'teach.redact',
|
|
640
|
+
'CHAIN',
|
|
641
|
+
{ 'imprint.site': site },
|
|
642
|
+
async (span) => {
|
|
643
|
+
const pageMintedHeaders = detectPageMintedHeaders(session);
|
|
644
|
+
const redaction = redactSession(session, {
|
|
645
|
+
replacements: confirmedReplacements,
|
|
646
|
+
keepHeaders: pageMintedHeaders,
|
|
647
|
+
});
|
|
648
|
+
writeFileSync(
|
|
649
|
+
redactedPath as string,
|
|
650
|
+
`${JSON.stringify(redaction.session, null, 2)}\n`,
|
|
651
|
+
'utf8',
|
|
652
|
+
);
|
|
653
|
+
setSpanAttributes(span, {
|
|
654
|
+
'imprint.redact.totalRedactions': redaction.stats.totalRedactions,
|
|
655
|
+
'imprint.redact.requestsRedacted': redaction.stats.requestsRedacted,
|
|
656
|
+
'imprint.redact.cookiesRedacted': redaction.stats.cookiesRedacted,
|
|
657
|
+
'imprint.redact.placeholdersInjected': redaction.stats.placeholdersInjected,
|
|
658
|
+
'imprint.redact.freeformRedactions': redaction.stats.freeformRedactions,
|
|
659
|
+
});
|
|
660
|
+
return redaction;
|
|
661
|
+
},
|
|
662
|
+
);
|
|
592
663
|
const placeholderNote =
|
|
593
664
|
stats.placeholdersInjected > 0
|
|
594
665
|
? `, ${stats.placeholdersInjected} replaced with credential placeholders`
|
|
@@ -599,8 +670,38 @@ export async function teach(opts: TeachOptions): Promise<TeachResult> {
|
|
|
599
670
|
`Redacted ${stats.totalRedactions} value(s) across ${stats.requestsRedacted} request(s) and ${stats.cookiesRedacted} cookie(s)${placeholderNote}${freeformNote}.`,
|
|
600
671
|
);
|
|
601
672
|
|
|
673
|
+
// Post-redact pairing audit: if any request body contained a
|
|
674
|
+
// password-shaped field but credential extraction failed to produce a
|
|
675
|
+
// confirmed username+password pair, the downstream compile stage will
|
|
676
|
+
// template credentials as `${param.X}` instead of `${credential.X}` —
|
|
677
|
+
// shipping a broken MCP tool that asks callers to provide credentials
|
|
678
|
+
// by hand instead of pulling from the credential store.
|
|
679
|
+
//
|
|
680
|
+
// The most common reason is an unusual request framing (custom
|
|
681
|
+
// Content-Type, unusual key naming) that the extractor's dictionaries
|
|
682
|
+
// or parsers don't yet cover. Surface this loudly so the user can
|
|
683
|
+
// either re-record, file a bug, or proceed knowing the tool needs
|
|
684
|
+
// hand-editing.
|
|
685
|
+
const warnings: string[] = [];
|
|
686
|
+
const unpairedPasswordSeqs = findUnpairedPasswordRequests(session);
|
|
687
|
+
if (unpairedPasswordSeqs.length > 0 && confirmedReplacements.length === 0) {
|
|
688
|
+
warnings.push('credentials_not_paired');
|
|
689
|
+
const seqList = unpairedPasswordSeqs.slice(0, 5).join(', ');
|
|
690
|
+
const more = unpairedPasswordSeqs.length > 5 ? ', …' : '';
|
|
691
|
+
p.log.warn(
|
|
692
|
+
[
|
|
693
|
+
`Detected ${unpairedPasswordSeqs.length} request(s) with a password-shaped field (seqs: ${seqList}${more}) but no username+password pair was extracted.`,
|
|
694
|
+
'The generated workflow will treat credentials as plain parameters and will NOT pull from the credential store.',
|
|
695
|
+
'This usually means the request body uses an unusual framing (Content-Type, key naming, multipart variant) the extractor did not recognise.',
|
|
696
|
+
`→ Recommended: file a bug with the redacted session at ${toRelative(site, redactedPath)}, then re-record once the extractor is fixed.`,
|
|
697
|
+
'→ To proceed anyway, just continue — the tool will need manual credential wiring before it works.',
|
|
698
|
+
].join('\n'),
|
|
699
|
+
);
|
|
700
|
+
}
|
|
701
|
+
|
|
602
702
|
updateCheckpoint(site, state, workflowKey, 'redact', {
|
|
603
703
|
redactedPath: toRelative(site, redactedPath),
|
|
704
|
+
warnings: warnings.length > 0 ? warnings : undefined,
|
|
604
705
|
});
|
|
605
706
|
}
|
|
606
707
|
|
|
@@ -717,7 +818,7 @@ export async function teach(opts: TeachOptions): Promise<TeachResult> {
|
|
|
717
818
|
const model = await getModel();
|
|
718
819
|
mp.pause();
|
|
719
820
|
mp.clear();
|
|
720
|
-
spinner.start('Triaging requests
|
|
821
|
+
spinner.start('Triaging requests');
|
|
721
822
|
localTriageResult = await triageRequests(triageSession, {
|
|
722
823
|
provider: providerName,
|
|
723
824
|
model,
|
|
@@ -751,7 +852,7 @@ export async function teach(opts: TeachOptions): Promise<TeachResult> {
|
|
|
751
852
|
const model = await getModel();
|
|
752
853
|
mp.pause();
|
|
753
854
|
mp.clear();
|
|
754
|
-
spinner.start('Detecting candidate tools
|
|
855
|
+
spinner.start('Detecting candidate tools');
|
|
755
856
|
const detection = await detectTeachCandidates({
|
|
756
857
|
sessionPath: compileSessionPath,
|
|
757
858
|
providerName,
|
|
@@ -827,7 +928,7 @@ export async function teach(opts: TeachOptions): Promise<TeachResult> {
|
|
|
827
928
|
await new Promise((r) => setTimeout(r, 0));
|
|
828
929
|
const showedSpinner = !replaySettled;
|
|
829
930
|
if (showedSpinner) {
|
|
830
|
-
spinner.start('Waiting for replay to finish
|
|
931
|
+
spinner.start('Waiting for replay to finish');
|
|
831
932
|
}
|
|
832
933
|
siteClassifications = await replayPromise;
|
|
833
934
|
if (showedSpinner) {
|
|
@@ -885,7 +986,7 @@ export async function teach(opts: TeachOptions): Promise<TeachResult> {
|
|
|
885
986
|
let compileModel = '';
|
|
886
987
|
if (needsCompileProvider) {
|
|
887
988
|
compileModel = await getModel();
|
|
888
|
-
const timeoutMs = opts.maxDurationMs ??
|
|
989
|
+
const timeoutMs = opts.maxDurationMs ?? 20 * 60 * 1000;
|
|
889
990
|
const timeoutDisplay =
|
|
890
991
|
timeoutMs >= 3_600_000
|
|
891
992
|
? `${Math.round(timeoutMs / 3_600_000)}h`
|
|
@@ -898,10 +999,22 @@ export async function teach(opts: TeachOptions): Promise<TeachResult> {
|
|
|
898
999
|
`Timeout: ${timeoutDisplay} per tool`,
|
|
899
1000
|
'',
|
|
900
1001
|
plans.length === 1
|
|
901
|
-
? 'An LLM agent will reverse-engineer the API response format
|
|
902
|
-
: `${plans.length} LLM compile agents will reverse-engineer selected tools with concurrency
|
|
903
|
-
|
|
904
|
-
'
|
|
1002
|
+
? 'An LLM agent will reverse-engineer the API response format,'
|
|
1003
|
+
: `${plans.length} LLM compile agents will reverse-engineer selected tools with concurrency ${COMPILE_CONCURRENCY},`,
|
|
1004
|
+
'write the MCP server, and run thorough verification tests.',
|
|
1005
|
+
'Most complex tools take 10-15 minutes — please be patient.',
|
|
1006
|
+
`Timeout: ${timeoutDisplay} per tool. You can interrupt with Ctrl-C.`,
|
|
1007
|
+
...(plans.length > 1
|
|
1008
|
+
? [
|
|
1009
|
+
'',
|
|
1010
|
+
'Shared helper modules are planned + built once under _shared/ before',
|
|
1011
|
+
'the tools compile, so each tool reuses them. Set IMPRINT_NO_BUILD_PLAN=1',
|
|
1012
|
+
'to disable and compile every tool independently.',
|
|
1013
|
+
]
|
|
1014
|
+
: []),
|
|
1015
|
+
'',
|
|
1016
|
+
'To persist the generated tests after compilation, set IMPRINT_KEEP_TEST=1',
|
|
1017
|
+
'or pass --keep-test.',
|
|
905
1018
|
].join('\n'),
|
|
906
1019
|
'Compile step',
|
|
907
1020
|
);
|
|
@@ -937,7 +1050,75 @@ export async function teach(opts: TeachOptions): Promise<TeachResult> {
|
|
|
937
1050
|
}
|
|
938
1051
|
}
|
|
939
1052
|
|
|
940
|
-
|
|
1053
|
+
// ── plan-prereqs: plan + build shared modules once before the fan-out ──
|
|
1054
|
+
// Only engages for ≥2 selected tools that are about to be (re)generated.
|
|
1055
|
+
// Single-tool runs and resumes-past-generate are unchanged.
|
|
1056
|
+
const selectedCandidates = plans.map((pl) => pl.candidate).filter((c): c is ToolCandidate => !!c);
|
|
1057
|
+
const willGenerate = plans.some((pl) => STEPS.indexOf(pl.startFrom) <= STEPS.indexOf('generate'));
|
|
1058
|
+
let buildPlanPath = '';
|
|
1059
|
+
let sharedModulesManifest: SharedModuleManifestEntry[] = [];
|
|
1060
|
+
if (selectedCandidates.length >= 2 && willGenerate && compileModel) {
|
|
1061
|
+
const sidecar = buildPlanSidecarPath(site);
|
|
1062
|
+
const firstWs = state.workflows[plans[0]?.workflowKey ?? ''];
|
|
1063
|
+
const alreadyPlanned =
|
|
1064
|
+
plans.every((pl) =>
|
|
1065
|
+
state.workflows[pl.workflowKey]?.completedSteps.includes('plan-prereqs'),
|
|
1066
|
+
) && existsSync(sidecar);
|
|
1067
|
+
if (alreadyPlanned && firstWs) {
|
|
1068
|
+
// Resume past plan-prereqs — reuse the persisted plan + manifest.
|
|
1069
|
+
buildPlanPath = sidecar;
|
|
1070
|
+
sharedModulesManifest = firstWs.sharedModules ?? [];
|
|
1071
|
+
} else {
|
|
1072
|
+
// Mute raw `[imprint …]` logs from the planning subtree (build-plan,
|
|
1073
|
+
// teach-plan, prereq-builder) while the spinner is live — progress flows
|
|
1074
|
+
// through onProgress → spinner.message instead, matching the replay and
|
|
1075
|
+
// compile phases. The skip/timeout reason is surfaced cleanly below.
|
|
1076
|
+
muteLog();
|
|
1077
|
+
spinner.start('Planning shared modules');
|
|
1078
|
+
try {
|
|
1079
|
+
const prereq = await planAndBuildPrereqs({
|
|
1080
|
+
site,
|
|
1081
|
+
redactedSessionPath: compileSessionPath,
|
|
1082
|
+
candidates: selectedCandidates,
|
|
1083
|
+
sharedContext: plans[0]?.sharedContext,
|
|
1084
|
+
siteClassifications,
|
|
1085
|
+
providerName: compileProviderName,
|
|
1086
|
+
model: compileModel,
|
|
1087
|
+
onProgress: (msg) => spinner.message(msg),
|
|
1088
|
+
});
|
|
1089
|
+
buildPlanPath = prereq.buildPlanPath;
|
|
1090
|
+
sharedModulesManifest = prereq.sharedModules;
|
|
1091
|
+
const verified = sharedModulesManifest.filter((m) => m.verified).length;
|
|
1092
|
+
spinner.stop(
|
|
1093
|
+
buildPlanPath
|
|
1094
|
+
? `Build plan ready (${verified}/${sharedModulesManifest.length} shared module${sharedModulesManifest.length === 1 ? '' : 's'} verified).`
|
|
1095
|
+
: 'Build plan skipped.',
|
|
1096
|
+
);
|
|
1097
|
+
if (prereq.skippedReason) p.log.warn(prereq.skippedReason);
|
|
1098
|
+
} catch (err) {
|
|
1099
|
+
spinner.stop('Build planning failed — compiling tools independently.');
|
|
1100
|
+
p.log.warn(
|
|
1101
|
+
`Build planning failed: ${err instanceof Error ? err.message : String(err)}\nTools will compile without shared modules.`,
|
|
1102
|
+
);
|
|
1103
|
+
buildPlanPath = '';
|
|
1104
|
+
sharedModulesManifest = [];
|
|
1105
|
+
} finally {
|
|
1106
|
+
unmuteLog();
|
|
1107
|
+
}
|
|
1108
|
+
for (const pl of plans) {
|
|
1109
|
+
updateCheckpoint(site, state, pl.workflowKey, 'plan-prereqs', {
|
|
1110
|
+
buildPlanPath: buildPlanPath ? toRelative(site, buildPlanPath) : undefined,
|
|
1111
|
+
sharedModules: sharedModulesManifest,
|
|
1112
|
+
});
|
|
1113
|
+
}
|
|
1114
|
+
}
|
|
1115
|
+
}
|
|
1116
|
+
|
|
1117
|
+
// Mute raw `[imprint …]` logs from the compile subtree while the spinner /
|
|
1118
|
+
// MultiProgress is live. This covers single-tool runs too: they drive the
|
|
1119
|
+
// shared spinner and would otherwise leak compile.ts diagnostics into it,
|
|
1120
|
+
// just as concurrent multi-tool runs would interleave their logs.
|
|
1121
|
+
muteLog();
|
|
941
1122
|
let results: TeachToolResult[];
|
|
942
1123
|
try {
|
|
943
1124
|
results = await compileCandidatePlans({
|
|
@@ -953,9 +1134,12 @@ export async function teach(opts: TeachOptions): Promise<TeachResult> {
|
|
|
953
1134
|
sharedTriageResult: triageResult,
|
|
954
1135
|
siteClassifications,
|
|
955
1136
|
teachCredentials,
|
|
1137
|
+
allTools: opts.allTools,
|
|
1138
|
+
buildPlanPath: buildPlanPath || undefined,
|
|
1139
|
+
sharedModules: sharedModulesManifest.length > 0 ? sharedModulesManifest : undefined,
|
|
956
1140
|
});
|
|
957
1141
|
} finally {
|
|
958
|
-
|
|
1142
|
+
unmuteLog();
|
|
959
1143
|
}
|
|
960
1144
|
|
|
961
1145
|
if (results.length === 0) {
|
|
@@ -1019,8 +1203,32 @@ export async function teach(opts: TeachOptions): Promise<TeachResult> {
|
|
|
1019
1203
|
updateCheckpoint(site, state, result.workflow.toolName, 'register');
|
|
1020
1204
|
}
|
|
1021
1205
|
|
|
1206
|
+
// Drop the transient compile-time stealth token (shared across this site's
|
|
1207
|
+
// per-tool `bun test` processes). It holds a live session token and is no
|
|
1208
|
+
// longer needed once every tool has compiled.
|
|
1209
|
+
clearCachedToken(localSiteDir(site));
|
|
1210
|
+
|
|
1211
|
+
// Surface any tools that shipped without a passing live integration test
|
|
1212
|
+
// (waived during compile due to anti-bot / infra). These rely on the runtime
|
|
1213
|
+
// playbook last-ditch path, which is a degraded fallback — operators should
|
|
1214
|
+
// know rather than discover at audit/runtime.
|
|
1215
|
+
const unverified = results.filter((r) => r.workflow.liveVerified === false);
|
|
1216
|
+
if (unverified.length > 0) {
|
|
1217
|
+
for (const r of unverified) {
|
|
1218
|
+
const waiver = r.workflow.liveVerifiedWaiver;
|
|
1219
|
+
const reason = waiver
|
|
1220
|
+
? `${waiver.kind} (exhausted: ${waiver.exhaustedBackends.join(', ') || 'n/a'}; first error: ${waiver.firstError})`
|
|
1221
|
+
: 'reason not recorded';
|
|
1222
|
+
p.log.warn(
|
|
1223
|
+
`tool "${r.workflow.toolName}" shipped without live verification: ${reason}\n → runtime callers fall through to the playbook last-ditch rung; treat this tool as unverified until audit confirms it.`,
|
|
1224
|
+
);
|
|
1225
|
+
}
|
|
1226
|
+
}
|
|
1227
|
+
|
|
1022
1228
|
p.outro(
|
|
1023
|
-
`Done! ${results.length} tool${results.length === 1 ? '' : 's'} ready: ${results.map((r) => r.workflow.toolName).join(', ')}
|
|
1229
|
+
`Done! ${results.length} tool${results.length === 1 ? '' : 's'} ready: ${results.map((r) => r.workflow.toolName).join(', ')}${
|
|
1230
|
+
unverified.length > 0 ? ` (${unverified.length} unverified — see warnings above)` : ''
|
|
1231
|
+
}`,
|
|
1024
1232
|
);
|
|
1025
1233
|
|
|
1026
1234
|
return {
|
|
@@ -1036,7 +1244,7 @@ export async function teach(opts: TeachOptions): Promise<TeachResult> {
|
|
|
1036
1244
|
|
|
1037
1245
|
// ─── Candidate detection + per-tool compile ────────────────────────────────
|
|
1038
1246
|
|
|
1039
|
-
interface CandidateCompilePlan {
|
|
1247
|
+
export interface CandidateCompilePlan {
|
|
1040
1248
|
workflowKey: string;
|
|
1041
1249
|
startFrom: Step;
|
|
1042
1250
|
candidate?: ToolCandidate;
|
|
@@ -1116,8 +1324,16 @@ async function compileCandidatePlans(opts: {
|
|
|
1116
1324
|
sharedTriageResult?: TriageResult;
|
|
1117
1325
|
siteClassifications?: ClassifiedValue[];
|
|
1118
1326
|
teachCredentials?: { site: string; values: Record<string, string> };
|
|
1327
|
+
/** Mirror of TeachOptions.allTools — when true, partial failures abort
|
|
1328
|
+
* the run with a non-zero exit so the user notices missing tools instead
|
|
1329
|
+
* of getting a silent warning. */
|
|
1330
|
+
allTools?: boolean;
|
|
1331
|
+
/** Absolute path to the multi-tool build plan sidecar (.build-plan.json). */
|
|
1332
|
+
buildPlanPath?: string;
|
|
1333
|
+
/** Shared-module build manifest (verified flags) for this site. */
|
|
1334
|
+
sharedModules?: SharedModuleManifestEntry[];
|
|
1119
1335
|
}): Promise<TeachToolResult[]> {
|
|
1120
|
-
const concurrency = opts.plans.length === 1 ? 1 :
|
|
1336
|
+
const concurrency = opts.plans.length === 1 ? 1 : COMPILE_CONCURRENCY;
|
|
1121
1337
|
const mp = opts.plans.length > 1 ? new MultiProgress() : null;
|
|
1122
1338
|
|
|
1123
1339
|
// Mutex for deadline prompts: concurrent compile agents can hit their
|
|
@@ -1126,7 +1342,7 @@ async function compileCandidatePlans(opts: {
|
|
|
1126
1342
|
// input from the first, causing it to auto-resolve as cancelled.
|
|
1127
1343
|
let promptLock: Promise<void> = Promise.resolve();
|
|
1128
1344
|
|
|
1129
|
-
const
|
|
1345
|
+
const compileOne = async (plan: CandidateCompilePlan) => {
|
|
1130
1346
|
const displayName = plan.candidate?.toolName ?? plan.workflowKey;
|
|
1131
1347
|
let lastActivity = '';
|
|
1132
1348
|
const onProgress = (progress: CompileAgentProgress): void => {
|
|
@@ -1164,7 +1380,7 @@ async function compileCandidatePlans(opts: {
|
|
|
1164
1380
|
if (mp) {
|
|
1165
1381
|
mp.resume();
|
|
1166
1382
|
} else {
|
|
1167
|
-
opts.spinner.start(`Compiling ${displayName}
|
|
1383
|
+
opts.spinner.start(`Compiling ${displayName}`);
|
|
1168
1384
|
}
|
|
1169
1385
|
if (p.isCancel(extend) || !extend) return null;
|
|
1170
1386
|
return 10 * 60 * 1000;
|
|
@@ -1174,7 +1390,7 @@ async function compileCandidatePlans(opts: {
|
|
|
1174
1390
|
}
|
|
1175
1391
|
: undefined;
|
|
1176
1392
|
|
|
1177
|
-
if (!mp) opts.spinner.start(`Compiling ${displayName}
|
|
1393
|
+
if (!mp) opts.spinner.start(`Compiling ${displayName}`);
|
|
1178
1394
|
try {
|
|
1179
1395
|
const result = await compileSelectedCandidate({
|
|
1180
1396
|
...opts,
|
|
@@ -1209,29 +1425,109 @@ async function compileCandidatePlans(opts: {
|
|
|
1209
1425
|
}
|
|
1210
1426
|
throw err;
|
|
1211
1427
|
}
|
|
1212
|
-
}
|
|
1428
|
+
};
|
|
1429
|
+
|
|
1430
|
+
// Compile producer tools before their consumers so a consumer's chained
|
|
1431
|
+
// verification test can mint a fresh token from the producer's live workflow.
|
|
1432
|
+
// With no token contracts declared, every tool lands in a single level — the
|
|
1433
|
+
// behavior is identical to the prior single concurrent fan-out.
|
|
1434
|
+
type CompileOutcome = { ok: true; value: TeachToolResult } | { ok: false; error: unknown };
|
|
1435
|
+
const buildPlan = opts.buildPlanPath ? readBuildPlanFile(opts.buildPlanPath) : null;
|
|
1436
|
+
const levels = topoLevelsForTools(
|
|
1437
|
+
opts.plans.map((plan) => ({ toolName: plan.candidate?.toolName ?? plan.workflowKey, plan })),
|
|
1438
|
+
buildPlan,
|
|
1439
|
+
);
|
|
1440
|
+
const outcomeByKey = new Map<string, CompileOutcome>();
|
|
1441
|
+
for (const level of levels) {
|
|
1442
|
+
const levelPlans = level.map((k) => k.plan);
|
|
1443
|
+
const levelOutcomes = await mapLimitSettled(levelPlans, concurrency, compileOne);
|
|
1444
|
+
levelPlans.forEach((plan, i) => {
|
|
1445
|
+
const outcome = levelOutcomes[i];
|
|
1446
|
+
if (outcome) outcomeByKey.set(plan.workflowKey, outcome);
|
|
1447
|
+
});
|
|
1448
|
+
}
|
|
1449
|
+
const outcomes: CompileOutcome[] = opts.plans.map(
|
|
1450
|
+
(plan) =>
|
|
1451
|
+
outcomeByKey.get(plan.workflowKey) ?? {
|
|
1452
|
+
ok: false,
|
|
1453
|
+
error: new Error(`no compile outcome recorded for ${plan.workflowKey}`),
|
|
1454
|
+
},
|
|
1455
|
+
);
|
|
1456
|
+
|
|
1457
|
+
const summary = summarizeCompileOutcomes(outcomes, opts.plans);
|
|
1213
1458
|
|
|
1459
|
+
// Print the structured summary on every multi-tool run so users see
|
|
1460
|
+
// exactly what compiled vs what failed — a single warn line buried in
|
|
1461
|
+
// log output is easy to miss when 4 of 6 tools compiled cleanly.
|
|
1462
|
+
if (opts.plans.length > 1) {
|
|
1463
|
+
const lines = renderCompileSummary(summary);
|
|
1464
|
+
if (summary.failures.length === 0) {
|
|
1465
|
+
p.log.success(lines.join('\n'));
|
|
1466
|
+
} else {
|
|
1467
|
+
p.log.warn(lines.join('\n'));
|
|
1468
|
+
}
|
|
1469
|
+
} else if (summary.failures.length > 0) {
|
|
1470
|
+
// Single-tool run: keep the old single-line warn for backwards-compat
|
|
1471
|
+
// since there's nothing to summarize.
|
|
1472
|
+
const first = summary.failures[0];
|
|
1473
|
+
if (first) p.log.warn(`${first.name}: ${first.firstLineError}`);
|
|
1474
|
+
}
|
|
1475
|
+
|
|
1476
|
+
// Hard-fail when --all-tools was requested AND any tool failed. Silent
|
|
1477
|
+
// partial compiles ship MCP servers with missing tools; the user only
|
|
1478
|
+
// notices later when an LLM tries to call one that doesn't exist.
|
|
1479
|
+
if (opts.allTools && summary.failures.length > 0) {
|
|
1480
|
+
throw new Error(
|
|
1481
|
+
`--all-tools requested but ${summary.failures.length} of ${opts.plans.length} tools failed to compile. See the summary above; re-run \`imprint teach\` after addressing the failures (or omit --all-tools to ship only what compiled).`,
|
|
1482
|
+
);
|
|
1483
|
+
}
|
|
1484
|
+
|
|
1485
|
+
return summary.successes;
|
|
1486
|
+
}
|
|
1487
|
+
|
|
1488
|
+
/** Pure summarizer — extracted so unit tests can drive arbitrary outcome
|
|
1489
|
+
* shapes without spinning up real compile pipelines. */
|
|
1490
|
+
interface CompileOutcomeSummary {
|
|
1491
|
+
detected: number;
|
|
1492
|
+
successes: TeachToolResult[];
|
|
1493
|
+
successNames: string[];
|
|
1494
|
+
failures: Array<{ name: string; firstLineError: string }>;
|
|
1495
|
+
}
|
|
1496
|
+
|
|
1497
|
+
export function summarizeCompileOutcomes(
|
|
1498
|
+
outcomes: Array<{ ok: true; value: TeachToolResult } | { ok: false; error: unknown } | null>,
|
|
1499
|
+
plans: CandidateCompilePlan[],
|
|
1500
|
+
): CompileOutcomeSummary {
|
|
1214
1501
|
const successes: TeachToolResult[] = [];
|
|
1215
|
-
const
|
|
1502
|
+
const successNames: string[] = [];
|
|
1503
|
+
const failures: Array<{ name: string; firstLineError: string }> = [];
|
|
1216
1504
|
for (let i = 0; i < outcomes.length; i++) {
|
|
1217
1505
|
const outcome = outcomes[i];
|
|
1218
|
-
const displayName =
|
|
1506
|
+
const displayName = plans[i]?.candidate?.toolName ?? plans[i]?.workflowKey ?? '?';
|
|
1219
1507
|
if (outcome?.ok) {
|
|
1220
1508
|
successes.push(outcome.value);
|
|
1509
|
+
successNames.push(displayName);
|
|
1221
1510
|
} else {
|
|
1222
1511
|
const msg = outcome?.error instanceof Error ? outcome.error.message : String(outcome?.error);
|
|
1223
|
-
failures.push(
|
|
1512
|
+
failures.push({ name: displayName, firstLineError: msg.split('\n')[0] ?? '' });
|
|
1224
1513
|
}
|
|
1225
1514
|
}
|
|
1515
|
+
return { detected: plans.length, successes, successNames, failures };
|
|
1516
|
+
}
|
|
1226
1517
|
|
|
1227
|
-
|
|
1228
|
-
|
|
1229
|
-
|
|
1230
|
-
|
|
1231
|
-
);
|
|
1518
|
+
function renderCompileSummary(summary: CompileOutcomeSummary): string[] {
|
|
1519
|
+
const lines: string[] = [];
|
|
1520
|
+
lines.push(`Compile summary: ${summary.successes.length}/${summary.detected} tools compiled.`);
|
|
1521
|
+
if (summary.successNames.length > 0) {
|
|
1522
|
+
lines.push(`Compiled: ${summary.successNames.join(', ')}`);
|
|
1232
1523
|
}
|
|
1233
|
-
|
|
1234
|
-
|
|
1524
|
+
if (summary.failures.length > 0) {
|
|
1525
|
+
lines.push(`Failed (${summary.failures.length}):`);
|
|
1526
|
+
for (const f of summary.failures) {
|
|
1527
|
+
lines.push(` • ${f.name}: ${f.firstLineError}`);
|
|
1528
|
+
}
|
|
1529
|
+
}
|
|
1530
|
+
return lines;
|
|
1235
1531
|
}
|
|
1236
1532
|
|
|
1237
1533
|
async function compileSelectedCandidate(opts: {
|
|
@@ -1248,6 +1544,8 @@ async function compileSelectedCandidate(opts: {
|
|
|
1248
1544
|
sharedTriageResult?: TriageResult;
|
|
1249
1545
|
siteClassifications?: ClassifiedValue[];
|
|
1250
1546
|
teachCredentials?: { site: string; values: Record<string, string> };
|
|
1547
|
+
buildPlanPath?: string;
|
|
1548
|
+
sharedModules?: SharedModuleManifestEntry[];
|
|
1251
1549
|
}): Promise<TeachToolResult> {
|
|
1252
1550
|
const { plan, site, state } = opts;
|
|
1253
1551
|
const startIdx = STEPS.indexOf(plan.startFrom);
|
|
@@ -1255,14 +1553,34 @@ async function compileSelectedCandidate(opts: {
|
|
|
1255
1553
|
const workflowDir = localToolDir(site, toolName);
|
|
1256
1554
|
mkdirSync(workflowDir, { recursive: true });
|
|
1257
1555
|
|
|
1258
|
-
// ── Step 1:
|
|
1259
|
-
let genResult: { workflow: Workflow; workflowPath: string };
|
|
1556
|
+
// ── Step 1: plan THEN execute (workflow.json) ──
|
|
1557
|
+
let genResult: { workflow: Workflow; workflowPath: string } | undefined;
|
|
1260
1558
|
if (startIdx <= STEPS.indexOf('generate')) {
|
|
1559
|
+
const llmConfig = { provider: opts.providerName, model: opts.compileModel };
|
|
1560
|
+
|
|
1561
|
+
// Plan THEN execute: derive a per-tool implementation plan (param→field
|
|
1562
|
+
// mapping, request construction, response parsing, shared-module imports),
|
|
1563
|
+
// then run a single compile that follows it. Best-effort — a timeout or
|
|
1564
|
+
// error yields no plan and the compile proceeds exactly as before.
|
|
1565
|
+
const toolPlan = plan.candidate
|
|
1566
|
+
? await planToolCompile({
|
|
1567
|
+
site,
|
|
1568
|
+
toolName,
|
|
1569
|
+
candidate: plan.candidate,
|
|
1570
|
+
sharedContext: plan.sharedContext,
|
|
1571
|
+
sessionPath: opts.sessionPath,
|
|
1572
|
+
buildPlanPath: opts.buildPlanPath,
|
|
1573
|
+
sharedModules: opts.sharedModules,
|
|
1574
|
+
providerName: opts.providerName,
|
|
1575
|
+
model: opts.compileModel,
|
|
1576
|
+
})
|
|
1577
|
+
: undefined;
|
|
1578
|
+
|
|
1261
1579
|
const result = await generate({
|
|
1262
1580
|
sessionPath: opts.sessionPath,
|
|
1263
1581
|
outDir: workflowDir,
|
|
1264
1582
|
maxDurationMs: opts.maxDurationMs,
|
|
1265
|
-
llmConfig
|
|
1583
|
+
llmConfig,
|
|
1266
1584
|
keepTest: opts.keepTest,
|
|
1267
1585
|
candidate: plan.candidate,
|
|
1268
1586
|
sharedContext: plan.sharedContext,
|
|
@@ -1270,7 +1588,11 @@ async function compileSelectedCandidate(opts: {
|
|
|
1270
1588
|
onDeadlineReached: opts.onDeadlineReached,
|
|
1271
1589
|
classifications: opts.siteClassifications,
|
|
1272
1590
|
teachCredentials: opts.teachCredentials,
|
|
1591
|
+
buildPlanPath: opts.buildPlanPath,
|
|
1592
|
+
sharedModules: opts.sharedModules,
|
|
1593
|
+
toolPlan,
|
|
1273
1594
|
});
|
|
1595
|
+
|
|
1274
1596
|
assertCandidateToolName('Compiled workflow', result.workflow.toolName, plan.candidate);
|
|
1275
1597
|
genResult = { workflow: result.workflow, workflowPath: result.workflowPath };
|
|
1276
1598
|
updateCheckpoint(site, state, plan.workflowKey, 'generate', {
|
|
@@ -1287,6 +1609,9 @@ async function compileSelectedCandidate(opts: {
|
|
|
1287
1609
|
);
|
|
1288
1610
|
genResult = { workflow, workflowPath };
|
|
1289
1611
|
}
|
|
1612
|
+
if (!genResult) {
|
|
1613
|
+
throw new Error(`generate step did not produce a workflow for "${toolName}".`);
|
|
1614
|
+
}
|
|
1290
1615
|
|
|
1291
1616
|
// ── Step 2: compile-playbook (after generate — runtime artifact, not needed for dual-pass) ──
|
|
1292
1617
|
let pbResult: { playbook: Playbook; playbookPath: string };
|
|
@@ -1349,7 +1674,9 @@ async function siteReplayAndDiff(
|
|
|
1349
1674
|
): Promise<ClassifiedValue[] | undefined> {
|
|
1350
1675
|
try {
|
|
1351
1676
|
const { replayRawSession } = await import('./replay-capture.ts');
|
|
1352
|
-
const { diffTriagedSessions, triageByAlignment } = await import(
|
|
1677
|
+
const { diffTriagedSessions, triageByAlignment, mergeClassifications } = await import(
|
|
1678
|
+
'./session-diff.ts'
|
|
1679
|
+
);
|
|
1353
1680
|
|
|
1354
1681
|
const session = loadJsonFile(
|
|
1355
1682
|
sessionPath,
|
|
@@ -1392,9 +1719,49 @@ async function siteReplayAndDiff(
|
|
|
1392
1719
|
|
|
1393
1720
|
mp.update('replay', 'Diffing replay against original...');
|
|
1394
1721
|
|
|
1722
|
+
// Pass 1: original recording vs the automated browser replay.
|
|
1395
1723
|
const triaged2Seqs = triageByAlignment(session.requests, replayRequests);
|
|
1396
1724
|
const triaged2Requests = replayRequests.filter((r) => triaged2Seqs.includes(r.seq));
|
|
1397
|
-
const
|
|
1725
|
+
const replayDiff = diffTriagedSessions(session, { requests: triaged2Requests });
|
|
1726
|
+
const diffPasses: ClassifiedValue[][] = [replayDiff.classifications];
|
|
1727
|
+
|
|
1728
|
+
// Additional passes: original recording vs every OTHER real recording of
|
|
1729
|
+
// this site. Real recordings come from a trusted browser, so they reproduce
|
|
1730
|
+
// anti-bot-protected requests the automated replay may be blocked from
|
|
1731
|
+
// making (e.g. Akamai denies Playwright at the page level). A value
|
|
1732
|
+
// identical across time-separated recordings is static infrastructure
|
|
1733
|
+
// (GraphQL safelisting signatures, persisted-query hashes, app keys) and
|
|
1734
|
+
// must be kept even when the replay never observed it — see
|
|
1735
|
+
// mergeClassifications. All passes share `session` as the original, so
|
|
1736
|
+
// originalSeq aligns them.
|
|
1737
|
+
let crossRecordingCount = 0;
|
|
1738
|
+
try {
|
|
1739
|
+
const sessionAbs = pathResolve(sessionPath);
|
|
1740
|
+
const others = listSiteSessions(site).filter((s) => pathResolve(s.absPath) !== sessionAbs);
|
|
1741
|
+
for (const info of others) {
|
|
1742
|
+
try {
|
|
1743
|
+
const other = loadJsonFile(
|
|
1744
|
+
info.absPath,
|
|
1745
|
+
SessionSchema,
|
|
1746
|
+
{ notFound: 'Other recording not found.' },
|
|
1747
|
+
'session',
|
|
1748
|
+
);
|
|
1749
|
+
const seqs = triageByAlignment(session.requests, other.requests);
|
|
1750
|
+
const reqs = other.requests.filter((r) => seqs.includes(r.seq));
|
|
1751
|
+
diffPasses.push(diffTriagedSessions(session, { requests: reqs }).classifications);
|
|
1752
|
+
crossRecordingCount++;
|
|
1753
|
+
} catch {
|
|
1754
|
+
// Skip a malformed sibling recording; the other passes still stand.
|
|
1755
|
+
}
|
|
1756
|
+
}
|
|
1757
|
+
} catch {
|
|
1758
|
+
// No sibling recordings available — replay-only classification stands.
|
|
1759
|
+
}
|
|
1760
|
+
|
|
1761
|
+
const diffResult = {
|
|
1762
|
+
...replayDiff,
|
|
1763
|
+
classifications: mergeClassifications(diffPasses),
|
|
1764
|
+
};
|
|
1398
1765
|
|
|
1399
1766
|
const classPath = pathJoin(localSiteDir(site), '.classifications.json');
|
|
1400
1767
|
writeFileSync(classPath, JSON.stringify(diffResult, null, 2));
|
|
@@ -1402,6 +1769,10 @@ async function siteReplayAndDiff(
|
|
|
1402
1769
|
mp.clear();
|
|
1403
1770
|
mp.remove('replay');
|
|
1404
1771
|
|
|
1772
|
+
const sourcesLabel =
|
|
1773
|
+
crossRecordingCount > 0
|
|
1774
|
+
? `replay + ${crossRecordingCount} recording${crossRecordingCount === 1 ? '' : 's'}`
|
|
1775
|
+
: 'replay';
|
|
1405
1776
|
const nonConstant = diffResult.classifications.filter((c) => c.classification !== 'constant');
|
|
1406
1777
|
if (nonConstant.length > 0) {
|
|
1407
1778
|
const counts: Record<string, number> = {};
|
|
@@ -1410,10 +1781,12 @@ async function siteReplayAndDiff(
|
|
|
1410
1781
|
.map(([k, v]) => `${v} ${k}`)
|
|
1411
1782
|
.join(', ');
|
|
1412
1783
|
p.log.info(
|
|
1413
|
-
`Dual-pass: ${nonConstant.length} ephemeral values (${breakdown}). ${replayRequests.length} requests captured.`,
|
|
1784
|
+
`Dual-pass (${sourcesLabel}): ${nonConstant.length} ephemeral values (${breakdown}). ${replayRequests.length} requests captured.`,
|
|
1414
1785
|
);
|
|
1415
1786
|
} else {
|
|
1416
|
-
p.log.info(
|
|
1787
|
+
p.log.info(
|
|
1788
|
+
`Dual-pass (${sourcesLabel}): all values constant. ${replayRequests.length} requests captured.`,
|
|
1789
|
+
);
|
|
1417
1790
|
}
|
|
1418
1791
|
|
|
1419
1792
|
mp.render();
|
|
@@ -1427,55 +1800,9 @@ async function siteReplayAndDiff(
|
|
|
1427
1800
|
}
|
|
1428
1801
|
}
|
|
1429
1802
|
|
|
1430
|
-
|
|
1431
|
-
|
|
1432
|
-
|
|
1433
|
-
fn: (item: T) => Promise<R>,
|
|
1434
|
-
): Promise<R[]> {
|
|
1435
|
-
const results = new Array<R>(items.length);
|
|
1436
|
-
let next = 0;
|
|
1437
|
-
let firstError: unknown;
|
|
1438
|
-
const workers = Array.from({ length: Math.min(concurrency, items.length) }, async () => {
|
|
1439
|
-
while (next < items.length && firstError === undefined) {
|
|
1440
|
-
const index = next++;
|
|
1441
|
-
const item = items[index];
|
|
1442
|
-
if (item === undefined) continue;
|
|
1443
|
-
try {
|
|
1444
|
-
results[index] = await fn(item);
|
|
1445
|
-
} catch (err) {
|
|
1446
|
-
firstError ??= err;
|
|
1447
|
-
}
|
|
1448
|
-
}
|
|
1449
|
-
});
|
|
1450
|
-
await Promise.allSettled(workers);
|
|
1451
|
-
if (firstError !== undefined) throw firstError;
|
|
1452
|
-
return results;
|
|
1453
|
-
}
|
|
1454
|
-
|
|
1455
|
-
type SettledResult<R> = { ok: true; value: R } | { ok: false; error: unknown };
|
|
1456
|
-
|
|
1457
|
-
export async function mapLimitSettled<T, R>(
|
|
1458
|
-
items: T[],
|
|
1459
|
-
concurrency: number,
|
|
1460
|
-
fn: (item: T) => Promise<R>,
|
|
1461
|
-
): Promise<SettledResult<R>[]> {
|
|
1462
|
-
const results = new Array<SettledResult<R>>(items.length);
|
|
1463
|
-
let next = 0;
|
|
1464
|
-
const workers = Array.from({ length: Math.min(concurrency, items.length) }, async () => {
|
|
1465
|
-
while (next < items.length) {
|
|
1466
|
-
const index = next++;
|
|
1467
|
-
const item = items[index];
|
|
1468
|
-
if (item === undefined) continue;
|
|
1469
|
-
try {
|
|
1470
|
-
results[index] = { ok: true, value: await fn(item) };
|
|
1471
|
-
} catch (err) {
|
|
1472
|
-
results[index] = { ok: false, error: err };
|
|
1473
|
-
}
|
|
1474
|
-
}
|
|
1475
|
-
});
|
|
1476
|
-
await Promise.allSettled(workers);
|
|
1477
|
-
return results;
|
|
1478
|
-
}
|
|
1803
|
+
// Bounded-concurrency fan-out helpers now live in concurrency.ts (so teach-plan.ts
|
|
1804
|
+
// can reuse them without an import cycle). Re-exported here for existing callers.
|
|
1805
|
+
export { mapLimit, mapLimitSettled };
|
|
1479
1806
|
|
|
1480
1807
|
// ─── Credential capture (interactive) ───────────────────────────────────────
|
|
1481
1808
|
|
|
@@ -1568,6 +1895,92 @@ async function promptAndPersistCredentials(opts: {
|
|
|
1568
1895
|
};
|
|
1569
1896
|
}
|
|
1570
1897
|
|
|
1898
|
+
/** Find request seqs whose body contains a password-shaped key (per the
|
|
1899
|
+
* shared sensitive-keys dictionary) — regardless of whether credential
|
|
1900
|
+
* extraction succeeded in pairing it with a username.
|
|
1901
|
+
*
|
|
1902
|
+
* Used by the post-redact pairing audit to detect the failure mode where
|
|
1903
|
+
* a recorded login *did* happen but the extractor couldn't pair its
|
|
1904
|
+
* fields, so the redacted session has no `${credential.X}` placeholders
|
|
1905
|
+
* and the compile stage will template credentials as plain parameters.
|
|
1906
|
+
*
|
|
1907
|
+
* Body shapes covered:
|
|
1908
|
+
* - JSON (any nesting depth)
|
|
1909
|
+
* - form-urlencoded (`a=b&c=d`)
|
|
1910
|
+
* - multipart/form-data (sniffed by leading `--<boundary>`)
|
|
1911
|
+
* - URL query string (covers GET-based logins)
|
|
1912
|
+
*
|
|
1913
|
+
* The scan is intentionally lossy and fast: we substring-check for
|
|
1914
|
+
* password-like key names in the raw body text plus exact-key checks in
|
|
1915
|
+
* parsed JSON. False positives are tolerable here (one extra warning);
|
|
1916
|
+
* false negatives are not (silent failure recurrence). */
|
|
1917
|
+
export function findUnpairedPasswordRequests(session: Session): number[] {
|
|
1918
|
+
const PASSWORD_LIKE_TOKENS = passwordLikeTokens();
|
|
1919
|
+
const out: number[] = [];
|
|
1920
|
+
for (const req of session.requests) {
|
|
1921
|
+
let hit = false;
|
|
1922
|
+
// 1. Check URL query string for password-shaped param names.
|
|
1923
|
+
try {
|
|
1924
|
+
const u = new URL(req.url);
|
|
1925
|
+
for (const k of u.searchParams.keys()) {
|
|
1926
|
+
if (isSensitiveCredentialKey(k)) {
|
|
1927
|
+
hit = true;
|
|
1928
|
+
break;
|
|
1929
|
+
}
|
|
1930
|
+
}
|
|
1931
|
+
} catch {
|
|
1932
|
+
// Bad URL — skip URL-side check.
|
|
1933
|
+
}
|
|
1934
|
+
|
|
1935
|
+
// 2. Check body — try JSON first, then fall back to substring scan
|
|
1936
|
+
// that covers form-urlencoded and multipart in one pass.
|
|
1937
|
+
if (!hit && req.body) {
|
|
1938
|
+
const body = req.body;
|
|
1939
|
+
// JSON path.
|
|
1940
|
+
try {
|
|
1941
|
+
const parsed = JSON.parse(body);
|
|
1942
|
+
if (hasPasswordLikeKey(parsed)) hit = true;
|
|
1943
|
+
} catch {
|
|
1944
|
+
// Not JSON — substring scan handles form / multipart / anything
|
|
1945
|
+
// else that contains the key name verbatim.
|
|
1946
|
+
}
|
|
1947
|
+
if (!hit) {
|
|
1948
|
+
const lower = body.toLowerCase();
|
|
1949
|
+
for (const tok of PASSWORD_LIKE_TOKENS) {
|
|
1950
|
+
// Match a key-shaped occurrence: `"password"` (JSON), `password=`
|
|
1951
|
+
// (form/query), or `name="password"` (multipart). Avoid bare
|
|
1952
|
+
// substring matches that could fire on prose payloads.
|
|
1953
|
+
if (
|
|
1954
|
+
lower.includes(`"${tok}"`) ||
|
|
1955
|
+
lower.includes(`${tok}=`) ||
|
|
1956
|
+
lower.includes(`name="${tok}"`)
|
|
1957
|
+
) {
|
|
1958
|
+
hit = true;
|
|
1959
|
+
break;
|
|
1960
|
+
}
|
|
1961
|
+
}
|
|
1962
|
+
}
|
|
1963
|
+
}
|
|
1964
|
+
if (hit) out.push(req.seq);
|
|
1965
|
+
}
|
|
1966
|
+
return out;
|
|
1967
|
+
}
|
|
1968
|
+
|
|
1969
|
+
/** Recursive helper for findUnpairedPasswordRequests' JSON path. */
|
|
1970
|
+
function hasPasswordLikeKey(node: unknown): boolean {
|
|
1971
|
+
if (Array.isArray(node)) {
|
|
1972
|
+
for (const v of node) if (hasPasswordLikeKey(v)) return true;
|
|
1973
|
+
return false;
|
|
1974
|
+
}
|
|
1975
|
+
if (node && typeof node === 'object') {
|
|
1976
|
+
for (const [k, v] of Object.entries(node)) {
|
|
1977
|
+
if (isSensitiveCredentialKey(k)) return true;
|
|
1978
|
+
if (hasPasswordLikeKey(v)) return true;
|
|
1979
|
+
}
|
|
1980
|
+
}
|
|
1981
|
+
return false;
|
|
1982
|
+
}
|
|
1983
|
+
|
|
1571
1984
|
/** Write `<workflowDir>/credentials.manifest.json` so consumers of the
|
|
1572
1985
|
* generated tool know what credentials to provision. No values, just names. */
|
|
1573
1986
|
function exportSiteManifest(
|
|
@@ -1958,47 +2371,58 @@ async function offerSkillExport(opts: {
|
|
|
1958
2371
|
}
|
|
1959
2372
|
}
|
|
1960
2373
|
|
|
1961
|
-
// ─── Session combination (post-record, pre-redact)
|
|
2374
|
+
// ─── Session combination (post-record or post-from-session, pre-redact) ──
|
|
1962
2375
|
|
|
1963
|
-
async function
|
|
2376
|
+
async function combineAvailableSessions(opts: {
|
|
1964
2377
|
site: string;
|
|
1965
2378
|
currentSessionPath: string;
|
|
1966
2379
|
noInteractive: boolean;
|
|
2380
|
+
fromSession: boolean;
|
|
1967
2381
|
}): Promise<string> {
|
|
1968
|
-
|
|
1969
|
-
|
|
1970
|
-
|
|
1971
|
-
|
|
1972
|
-
|
|
2382
|
+
// Discover sibling sessions. For --from-session, look in the source
|
|
2383
|
+
// directory (which may differ from the target site's sessions dir).
|
|
2384
|
+
// For normal recordings, look in the site's sessions directory.
|
|
2385
|
+
const pastSessions = opts.fromSession
|
|
2386
|
+
? listSessionsInDir(pathDirname(opts.currentSessionPath)).filter(
|
|
2387
|
+
(s) => s.absPath !== opts.currentSessionPath,
|
|
2388
|
+
)
|
|
2389
|
+
: listSiteSessions(opts.site).filter((s) => s.absPath !== opts.currentSessionPath);
|
|
1973
2390
|
|
|
1974
2391
|
if (pastSessions.length === 0) return opts.currentSessionPath;
|
|
1975
2392
|
|
|
1976
|
-
|
|
1977
|
-
message: `Found ${pastSessions.length} past recording session${pastSessions.length === 1 ? '' : 's'} for "${opts.site}". Combine with the new recording?`,
|
|
1978
|
-
initialValue: false,
|
|
1979
|
-
});
|
|
2393
|
+
let selectedPaths: string[];
|
|
1980
2394
|
|
|
1981
|
-
if (
|
|
2395
|
+
if (opts.noInteractive) {
|
|
2396
|
+
// Auto-combine all available sessions
|
|
2397
|
+
selectedPaths = pastSessions.map((s) => s.absPath);
|
|
2398
|
+
p.log.info(`Auto-combining ${pastSessions.length + 1} session(s) for "${opts.site}".`);
|
|
2399
|
+
} else {
|
|
2400
|
+
const combine = await p.confirm({
|
|
2401
|
+
message: `Found ${pastSessions.length} past recording session${pastSessions.length === 1 ? '' : 's'}${opts.fromSession ? ' in the source directory' : ` for "${opts.site}"`}. Combine with the ${opts.fromSession ? 'provided' : 'new'} recording?`,
|
|
2402
|
+
initialValue: true,
|
|
2403
|
+
});
|
|
1982
2404
|
|
|
1983
|
-
|
|
1984
|
-
message:
|
|
1985
|
-
'Select sessions to combine with the new recording:\n (press [space] to toggle, [enter] to submit)',
|
|
1986
|
-
required: true,
|
|
1987
|
-
initialValues: pastSessions.map((s) => s.absPath),
|
|
1988
|
-
options: pastSessions.map((s) => ({
|
|
1989
|
-
value: s.absPath,
|
|
1990
|
-
label: `${s.friendlyTimestamp} — ${s.url}`,
|
|
1991
|
-
hint: `${s.requestCount} requests, ${s.narrationCount} narrations`,
|
|
1992
|
-
})),
|
|
1993
|
-
});
|
|
2405
|
+
if (p.isCancel(combine) || !combine) return opts.currentSessionPath;
|
|
1994
2406
|
|
|
1995
|
-
|
|
2407
|
+
const selected = await p.multiselect({
|
|
2408
|
+
message: 'Select sessions to combine:\n (press [space] to toggle, [enter] to submit)',
|
|
2409
|
+
required: true,
|
|
2410
|
+
initialValues: pastSessions.map((s) => s.absPath),
|
|
2411
|
+
options: pastSessions.map((s) => ({
|
|
2412
|
+
value: s.absPath,
|
|
2413
|
+
label: `${s.friendlyTimestamp} — ${s.url}`,
|
|
2414
|
+
hint: `${s.requestCount} requests, ${s.narrationCount} narrations`,
|
|
2415
|
+
})),
|
|
2416
|
+
});
|
|
1996
2417
|
|
|
1997
|
-
|
|
1998
|
-
|
|
2418
|
+
if (p.isCancel(selected)) return opts.currentSessionPath;
|
|
2419
|
+
|
|
2420
|
+
selectedPaths = selected as string[];
|
|
2421
|
+
if (selectedPaths.length === 0) return opts.currentSessionPath;
|
|
2422
|
+
}
|
|
1999
2423
|
|
|
2000
2424
|
const spinner = p.spinner();
|
|
2001
|
-
spinner.start('Combining sessions
|
|
2425
|
+
spinner.start('Combining sessions');
|
|
2002
2426
|
|
|
2003
2427
|
const sessions: Session[] = [];
|
|
2004
2428
|
for (const path of selectedPaths) {
|
|
@@ -2020,8 +2444,21 @@ async function promptSessionCombine(opts: {
|
|
|
2020
2444
|
),
|
|
2021
2445
|
);
|
|
2022
2446
|
|
|
2023
|
-
const combined =
|
|
2024
|
-
|
|
2447
|
+
const { combined, combinedPath } = await traced(
|
|
2448
|
+
'teach.combine_sessions',
|
|
2449
|
+
'CHAIN',
|
|
2450
|
+
{ 'imprint.site': opts.site },
|
|
2451
|
+
async (span) => {
|
|
2452
|
+
const merged = mergeSessions(sessions);
|
|
2453
|
+
const path = writeCombinedSession(opts.site, merged);
|
|
2454
|
+
setSpanAttributes(span, {
|
|
2455
|
+
'imprint.combine.session_count': sessions.length,
|
|
2456
|
+
'imprint.combine.request_count': merged.requests.length,
|
|
2457
|
+
'imprint.combine.narration_count': merged.narration.length,
|
|
2458
|
+
});
|
|
2459
|
+
return { combined: merged, combinedPath: path };
|
|
2460
|
+
},
|
|
2461
|
+
);
|
|
2025
2462
|
|
|
2026
2463
|
spinner.stop(
|
|
2027
2464
|
`Combined ${sessions.length} sessions (${combined.requests.length} requests, ${combined.narration.length} narrations).`,
|
|
@@ -2110,9 +2547,7 @@ async function writeQuickBackendsCache(workflowDir: string, workflow: Workflow):
|
|
|
2110
2547
|
},
|
|
2111
2548
|
};
|
|
2112
2549
|
writeFileSync(backendsPath, `${JSON.stringify(cache, null, 2)}\n`);
|
|
2113
|
-
|
|
2114
|
-
`[imprint teach] backend probe: fetch blocked → wrote ${backendsPath}\n`,
|
|
2115
|
-
);
|
|
2550
|
+
log(`backend probe: fetch blocked → wrote ${backendsPath}`);
|
|
2116
2551
|
}
|
|
2117
2552
|
} catch {
|
|
2118
2553
|
// Fetch failed (timeout, network error) — don't write cache, let runtime discover
|