imprint-mcp 0.2.1 → 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 +131 -27
- 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 +109 -2
- 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 +63 -25
- package/src/imprint/compile-tools.ts +1656 -64
- package/src/imprint/compile.ts +13 -1
- package/src/imprint/concurrency.ts +87 -0
- package/src/imprint/cron.ts +1 -0
- package/src/imprint/doctor.ts +39 -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/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 +10 -0
- package/src/imprint/teach.ts +456 -142
- 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
|
@@ -0,0 +1,1051 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Build-plan generation for multi-tool `imprint teach`.
|
|
3
|
+
*
|
|
4
|
+
* After candidate detection + user selection, this single-shot LLM pass
|
|
5
|
+
* produces a BuildPlan: the shared utility modules to create once under
|
|
6
|
+
* `~/.imprint/<site>/_shared/` (so per-tool compile agents import vetted code
|
|
7
|
+
* instead of independently re-deriving signing/parsing logic), plus per-tool
|
|
8
|
+
* guidance and an auth recipe each agent replicates inline. The prereq builder
|
|
9
|
+
* (prereq-builder.ts) writes + verifies the shared modules before the per-tool
|
|
10
|
+
* compile fan-out. See prompts/build-planning.md for the system prompt.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
14
|
+
import { join as pathJoin } from 'node:path';
|
|
15
|
+
import { z } from 'zod';
|
|
16
|
+
import { TimeoutError, withTimeout } from './concurrency.ts';
|
|
17
|
+
import { type LLMOptions, extractJsonObject, resolveProvider } from './llm.ts';
|
|
18
|
+
import { createLog } from './log.ts';
|
|
19
|
+
import { localSiteDir } from './paths.ts';
|
|
20
|
+
import { compactRequestContexts, requestContextDigest } from './request-context.ts';
|
|
21
|
+
import { type ClassifiedValue, looksLikeToken } from './session-diff.ts';
|
|
22
|
+
import type { SharedCompileContext, ToolCandidate } from './tool-candidates.ts';
|
|
23
|
+
import { setSpanAttributes, traced } from './tracing.ts';
|
|
24
|
+
import type { Session } from './types.ts';
|
|
25
|
+
|
|
26
|
+
const PROMPTS_DIR = pathJoin(import.meta.dir, '..', '..', 'prompts');
|
|
27
|
+
const BODY_LIMIT = 800;
|
|
28
|
+
const RESPONSE_PREVIEW_LIMIT = 500;
|
|
29
|
+
const HEADER_LIMIT = 600;
|
|
30
|
+
const log = createLog('build-plan');
|
|
31
|
+
|
|
32
|
+
// ─── Schema ─────────────────────────────────────────────────────────────────
|
|
33
|
+
|
|
34
|
+
const SharedModuleKindSchema = z.enum(['request-transform', 'parser-helper', 'types']);
|
|
35
|
+
export type SharedModuleKind = z.infer<typeof SharedModuleKindSchema>;
|
|
36
|
+
|
|
37
|
+
/** Shared modules live under `_shared/` and are imported by per-tool artifacts
|
|
38
|
+
* via the relative path `../_shared/<name>.ts` (the runtime resolves
|
|
39
|
+
* parserModule/requestTransformModule relative to each tool's workflow.json). */
|
|
40
|
+
const SHARED_MODULE_PATH_RE = /^_shared\/[A-Za-z0-9._-]+\.ts$/;
|
|
41
|
+
|
|
42
|
+
export const SharedModuleSpecSchema = z.object({
|
|
43
|
+
path: z
|
|
44
|
+
.string()
|
|
45
|
+
.regex(SHARED_MODULE_PATH_RE, 'shared module path must look like "_shared/<name>.ts"'),
|
|
46
|
+
kind: SharedModuleKindSchema,
|
|
47
|
+
purpose: z.string().min(1),
|
|
48
|
+
exportSignatures: z.array(z.string().min(1)).min(1),
|
|
49
|
+
spec: z.string().min(1),
|
|
50
|
+
sourceSeqs: z.array(z.number().int().nonnegative()).default([]),
|
|
51
|
+
dependsOn: z.array(z.string()).default([]),
|
|
52
|
+
});
|
|
53
|
+
export type SharedModuleSpec = z.infer<typeof SharedModuleSpecSchema>;
|
|
54
|
+
|
|
55
|
+
const AuthCaptureSchema = z.object({
|
|
56
|
+
name: z.string().min(1),
|
|
57
|
+
/** Capture source: json | response_header | cookie | text_regex. */
|
|
58
|
+
source: z.string().min(1),
|
|
59
|
+
/** Path / header name / cookie name / regex that locates the value. */
|
|
60
|
+
locator: z.string().min(1),
|
|
61
|
+
/** Where the captured value is injected downstream, e.g. "header:Authorization". */
|
|
62
|
+
usedAs: z.string().default(''),
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
const AuthRecipeSchema = z
|
|
66
|
+
.object({
|
|
67
|
+
required: z.boolean().default(false),
|
|
68
|
+
loginRequestSeqs: z.array(z.number().int().nonnegative()).default([]),
|
|
69
|
+
credentialNames: z.array(z.string()).default([]),
|
|
70
|
+
captures: z.array(AuthCaptureSchema).default([]),
|
|
71
|
+
notes: z.string().default(''),
|
|
72
|
+
})
|
|
73
|
+
.default({});
|
|
74
|
+
|
|
75
|
+
/** A field this tool's parser MUST emit so a sibling consumer tool can use it as
|
|
76
|
+
* an input param (producer side of an opaque-token chain). `shape` describes the
|
|
77
|
+
* exact form the consumer needs (e.g. a pipe-joined composite), so the producer
|
|
78
|
+
* emits the full value rather than a bare fragment. */
|
|
79
|
+
const EmittedTokenSchema = z.object({
|
|
80
|
+
field: z.string().min(1),
|
|
81
|
+
shape: z.string().default(''),
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
/** An input param whose value is an opaque token/id minted by a sibling tool
|
|
85
|
+
* (consumer side). The consumer takes `sourceTool`'s `sourceField` output as-is;
|
|
86
|
+
* the gate requires a chained verification test and the MCP description tells the
|
|
87
|
+
* orchestrating LLM to call `sourceTool` first and reuse the value. */
|
|
88
|
+
const TokenParamSchema = z.object({
|
|
89
|
+
param: z.string().min(1),
|
|
90
|
+
sourceTool: z.string().min(1),
|
|
91
|
+
sourceField: z.string().min(1),
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
const PerToolPlanSchema = z.object({
|
|
95
|
+
toolName: z.string().regex(/^[a-z][a-z0-9_]*$/),
|
|
96
|
+
usesSharedModules: z.array(z.string()).default([]),
|
|
97
|
+
loadBearingSeqs: z.array(z.number().int().nonnegative()).default([]),
|
|
98
|
+
parserGuidance: z.string().default(''),
|
|
99
|
+
paramChecklist: z.array(z.string()).default([]),
|
|
100
|
+
authRecipe: AuthRecipeSchema,
|
|
101
|
+
/** Opaque-token chain — producer side: fields this tool's parser must emit for
|
|
102
|
+
* sibling consumers. */
|
|
103
|
+
emitsTokens: z.array(EmittedTokenSchema).default([]),
|
|
104
|
+
/** Opaque-token chain — consumer side: params minted by a sibling producer. */
|
|
105
|
+
tokenParams: z.array(TokenParamSchema).default([]),
|
|
106
|
+
});
|
|
107
|
+
type PerToolPlan = z.infer<typeof PerToolPlanSchema>;
|
|
108
|
+
|
|
109
|
+
export const BuildPlanSchema = z
|
|
110
|
+
.object({
|
|
111
|
+
sharedModules: z.array(SharedModuleSpecSchema).default([]),
|
|
112
|
+
perTool: z.array(PerToolPlanSchema).min(1),
|
|
113
|
+
})
|
|
114
|
+
.superRefine((value, ctx) => {
|
|
115
|
+
const modulePaths = new Set<string>();
|
|
116
|
+
for (const [i, m] of value.sharedModules.entries()) {
|
|
117
|
+
if (modulePaths.has(m.path)) {
|
|
118
|
+
ctx.addIssue({
|
|
119
|
+
code: z.ZodIssueCode.custom,
|
|
120
|
+
path: ['sharedModules', i, 'path'],
|
|
121
|
+
message: `duplicate shared module path "${m.path}"`,
|
|
122
|
+
});
|
|
123
|
+
}
|
|
124
|
+
modulePaths.add(m.path);
|
|
125
|
+
}
|
|
126
|
+
for (const [i, m] of value.sharedModules.entries()) {
|
|
127
|
+
for (const [j, dep] of m.dependsOn.entries()) {
|
|
128
|
+
if (!modulePaths.has(dep)) {
|
|
129
|
+
ctx.addIssue({
|
|
130
|
+
code: z.ZodIssueCode.custom,
|
|
131
|
+
path: ['sharedModules', i, 'dependsOn', j],
|
|
132
|
+
message: `dependsOn references unknown module "${dep}"`,
|
|
133
|
+
});
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
if (moduleGraphHasCycle(value.sharedModules)) {
|
|
138
|
+
ctx.addIssue({
|
|
139
|
+
code: z.ZodIssueCode.custom,
|
|
140
|
+
path: ['sharedModules'],
|
|
141
|
+
message: 'sharedModules dependsOn graph has a cycle',
|
|
142
|
+
});
|
|
143
|
+
}
|
|
144
|
+
const toolNames = new Set<string>();
|
|
145
|
+
for (const [i, t] of value.perTool.entries()) {
|
|
146
|
+
if (toolNames.has(t.toolName)) {
|
|
147
|
+
ctx.addIssue({
|
|
148
|
+
code: z.ZodIssueCode.custom,
|
|
149
|
+
path: ['perTool', i, 'toolName'],
|
|
150
|
+
message: `duplicate toolName "${t.toolName}"`,
|
|
151
|
+
});
|
|
152
|
+
}
|
|
153
|
+
toolNames.add(t.toolName);
|
|
154
|
+
for (const [j, used] of t.usesSharedModules.entries()) {
|
|
155
|
+
if (!modulePaths.has(used)) {
|
|
156
|
+
ctx.addIssue({
|
|
157
|
+
code: z.ZodIssueCode.custom,
|
|
158
|
+
path: ['perTool', i, 'usesSharedModules', j],
|
|
159
|
+
message: `tool "${t.toolName}" references unknown shared module "${used}"`,
|
|
160
|
+
});
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
// Opaque-token chain validation: each consumer's tokenParam must point at a
|
|
165
|
+
// real sibling producer that declares the consumed field in `emitsTokens`.
|
|
166
|
+
const emittedByTool = new Map(
|
|
167
|
+
value.perTool.map((t) => [t.toolName, new Set(t.emitsTokens.map((e) => e.field))]),
|
|
168
|
+
);
|
|
169
|
+
for (const [i, t] of value.perTool.entries()) {
|
|
170
|
+
for (const [j, tp] of t.tokenParams.entries()) {
|
|
171
|
+
if (tp.sourceTool === t.toolName) {
|
|
172
|
+
ctx.addIssue({
|
|
173
|
+
code: z.ZodIssueCode.custom,
|
|
174
|
+
path: ['perTool', i, 'tokenParams', j, 'sourceTool'],
|
|
175
|
+
message: `tokenParam "${tp.param}" cannot source from its own tool "${t.toolName}"`,
|
|
176
|
+
});
|
|
177
|
+
} else if (!toolNames.has(tp.sourceTool)) {
|
|
178
|
+
ctx.addIssue({
|
|
179
|
+
code: z.ZodIssueCode.custom,
|
|
180
|
+
path: ['perTool', i, 'tokenParams', j, 'sourceTool'],
|
|
181
|
+
message: `tokenParam "${tp.param}" references unknown producer tool "${tp.sourceTool}"`,
|
|
182
|
+
});
|
|
183
|
+
} else if (!emittedByTool.get(tp.sourceTool)?.has(tp.sourceField)) {
|
|
184
|
+
ctx.addIssue({
|
|
185
|
+
code: z.ZodIssueCode.custom,
|
|
186
|
+
path: ['perTool', i, 'tokenParams', j, 'sourceField'],
|
|
187
|
+
message: `producer "${tp.sourceTool}" does not declare emitted field "${tp.sourceField}" (add it to that tool's emitsTokens)`,
|
|
188
|
+
});
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
});
|
|
193
|
+
export type BuildPlan = z.infer<typeof BuildPlanSchema>;
|
|
194
|
+
|
|
195
|
+
/** Manifest entry persisted on WorkflowState after the prereq builder runs.
|
|
196
|
+
* `verified` is false when the builder could not produce a passing module
|
|
197
|
+
* (the orchestrator then prunes it from each tool's usesSharedModules). */
|
|
198
|
+
export const SharedModuleManifestEntrySchema = z.object({
|
|
199
|
+
path: z.string(),
|
|
200
|
+
kind: SharedModuleKindSchema,
|
|
201
|
+
verified: z.boolean(),
|
|
202
|
+
});
|
|
203
|
+
export type SharedModuleManifestEntry = z.infer<typeof SharedModuleManifestEntrySchema>;
|
|
204
|
+
export const SharedModuleManifestSchema = z.array(SharedModuleManifestEntrySchema);
|
|
205
|
+
|
|
206
|
+
// ─── Graph helpers ──────────────────────────────────────────────────────────
|
|
207
|
+
|
|
208
|
+
function moduleGraphHasCycle(modules: SharedModuleSpec[]): boolean {
|
|
209
|
+
const byPath = new Map(modules.map((m) => [m.path, m]));
|
|
210
|
+
const state = new Map<string, 1 | 2>();
|
|
211
|
+
const visit = (path: string): boolean => {
|
|
212
|
+
const st = state.get(path);
|
|
213
|
+
if (st === 1) return true;
|
|
214
|
+
if (st === 2) return false;
|
|
215
|
+
state.set(path, 1);
|
|
216
|
+
for (const dep of byPath.get(path)?.dependsOn ?? []) {
|
|
217
|
+
if (byPath.has(dep) && visit(dep)) return true;
|
|
218
|
+
}
|
|
219
|
+
state.set(path, 2);
|
|
220
|
+
return false;
|
|
221
|
+
};
|
|
222
|
+
for (const m of modules) {
|
|
223
|
+
if (visit(m.path)) return true;
|
|
224
|
+
}
|
|
225
|
+
return false;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/** Kahn layering shared by topoLevels (shared modules) and topoLevelsForTools.
|
|
229
|
+
* Groups items into dependency "levels": level 0 has no in-set dependency, each
|
|
230
|
+
* later level's deps are satisfied by earlier levels. Items within a level are
|
|
231
|
+
* mutually independent (safe to build/compile concurrently); no item precedes
|
|
232
|
+
* one it depends on. Cycle-safe — any residual cycle members are appended as a
|
|
233
|
+
* final level so nothing is silently dropped. Edges to ids outside `items`, and
|
|
234
|
+
* self-edges, are ignored. */
|
|
235
|
+
function kahnLevels<T>(
|
|
236
|
+
items: T[],
|
|
237
|
+
idOf: (item: T) => string,
|
|
238
|
+
depsOf: (item: T) => Iterable<string>,
|
|
239
|
+
): T[][] {
|
|
240
|
+
const ids = new Set(items.map(idOf));
|
|
241
|
+
const byId = new Map(items.map((item) => [idOf(item), item]));
|
|
242
|
+
const indegree = new Map<string, number>();
|
|
243
|
+
const dependents = new Map<string, string[]>();
|
|
244
|
+
for (const item of items) {
|
|
245
|
+
const id = idOf(item);
|
|
246
|
+
const deps = [...depsOf(item)].filter((d) => d !== id && ids.has(d));
|
|
247
|
+
indegree.set(id, deps.length);
|
|
248
|
+
for (const dep of deps) {
|
|
249
|
+
const list = dependents.get(dep);
|
|
250
|
+
if (list) list.push(id);
|
|
251
|
+
else dependents.set(dep, [id]);
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
const levels: T[][] = [];
|
|
256
|
+
const placed = new Set<string>();
|
|
257
|
+
let frontier = items.filter((item) => (indegree.get(idOf(item)) ?? 0) === 0);
|
|
258
|
+
while (frontier.length > 0) {
|
|
259
|
+
levels.push(frontier);
|
|
260
|
+
for (const item of frontier) placed.add(idOf(item));
|
|
261
|
+
const next: T[] = [];
|
|
262
|
+
for (const item of frontier) {
|
|
263
|
+
for (const depId of dependents.get(idOf(item)) ?? []) {
|
|
264
|
+
const remaining = (indegree.get(depId) ?? 0) - 1;
|
|
265
|
+
indegree.set(depId, remaining);
|
|
266
|
+
if (remaining === 0) {
|
|
267
|
+
const dependent = byId.get(depId);
|
|
268
|
+
if (dependent) next.push(dependent);
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
frontier = next;
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
// Defensive: an unexpected cycle would leave members unplaced — append them so
|
|
276
|
+
// nothing is dropped (cycles are already rejected at parse time).
|
|
277
|
+
const leftover = items.filter((item) => !placed.has(idOf(item)));
|
|
278
|
+
if (leftover.length > 0) levels.push(leftover);
|
|
279
|
+
return levels;
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
/** Return the shared modules ordered so every module comes after its
|
|
283
|
+
* dependsOn targets. Throws on cycle (already rejected at parse time, but
|
|
284
|
+
* callers that build a plan by hand get a clear error). */
|
|
285
|
+
export function topoSortSharedModules(modules: SharedModuleSpec[]): SharedModuleSpec[] {
|
|
286
|
+
const byPath = new Map(modules.map((m) => [m.path, m]));
|
|
287
|
+
const state = new Map<string, 1 | 2>();
|
|
288
|
+
const result: SharedModuleSpec[] = [];
|
|
289
|
+
const visit = (path: string): void => {
|
|
290
|
+
const st = state.get(path);
|
|
291
|
+
if (st === 2) return;
|
|
292
|
+
if (st === 1) throw new Error(`shared module dependency cycle at "${path}"`);
|
|
293
|
+
state.set(path, 1);
|
|
294
|
+
const mod = byPath.get(path);
|
|
295
|
+
if (mod) {
|
|
296
|
+
for (const dep of mod.dependsOn) {
|
|
297
|
+
if (byPath.has(dep)) visit(dep);
|
|
298
|
+
}
|
|
299
|
+
result.push(mod);
|
|
300
|
+
}
|
|
301
|
+
state.set(path, 2);
|
|
302
|
+
};
|
|
303
|
+
for (const m of modules) visit(m.path);
|
|
304
|
+
return result;
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
/** Group the shared modules into dependency "levels" via Kahn layering: level 0
|
|
308
|
+
* is every module with no in-set dependency, level 1 is modules whose deps are
|
|
309
|
+
* all satisfied by level 0, and so on. Modules within a level are mutually
|
|
310
|
+
* independent and may be built concurrently; no module appears before one it
|
|
311
|
+
* dependsOn. Cycle-safe — cycles are rejected at parse time, but any residual
|
|
312
|
+
* cycle members are appended as a final level so no module is silently dropped.
|
|
313
|
+
* Flattening the result yields a valid topological order (cf. topoSortSharedModules). */
|
|
314
|
+
export function topoLevels(modules: SharedModuleSpec[]): SharedModuleSpec[][] {
|
|
315
|
+
return kahnLevels(
|
|
316
|
+
modules,
|
|
317
|
+
(m) => m.path,
|
|
318
|
+
(m) => m.dependsOn,
|
|
319
|
+
);
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
interface BuildPlanSlice {
|
|
323
|
+
tool: PerToolPlan;
|
|
324
|
+
/** The shared modules this tool is assigned, resolved from usesSharedModules. */
|
|
325
|
+
sharedModules: SharedModuleSpec[];
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
/** Project the plan down to a single tool's slice — what the per-tool compile
|
|
329
|
+
* agent reads via the read_build_plan tool. */
|
|
330
|
+
export function planSliceForTool(plan: BuildPlan, toolName: string): BuildPlanSlice | undefined {
|
|
331
|
+
const tool = plan.perTool.find((t) => t.toolName === toolName);
|
|
332
|
+
if (!tool) return undefined;
|
|
333
|
+
const byPath = new Map(plan.sharedModules.map((m) => [m.path, m]));
|
|
334
|
+
const sharedModules = tool.usesSharedModules
|
|
335
|
+
.map((p) => byPath.get(p))
|
|
336
|
+
.filter((m): m is SharedModuleSpec => m != null);
|
|
337
|
+
return { tool, sharedModules };
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
/** A shared module a tool must import, with the relative import path the tool
|
|
341
|
+
* uses (`../_shared/<name>.ts`) and whether the prereq builder verified it. */
|
|
342
|
+
export interface AssignedSharedModule {
|
|
343
|
+
path: string;
|
|
344
|
+
kind: SharedModuleKind;
|
|
345
|
+
verified: boolean;
|
|
346
|
+
importPath: string;
|
|
347
|
+
exportSignatures: string[];
|
|
348
|
+
purpose: string;
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
/** Relative path a tool under `~/.imprint/<site>/<toolName>/` uses to import a
|
|
352
|
+
* shared module at `~/.imprint/<site>/_shared/<name>.ts`. */
|
|
353
|
+
export function sharedModuleImportPath(modulePath: string): string {
|
|
354
|
+
return `../_shared/${modulePath.replace(/^_shared\//, '')}`;
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
/** Resolve the shared modules assigned to `toolName`, annotating each with its
|
|
358
|
+
* verified status from the build manifest. When `manifest` is omitted every
|
|
359
|
+
* module is treated as verified (best-effort). */
|
|
360
|
+
export function resolveAssignedModules(
|
|
361
|
+
plan: BuildPlan,
|
|
362
|
+
toolName: string,
|
|
363
|
+
manifest?: SharedModuleManifestEntry[],
|
|
364
|
+
): AssignedSharedModule[] {
|
|
365
|
+
const slice = planSliceForTool(plan, toolName);
|
|
366
|
+
if (!slice) return [];
|
|
367
|
+
const verifiedByPath = new Map((manifest ?? []).map((m) => [m.path, m.verified]));
|
|
368
|
+
return slice.sharedModules.map((m) => ({
|
|
369
|
+
path: m.path,
|
|
370
|
+
kind: m.kind,
|
|
371
|
+
verified: manifest ? (verifiedByPath.get(m.path) ?? false) : true,
|
|
372
|
+
importPath: sharedModuleImportPath(m.path),
|
|
373
|
+
exportSignatures: m.exportSignatures,
|
|
374
|
+
purpose: m.purpose,
|
|
375
|
+
}));
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
/** Human-readable block injected into each per-tool compile agent's initial
|
|
379
|
+
* prompt, listing the verified shared modules it must import. Shared by all
|
|
380
|
+
* three compile drivers. Returns '' when nothing is assigned. */
|
|
381
|
+
export function describeAssignedModules(assigned: AssignedSharedModule[]): string {
|
|
382
|
+
const verified = assigned.filter((m) => m.verified);
|
|
383
|
+
if (verified.length === 0) return '';
|
|
384
|
+
const lines = verified.map(
|
|
385
|
+
(m) =>
|
|
386
|
+
`- ${m.importPath} (${m.kind}): ${m.purpose}\n exports: ${m.exportSignatures.join('; ')}`,
|
|
387
|
+
);
|
|
388
|
+
return `
|
|
389
|
+
|
|
390
|
+
Assigned shared modules — import these instead of re-implementing their logic (call read_build_plan for the full slice):
|
|
391
|
+
${lines.join('\n')}
|
|
392
|
+
|
|
393
|
+
For a request-transform module, set "requestTransformModule": "<importPath>" in workflow.json. For a parser-helper/types module, import it in parser.ts. The verifier fails this tool if an assigned module is not imported.`;
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
/** The producer→consumer token contracts the build plan declared for a tool
|
|
397
|
+
* (consumer side). Threaded into `externalVerification` so the gate requires a
|
|
398
|
+
* chained test and stamps `sourcedFrom`. Empty when the plan declared none. */
|
|
399
|
+
export function resolveTokenParams(
|
|
400
|
+
plan: BuildPlan,
|
|
401
|
+
toolName: string,
|
|
402
|
+
): Array<{ param: string; sourceTool: string; sourceField: string }> {
|
|
403
|
+
return plan.perTool.find((t) => t.toolName === toolName)?.tokenParams ?? [];
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
/** The fields a tool's parser MUST emit for sibling consumers (producer side).
|
|
407
|
+
* Threaded into `externalVerification` so the gate fails a producer that does
|
|
408
|
+
* not emit a declared field. Empty when the plan declared none. Internal —
|
|
409
|
+
* reached through `resolvePlanSliceFromFile`. */
|
|
410
|
+
function resolveEmittedTokens(
|
|
411
|
+
plan: BuildPlan,
|
|
412
|
+
toolName: string,
|
|
413
|
+
): Array<{ field: string; shape: string }> {
|
|
414
|
+
return plan.perTool.find((t) => t.toolName === toolName)?.emitsTokens ?? [];
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
/** Read a build-plan sidecar and project it to one tool's slice in the shape
|
|
418
|
+
* every compile driver needs: the shared modules it must import (with verified
|
|
419
|
+
* flags from `manifest`) plus the producer/consumer token-contract arrays.
|
|
420
|
+
* Returns empty values when no plan path / tool name is supplied or the sidecar
|
|
421
|
+
* is missing/invalid — so a driver with no build plan behaves exactly as before.
|
|
422
|
+
* Shared by the in-process loop, the MCP compile server, and both CLI drivers. */
|
|
423
|
+
export function resolvePlanSliceFromFile(
|
|
424
|
+
buildPlanPath: string | undefined,
|
|
425
|
+
toolName: string | undefined,
|
|
426
|
+
manifest?: SharedModuleManifestEntry[],
|
|
427
|
+
): {
|
|
428
|
+
assignedSharedModules: AssignedSharedModule[] | undefined;
|
|
429
|
+
tokenParams: Array<{ param: string; sourceTool: string; sourceField: string }>;
|
|
430
|
+
emittedTokens: Array<{ field: string; shape: string }>;
|
|
431
|
+
} {
|
|
432
|
+
const plan = buildPlanPath && toolName ? readBuildPlanFile(buildPlanPath) : null;
|
|
433
|
+
if (!plan || !toolName) {
|
|
434
|
+
return { assignedSharedModules: undefined, tokenParams: [], emittedTokens: [] };
|
|
435
|
+
}
|
|
436
|
+
return {
|
|
437
|
+
assignedSharedModules: resolveAssignedModules(plan, toolName, manifest),
|
|
438
|
+
tokenParams: resolveTokenParams(plan, toolName),
|
|
439
|
+
emittedTokens: resolveEmittedTokens(plan, toolName),
|
|
440
|
+
};
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
/** Order tools producer-before-consumer for the compile fan-out: edge
|
|
444
|
+
* consumer → its tokenParams' sourceTool. Returns Kahn levels (tools within a
|
|
445
|
+
* level are independent and may compile concurrently). Cycle-safe — any residual
|
|
446
|
+
* cycle members are appended as a final level (matches `topoLevels`). A consumer
|
|
447
|
+
* whose producer compiles first can run its chained verification test live. */
|
|
448
|
+
export function topoLevelsForTools<T extends { toolName: string }>(
|
|
449
|
+
tools: T[],
|
|
450
|
+
plan: BuildPlan | null,
|
|
451
|
+
): T[][] {
|
|
452
|
+
return kahnLevels(
|
|
453
|
+
tools,
|
|
454
|
+
(t) => t.toolName,
|
|
455
|
+
(t) => (plan ? resolveTokenParams(plan, t.toolName).map((tp) => tp.sourceTool) : []),
|
|
456
|
+
);
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
/** Load a build plan from an explicit file path (the sidecar threaded into the
|
|
460
|
+
* compile drivers). Returns null on missing/invalid file. */
|
|
461
|
+
export function readBuildPlanFile(path: string): BuildPlan | null {
|
|
462
|
+
if (!existsSync(path)) return null;
|
|
463
|
+
try {
|
|
464
|
+
return BuildPlanSchema.parse(JSON.parse(readFileSync(path, 'utf8')));
|
|
465
|
+
} catch {
|
|
466
|
+
return null;
|
|
467
|
+
}
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
// ─── Validation ─────────────────────────────────────────────────────────────
|
|
471
|
+
|
|
472
|
+
/** Parse + normalize an LLM/disk plan. When `selected` is provided, drops
|
|
473
|
+
* perTool entries for tools that weren't selected and backfills a minimal
|
|
474
|
+
* entry for any selected tool the planner omitted, so the fan-out always has
|
|
475
|
+
* a slice for every tool it will compile. */
|
|
476
|
+
export function validateBuildPlan(
|
|
477
|
+
input: unknown,
|
|
478
|
+
selected?: Array<ToolCandidate | string>,
|
|
479
|
+
): BuildPlan {
|
|
480
|
+
const plan = BuildPlanSchema.parse(input);
|
|
481
|
+
if (selected && selected.length > 0) {
|
|
482
|
+
const names = new Set(selected.map((t) => (typeof t === 'string' ? t : t.toolName)));
|
|
483
|
+
plan.perTool = plan.perTool.filter((t) => names.has(t.toolName));
|
|
484
|
+
for (const name of names) {
|
|
485
|
+
if (!plan.perTool.some((t) => t.toolName === name)) {
|
|
486
|
+
plan.perTool.push(
|
|
487
|
+
PerToolPlanSchema.parse({ toolName: name, authRecipe: {} }) as PerToolPlan,
|
|
488
|
+
);
|
|
489
|
+
}
|
|
490
|
+
}
|
|
491
|
+
if (plan.perTool.length === 0) {
|
|
492
|
+
throw new Error('Build plan has no perTool entries for the selected tools.');
|
|
493
|
+
}
|
|
494
|
+
}
|
|
495
|
+
return plan;
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
// ─── Deterministic cross-tool token detection ────────────────────────────────
|
|
499
|
+
|
|
500
|
+
/** A grounded producer→consumer opaque-token edge derived DETERMINISTICALLY from
|
|
501
|
+
* the dual-pass classifications — not LLM inference. The consumer sends, at
|
|
502
|
+
* `consumerLocation`, a value the diff classified `server_derived` from
|
|
503
|
+
* `producerPath` in a response owned by a DIFFERENT selected tool. Fed to the
|
|
504
|
+
* planner as a grounded hint and used to reconcile the returned plan so a
|
|
505
|
+
* planner shortcut cannot silently drop (or half-declare) the contract. */
|
|
506
|
+
export interface TokenContractHint {
|
|
507
|
+
consumerTool: string;
|
|
508
|
+
/** Param name derived from `consumerLocation` (reconciled to a `likelyParams`
|
|
509
|
+
* name when one matches case-insensitively). */
|
|
510
|
+
consumerParam: string;
|
|
511
|
+
consumerLocation: string;
|
|
512
|
+
producerTool: string;
|
|
513
|
+
/** Output field name derived from `producerPath`. */
|
|
514
|
+
producerField: string;
|
|
515
|
+
producerPath: string;
|
|
516
|
+
/** Whether the consumer slot maps to a clean, nameable param (a real query/body
|
|
517
|
+
* key or a known `likelyParams` name) rather than an opaque JSPB index path.
|
|
518
|
+
* Only nameable edges are auto-injected when the planner misses them; unnamed
|
|
519
|
+
* ones are left to the compile-time chained-test gate to enforce. */
|
|
520
|
+
nameable: boolean;
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
/** Sanitize a raw path/param segment into a safe identifier. General — no
|
|
524
|
+
* site-specific shapes. */
|
|
525
|
+
function toIdentifier(raw: string): string {
|
|
526
|
+
const cleaned = raw.replace(/[^A-Za-z0-9_]/g, '_').replace(/^_+|_+$/g, '');
|
|
527
|
+
return cleaned || 'token';
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
/** Derive a consumer param name from a classification `location`
|
|
531
|
+
* ("url_param:hotel_id" → "hotel_id", "body:$.hotel.id" → "id"). Returns '' for
|
|
532
|
+
* `header:` locations — header tokens are session/anti-bot state handled by the
|
|
533
|
+
* bootstrap ladder, NOT cross-tool entity params (out of scope here). */
|
|
534
|
+
function paramNameFromLocation(location: string): string {
|
|
535
|
+
const idx = location.indexOf(':');
|
|
536
|
+
const kind = (idx >= 0 ? location.slice(0, idx) : '').toLowerCase();
|
|
537
|
+
if (kind === 'header') return '';
|
|
538
|
+
const rest = (idx >= 0 ? location.slice(idx + 1) : location).replace(/^\$\.?/, '');
|
|
539
|
+
const seg =
|
|
540
|
+
rest
|
|
541
|
+
.split(/[.[\]]/)
|
|
542
|
+
.filter(Boolean)
|
|
543
|
+
.pop() ?? rest;
|
|
544
|
+
return toIdentifier(seg);
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
/** Derive a producer output field name from a `producerPath`
|
|
548
|
+
* ("$.results[0].detailToken" → "detailToken"); skips pure-numeric indices. */
|
|
549
|
+
function fieldNameFromPath(path: string): string {
|
|
550
|
+
const seg = path
|
|
551
|
+
.replace(/^\$\.?/, '')
|
|
552
|
+
.split(/[.[\]]/)
|
|
553
|
+
.filter((s) => s && !/^\d+$/.test(s))
|
|
554
|
+
.pop();
|
|
555
|
+
return toIdentifier(seg ?? 'token');
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
/**
|
|
559
|
+
* Deterministically detect cross-tool opaque-token edges from the dual-pass
|
|
560
|
+
* classifications. A `server_derived` value SENT by one tool (the sole owner of
|
|
561
|
+
* `originalSeq`) but PRODUCED in a response owned by a DIFFERENT tool (the sole
|
|
562
|
+
* owner of `producerSeq`) is a producer→consumer token. Pure; grounds the
|
|
563
|
+
* contract in the recording instead of leaving detection to the planner LLM
|
|
564
|
+
* (whose shortcut on exactly this is the defect this feature fixes). Conservative
|
|
565
|
+
* by design: a seq owned by >1 tool (a shared request) is ambiguous and skipped;
|
|
566
|
+
* `header:` locations (session/anti-bot tokens) are out of scope and skipped.
|
|
567
|
+
*/
|
|
568
|
+
export function deriveTokenContractHints(payload: {
|
|
569
|
+
selectedTools: Array<{
|
|
570
|
+
toolName: string;
|
|
571
|
+
requestSeqs: number[];
|
|
572
|
+
likelyParams?: Array<{ name: string }>;
|
|
573
|
+
}>;
|
|
574
|
+
ephemeralValues: Array<{
|
|
575
|
+
classification: string;
|
|
576
|
+
originalSeq: number;
|
|
577
|
+
location: string;
|
|
578
|
+
producerSeq?: number;
|
|
579
|
+
producerPath?: string;
|
|
580
|
+
value?: string;
|
|
581
|
+
}>;
|
|
582
|
+
}): TokenContractHint[] {
|
|
583
|
+
const ownersBySeq = new Map<number, Set<string>>();
|
|
584
|
+
for (const t of payload.selectedTools) {
|
|
585
|
+
for (const s of t.requestSeqs) {
|
|
586
|
+
const set = ownersBySeq.get(s);
|
|
587
|
+
if (set) set.add(t.toolName);
|
|
588
|
+
else ownersBySeq.set(s, new Set([t.toolName]));
|
|
589
|
+
}
|
|
590
|
+
}
|
|
591
|
+
const soleOwner = (seq: number): string | undefined => {
|
|
592
|
+
const set = ownersBySeq.get(seq);
|
|
593
|
+
return set && set.size === 1 ? [...set][0] : undefined;
|
|
594
|
+
};
|
|
595
|
+
const paramsByTool = new Map(
|
|
596
|
+
payload.selectedTools.map((t) => [t.toolName, (t.likelyParams ?? []).map((p) => p.name)]),
|
|
597
|
+
);
|
|
598
|
+
const seen = new Set<string>();
|
|
599
|
+
const out: TokenContractHint[] = [];
|
|
600
|
+
for (const ev of payload.ephemeralValues) {
|
|
601
|
+
// Any value with recovered producer provenance — `server_derived` (varied
|
|
602
|
+
// across runs) OR a stable `constant` whose opaque value was found in a
|
|
603
|
+
// sibling response — is a cross-tool token candidate.
|
|
604
|
+
if (ev.producerSeq == null || !ev.producerPath) continue;
|
|
605
|
+
if (!ev.value || !looksLikeToken(ev.value)) continue; // skip echoed query text, etc.
|
|
606
|
+
const consumerTool = soleOwner(ev.originalSeq);
|
|
607
|
+
const producerTool = soleOwner(ev.producerSeq);
|
|
608
|
+
if (!consumerTool || !producerTool || consumerTool === producerTool) continue;
|
|
609
|
+
let param = paramNameFromLocation(ev.location);
|
|
610
|
+
if (!param) continue;
|
|
611
|
+
// Reconcile to an actual exposed param name when one matches case-insensitively.
|
|
612
|
+
const known = paramsByTool.get(consumerTool) ?? [];
|
|
613
|
+
if (!known.includes(param)) {
|
|
614
|
+
const ci = known.find((n) => n.toLowerCase() === param.toLowerCase());
|
|
615
|
+
if (ci) param = ci;
|
|
616
|
+
}
|
|
617
|
+
// Nameable = a real param the consumer exposes, or a clean identifier-shaped
|
|
618
|
+
// key — NOT an opaque JSPB index path (e.g. "body[0][10]" -> "0").
|
|
619
|
+
const nameable = known.includes(param) || /^[A-Za-z][A-Za-z0-9_]*$/.test(param);
|
|
620
|
+
const producerField = fieldNameFromPath(ev.producerPath);
|
|
621
|
+
const key = `${consumerTool}${param}${producerTool}${producerField}`;
|
|
622
|
+
if (seen.has(key)) continue;
|
|
623
|
+
seen.add(key);
|
|
624
|
+
out.push({
|
|
625
|
+
consumerTool,
|
|
626
|
+
consumerParam: param,
|
|
627
|
+
consumerLocation: ev.location,
|
|
628
|
+
producerTool,
|
|
629
|
+
producerField,
|
|
630
|
+
producerPath: ev.producerPath,
|
|
631
|
+
nameable,
|
|
632
|
+
});
|
|
633
|
+
}
|
|
634
|
+
return out;
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
/** Loose perTool shape for reconciling a freshly-parsed planner plan in place,
|
|
638
|
+
* before zod applies defaults + the cross-tool superRefine. */
|
|
639
|
+
interface LoosePerToolPlan {
|
|
640
|
+
toolName: string;
|
|
641
|
+
authRecipe?: unknown;
|
|
642
|
+
emitsTokens?: Array<{ field: string; shape?: string }>;
|
|
643
|
+
tokenParams?: Array<{ param: string; sourceTool: string; sourceField: string }>;
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
/**
|
|
647
|
+
* Reconcile a parsed planner plan against the deterministically-detected token
|
|
648
|
+
* edges, IN PLACE, before validation. Edge-centric (one decision per
|
|
649
|
+
* consumer→producer pair) so the planner's own naming/shape always wins:
|
|
650
|
+
* - if the consumer already declares ANY `tokenParam` sourced from that producer,
|
|
651
|
+
* the edge is covered — only ensure the producer emits each referenced
|
|
652
|
+
* `sourceField` (repairs a half-declared contract `superRefine` would reject);
|
|
653
|
+
* - else if the consumer already binds that param name to a different producer,
|
|
654
|
+
* trust the planner and warn;
|
|
655
|
+
* - else if the edge is confidently nameable, inject the full contract (consumer
|
|
656
|
+
* `tokenParam` + producer `emitsTokens`) so a planner shortcut can't drop it;
|
|
657
|
+
* - else (an opaque JSPB slot we can't safely name) warn and leave enforcement to
|
|
658
|
+
* the compile-time chained-test gate.
|
|
659
|
+
* Returns counts + warnings for logging. No-op when `hints` is empty (so
|
|
660
|
+
* single-tool / non-chained sites behave exactly as before).
|
|
661
|
+
*/
|
|
662
|
+
export function reconcileTokenContracts(
|
|
663
|
+
parsed: unknown,
|
|
664
|
+
hints: TokenContractHint[],
|
|
665
|
+
selectedToolNames: Set<string>,
|
|
666
|
+
): { injected: number; repaired: number; warnings: string[] } {
|
|
667
|
+
const result = { injected: 0, repaired: 0, warnings: [] as string[] };
|
|
668
|
+
if (hints.length === 0 || typeof parsed !== 'object' || parsed === null) return result;
|
|
669
|
+
const obj = parsed as { perTool?: LoosePerToolPlan[] };
|
|
670
|
+
if (!Array.isArray(obj.perTool)) obj.perTool = [];
|
|
671
|
+
const byName = new Map<string, LoosePerToolPlan>();
|
|
672
|
+
for (const t of obj.perTool) {
|
|
673
|
+
if (t && typeof t.toolName === 'string') byName.set(t.toolName, t);
|
|
674
|
+
}
|
|
675
|
+
const ensure = (name: string): LoosePerToolPlan => {
|
|
676
|
+
let e = byName.get(name);
|
|
677
|
+
if (!e) {
|
|
678
|
+
e = { toolName: name, authRecipe: {} };
|
|
679
|
+
obj.perTool?.push(e);
|
|
680
|
+
byName.set(name, e);
|
|
681
|
+
}
|
|
682
|
+
return e;
|
|
683
|
+
};
|
|
684
|
+
const shapeNote = (h: TokenContractHint) =>
|
|
685
|
+
`value consumed by ${h.consumerTool}.${h.consumerParam} (recorded at ${h.producerPath})`;
|
|
686
|
+
// One decision per consumer→producer edge; prefer a nameable hint for naming.
|
|
687
|
+
const edges = new Map<string, TokenContractHint>();
|
|
688
|
+
for (const h of hints) {
|
|
689
|
+
if (!selectedToolNames.has(h.consumerTool) || !selectedToolNames.has(h.producerTool)) continue;
|
|
690
|
+
const key = `${h.consumerTool}|${h.producerTool}`;
|
|
691
|
+
const prev = edges.get(key);
|
|
692
|
+
if (!prev || (h.nameable && !prev.nameable)) edges.set(key, h);
|
|
693
|
+
}
|
|
694
|
+
for (const h of edges.values()) {
|
|
695
|
+
const consumer = ensure(h.consumerTool);
|
|
696
|
+
const producer = ensure(h.producerTool);
|
|
697
|
+
if (!Array.isArray(consumer.tokenParams)) consumer.tokenParams = [];
|
|
698
|
+
if (!Array.isArray(producer.emitsTokens)) producer.emitsTokens = [];
|
|
699
|
+
const fromProducer = consumer.tokenParams.filter(
|
|
700
|
+
(tp) => tp && tp.sourceTool === h.producerTool,
|
|
701
|
+
);
|
|
702
|
+
if (fromProducer.length > 0) {
|
|
703
|
+
// Edge covered by the planner — repair any field the producer forgot to emit
|
|
704
|
+
// (a half-declared contract `superRefine` would otherwise reject).
|
|
705
|
+
for (const tp of fromProducer) {
|
|
706
|
+
if (tp.sourceField && !producer.emitsTokens.some((e) => e && e.field === tp.sourceField)) {
|
|
707
|
+
producer.emitsTokens.push({ field: tp.sourceField, shape: shapeNote(h) });
|
|
708
|
+
result.repaired++;
|
|
709
|
+
}
|
|
710
|
+
}
|
|
711
|
+
continue;
|
|
712
|
+
}
|
|
713
|
+
if (consumer.tokenParams.some((tp) => tp && tp.param === h.consumerParam)) {
|
|
714
|
+
// The planner already binds this param to a different producer — trust it.
|
|
715
|
+
result.warnings.push(
|
|
716
|
+
`${h.consumerTool}.${h.consumerParam} also looks sourced from ${h.producerTool}; keeping the planner's binding`,
|
|
717
|
+
);
|
|
718
|
+
continue;
|
|
719
|
+
}
|
|
720
|
+
if (!h.nameable) {
|
|
721
|
+
// An opaque JSPB slot we can't safely name — let the compile-time
|
|
722
|
+
// chained-test gate enforce it (block) instead of injecting a junk contract.
|
|
723
|
+
result.warnings.push(
|
|
724
|
+
`detected ${h.consumerTool} <- ${h.producerTool} (recorded at ${h.producerPath}) but the consumer slot is an opaque path; leaving enforcement to the compile-time chained-test gate`,
|
|
725
|
+
);
|
|
726
|
+
continue;
|
|
727
|
+
}
|
|
728
|
+
// Inject a best-effort contract for a confidently-nameable missed edge. Name
|
|
729
|
+
// the producer field after the clean consumer param (same logical value).
|
|
730
|
+
const field = h.consumerParam;
|
|
731
|
+
if (!producer.emitsTokens.some((e) => e && e.field === field)) {
|
|
732
|
+
producer.emitsTokens.push({ field, shape: shapeNote(h) });
|
|
733
|
+
}
|
|
734
|
+
consumer.tokenParams.push({
|
|
735
|
+
param: h.consumerParam,
|
|
736
|
+
sourceTool: h.producerTool,
|
|
737
|
+
sourceField: field,
|
|
738
|
+
});
|
|
739
|
+
result.injected++;
|
|
740
|
+
}
|
|
741
|
+
return result;
|
|
742
|
+
}
|
|
743
|
+
|
|
744
|
+
// ─── Planner payload ────────────────────────────────────────────────────────
|
|
745
|
+
|
|
746
|
+
interface BuildPlanRequestPayload {
|
|
747
|
+
seq: number;
|
|
748
|
+
timestamp: number;
|
|
749
|
+
method: string;
|
|
750
|
+
url: string;
|
|
751
|
+
resourceType: string;
|
|
752
|
+
status?: number;
|
|
753
|
+
mimeType?: string;
|
|
754
|
+
headers: string;
|
|
755
|
+
body?: string;
|
|
756
|
+
bodyDigest?: string;
|
|
757
|
+
bodyLength?: number;
|
|
758
|
+
responsePreview?: string;
|
|
759
|
+
responseBodyDigest?: string;
|
|
760
|
+
responseBodyLength?: number;
|
|
761
|
+
repeatCount?: number;
|
|
762
|
+
repeatedSeqs?: number[];
|
|
763
|
+
lastTimestamp?: number;
|
|
764
|
+
}
|
|
765
|
+
|
|
766
|
+
interface BuildPlanPayload {
|
|
767
|
+
site: string;
|
|
768
|
+
url: string;
|
|
769
|
+
narration: Array<{ timestamp: number; text: string }>;
|
|
770
|
+
sharedContext?: SharedCompileContext;
|
|
771
|
+
selectedTools: Array<{
|
|
772
|
+
toolName: string;
|
|
773
|
+
description: string;
|
|
774
|
+
expectedOutput: string;
|
|
775
|
+
requestSeqs: number[];
|
|
776
|
+
dependencySeqs: number[];
|
|
777
|
+
likelyParams: ToolCandidate['likelyParams'];
|
|
778
|
+
}>;
|
|
779
|
+
ephemeralValues: Array<{
|
|
780
|
+
classification: string;
|
|
781
|
+
originalSeq: number;
|
|
782
|
+
location: string;
|
|
783
|
+
producerSeq?: number;
|
|
784
|
+
producerPath?: string;
|
|
785
|
+
suggestedStateName?: string;
|
|
786
|
+
}>;
|
|
787
|
+
/** Producer→consumer opaque-token edges detected deterministically from the
|
|
788
|
+
* dual-pass diff (see `deriveTokenContractHints`). Fed to the planner as
|
|
789
|
+
* grounded contracts to declare. */
|
|
790
|
+
tokenContractHints: TokenContractHint[];
|
|
791
|
+
requests: BuildPlanRequestPayload[];
|
|
792
|
+
}
|
|
793
|
+
|
|
794
|
+
export function buildBuildPlanPayload(opts: {
|
|
795
|
+
session: Session;
|
|
796
|
+
candidates: ToolCandidate[];
|
|
797
|
+
sharedContext?: SharedCompileContext;
|
|
798
|
+
classifications?: ClassifiedValue[];
|
|
799
|
+
}): BuildPlanPayload {
|
|
800
|
+
const { session, candidates, sharedContext, classifications } = opts;
|
|
801
|
+
|
|
802
|
+
const scope = new Set<number>();
|
|
803
|
+
for (const c of candidates) {
|
|
804
|
+
for (const s of c.requestSeqs) scope.add(s);
|
|
805
|
+
for (const s of c.dependencySeqs) scope.add(s);
|
|
806
|
+
}
|
|
807
|
+
for (const s of sharedContext?.loginRequestSeqs ?? []) scope.add(s);
|
|
808
|
+
|
|
809
|
+
// Compact WITHOUT preserveSeqs so identical requests shared across tools
|
|
810
|
+
// collapse into one row — a strong signal for a shared module candidate.
|
|
811
|
+
const requests = compactRequestContexts(
|
|
812
|
+
session.requests
|
|
813
|
+
.filter((r) => scope.has(r.seq))
|
|
814
|
+
.map((r) => ({
|
|
815
|
+
seq: r.seq,
|
|
816
|
+
timestamp: r.timestamp,
|
|
817
|
+
method: r.method,
|
|
818
|
+
url: r.url,
|
|
819
|
+
resourceType: r.resourceType,
|
|
820
|
+
status: r.response?.status,
|
|
821
|
+
mimeType: r.response?.mimeType,
|
|
822
|
+
headers: truncate(JSON.stringify(r.headers), HEADER_LIMIT) ?? '{}',
|
|
823
|
+
body: truncate(r.body, BODY_LIMIT),
|
|
824
|
+
bodyDigest: requestContextDigest(r.body),
|
|
825
|
+
bodyLength: r.body?.length,
|
|
826
|
+
responsePreview: truncate(r.response?.body, RESPONSE_PREVIEW_LIMIT),
|
|
827
|
+
responseBodyDigest: requestContextDigest(r.response?.body),
|
|
828
|
+
responseBodyLength: r.response?.body?.length,
|
|
829
|
+
})),
|
|
830
|
+
buildPlanRequestGroupKey,
|
|
831
|
+
);
|
|
832
|
+
|
|
833
|
+
const ephemeralValues = (classifications ?? [])
|
|
834
|
+
// Non-constant values, plus stable constants that carry recovered producer
|
|
835
|
+
// provenance (server-provided per-entity tokens) — both are signals for the planner.
|
|
836
|
+
.filter((c) => c.classification !== 'constant' || c.producerSeq != null)
|
|
837
|
+
.map((c) => ({
|
|
838
|
+
classification: c.classification,
|
|
839
|
+
originalSeq: c.originalSeq,
|
|
840
|
+
location: c.location,
|
|
841
|
+
producerSeq: c.producerSeq,
|
|
842
|
+
producerPath: c.producerPath,
|
|
843
|
+
suggestedStateName: c.suggestedStateName,
|
|
844
|
+
}));
|
|
845
|
+
|
|
846
|
+
const selectedTools = candidates.map((c) => ({
|
|
847
|
+
toolName: c.toolName,
|
|
848
|
+
description: c.description,
|
|
849
|
+
expectedOutput: c.expectedOutput,
|
|
850
|
+
requestSeqs: c.requestSeqs,
|
|
851
|
+
dependencySeqs: c.dependencySeqs,
|
|
852
|
+
likelyParams: c.likelyParams,
|
|
853
|
+
}));
|
|
854
|
+
|
|
855
|
+
return {
|
|
856
|
+
site: session.site,
|
|
857
|
+
url: session.url,
|
|
858
|
+
narration: session.narration.map((n) => ({ timestamp: n.timestamp, text: n.text })),
|
|
859
|
+
sharedContext,
|
|
860
|
+
selectedTools,
|
|
861
|
+
ephemeralValues,
|
|
862
|
+
// Detect from the FULL classifications (incl. the value, for the opacity gate);
|
|
863
|
+
// the payload's `ephemeralValues` stays slim (no raw value sent to the planner).
|
|
864
|
+
tokenContractHints: deriveTokenContractHints({
|
|
865
|
+
selectedTools,
|
|
866
|
+
// Any classification carrying producer provenance — server_derived OR a
|
|
867
|
+
// stable constant whose opaque value was found in a sibling response.
|
|
868
|
+
ephemeralValues: (classifications ?? [])
|
|
869
|
+
.filter((c) => c.producerSeq != null)
|
|
870
|
+
.map((c) => ({
|
|
871
|
+
classification: c.classification,
|
|
872
|
+
originalSeq: c.originalSeq,
|
|
873
|
+
location: c.location,
|
|
874
|
+
producerSeq: c.producerSeq,
|
|
875
|
+
producerPath: c.producerPath,
|
|
876
|
+
value: c.value1,
|
|
877
|
+
})),
|
|
878
|
+
}),
|
|
879
|
+
requests,
|
|
880
|
+
};
|
|
881
|
+
}
|
|
882
|
+
|
|
883
|
+
function buildPlanRequestGroupKey(request: BuildPlanRequestPayload): unknown[] {
|
|
884
|
+
return [
|
|
885
|
+
request.method,
|
|
886
|
+
request.url,
|
|
887
|
+
request.bodyDigest,
|
|
888
|
+
request.bodyLength,
|
|
889
|
+
request.status,
|
|
890
|
+
request.mimeType,
|
|
891
|
+
request.responseBodyDigest,
|
|
892
|
+
request.responseBodyLength,
|
|
893
|
+
];
|
|
894
|
+
}
|
|
895
|
+
|
|
896
|
+
// ─── Generation ─────────────────────────────────────────────────────────────
|
|
897
|
+
|
|
898
|
+
interface GenerateBuildPlanResult extends BuildPlan {
|
|
899
|
+
inputTokens: number | null;
|
|
900
|
+
outputTokens: number | null;
|
|
901
|
+
durationMs: number;
|
|
902
|
+
}
|
|
903
|
+
|
|
904
|
+
export async function generateBuildPlan(opts: {
|
|
905
|
+
session: Session;
|
|
906
|
+
candidates: ToolCandidate[];
|
|
907
|
+
sharedContext?: SharedCompileContext;
|
|
908
|
+
classifications?: ClassifiedValue[];
|
|
909
|
+
llmConfig?: LLMOptions;
|
|
910
|
+
/** Wall-clock cap on the planner LLM call. On timeout the span is closed with
|
|
911
|
+
* `imprint.plan.timed_out` + ERROR and a TimeoutError is thrown for the caller
|
|
912
|
+
* to degrade. Omit/0 to wait indefinitely. */
|
|
913
|
+
timeoutMs?: number;
|
|
914
|
+
/** Optional sink for progress narration. When provided (e.g. the teach
|
|
915
|
+
* spinner), planner progress flows here instead of raw stderr so it stays
|
|
916
|
+
* inside the TUI; standalone/test callers fall back to the module logger. */
|
|
917
|
+
onProgress?: (msg: string) => void;
|
|
918
|
+
}): Promise<GenerateBuildPlanResult> {
|
|
919
|
+
const narrate = (msg: string): void => {
|
|
920
|
+
(opts.onProgress ?? log)(msg);
|
|
921
|
+
};
|
|
922
|
+
return await traced(
|
|
923
|
+
'teach.plan_prereqs',
|
|
924
|
+
'AGENT',
|
|
925
|
+
{
|
|
926
|
+
'imprint.site': opts.session.site,
|
|
927
|
+
'imprint.provider': opts.llmConfig?.provider ?? 'auto',
|
|
928
|
+
'imprint.tool_count': opts.candidates.length,
|
|
929
|
+
},
|
|
930
|
+
async (span) => {
|
|
931
|
+
const promptPath = pathJoin(PROMPTS_DIR, 'build-planning.md');
|
|
932
|
+
if (!existsSync(promptPath)) {
|
|
933
|
+
throw new Error(
|
|
934
|
+
`Build-planning prompt not found at ${promptPath}\n→ this is an Imprint installation problem.`,
|
|
935
|
+
);
|
|
936
|
+
}
|
|
937
|
+
const systemPrompt = readFileSync(promptPath, 'utf8');
|
|
938
|
+
const payload = buildBuildPlanPayload(opts);
|
|
939
|
+
const payloadJson = JSON.stringify(payload);
|
|
940
|
+
|
|
941
|
+
// Record input size on the span BEFORE the call, so a timed-out or slow
|
|
942
|
+
// planning session is still debuggable on Phoenix (the success block below
|
|
943
|
+
// never runs on timeout). A large ephemeral_count is the usual bloat cause.
|
|
944
|
+
setSpanAttributes(span, {
|
|
945
|
+
'imprint.plan.request_count': payload.requests.length,
|
|
946
|
+
'imprint.plan.ephemeral_count': payload.ephemeralValues.length,
|
|
947
|
+
'imprint.plan.narration_count': payload.narration.length,
|
|
948
|
+
'imprint.plan.payload_chars': payloadJson.length,
|
|
949
|
+
'imprint.plan.prompt_chars': systemPrompt.length,
|
|
950
|
+
'imprint.plan.timeout_ms': opts.timeoutMs ?? 0,
|
|
951
|
+
});
|
|
952
|
+
narrate(
|
|
953
|
+
`planning ${opts.candidates.length} tool(s): ${payload.requests.length} request(s), ${payload.ephemeralValues.length} ephemeral value(s), ${payload.tokenContractHints.length} token edge(s), ${payload.narration.length} narration line(s); ${Math.round(payloadJson.length / 1024)} KB payload + ${Math.round(systemPrompt.length / 1024)} KB prompt → ${opts.llmConfig?.provider ?? 'auto'}/${opts.llmConfig?.model ?? 'default'}${opts.timeoutMs ? ` (timeout ${Math.round(opts.timeoutMs / 1000)}s)` : ''}`,
|
|
954
|
+
);
|
|
955
|
+
|
|
956
|
+
const llm = resolveProvider(opts.llmConfig ?? {});
|
|
957
|
+
const llmStart = Date.now();
|
|
958
|
+
narrate('calling planner LLM');
|
|
959
|
+
let result: Awaited<ReturnType<typeof llm.analyze>>;
|
|
960
|
+
try {
|
|
961
|
+
const call = llm.analyze(systemPrompt, payload);
|
|
962
|
+
result = opts.timeoutMs
|
|
963
|
+
? await withTimeout(call, opts.timeoutMs, 'build planner')
|
|
964
|
+
: await call;
|
|
965
|
+
} catch (err) {
|
|
966
|
+
const elapsedMs = Date.now() - llmStart;
|
|
967
|
+
const timedOut = err instanceof TimeoutError;
|
|
968
|
+
setSpanAttributes(span, {
|
|
969
|
+
'imprint.plan.timed_out': timedOut,
|
|
970
|
+
'imprint.plan.llm_elapsed_ms': elapsedMs,
|
|
971
|
+
});
|
|
972
|
+
narrate(
|
|
973
|
+
`planner LLM ${timedOut ? 'timed out' : 'failed'} after ${Math.round(elapsedMs / 1000)}s: ${err instanceof Error ? err.message : String(err)}`,
|
|
974
|
+
);
|
|
975
|
+
throw err;
|
|
976
|
+
}
|
|
977
|
+
narrate(
|
|
978
|
+
`planner LLM returned in ${Math.round((Date.now() - llmStart) / 1000)}s (in=${result.inputTokens ?? '?'}, out=${result.outputTokens ?? '?'} tokens, ${result.text.length} chars)`,
|
|
979
|
+
);
|
|
980
|
+
const objectText = extractJsonObject(result.text);
|
|
981
|
+
if (!objectText) {
|
|
982
|
+
throw new Error(
|
|
983
|
+
`Build planner did not return a JSON object.\nRaw response:\n${result.text.slice(0, 1000)}`,
|
|
984
|
+
);
|
|
985
|
+
}
|
|
986
|
+
|
|
987
|
+
let parsed: unknown;
|
|
988
|
+
try {
|
|
989
|
+
parsed = JSON.parse(objectText);
|
|
990
|
+
} catch (err) {
|
|
991
|
+
throw new Error(
|
|
992
|
+
`Build planner response was not valid JSON: ${err instanceof Error ? err.message : String(err)}\nExtracted:\n${objectText.slice(0, 1000)}`,
|
|
993
|
+
);
|
|
994
|
+
}
|
|
995
|
+
|
|
996
|
+
// Deterministic safety net: reconcile the planner's plan against the
|
|
997
|
+
// grounded token edges before validation, so a planner shortcut can't drop
|
|
998
|
+
// or half-declare a cross-tool contract (the original defect this fixes).
|
|
999
|
+
const selectedNames = new Set(opts.candidates.map((c) => c.toolName));
|
|
1000
|
+
const reconciled = reconcileTokenContracts(parsed, payload.tokenContractHints, selectedNames);
|
|
1001
|
+
if (reconciled.injected > 0 || reconciled.repaired > 0) {
|
|
1002
|
+
narrate(
|
|
1003
|
+
`token contracts: ${payload.tokenContractHints.length} edge(s) detected → injected ${reconciled.injected}, repaired ${reconciled.repaired}`,
|
|
1004
|
+
);
|
|
1005
|
+
}
|
|
1006
|
+
for (const w of reconciled.warnings) narrate(`token contract: ${w}`);
|
|
1007
|
+
|
|
1008
|
+
const plan = validateBuildPlan(parsed, opts.candidates);
|
|
1009
|
+
setSpanAttributes(span, {
|
|
1010
|
+
'imprint.plan.token_edge_count': payload.tokenContractHints.length,
|
|
1011
|
+
'imprint.plan.token_injected': reconciled.injected,
|
|
1012
|
+
'imprint.plan.token_repaired': reconciled.repaired,
|
|
1013
|
+
'imprint.plan.shared_module_count': plan.sharedModules.length,
|
|
1014
|
+
'imprint.plan.tool_count': plan.perTool.length,
|
|
1015
|
+
'imprint.plan.duration_ms': result.durationMs,
|
|
1016
|
+
'imprint.plan.input_tokens': result.inputTokens,
|
|
1017
|
+
'imprint.plan.output_tokens': result.outputTokens,
|
|
1018
|
+
});
|
|
1019
|
+
return {
|
|
1020
|
+
...plan,
|
|
1021
|
+
inputTokens: result.inputTokens,
|
|
1022
|
+
outputTokens: result.outputTokens,
|
|
1023
|
+
durationMs: result.durationMs,
|
|
1024
|
+
};
|
|
1025
|
+
},
|
|
1026
|
+
);
|
|
1027
|
+
}
|
|
1028
|
+
|
|
1029
|
+
// ─── Sidecar persistence ────────────────────────────────────────────────────
|
|
1030
|
+
|
|
1031
|
+
/** Site-level sidecar holding the full plan. Each compile driver loads it by
|
|
1032
|
+
* path and reads only its tool's slice — far cheaper than threading a large
|
|
1033
|
+
* plan through CLI spawn args. Modeled on the `.classifications.json` sidecar. */
|
|
1034
|
+
export function buildPlanSidecarPath(site: string): string {
|
|
1035
|
+
return pathJoin(localSiteDir(site), '.build-plan.json');
|
|
1036
|
+
}
|
|
1037
|
+
|
|
1038
|
+
export function writeBuildPlanSidecar(site: string, plan: BuildPlan): string {
|
|
1039
|
+
const path = buildPlanSidecarPath(site);
|
|
1040
|
+
mkdirSync(localSiteDir(site), { recursive: true });
|
|
1041
|
+
writeFileSync(path, `${JSON.stringify(plan, null, 2)}\n`, 'utf8');
|
|
1042
|
+
return path;
|
|
1043
|
+
}
|
|
1044
|
+
|
|
1045
|
+
// ─── Local helpers ──────────────────────────────────────────────────────────
|
|
1046
|
+
|
|
1047
|
+
function truncate(s: string | undefined, limit: number): string | undefined {
|
|
1048
|
+
if (!s) return undefined;
|
|
1049
|
+
if (s.length <= limit) return s;
|
|
1050
|
+
return `${s.slice(0, limit)}…(truncated, original length ${s.length})`;
|
|
1051
|
+
}
|