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.
Files changed (129) hide show
  1. package/README.md +165 -201
  2. package/examples/discoverandgo/README.md +1 -1
  3. package/examples/echo/README.md +1 -1
  4. package/examples/google-flights/README.md +28 -0
  5. package/examples/google-flights/_shared/batchexecute.ts +63 -0
  6. package/examples/google-flights/_shared/flights_request.ts +95 -0
  7. package/examples/google-flights/_shared/package.json +9 -0
  8. package/examples/google-flights/get_flight_booking_details/index.ts +159 -0
  9. package/examples/google-flights/get_flight_booking_details/package.json +9 -0
  10. package/examples/google-flights/get_flight_booking_details/parser.ts +182 -0
  11. package/examples/google-flights/get_flight_booking_details/playbook.yaml +138 -0
  12. package/examples/google-flights/get_flight_booking_details/request-transform.ts +86 -0
  13. package/examples/google-flights/get_flight_booking_details/workflow.json +98 -0
  14. package/examples/google-flights/get_flight_calendar_prices/index.ts +131 -0
  15. package/examples/google-flights/get_flight_calendar_prices/package.json +9 -0
  16. package/examples/google-flights/get_flight_calendar_prices/parser.ts +86 -0
  17. package/examples/google-flights/get_flight_calendar_prices/playbook.yaml +97 -0
  18. package/examples/google-flights/get_flight_calendar_prices/request-transform.ts +31 -0
  19. package/examples/google-flights/get_flight_calendar_prices/workflow.json +76 -0
  20. package/examples/google-flights/lookup_airport/index.ts +101 -0
  21. package/examples/google-flights/lookup_airport/package.json +9 -0
  22. package/examples/google-flights/lookup_airport/parser.ts +66 -0
  23. package/examples/google-flights/lookup_airport/playbook.yaml +47 -0
  24. package/examples/google-flights/lookup_airport/request-transform.ts +20 -0
  25. package/examples/google-flights/lookup_airport/workflow.json +57 -0
  26. package/examples/google-flights/search_flights/index.ts +219 -0
  27. package/examples/google-flights/search_flights/package.json +9 -0
  28. package/examples/google-flights/search_flights/parser.ts +169 -0
  29. package/examples/google-flights/search_flights/playbook.yaml +184 -0
  30. package/examples/google-flights/search_flights/request-transform.ts +119 -0
  31. package/examples/google-flights/search_flights/workflow.json +143 -0
  32. package/examples/google-hotels/README.md +29 -0
  33. package/examples/google-hotels/_shared/batchexecute.ts +73 -0
  34. package/examples/google-hotels/_shared/freq.ts +158 -0
  35. package/examples/google-hotels/_shared/package.json +9 -0
  36. package/examples/google-hotels/autocomplete_hotel_location/index.ts +80 -0
  37. package/examples/google-hotels/autocomplete_hotel_location/package.json +9 -0
  38. package/examples/google-hotels/autocomplete_hotel_location/parser.ts +71 -0
  39. package/examples/google-hotels/autocomplete_hotel_location/playbook.yaml +36 -0
  40. package/examples/google-hotels/autocomplete_hotel_location/request-transform.ts +37 -0
  41. package/examples/google-hotels/autocomplete_hotel_location/workflow.json +36 -0
  42. package/examples/google-hotels/get_hotel_booking_options/index.ts +143 -0
  43. package/examples/google-hotels/get_hotel_booking_options/package.json +9 -0
  44. package/examples/google-hotels/get_hotel_booking_options/parser.ts +271 -0
  45. package/examples/google-hotels/get_hotel_booking_options/playbook.yaml +154 -0
  46. package/examples/google-hotels/get_hotel_booking_options/request-transform.ts +154 -0
  47. package/examples/google-hotels/get_hotel_booking_options/workflow.json +84 -0
  48. package/examples/google-hotels/get_hotel_reviews/index.ts +81 -0
  49. package/examples/google-hotels/get_hotel_reviews/package.json +9 -0
  50. package/examples/google-hotels/get_hotel_reviews/parser.ts +128 -0
  51. package/examples/google-hotels/get_hotel_reviews/playbook.yaml +64 -0
  52. package/examples/google-hotels/get_hotel_reviews/request-transform.ts +42 -0
  53. package/examples/google-hotels/get_hotel_reviews/workflow.json +37 -0
  54. package/examples/google-hotels/search_hotels/index.ts +207 -0
  55. package/examples/google-hotels/search_hotels/package.json +9 -0
  56. package/examples/google-hotels/search_hotels/parser.ts +260 -0
  57. package/examples/google-hotels/search_hotels/playbook.yaml +87 -0
  58. package/examples/google-hotels/search_hotels/request-transform.ts +197 -0
  59. package/examples/google-hotels/search_hotels/workflow.json +127 -0
  60. package/package.json +3 -2
  61. package/prompts/audit-agent.md +71 -0
  62. package/prompts/build-planning.md +74 -0
  63. package/prompts/compile-agent.md +132 -28
  64. package/prompts/prereq-builder.md +64 -0
  65. package/prompts/prereq-planner.md +34 -0
  66. package/prompts/tool-planning.md +39 -0
  67. package/src/cli.ts +111 -4
  68. package/src/imprint/agent.ts +5 -0
  69. package/src/imprint/audit.ts +996 -0
  70. package/src/imprint/backend-ladder.ts +1214 -184
  71. package/src/imprint/build-plan.ts +1051 -0
  72. package/src/imprint/cdp-browser-fetch.ts +589 -0
  73. package/src/imprint/cdp-jar-cache.ts +320 -0
  74. package/src/imprint/chromium.ts +135 -0
  75. package/src/imprint/claude-cli-compile.ts +125 -25
  76. package/src/imprint/codex-cli-compile.ts +26 -23
  77. package/src/imprint/compile-agent-types.ts +38 -0
  78. package/src/imprint/compile-agent.ts +65 -27
  79. package/src/imprint/compile-tools.ts +1656 -64
  80. package/src/imprint/compile.ts +14 -2
  81. package/src/imprint/concurrency.ts +87 -0
  82. package/src/imprint/credential-extract.ts +174 -25
  83. package/src/imprint/cron.ts +1 -0
  84. package/src/imprint/doctor.ts +39 -0
  85. package/src/imprint/emit.ts +85 -0
  86. package/src/imprint/freeform-redact.ts +5 -4
  87. package/src/imprint/integrations.ts +2 -2
  88. package/src/imprint/llm.ts +56 -8
  89. package/src/imprint/mcp-compile-server.ts +43 -10
  90. package/src/imprint/mcp-maintenance.ts +9 -101
  91. package/src/imprint/mcp-server.ts +73 -7
  92. package/src/imprint/multi-progress.ts +7 -2
  93. package/src/imprint/param-grounding.ts +367 -0
  94. package/src/imprint/paths.ts +29 -0
  95. package/src/imprint/playbook-runner.ts +101 -40
  96. package/src/imprint/prereq-builder.ts +651 -0
  97. package/src/imprint/probe-backends.ts +6 -3
  98. package/src/imprint/record.ts +10 -1
  99. package/src/imprint/redact.ts +30 -2
  100. package/src/imprint/replay-capture.ts +19 -18
  101. package/src/imprint/runtime.ts +19 -10
  102. package/src/imprint/sensitive-keys.ts +141 -7
  103. package/src/imprint/session-diff.ts +79 -2
  104. package/src/imprint/session-merge.ts +9 -5
  105. package/src/imprint/stealth-chromium.ts +81 -0
  106. package/src/imprint/stealth-fetch.ts +309 -29
  107. package/src/imprint/stealth-token-cache.ts +88 -0
  108. package/src/imprint/teach-plan.ts +251 -0
  109. package/src/imprint/teach-state.ts +17 -0
  110. package/src/imprint/teach.ts +582 -147
  111. package/src/imprint/tool-candidates.ts +72 -14
  112. package/src/imprint/tool-plan.ts +313 -0
  113. package/src/imprint/tracing.ts +135 -6
  114. package/src/imprint/types.ts +61 -3
  115. package/examples/google-flights/search_google_flights/index.ts +0 -101
  116. package/examples/google-flights/search_google_flights/parser.test.ts +0 -140
  117. package/examples/google-flights/search_google_flights/parser.ts +0 -189
  118. package/examples/google-flights/search_google_flights/playbook.yaml +0 -130
  119. package/examples/google-flights/search_google_flights/workflow.json +0 -48
  120. package/examples/google-hotels/search_google_hotels/index.ts +0 -194
  121. package/examples/google-hotels/search_google_hotels/parser.test.ts +0 -168
  122. package/examples/google-hotels/search_google_hotels/parser.ts +0 -330
  123. package/examples/google-hotels/search_google_hotels/playbook.yaml +0 -125
  124. package/examples/google-hotels/search_google_hotels/workflow.json +0 -111
  125. package/examples/namecheap-domains/search_namecheap_domains/index.ts +0 -144
  126. package/examples/namecheap-domains/search_namecheap_domains/parser.ts +0 -380
  127. package/examples/namecheap-domains/search_namecheap_domains/playbook.yaml +0 -50
  128. package/examples/namecheap-domains/search_namecheap_domains/request-transform.ts +0 -136
  129. package/examples/namecheap-domains/search_namecheap_domains/workflow.json +0 -97
@@ -6,6 +6,7 @@ import {
6
6
  llmSpanAttributes,
7
7
  resolveTraceTokenCount,
8
8
  setSpanAttributes,
9
+ totalPromptTokens,
9
10
  traceLlmIoEnabled,
10
11
  traceLlmMessages,
11
12
  traced,
@@ -17,6 +18,16 @@ interface AnalyzeResult {
17
18
  text: string;
18
19
  inputTokens: number | null;
19
20
  outputTokens: number | null;
21
+ /**
22
+ * Prompt-cache token counts, when the provider reports them. `inputTokens` is
23
+ * the *uncached* input only (the Anthropic/CLI `usage.input_tokens`); the bulk
24
+ * of a cache-hit call lives here. Threaded through so `llm.analyze` cost is
25
+ * cache-aware (cache reads bill at 0.1×, writes at 1.25×) instead of charging
26
+ * the whole prompt at the full input rate. Null/undefined for providers that
27
+ * don't expose usage (codex-cli, cursor-cli).
28
+ */
29
+ cacheReadInputTokens?: number | null;
30
+ cacheCreationInputTokens?: number | null;
20
31
  durationMs: number;
21
32
  stopReason: string | null;
22
33
  }
@@ -129,6 +140,8 @@ class AnthropicApiProvider implements LLMProvider {
129
140
  text,
130
141
  inputTokens: response.usage.input_tokens,
131
142
  outputTokens: response.usage.output_tokens,
143
+ cacheReadInputTokens: response.usage.cache_read_input_tokens ?? null,
144
+ cacheCreationInputTokens: response.usage.cache_creation_input_tokens ?? null,
132
145
  durationMs: Date.now() - t0,
133
146
  stopReason: response.stop_reason ?? null,
134
147
  };
@@ -256,7 +269,15 @@ class ClaudeCliProvider implements LLMProvider {
256
269
  );
257
270
  }
258
271
 
259
- let parsed: { result?: string; usage?: { input_tokens?: number; output_tokens?: number } };
272
+ let parsed: {
273
+ result?: string;
274
+ usage?: {
275
+ input_tokens?: number;
276
+ output_tokens?: number;
277
+ cache_read_input_tokens?: number;
278
+ cache_creation_input_tokens?: number;
279
+ };
280
+ };
260
281
  try {
261
282
  parsed = JSON.parse(stdout);
262
283
  } catch (parseErr) {
@@ -273,6 +294,8 @@ class ClaudeCliProvider implements LLMProvider {
273
294
  text: parsed.result,
274
295
  inputTokens: parsed.usage?.input_tokens ?? null,
275
296
  outputTokens: parsed.usage?.output_tokens ?? null,
297
+ cacheReadInputTokens: parsed.usage?.cache_read_input_tokens ?? null,
298
+ cacheCreationInputTokens: parsed.usage?.cache_creation_input_tokens ?? null,
276
299
  durationMs: Date.now() - t0,
277
300
  stopReason: null,
278
301
  };
@@ -437,7 +460,20 @@ async function traceAnalyze(
437
460
  },
438
461
  async (span) => {
439
462
  const result = await fn();
440
- const inputTokens = resolveTraceTokenCount(result.inputTokens, details?.inputText);
463
+ // Providers report `inputTokens` as the *uncached* input only; the cached
464
+ // portion lives in the cache fields. `llmCostAttributes` expects the TOTAL
465
+ // prompt tokens (it derives uncached = total − cacheRead − cacheWrite), so
466
+ // sum them here. A real total is also large enough to clear the
467
+ // resolveTraceTokenCount sanity check, so cache-hit calls stop falling back
468
+ // to the chars/4 estimate.
469
+ const cacheReadTokens = result.cacheReadInputTokens ?? undefined;
470
+ const cacheWriteTokens = result.cacheCreationInputTokens ?? undefined;
471
+ const totalInputTokens = totalPromptTokens(
472
+ result.inputTokens,
473
+ cacheReadTokens,
474
+ cacheWriteTokens,
475
+ );
476
+ const inputTokens = resolveTraceTokenCount(totalInputTokens, details?.inputText);
441
477
  const outputTokens = resolveTraceTokenCount(result.outputTokens, result.text);
442
478
  setSpanAttributes(span, {
443
479
  ...llmSpanAttributes({
@@ -445,6 +481,8 @@ async function traceAnalyze(
445
481
  model,
446
482
  inputTokens: inputTokens.tokens,
447
483
  outputTokens: outputTokens.tokens,
484
+ cacheReadTokens,
485
+ cacheWriteTokens,
448
486
  tokenCountsEstimated:
449
487
  inputTokens.source === 'estimated' || outputTokens.source === 'estimated',
450
488
  inputTokenSource: inputTokens.source,
@@ -512,12 +550,20 @@ async function traceMessageWithTools(
512
550
  return `[${b.type}]`;
513
551
  })
514
552
  .join('\n');
553
+ const cacheReadTokens = response.usage.cache_read_input_tokens ?? undefined;
554
+ const cacheWriteTokens = response.usage.cache_creation_input_tokens ?? undefined;
515
555
  setSpanAttributes(span, {
516
556
  ...llmSpanAttributes({
517
557
  provider,
518
558
  model,
519
- inputTokens: response.usage.input_tokens,
559
+ inputTokens: totalPromptTokens(
560
+ response.usage.input_tokens,
561
+ cacheReadTokens,
562
+ cacheWriteTokens,
563
+ ),
520
564
  outputTokens: response.usage.output_tokens,
565
+ cacheReadTokens,
566
+ cacheWriteTokens,
521
567
  stopReason: response.stop_reason,
522
568
  outputMessages: captureIo
523
569
  ? traceLlmMessages([{ role: 'assistant', content: outputText }])
@@ -824,7 +870,7 @@ export function detectTeachProvider(): ProviderName {
824
870
  }
825
871
 
826
872
  function createProvider(name: ProviderName, opts: LLMOptions = {}): LLMProvider {
827
- const model = opts.model ?? process.env.ANTHROPIC_MODEL ?? 'claude-opus-4-7';
873
+ const model = opts.model ?? process.env.ANTHROPIC_MODEL ?? 'claude-opus-4-8';
828
874
  const temperature = opts.temperature ?? 0;
829
875
  const maxTokens = opts.maxTokens ?? 8192;
830
876
 
@@ -865,11 +911,11 @@ export function preferredAgentModel(provider: ProviderName): string {
865
911
  switch (provider) {
866
912
  case 'anthropic-api':
867
913
  case 'claude-cli':
868
- return 'claude-opus-4-7';
914
+ return 'claude-opus-4-8';
869
915
  case 'codex-cli':
870
916
  return 'gpt-5.5';
871
917
  case 'cursor-cli':
872
- return 'claude-opus-4-7'; // best-effort; cursor passes through
918
+ return 'claude-opus-4-8'; // best-effort; cursor passes through
873
919
  }
874
920
  }
875
921
 
@@ -883,7 +929,8 @@ export function availableModelsForProvider(provider: ProviderName): ModelOption[
883
929
  case 'anthropic-api':
884
930
  case 'claude-cli':
885
931
  return [
886
- { model: 'claude-opus-4-7', isDefault: true },
932
+ { model: 'claude-opus-4-8', isDefault: true },
933
+ { model: 'claude-opus-4-7', isDefault: false },
887
934
  { model: 'claude-sonnet-4-6', isDefault: false },
888
935
  { model: 'claude-haiku-4-5', isDefault: false },
889
936
  { model: 'claude-opus-4-6', isDefault: false },
@@ -908,7 +955,8 @@ export function availableModelsForProvider(provider: ProviderName): ModelOption[
908
955
  ];
909
956
  case 'cursor-cli':
910
957
  return [
911
- { model: 'claude-opus-4-7', isDefault: true },
958
+ { model: 'claude-opus-4-8', isDefault: true },
959
+ { model: 'claude-opus-4-7', isDefault: false },
912
960
  { model: 'claude-sonnet-4-6', isDefault: false },
913
961
  { model: 'claude-haiku-4-5', isDefault: false },
914
962
  { model: 'gpt-5.5', isDefault: false },
@@ -24,7 +24,13 @@ import {
24
24
  ListToolsRequestSchema,
25
25
  type Tool,
26
26
  } from '@modelcontextprotocol/sdk/types.js';
27
- import { buildCompileTools, externalVerification } from './compile-tools.ts';
27
+ import { type SharedModuleManifestEntry, resolvePlanSliceFromFile } from './build-plan.ts';
28
+ import {
29
+ applyLiveVerification,
30
+ applyParamVerification,
31
+ buildCompileTools,
32
+ externalVerification,
33
+ } from './compile-tools.ts';
28
34
  import { loadJsonFile } from './load-json.ts';
29
35
  import { createLog } from './log.ts';
30
36
  import { redactSession } from './redact.ts';
@@ -43,6 +49,10 @@ interface RunCompileMcpServerOptions {
43
49
  maxVerificationCycles?: number;
44
50
  candidate?: ToolCandidate;
45
51
  sharedContext?: SharedCompileContext;
52
+ /** Absolute path to the multi-tool build plan sidecar (.build-plan.json). */
53
+ buildPlanPath?: string;
54
+ /** Shared-module build manifest for this site (verified flags). */
55
+ sharedModules?: SharedModuleManifestEntry[];
46
56
  }
47
57
 
48
58
  const DONE_SENTINEL = '.compile-done.json';
@@ -67,12 +77,24 @@ export async function runCompileMcpServer(opts: RunCompileMcpServerOptions): Pro
67
77
  session = redactSession(session).session;
68
78
  }
69
79
 
70
- // Build the 8 read/write tools (same as the in-process loop).
80
+ // Build the read/write tools (same as the in-process loop). When a build
81
+ // plan is present, buildCompileTools also exposes read_build_plan.
71
82
  const compileTools = buildCompileTools(session, opts.toolDir, opts.sessionPath, {
72
83
  candidate: opts.candidate,
73
84
  sharedContext: opts.sharedContext,
85
+ buildPlanPath: opts.buildPlanPath,
86
+ sharedModules: opts.sharedModules,
74
87
  });
75
88
 
89
+ // Resolve the shared modules + producer→consumer token contracts the plan
90
+ // assigned this tool, so verification can assert modules are imported and
91
+ // require a chained test for each producer-sourced token param.
92
+ const { assignedSharedModules, tokenParams, emittedTokens } = resolvePlanSliceFromFile(
93
+ opts.buildPlanPath,
94
+ opts.candidate?.toolName,
95
+ opts.sharedModules,
96
+ );
97
+
76
98
  // The custom done/give_up tools live alongside in MCP space.
77
99
  const doneTool: Tool = {
78
100
  name: 'done',
@@ -136,25 +158,36 @@ export async function runCompileMcpServer(opts: RunCompileMcpServerOptions): Pro
136
158
  if (name === 'done') {
137
159
  const summary = (args as { summary?: string }).summary ?? 'Task completed';
138
160
  log(`done() called: ${summary}`);
139
- const { failures, warnings } = await externalVerification(
140
- opts.toolDir,
141
- session,
142
- opts.sessionPath,
143
- {
161
+ const { failures, warnings, paramVerification, liveVerification } =
162
+ await externalVerification(opts.toolDir, session, opts.sessionPath, {
144
163
  expectedToolName: opts.candidate?.toolName,
145
164
  likelyParams: opts.candidate?.likelyParams,
146
165
  candidateRequestSeqs: opts.candidate?.requestSeqs,
147
- },
148
- );
166
+ // Widen Fix B's variation pool to dependency requests so a token that
167
+ // varies only across them and is frozen as a literal in the tool's
168
+ // request is caught (the cross-request session-token leak case).
169
+ dependencyRequestSeqs: opts.candidate?.dependencySeqs,
170
+ assignedSharedModules,
171
+ tokenParams,
172
+ emittedTokens,
173
+ });
149
174
  if (warnings.length > 0) {
150
175
  log(`verification warnings (non-blocking):\n${warnings.join('\n')}`);
151
176
  }
152
177
  if (failures.length === 0) {
178
+ // Persist per-parameter verified flags + the live-verification stamp
179
+ // onto workflow.json. Audit and teach read the stamp.
180
+ applyLiveVerification(opts.toolDir, liveVerification);
181
+ const paramWarnings = applyParamVerification(opts.toolDir, paramVerification);
182
+ if (paramWarnings.length > 0) {
183
+ log(`parameter verification:\n${paramWarnings.join('\n')}`);
184
+ }
185
+ const allWarnings = [...warnings, ...paramWarnings];
153
186
  const sentinel = pathJoin(opts.toolDir, DONE_SENTINEL);
154
187
  writeFileSync(
155
188
  sentinel,
156
189
  JSON.stringify(
157
- { summary, verification: 'passed', warnings, timestamp: Date.now() },
190
+ { summary, verification: 'passed', warnings: allWarnings, timestamp: Date.now() },
158
191
  null,
159
192
  2,
160
193
  ),
@@ -28,7 +28,6 @@ import * as p from '@clack/prompts';
28
28
  import YAML from 'yaml';
29
29
  import { imprintHomeDir, localSiteDir } from './paths.ts';
30
30
  import {
31
- type TeachState,
32
31
  type WorkflowState,
33
32
  loadTeachState,
34
33
  resolveTeachStatePath,
@@ -38,7 +37,7 @@ import {
38
37
 
39
38
  type McpClient = 'claude-code' | 'codex' | 'claude-desktop' | 'openclaw' | 'hermes';
40
39
  type LocalDeleteMode = 'none' | 'tool' | 'site';
41
- type IssueKind = 'incomplete' | 'missing-session' | 'orphan-session' | 'stale-registration';
40
+ type IssueKind = 'incomplete' | 'missing-session' | 'stale-registration';
42
41
 
43
42
  const CLIENTS: McpClient[] = ['claude-code', 'codex', 'claude-desktop', 'openclaw', 'hermes'];
44
43
  const DISABLED_STORE_VERSION = 1;
@@ -100,7 +99,6 @@ interface LocalSiteStatus {
100
99
  dir: string;
101
100
  tools: LocalToolStatus[];
102
101
  workflows: LocalWorkflowStatus[];
103
- orphanSessions: string[];
104
102
  }
105
103
 
106
104
  interface McpIssue {
@@ -393,22 +391,6 @@ async function runInteractiveIssueFix(status: McpStatus, ctx: MaintenanceContext
393
391
  return;
394
392
  }
395
393
 
396
- const orphanIssues = indices
397
- .map((i) => status.issues[i])
398
- .filter(
399
- (issue): issue is McpIssue => !!issue && issue.kind === 'orphan-session' && !!issue.path,
400
- );
401
-
402
- let deleteOrphans = true;
403
- if (orphanIssues.length > 0) {
404
- const confirm = await p.confirm({
405
- message: `Delete ${orphanIssues.length} orphan session file${orphanIssues.length === 1 ? '' : 's'}?`,
406
- initialValue: false,
407
- });
408
- if (p.isCancel(confirm)) return;
409
- deleteOrphans = confirm === true;
410
- }
411
-
412
394
  const aggregate: MutationResult = { changed: [], skipped: [] };
413
395
  for (const index of indices) {
414
396
  const issue = status.issues[index];
@@ -416,17 +398,12 @@ async function runInteractiveIssueFix(status: McpStatus, ctx: MaintenanceContext
416
398
  appendMutation(aggregate, { changed: [], skipped: ['selection disappeared'] });
417
399
  continue;
418
400
  }
419
- appendMutation(aggregate, fixIssue(issue, status, ctx, { deleteOrphans }));
401
+ appendMutation(aggregate, fixIssue(issue, status, ctx));
420
402
  }
421
403
  reportMutation(aggregate);
422
404
  }
423
405
 
424
- function fixIssue(
425
- issue: McpIssue,
426
- status: McpStatus,
427
- ctx: MaintenanceContext,
428
- opts: { deleteOrphans: boolean },
429
- ): MutationResult {
406
+ function fixIssue(issue: McpIssue, status: McpStatus, ctx: MaintenanceContext): MutationResult {
430
407
  if (issue.kind === 'stale-registration') {
431
408
  const reg = status.registrations.find(
432
409
  (r) =>
@@ -446,13 +423,6 @@ function fixIssue(
446
423
  return pruneSingleTeachWorkflow(issue.site, issue.workflow);
447
424
  }
448
425
 
449
- if (issue.kind === 'orphan-session' && issue.path) {
450
- if (!opts.deleteOrphans) {
451
- return { changed: [], skipped: [`kept orphan session ${issue.path}`] };
452
- }
453
- return deleteOrphanSessionFile(issue.path);
454
- }
455
-
456
426
  return { changed: [], skipped: [`no automatic fix for ${issue.kind}`] };
457
427
  }
458
428
 
@@ -463,7 +433,7 @@ async function runInteractiveLocalDelete(status: McpStatus): Promise<void> {
463
433
  const complete = s.tools.filter((t) => t.complete).length;
464
434
  return {
465
435
  value: s.site,
466
- label: `${s.site} (${complete} complete tool${complete === 1 ? '' : 's'}, ${s.orphanSessions.length} orphan session${s.orphanSessions.length === 1 ? '' : 's'})`,
436
+ label: `${s.site} (${complete} complete tool${complete === 1 ? '' : 's'})`,
467
437
  };
468
438
  }),
469
439
  });
@@ -686,7 +656,7 @@ function formatMcpStatus(status: McpStatus): string {
686
656
  const incomplete = s.workflows.filter((w) => w.incomplete).length;
687
657
  const missing = s.workflows.filter((w) => w.missingSession).length;
688
658
  lines.push(
689
- ` ${s.site}: ${complete} complete tool${complete === 1 ? '' : 's'}, ${incomplete} incomplete workflow${incomplete === 1 ? '' : 's'}, ${missing} missing-session issue${missing === 1 ? '' : 's'}, ${s.orphanSessions.length} orphan session${s.orphanSessions.length === 1 ? '' : 's'}`,
659
+ ` ${s.site}: ${complete} complete tool${complete === 1 ? '' : 's'}, ${incomplete} incomplete workflow${incomplete === 1 ? '' : 's'}, ${missing} missing-session issue${missing === 1 ? '' : 's'}`,
690
660
  );
691
661
  }
692
662
  }
@@ -714,8 +684,6 @@ function issueFixHint(issue: McpIssue): string | null {
714
684
  return `choose "Fix an issue" or run: imprint mcp prune-state --site ${issue.site} --incomplete --yes`;
715
685
  case 'missing-session':
716
686
  return `choose "Fix an issue" or run: imprint mcp prune-state --site ${issue.site} --missing-session --yes`;
717
- case 'orphan-session':
718
- return 'choose "Fix an issue" to delete this recording, or keep it if you still need it';
719
687
  }
720
688
  return null;
721
689
  }
@@ -737,12 +705,12 @@ function scanLocalSites(ctx: MaintenanceContext): LocalSiteStatus[] {
737
705
  if (entry === 'node_modules' || entry.startsWith('.')) continue;
738
706
  const dir = pathJoin(ctx.imprintHome, entry);
739
707
  if (!safeIsDir(dir)) continue;
740
- sites.push(scanLocalSite(ctx, entry, dir));
708
+ sites.push(scanLocalSite(entry, dir));
741
709
  }
742
710
  return sites;
743
711
  }
744
712
 
745
- function scanLocalSite(ctx: MaintenanceContext, site: string, dir: string): LocalSiteStatus {
713
+ function scanLocalSite(site: string, dir: string): LocalSiteStatus {
746
714
  const tools: LocalToolStatus[] = [];
747
715
  for (const entry of readdirSync(dir).sort()) {
748
716
  if (entry === 'sessions' || entry === '_shared' || entry.startsWith('.')) continue;
@@ -764,12 +732,8 @@ function scanLocalSite(ctx: MaintenanceContext, site: string, dir: string): Loca
764
732
  const workflows = Object.entries(state.workflows)
765
733
  .map(([name, ws]) => workflowStatus(site, name, ws, tools))
766
734
  .sort((a, b) => a.name.localeCompare(b.name));
767
- const referenced = referencedSessionPaths(site, state);
768
- const orphanSessions = discoverSessionFiles(pathJoin(dir, 'sessions')).filter(
769
- (session) => !isReferencedSessionFile(site, session, ctx, referenced),
770
- );
771
735
 
772
- return { site, dir, tools, workflows, orphanSessions };
736
+ return { site, dir, tools, workflows };
773
737
  }
774
738
 
775
739
  function workflowStatus(
@@ -813,47 +777,6 @@ function workflowJsonToolName(toolDir: string): string | null {
813
777
  }
814
778
  }
815
779
 
816
- function referencedSessionPaths(site: string, state: TeachState): Set<string> {
817
- const out = new Set<string>();
818
- for (const ws of Object.values(state.workflows)) {
819
- for (const stored of [ws.sessionPath, ws.redactedPath, ws.triagedPath]) {
820
- if (!stored) continue;
821
- out.add(stored);
822
- const resolved = resolveTeachStatePath(site, stored);
823
- if (resolved) out.add(resolved);
824
- }
825
- }
826
- return out;
827
- }
828
-
829
- function discoverSessionFiles(sessionDir: string): string[] {
830
- if (!existsSync(sessionDir)) return [];
831
- return readdirSync(sessionDir)
832
- .filter((f) => (f.endsWith('.json') || f.endsWith('.jsonl')) && !f.includes('.triaged'))
833
- .map((f) => pathJoin(sessionDir, f))
834
- .sort();
835
- }
836
-
837
- function isReferencedSessionFile(
838
- site: string,
839
- absolutePath: string,
840
- ctx: MaintenanceContext,
841
- referenced: Set<string>,
842
- ): boolean {
843
- const candidates = [absolutePath, relativeToSite(site, absolutePath, ctx)];
844
- if (absolutePath.endsWith('.jsonl')) {
845
- const jsonPath = absolutePath.replace(/\.jsonl$/, '.json');
846
- candidates.push(jsonPath, relativeToSite(site, jsonPath, ctx));
847
- }
848
- return candidates.some((candidate) => referenced.has(candidate));
849
- }
850
-
851
- function relativeToSite(site: string, absolutePath: string, ctx: MaintenanceContext): string {
852
- const siteDir = pathJoin(ctx.imprintHome, site);
853
- const prefix = `${siteDir}/`;
854
- return absolutePath.startsWith(prefix) ? absolutePath.slice(prefix.length) : absolutePath;
855
- }
856
-
857
780
  function collectIssues(opts: {
858
781
  registrations: McpRegistration[];
859
782
  sites: LocalSiteStatus[];
@@ -881,14 +804,6 @@ function collectIssues(opts: {
881
804
  });
882
805
  }
883
806
  }
884
- for (const session of site.orphanSessions) {
885
- issues.push({
886
- kind: 'orphan-session',
887
- site: site.site,
888
- message: `${site.site} has an untracked session ${session}`,
889
- path: session,
890
- });
891
- }
892
807
  }
893
808
 
894
809
  for (const r of opts.registrations) {
@@ -1174,7 +1089,7 @@ function pruneTeachState(
1174
1089
  for (const site of sites) {
1175
1090
  const statePath = teachStatePath(site);
1176
1091
  if (!existsSync(statePath)) continue;
1177
- const status = scanLocalSite(ctx, site, localSiteDir(site));
1092
+ const status = scanLocalSite(site, localSiteDir(site));
1178
1093
  const remove = new Set(
1179
1094
  status.workflows
1180
1095
  .filter(
@@ -1208,13 +1123,6 @@ function pruneSingleTeachWorkflow(site: string, workflow: string): MutationResul
1208
1123
  return { changed: [`pruned teach-state entry ${site}/${workflow}`], skipped: [] };
1209
1124
  }
1210
1125
 
1211
- function deleteOrphanSessionFile(path: string): MutationResult {
1212
- if (!existsSync(path))
1213
- return { changed: [], skipped: [`orphan session ${path} no longer exists`] };
1214
- rmSync(path, { force: true });
1215
- return { changed: [`deleted orphan session ${path}`], skipped: [] };
1216
- }
1217
-
1218
1126
  function matchesTarget(
1219
1127
  reg: McpRegistration,
1220
1128
  target: string,
@@ -17,6 +17,7 @@ import {
17
17
  type Tool,
18
18
  } from '@modelcontextprotocol/sdk/types.js';
19
19
  import { resolveLadder, runWithLadder } from './backend-ladder.ts';
20
+ import type { CdpBrowserFetch } from './cdp-browser-fetch.ts';
20
21
  import { createLog } from './log.ts';
21
22
  import { imprintHomeDir } from './paths.ts';
22
23
  import { loadBackendsCache } from './probe-backends.ts';
@@ -66,11 +67,17 @@ function buildToolDescription(w: ResolvedTool['workflow']): string {
66
67
 
67
68
  /** MCP advertises tool input as JSON Schema; build it directly from
68
69
  * workflow parameters rather than going through Zod. */
69
- function buildJsonSchema(parameters: WorkflowParameter[]): Tool['inputSchema'] {
70
+ export function buildJsonSchema(parameters: WorkflowParameter[]): Tool['inputSchema'] {
70
71
  const properties: Record<string, { type: string; description: string }> = {};
71
72
  const required: string[] = [];
72
73
  for (const p of parameters) {
73
- properties[p.name] = { type: p.type, description: p.description };
74
+ // Producer-sourced token params: tell the orchestrating LLM where to mint the
75
+ // value so it calls the producer once and reuses it, rather than fabricating
76
+ // an opaque token (which the tool would reject).
77
+ const description = p.sourcedFrom
78
+ ? `${p.description} Obtain this value from the \`${p.sourcedFrom.tool}\` tool's \`${p.sourcedFrom.field}\` output — call \`${p.sourcedFrom.tool}\` first and reuse the value across calls (no need to re-fetch each time).`
79
+ : p.description;
80
+ properties[p.name] = { type: p.type, description };
74
81
  if (p.default === undefined) required.push(p.name);
75
82
  }
76
83
  return {
@@ -88,13 +95,13 @@ function buildServer(
88
95
  version: string,
89
96
  tools: ResolvedTool[],
90
97
  assetRoot: string,
91
- ): Server {
98
+ ): { server: Server; closeCdpPool: () => Promise<void> } {
92
99
  const server = new Server(
93
100
  { name, version },
94
101
  {
95
102
  capabilities: { tools: {} },
96
103
  instructions:
97
- 'Imprint runs deterministic workflows captured from real browser sessions. Tools prefer fetch API replay, may use gated fetch-bootstrap only for declared browser-minted state, then stealth-fetch for bot-defense state, and playbook only for full DOM interaction. Error codes: AUTH_EXPIRED (401, run `imprint login <site>`); STATE_MISSING (required cookie/state was unavailable or ambiguous); FORBIDDEN (403); RATE_LIMITED (429, back off); BAD_RESPONSE (other 4xx/5xx); NETWORK (fetch failed); UNKNOWN (everything else).',
104
+ 'Imprint runs deterministic workflows captured from real browser sessions. Tools prefer fetch API replay, may use gated fetch-bootstrap only for declared browser-minted state, then cdp-replay (API requests run inside a live trusted Chrome so a protected POST refreshes its anti-bot token between calls) for multi-step state-changing flows, then stealth-fetch for bot-defense state, and playbook only for full DOM interaction. Error codes: AUTH_EXPIRED (401, run `imprint login <site>`); STATE_MISSING (required cookie/state was unavailable or ambiguous); FORBIDDEN (403); RATE_LIMITED (429, back off); BAD_RESPONSE (other 4xx/5xx); NETWORK (fetch failed); UNKNOWN (everything else).',
98
105
  },
99
106
  );
100
107
 
@@ -105,6 +112,21 @@ function buildServer(
105
112
  // Per-site stealth-fetch cache so the ~12s bootstrap runs once per site.
106
113
  const stealthCache = new Map<string, StealthFetch>();
107
114
 
115
+ // Per-site CDP browser pool: cdp-replay stores its live Chrome here after
116
+ // the first successful call so subsequent calls reuse it (~2-5s vs ~33s).
117
+ const cdpPool = new Map<string, CdpBrowserFetch>();
118
+ const cdpIdleTimers = new Map<string, ReturnType<typeof setTimeout>>();
119
+ const CDP_IDLE_TIMEOUT_MS = 5 * 60 * 1000;
120
+
121
+ // Per-tool memo of the winning backend for THIS server session. After the
122
+ // first call discovers the right rung, later calls skip the doomed early ones
123
+ // (e.g. southwest's ~80s fetch-bootstrap FORBIDDEN before cdp-replay wins). Its
124
+ // lifetime is tied to `cdpPool`: the memoized cdp-replay is only cheap while
125
+ // its Chrome is pooled, so a site's memo is evicted when that pool entry is
126
+ // idle-closed (below) — otherwise the next call would start at a now-cold
127
+ // cdp-replay and re-pay the ~33s relaunch.
128
+ const winnerCache = new Map<string, ConcreteBackend>();
129
+
108
130
  server.setRequestHandler(ListToolsRequestSchema, async () => ({
109
131
  tools: tools.map((t) => ({
110
132
  name: t.workflow.toolName,
@@ -140,6 +162,15 @@ function buildServer(
140
162
  string | number | boolean
141
163
  >;
142
164
 
165
+ // Audit-only pacing: when the audit harness sets IMPRINT_AUDIT_PACING_MS,
166
+ // sleep before each tool call so the auditor's per-parameter differential
167
+ // probing of bot-defended idempotent reads stays steady enough not to trip
168
+ // the per-IP anti-bot defense. Unset in production → no delay.
169
+ const pacingMs = Number(process.env.IMPRINT_AUDIT_PACING_MS);
170
+ if (Number.isFinite(pacingMs) && pacingMs > 0) {
171
+ await new Promise((r) => setTimeout(r, pacingMs));
172
+ }
173
+
143
174
  try {
144
175
  const ladder = resolveLadder('auto', tool.preferredOrder);
145
176
  const { result, usedBackend } = await runWithLadder(
@@ -148,7 +179,29 @@ function buildServer(
148
179
  args,
149
180
  assetRoot,
150
181
  stealthCache,
182
+ { cdpPool, winnerCache },
151
183
  );
184
+ // Reset the idle timer for this site's pooled Chrome.
185
+ if (result.ok && usedBackend === 'cdp-replay' && cdpPool.has(tool.site)) {
186
+ const prev = cdpIdleTimers.get(tool.site);
187
+ if (prev) clearTimeout(prev);
188
+ const timer = setTimeout(() => {
189
+ const cf = cdpPool.get(tool.site);
190
+ if (cf) {
191
+ log(`closing idle CDP session for ${tool.site}`);
192
+ cf.close().catch(() => {});
193
+ cdpPool.delete(tool.site);
194
+ cdpIdleTimers.delete(tool.site);
195
+ // Drop this site's winner memo too: a memoized cdp-replay would now
196
+ // point at a closed Chrome and re-pay the cold relaunch.
197
+ for (const key of winnerCache.keys()) {
198
+ if (key.startsWith(`${tool.site}:`)) winnerCache.delete(key);
199
+ }
200
+ }
201
+ }, CDP_IDLE_TIMEOUT_MS);
202
+ timer.unref();
203
+ cdpIdleTimers.set(tool.site, timer);
204
+ }
152
205
  if (!result.ok) {
153
206
  const text = formatToolError(result);
154
207
  return {
@@ -165,7 +218,18 @@ function buildServer(
165
218
  }
166
219
  });
167
220
 
168
- return server;
221
+ async function closeCdpPool(): Promise<void> {
222
+ for (const [site, cf] of cdpPool) {
223
+ log(`shutdown: closing CDP session for ${site}`);
224
+ await cf.close().catch(() => {});
225
+ }
226
+ cdpPool.clear();
227
+ for (const timer of cdpIdleTimers.values()) clearTimeout(timer);
228
+ cdpIdleTimers.clear();
229
+ winnerCache.clear();
230
+ }
231
+
232
+ return { server, closeCdpPool };
169
233
  }
170
234
 
171
235
  function formatToolError(result: Extract<ToolResult, { ok: false }>): string {
@@ -263,7 +327,7 @@ async function runStdio(
263
327
  tools: ResolvedTool[],
264
328
  assetRoot: string,
265
329
  ): Promise<void> {
266
- const server = buildServer(name, version, tools, assetRoot);
330
+ const { server, closeCdpPool } = buildServer(name, version, tools, assetRoot);
267
331
  const transport = new StdioServerTransport();
268
332
  await server.connect(transport);
269
333
  log(`stdio transport ready (${tools.length} tool${tools.length === 1 ? '' : 's'})`);
@@ -277,6 +341,7 @@ async function runStdio(
277
341
  process.once('SIGINT', () => done('SIGINT'));
278
342
  process.once('SIGTERM', () => done('SIGTERM'));
279
343
  });
344
+ await closeCdpPool();
280
345
  }
281
346
 
282
347
  /**
@@ -296,7 +361,7 @@ async function runHttp(
296
361
  port: number,
297
362
  assetRoot: string,
298
363
  ): Promise<void> {
299
- const server = buildServer(name, version, tools, assetRoot);
364
+ const { server, closeCdpPool } = buildServer(name, version, tools, assetRoot);
300
365
  const transport = new StreamableHTTPServerTransport({
301
366
  sessionIdGenerator: () => crypto.randomUUID(),
302
367
  });
@@ -347,4 +412,5 @@ async function runHttp(
347
412
  process.once('SIGINT', () => shutdown('SIGINT'));
348
413
  process.once('SIGTERM', () => shutdown('SIGTERM'));
349
414
  });
415
+ await closeCdpPool();
350
416
  }
@@ -55,15 +55,20 @@ export class MultiProgress {
55
55
  }
56
56
 
57
57
  private redraw(): void {
58
+ const cols = process.stderr.columns || 80;
58
59
  let buf = '';
59
60
  if (this.renderedCount > 0) {
60
61
  buf += `\x1b[${this.renderedCount}F`;
61
62
  }
62
63
  buf += '\x1b[J';
64
+ let physicalLines = 0;
63
65
  for (const [, msg] of this.lines) {
64
- buf += `│ ${msg}\n`;
66
+ const line = `│ ${msg}`;
67
+ const truncated = line.length >= cols ? line.slice(0, cols - 1) : line;
68
+ buf += `${truncated}\n`;
69
+ physicalLines += 1;
65
70
  }
66
71
  process.stderr.write(buf);
67
- this.renderedCount = this.lines.size;
72
+ this.renderedCount = physicalLines;
68
73
  }
69
74
  }