@assistant-ui/x-generative-compiler 0.0.5 → 0.0.6

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/src/compile.ts CHANGED
@@ -43,6 +43,13 @@ const DISTRIBUTION_PACKAGES = [
43
43
  * through untouched — the library, not this compiler, routes its halves.
44
44
  */
45
45
  const GENERATIVE_FACTORY = "JSONGenerativeUI";
46
+ /**
47
+ * The factory producing an interactable's complete tool entry. Unlike
48
+ * `JSONGenerativeUI`, its package has no per-target builds, so this compiler
49
+ * splits the inline config: the client keeps `render`, the server drops it.
50
+ * The factory's own `execute` is internal and client-safe (frontend tool).
51
+ */
52
+ const INTERACTABLE_TOOL_FACTORY = "unstable_interactableTool";
46
53
 
47
54
  /** Mutable per-build outcomes the toolkit pass reports back for directive/guard injection. */
48
55
  interface TargetFlags {
@@ -78,7 +85,9 @@ export interface CompileResult {
78
85
  /** Thrown when a `"use generative"` file violates an authoring constraint. */
79
86
  export class GenerativeCompileError extends Error {
80
87
  constructor(message: string, filename?: string) {
81
- super(`[assistant-ui/next]${filename ? ` ${filename}:` : ""} ${message}`);
88
+ super(
89
+ `[assistant-ui/use-generative]${filename ? ` ${filename}:` : ""} ${message}`,
90
+ );
82
91
  this.name = "GenerativeCompileError";
83
92
  }
84
93
  }
@@ -103,11 +112,126 @@ export function isGenerativeModule(code: string): boolean {
103
112
  break;
104
113
  }
105
114
  }
115
+
116
+ const quote = code[i];
117
+ if (quote !== '"' && quote !== "'") return false;
118
+
119
+ const directiveStart = i + 1;
120
+ if (!code.startsWith(DIRECTIVE, directiveStart)) return false;
121
+
122
+ const directiveEnd = directiveStart + DIRECTIVE.length;
123
+ if (code[directiveEnd] !== quote) return false;
124
+
125
+ return hasDirectiveTerminator(code, directiveEnd + 1);
126
+ }
127
+
128
+ function hasDirectiveTerminator(code: string, start: number): boolean {
129
+ let i = start;
130
+ let sawLineTerminator = false;
131
+ for (;;) {
132
+ if (i >= code.length) return true;
133
+
134
+ const char = code.charCodeAt(i);
135
+ if (isSemicolon(char)) return true;
136
+ if (isLineTerminator(char)) {
137
+ sawLineTerminator = true;
138
+ i++;
139
+ continue;
140
+ }
141
+
142
+ if (code.startsWith("//", i)) {
143
+ const lineEnd = nextLineTerminatorIndex(code, i + 2);
144
+ if (lineEnd === -1) return true;
145
+ sawLineTerminator = true;
146
+ i = lineEnd + 1;
147
+ continue;
148
+ }
149
+
150
+ if (code.startsWith("/*", i)) {
151
+ const end = code.indexOf("*/", i + 2);
152
+ if (end === -1) return false;
153
+ sawLineTerminator ||= containsLineTerminator(code, i + 2, end);
154
+ i = end + 2;
155
+ continue;
156
+ }
157
+
158
+ if (/\s/.test(code[i]!)) {
159
+ i++;
160
+ continue;
161
+ }
162
+
163
+ return sawLineTerminator && !startsExpressionContinuation(code, i);
164
+ }
165
+ }
166
+
167
+ function nextLineTerminatorIndex(code: string, start: number): number {
168
+ for (let i = start; i < code.length; i++) {
169
+ if (isLineTerminator(code.charCodeAt(i))) return i;
170
+ }
171
+ return -1;
172
+ }
173
+
174
+ function containsLineTerminator(
175
+ code: string,
176
+ start: number,
177
+ end: number,
178
+ ): boolean {
179
+ for (let i = start; i < end; i++) {
180
+ if (isLineTerminator(code.charCodeAt(i))) return true;
181
+ }
182
+ return false;
183
+ }
184
+
185
+ function startsExpressionContinuation(code: string, start: number): boolean {
186
+ switch (code[start]) {
187
+ case ".":
188
+ case "+":
189
+ case "-":
190
+ case "*":
191
+ case "/":
192
+ case "%":
193
+ case "<":
194
+ case ">":
195
+ case "=":
196
+ case "!":
197
+ case "&":
198
+ case "|":
199
+ case "^":
200
+ case "?":
201
+ case ":":
202
+ case ",":
203
+ case "[":
204
+ case "(":
205
+ case "`":
206
+ return true;
207
+ }
208
+
106
209
  return (
107
- code.startsWith(`"${DIRECTIVE}"`, i) || code.startsWith(`'${DIRECTIVE}'`, i)
210
+ startsKeywordContinuation(code, start, "as") ||
211
+ startsKeywordContinuation(code, start, "in") ||
212
+ startsKeywordContinuation(code, start, "instanceof") ||
213
+ startsKeywordContinuation(code, start, "satisfies")
108
214
  );
109
215
  }
110
216
 
217
+ function startsKeywordContinuation(
218
+ code: string,
219
+ start: number,
220
+ keyword: string,
221
+ ): boolean {
222
+ if (!code.startsWith(keyword, start)) return false;
223
+ const next = code[start + keyword.length];
224
+ return next === undefined || !/[\p{ID_Continue}$]/u.test(next);
225
+ }
226
+
227
+ function isSemicolon(char: number): boolean {
228
+ return char === 59;
229
+ }
230
+
231
+ function isLineTerminator(char: number): boolean {
232
+ return char === 10 || char === 13 || char === 0x2028 || char === 0x2029;
233
+ }
234
+
111
235
  /**
112
236
  * Rewrites a `"use generative"` module for a single build target, keeping only the
113
237
  * regions that target needs and pruning the imports the dropped regions used.
@@ -140,7 +264,12 @@ export function compileGenerative(
140
264
  // such entries pass through.
141
265
  ensureDefaultExport(ast, filename);
142
266
  const generativeInstances = collectGenerativeInstances(ast);
143
- const safeToolkitSpreads = collectSafeToolkitSpreads(ast, filename);
267
+ const interactableToolImports = collectInteractableToolImports(ast);
268
+ const toolkitSpreadNames = collectToolkitSpreadNames(
269
+ ast,
270
+ filename,
271
+ createToolkitNameContext(),
272
+ );
144
273
 
145
274
  const flags: TargetFlags = { keptRender: false, keptBackendExecute: false };
146
275
 
@@ -177,7 +306,8 @@ export function compileGenerative(
177
306
  object,
178
307
  target,
179
308
  generativeInstances,
180
- safeToolkitSpreads,
309
+ interactableToolImports,
310
+ toolkitSpreadNames,
181
311
  flags,
182
312
  filename,
183
313
  );
@@ -486,8 +616,63 @@ function collectGenerativeInstances(ast: t.File): Set<string> {
486
616
  return names;
487
617
  }
488
618
 
619
+ type ToolkitStaticNames = readonly string[] | null;
620
+ type ToolkitSpreadNames = Map<string, ToolkitStaticNames>;
621
+
622
+ interface ToolkitNameContext {
623
+ importedToolkitNamesByFile: Map<string, ToolkitStaticNames | undefined>;
624
+ resolvingImportedToolkitNames: Set<string>;
625
+ }
626
+
627
+ /**
628
+ * Collects the local names `unstable_interactableTool` is imported under from a
629
+ * distribution package, so toolkit entries calling it can be recognized
630
+ * (and a same-named local function can't smuggle an arbitrary call through).
631
+ */
632
+ function collectInteractableToolImports(ast: t.File): Set<string> {
633
+ const names = new Set<string>();
634
+ for (const statement of ast.program.body) {
635
+ if (!t.isImportDeclaration(statement)) continue;
636
+ if (!packageNameFromSpecifier(statement.source.value)) continue;
637
+ for (const specifier of statement.specifiers) {
638
+ if (
639
+ t.isImportSpecifier(specifier) &&
640
+ t.isIdentifier(specifier.imported, { name: INTERACTABLE_TOOL_FACTORY })
641
+ ) {
642
+ names.add(specifier.local.name);
643
+ }
644
+ }
645
+ }
646
+ return names;
647
+ }
648
+
649
+ function createToolkitNameContext(): ToolkitNameContext {
650
+ return {
651
+ importedToolkitNamesByFile: new Map(),
652
+ resolvingImportedToolkitNames: new Set(),
653
+ };
654
+ }
655
+
489
656
  /**
490
- * Toolkit identifiers that are safe to spread into a `defineToolkit({ ... })`.
657
+ * The inline config of an `unstable_interactableTool({ ... })` toolkit entry, or `null`
658
+ * when the entry is some other expression.
659
+ */
660
+ function interactableToolConfig(
661
+ value: t.Node,
662
+ imports: Set<string>,
663
+ ): t.ObjectExpression | null {
664
+ return t.isCallExpression(value) &&
665
+ t.isIdentifier(value.callee) &&
666
+ imports.has(value.callee.name) &&
667
+ t.isObjectExpression(value.arguments[0])
668
+ ? value.arguments[0]
669
+ : null;
670
+ }
671
+
672
+ /**
673
+ * Toolkit identifiers that are safe to spread into a `defineToolkit({ ... })`,
674
+ * paired with the static tool names they contain. A `null` name list means the
675
+ * spread is safe, but its names are not statically known for duplicate checks.
491
676
  *
492
677
  * Two kinds qualify:
493
678
  *
@@ -500,19 +685,21 @@ function collectGenerativeInstances(ast: t.File): Set<string> {
500
685
  * export crosses the generative-module boundary, so named imports don't
501
686
  * qualify — they would be `undefined` once that module is build-split.
502
687
  */
503
- function collectSafeToolkitSpreads(
688
+ function collectToolkitSpreadNames(
504
689
  ast: t.File,
505
690
  filename: string | undefined,
506
- ): Set<string> {
507
- const names = new Set<string>();
508
- const generativeBySource = new Map<string, boolean>();
691
+ context: ToolkitNameContext,
692
+ ): ToolkitSpreadNames {
693
+ const spreadNames: ToolkitSpreadNames = new Map();
694
+ const localToolkitCalls = new Map<string, t.CallExpression>();
509
695
 
510
696
  for (const statement of ast.program.body) {
511
697
  if (t.isVariableDeclaration(statement)) {
512
698
  for (const declaration of statement.declarations) {
513
699
  const { id, init } = declaration;
514
- if (t.isIdentifier(id) && init && unwrapToToolkitCall(init)) {
515
- names.add(id.name);
700
+ if (t.isIdentifier(id) && init) {
701
+ const toolkitCall = unwrapToToolkitCall(init);
702
+ if (toolkitCall) localToolkitCalls.set(id.name, toolkitCall);
516
703
  }
517
704
  }
518
705
  continue;
@@ -525,17 +712,40 @@ function collectSafeToolkitSpreads(
525
712
  );
526
713
  if (!defaultSpecifier) continue;
527
714
 
528
- const source = statement.source.value;
529
- let isGenerative = generativeBySource.get(source);
530
- if (isGenerative === undefined) {
531
- isGenerative = isGenerativeImport(source, filename);
532
- generativeBySource.set(source, isGenerative);
715
+ const names = getGenerativeImportToolkitNames(
716
+ statement.source.value,
717
+ filename,
718
+ context,
719
+ );
720
+ if (names !== undefined) {
721
+ spreadNames.set(defaultSpecifier.local.name, names);
533
722
  }
534
- if (isGenerative) names.add(defaultSpecifier.local.name);
535
723
  }
536
724
  }
537
725
 
538
- return names;
726
+ const resolveLocal = (name: string): ToolkitStaticNames | undefined => {
727
+ if (spreadNames.has(name)) return spreadNames.get(name);
728
+
729
+ const call = localToolkitCalls.get(name);
730
+ if (!call) return undefined;
731
+
732
+ const object = t.isObjectExpression(call.arguments[0])
733
+ ? call.arguments[0]
734
+ : null;
735
+ const names = object
736
+ ? collectToolkitObjectNames(object, spreadNames)
737
+ : null;
738
+ const publicNames = uniqueToolkitNames(names);
739
+
740
+ spreadNames.set(name, publicNames);
741
+ return publicNames;
742
+ };
743
+
744
+ for (const name of localToolkitCalls.keys()) {
745
+ resolveLocal(name);
746
+ }
747
+
748
+ return spreadNames;
539
749
  }
540
750
 
541
751
  const MODULE_EXTENSIONS = [
@@ -553,26 +763,78 @@ const MODULE_EXTENSIONS = [
553
763
  const REWRITABLE_JS_EXTENSIONS = new Set([".js", ".jsx", ".mjs", ".cjs"]);
554
764
 
555
765
  /**
556
- * Whether an import specifier resolves on disk to a `"use generative"` module.
557
- * Relative specifiers and `tsconfig` path aliases (e.g. `@/tools`) are resolved;
558
- * anything else (a bare package, an unresolvable alias) is treated as
559
- * non-generative, and thus an unsafe spread.
766
+ * Reads the static tool names from the default export of an imported
767
+ * `"use generative"` module. Relative specifiers and `tsconfig` path aliases
768
+ * (e.g. `@/tools`) are resolved; anything else (a bare package, an unresolvable
769
+ * alias) is treated as non-generative, and thus an unsafe spread.
560
770
  */
561
- function isGenerativeImport(
771
+ function getGenerativeImportToolkitNames(
562
772
  source: string,
563
773
  filename: string | undefined,
564
- ): boolean {
774
+ context: ToolkitNameContext,
775
+ ): ToolkitStaticNames | undefined {
565
776
  const cleanFilename = cleanAbsoluteFilename(filename);
566
- if (!cleanFilename) return false;
777
+ if (!cleanFilename) return undefined;
567
778
 
568
779
  const resolved = resolveImportedModuleFile(source, cleanFilename);
569
- if (!resolved) return false;
780
+ if (!resolved) return undefined;
570
781
 
782
+ if (context.importedToolkitNamesByFile.has(resolved)) {
783
+ return context.importedToolkitNamesByFile.get(resolved);
784
+ }
785
+ if (context.resolvingImportedToolkitNames.has(resolved)) return null;
786
+
787
+ let code: string;
571
788
  try {
572
- return isGenerativeModule(readFileSync(resolved, "utf8"));
789
+ code = readFileSync(resolved, "utf8");
573
790
  } catch {
574
- return false;
791
+ context.importedToolkitNamesByFile.set(resolved, undefined);
792
+ return undefined;
793
+ }
794
+
795
+ if (!isGenerativeModule(code)) {
796
+ context.importedToolkitNamesByFile.set(resolved, undefined);
797
+ return undefined;
798
+ }
799
+
800
+ context.resolvingImportedToolkitNames.add(resolved);
801
+ try {
802
+ const importedAst = parse(code, {
803
+ sourceType: "module",
804
+ plugins: ["typescript", "jsx", "explicitResourceManagement"],
805
+ });
806
+ const names = getDefaultExportToolkitNames(importedAst, resolved, context);
807
+ context.importedToolkitNamesByFile.set(resolved, names);
808
+ return names;
809
+ } catch (error) {
810
+ if (error instanceof GenerativeCompileError) throw error;
811
+ context.importedToolkitNamesByFile.set(resolved, null);
812
+ return null;
813
+ } finally {
814
+ context.resolvingImportedToolkitNames.delete(resolved);
815
+ }
816
+ }
817
+
818
+ function getDefaultExportToolkitNames(
819
+ ast: t.File,
820
+ filename: string | undefined,
821
+ context: ToolkitNameContext,
822
+ ): ToolkitStaticNames {
823
+ const def = ast.program.body.find(
824
+ (stmt): stmt is t.ExportDefaultDeclaration =>
825
+ t.isExportDefaultDeclaration(stmt),
826
+ );
827
+ if (!def) return null;
828
+
829
+ const toolkitCall = unwrapToToolkitCall(def.declaration);
830
+ if (!toolkitCall || !t.isObjectExpression(toolkitCall.arguments[0])) {
831
+ return null;
575
832
  }
833
+
834
+ const spreadNames = collectToolkitSpreadNames(ast, filename, context);
835
+ return uniqueToolkitNames(
836
+ collectToolkitObjectNames(toolkitCall.arguments[0], spreadNames),
837
+ );
576
838
  }
577
839
 
578
840
  /** Resolves an import specifier (relative or `tsconfig`-aliased) to a file on disk. */
@@ -797,12 +1059,90 @@ function unwrapToToolkitCall(node: t.Node): t.CallExpression | null {
797
1059
  );
798
1060
  }
799
1061
 
1062
+ function collectToolkitObjectNames(
1063
+ object: t.ObjectExpression,
1064
+ toolkitSpreadNames: ToolkitSpreadNames,
1065
+ ): ToolkitStaticNames {
1066
+ const names: string[] = [];
1067
+
1068
+ for (const entry of object.properties) {
1069
+ const entryNames = toolkitEntryNames(entry, toolkitSpreadNames);
1070
+ if (!entryNames) return null;
1071
+ names.push(...entryNames);
1072
+ }
1073
+
1074
+ return names;
1075
+ }
1076
+
1077
+ function uniqueToolkitNames(names: ToolkitStaticNames): ToolkitStaticNames {
1078
+ return names ? [...new Set(names)] : names;
1079
+ }
1080
+
1081
+ function toolkitEntryNames(
1082
+ entry: t.ObjectExpression["properties"][number],
1083
+ toolkitSpreadNames: ToolkitSpreadNames,
1084
+ ): ToolkitStaticNames {
1085
+ if (t.isSpreadElement(entry)) {
1086
+ if (t.isIdentifier(entry.argument)) {
1087
+ return toolkitSpreadNames.get(entry.argument.name) ?? null;
1088
+ }
1089
+
1090
+ const directMcpToolkit = unwrapToCall(entry.argument, MCP_TOOLKIT_WRAPPER);
1091
+ if (
1092
+ directMcpToolkit &&
1093
+ t.isObjectExpression(directMcpToolkit.arguments[0])
1094
+ ) {
1095
+ return collectToolkitObjectNames(
1096
+ directMcpToolkit.arguments[0],
1097
+ toolkitSpreadNames,
1098
+ );
1099
+ }
1100
+
1101
+ return null;
1102
+ }
1103
+
1104
+ if (t.isObjectProperty(entry) || t.isObjectMethod(entry)) {
1105
+ const name = memberName(entry.key, entry.computed);
1106
+ return name ? [name] : [];
1107
+ }
1108
+
1109
+ return [];
1110
+ }
1111
+
1112
+ function warnDuplicateToolkitNames(
1113
+ object: t.ObjectExpression,
1114
+ toolkitSpreadNames: ToolkitSpreadNames,
1115
+ filename: string | undefined,
1116
+ ): void {
1117
+ const names = collectToolkitObjectNames(object, toolkitSpreadNames);
1118
+ if (!names) return;
1119
+
1120
+ const seen = new Set<string>();
1121
+ const warned = new Set<string>();
1122
+ for (const name of names) {
1123
+ if (seen.has(name)) {
1124
+ if (!warned.has(name)) {
1125
+ console.warn(
1126
+ new GenerativeCompileError(
1127
+ `Duplicate tool name "${name}" while composing toolkits. ` +
1128
+ "JavaScript object spread keeps the last definition.",
1129
+ filename,
1130
+ ).message,
1131
+ );
1132
+ warned.add(name);
1133
+ }
1134
+ continue;
1135
+ }
1136
+ seen.add(name);
1137
+ }
1138
+ }
1139
+
800
1140
  function isSafeToolkitSpread(
801
1141
  entry: t.SpreadElement,
802
- safeToolkitSpreads: Set<string>,
1142
+ toolkitSpreadNames: ToolkitSpreadNames,
803
1143
  ): boolean {
804
1144
  if (t.isIdentifier(entry.argument)) {
805
- return safeToolkitSpreads.has(entry.argument.name);
1145
+ return toolkitSpreadNames.has(entry.argument.name);
806
1146
  }
807
1147
 
808
1148
  const directMcpToolkit = unwrapToCall(entry.argument, MCP_TOOLKIT_WRAPPER);
@@ -871,10 +1211,17 @@ function compileToolkit(
871
1211
  object: t.ObjectExpression,
872
1212
  target: Target,
873
1213
  instances: Set<string>,
874
- safeToolkitSpreads: Set<string>,
1214
+ interactableToolImports: Set<string>,
1215
+ toolkitSpreadNames: ToolkitSpreadNames,
875
1216
  flags: TargetFlags,
876
1217
  filename: string | undefined,
877
1218
  ): void {
1219
+ // Split builds compile both targets; emit target-independent warnings from
1220
+ // the client pass so each duplicate is logged once.
1221
+ if (target === "client") {
1222
+ warnDuplicateToolkitNames(object, toolkitSpreadNames, filename);
1223
+ }
1224
+
878
1225
  const nextProperties: t.ObjectExpression["properties"] = [];
879
1226
 
880
1227
  for (const entry of object.properties) {
@@ -882,7 +1229,7 @@ function compileToolkit(
882
1229
  if (!value) {
883
1230
  if (
884
1231
  t.isSpreadElement(entry) &&
885
- isSafeToolkitSpread(entry, safeToolkitSpreads)
1232
+ isSafeToolkitSpread(entry, toolkitSpreadNames)
886
1233
  ) {
887
1234
  nextProperties.push(entry);
888
1235
  continue;
@@ -896,11 +1243,20 @@ function compileToolkit(
896
1243
  nextProperties.push(entry);
897
1244
  continue;
898
1245
  }
1246
+ const config =
1247
+ raw && interactableToolConfig(raw, interactableToolImports);
1248
+ if (config) {
1249
+ if (target === "client") flags.keptRender = true;
1250
+ else removeMember(config, "render");
1251
+ nextProperties.push(entry);
1252
+ continue;
1253
+ }
899
1254
  throw new GenerativeCompileError(
900
1255
  "each tool must be an inline object literal (`name: { ... }`) or a " +
901
1256
  "compiler-visible toolkit spread / generative tool (e.g. " +
902
- "`...defineMcpToolkit(...)`, `...baseToolkit`, or " +
903
- "`generative.present()`) so its `execute` can be routed",
1257
+ "`...defineMcpToolkit(...)`, `...baseToolkit`, " +
1258
+ "`generative.present()`, or `unstable_interactableTool(...)`) so its " +
1259
+ "`execute` can be routed",
904
1260
  filename,
905
1261
  );
906
1262
  }
@@ -930,7 +1286,10 @@ function compileToolkit(
930
1286
  }
931
1287
 
932
1288
  if (type === "provider" && execute) {
933
- applyProviderToolConfig(value, execute, filename);
1289
+ const toolName = t.isObjectProperty(entry)
1290
+ ? memberName(entry.key, entry.computed)
1291
+ : undefined;
1292
+ applyProviderToolConfig(value, execute, toolName, filename);
934
1293
  }
935
1294
 
936
1295
  if (isExternal) {
@@ -972,6 +1331,7 @@ function compileToolkit(
972
1331
  function applyProviderToolConfig(
973
1332
  object: t.ObjectExpression,
974
1333
  execute: t.ObjectProperty | t.ObjectMethod,
1334
+ toolName: string | undefined,
975
1335
  filename: string | undefined,
976
1336
  ): void {
977
1337
  if (
@@ -1019,8 +1379,9 @@ function applyProviderToolConfig(
1019
1379
  );
1020
1380
  }
1021
1381
  if (existingNames.has(name) || configNames.has(name)) {
1382
+ const toolLabel = toolName ? ` for "${toolName}"` : "";
1022
1383
  throw new GenerativeCompileError(
1023
- "`providerTool(...)` config cannot duplicate tool properties",
1384
+ `\`providerTool(...)\` config${toolLabel} duplicates "${name}"`,
1024
1385
  filename,
1025
1386
  );
1026
1387
  }