@instruktlabs/kiln 1.1.0 → 1.2.1

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 (56) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/.codex-plugin/plugin.json +1 -1
  3. package/CHANGELOG.md +28 -0
  4. package/dist/agent-run.mjs +224 -151
  5. package/dist/build.json +22 -22
  6. package/dist/cli.mjs +229 -151
  7. package/dist/mcp-engine.mjs +400 -147
  8. package/dist/mcp-server.mjs +6 -6
  9. package/dist/viewer/app.js +180 -180
  10. package/dist/viewer/chat.html +183 -183
  11. package/docs/sdk.md +23 -0
  12. package/docs/tools-operations.md +1529 -0
  13. package/lib/asset-widget-uri.d.ts +1 -1
  14. package/lib/asset-widget-uri.js +1 -1
  15. package/lib/discovery/index.d.ts +1 -1
  16. package/lib/discovery/index.js +2 -2
  17. package/lib/discovery/service.d.ts +3 -1
  18. package/lib/discovery/service.js +4 -2
  19. package/lib/engine-identity.d.ts +1 -1
  20. package/lib/engine-identity.js +1 -1
  21. package/lib/mcp-core.d.ts +2 -1
  22. package/lib/mcp-core.js +1 -0
  23. package/lib/mcp-engine.d.ts +2 -0
  24. package/lib/mcp-engine.js +8 -3
  25. package/lib/tools/discovery.d.ts +1 -1
  26. package/lib/tools/discovery.js +11 -9
  27. package/lib/tools/materials.d.ts +1 -1
  28. package/lib/tools/materials.js +44 -14
  29. package/lib/tools/operation-contract.d.ts +8 -0
  30. package/lib/tools/operation-contract.js +37 -0
  31. package/lib/tools/operations.d.ts +3 -0
  32. package/lib/tools/operations.js +154 -0
  33. package/lib/tools/registry.d.ts +7 -4
  34. package/lib/tools/registry.js +73 -24
  35. package/package.json +2 -1
  36. package/plugin.json +1 -1
  37. package/plugins/kiln-engine/.claude-plugin/plugin.json +1 -1
  38. package/plugins/kiln-engine/README.md +3 -3
  39. package/plugins/kiln-engine/package-provenance.json +6 -6
  40. package/plugins/kiln-engine/plugin.json +1 -1
  41. package/plugins/kiln-engine/runtime.json +1 -1
  42. package/src/asset-widget-uri.ts +1 -1
  43. package/src/discovery/index.ts +2 -0
  44. package/src/discovery/service.ts +3 -1
  45. package/src/engine-identity.ts +1 -1
  46. package/src/generated/mcp-manifest.json +4 -4
  47. package/src/mcp-core.ts +3 -0
  48. package/src/mcp-engine.ts +11 -1
  49. package/src/tools/discovery.ts +20 -10
  50. package/src/tools/materials.ts +59 -15
  51. package/src/tools/operation-contract.ts +51 -0
  52. package/src/tools/operations.ts +177 -0
  53. package/src/tools/registry.ts +102 -21
  54. package/src/viewer/chat-app.ts +6 -2
  55. package/src/viewer/glb-loader.ts +78 -0
  56. package/src/viewer/scene.ts +9 -26
@@ -14,6 +14,7 @@ import { createMaterialRecordV1, decodeMaterialLibraryPayload } from '../materia
14
14
  import { createMaterialPresetDraft, listMaterialPresets } from '../material-presets';
15
15
  import { nestedRecordDescription, requireActionFields } from './actions';
16
16
  import type { KilnToolDef } from './registry';
17
+ import type { KilnActionContract } from './operation-contract';
17
18
 
18
19
  const MATERIAL_ACTIONS = [
19
20
  'presets',
@@ -24,13 +25,43 @@ const MATERIAL_ACTIONS = [
24
25
  'import',
25
26
  ] as const;
26
27
  type MaterialAction = (typeof MATERIAL_ACTIONS)[number];
27
- const REQUIREMENTS: Record<MaterialAction, Parameters<typeof requireActionFields>[3]> = {
28
- presets: { required: [] },
29
- 'create-preset': { required: ['presetId', 'seed', 'creator', 'license'] },
30
- list: { required: [] },
31
- get: { required: ['materialId'] },
32
- 'create-procedural': { required: ['draft'], shapes: { draft: 'shape:material-draft' } },
33
- import: { required: ['payload'], shapes: { payload: 'shape:material-import' } },
28
+ const MATERIAL_CONTRACT: Record<MaterialAction, KilnActionContract> = {
29
+ presets: {
30
+ fields: ['tag'],
31
+ required: [],
32
+ description: 'List shipped material recipes, optionally filtered by tag.',
33
+ },
34
+ 'create-preset': {
35
+ fields: ['presetId', 'seed', 'creator', 'license', 'size', 'materialId'],
36
+ required: ['presetId', 'seed', 'creator', 'license'],
37
+ description:
38
+ 'Create an immutable material from a shipped preset with explicit seed, creator and license.',
39
+ },
40
+ list: {
41
+ fields: ['tag'],
42
+ required: [],
43
+ description: 'List saved immutable material revisions, optionally filtered by tag.',
44
+ },
45
+ get: {
46
+ fields: ['materialId', 'revisionId'],
47
+ required: ['materialId'],
48
+ description:
49
+ 'Read a material revision, provenance and portable specification. Omit revisionId only when exactly one revision exists.',
50
+ },
51
+ 'create-procedural': {
52
+ fields: ['draft'],
53
+ required: ['draft'],
54
+ shapes: { draft: 'shape:material-draft' },
55
+ description:
56
+ 'Create an immutable material from a bounded procedural draft. Does not fetch URLs or execute source.',
57
+ },
58
+ import: {
59
+ fields: ['payload'],
60
+ required: ['payload'],
61
+ shapes: { payload: 'shape:material-import' },
62
+ description:
63
+ 'Import complete normalized material records and embedded PNGs. Does not fetch URLs or execute source.',
64
+ },
34
65
  };
35
66
  const record = (description: string) =>
36
67
  z.record(z.string(), z.unknown()).optional().describe(description);
@@ -74,9 +105,9 @@ const materialResult = (material: MaterialManifestV1) => ({
74
105
  material,
75
106
  portableSpec: materialLibraryPortableSpec(material),
76
107
  });
77
- const unknownMaterial = (materialId: string, revisionId?: string) =>
108
+ const unknownMaterial = (materialId: string, revisionId: string | undefined, listCall: string) =>
78
109
  new Error(
79
- `Unknown material ${materialId}${revisionId ? ` at ${revisionId}` : ''}. kiln_material { action: 'list' } lists the materials in this workspace with their revision IDs.`,
110
+ `Unknown material ${materialId}${revisionId ? ` at ${revisionId}` : ''}. ${listCall} lists the materials in this workspace with their revision IDs.`,
80
111
  );
81
112
  /**
82
113
  * The revision a `get` without `revisionId` means: the material's only one.
@@ -84,13 +115,17 @@ const unknownMaterial = (materialId: string, revisionId?: string) =>
84
115
  * caller to choose (two live sessions of 1 October 2026 called get with the
85
116
  * id alone and were refused).
86
117
  */
87
- async function onlyRevision(library: MaterialLibrary, materialId: string): Promise<string> {
118
+ async function onlyRevision(
119
+ library: MaterialLibrary,
120
+ materialId: string,
121
+ listCall: string,
122
+ ): Promise<string> {
88
123
  const revisions = (await library.list())
89
124
  .filter((material) => material.materialId === materialId)
90
125
  .map((material) => material.revisionId)
91
126
  .sort();
92
127
  if (revisions.length === 1) return revisions[0]!;
93
- if (revisions.length === 0) throw unknownMaterial(materialId);
128
+ if (revisions.length === 0) throw unknownMaterial(materialId, undefined, listCall);
94
129
  throw new Error(
95
130
  `Material ${materialId} has ${revisions.length} revisions: ${revisions.join(', ')}. Pass revisionId to get one.`,
96
131
  );
@@ -111,12 +146,20 @@ function aliasMaterialId(raw: unknown): unknown {
111
146
  );
112
147
  return { ...rest, materialId: resourceId };
113
148
  }
114
- export function createKilnMaterialDef(library: MaterialLibrary): KilnToolDef {
149
+ export function createKilnMaterialDef(
150
+ library: MaterialLibrary,
151
+ presentation: 'grouped' | 'operations' = 'grouped',
152
+ ): KilnToolDef {
153
+ const listCall =
154
+ presentation === 'operations'
155
+ ? 'kiln_material_search({ scope: "saved" })'
156
+ : "kiln_material { action: 'list' }";
115
157
  return {
116
158
  name: 'kiln_material',
117
159
  description:
118
160
  'Manage optional immutable material resources in this workspace. presets discovers shipped architecture, wood, metal, fabric and ground recipes; create-preset bakes one with an explicit seed, creator and license; list returns compact material/revision summaries; get returns full provenance, hashes, map conventions, physical repeat scale and a code-ready portable material spec; create-procedural bakes bounded editable layer recipes including optional height-derived normals; import accepts complete normalized records with embedded PNG bytes. No action downloads URLs or executes source. Pin the returned materialId/revisionId through per-invocation materialDependencies or project dependencies before authored evaluation resolves the resources; no project is required.',
119
161
  inputSchema: materialToolInput,
162
+ actionContract: MATERIAL_CONTRACT,
120
163
  annotations: {
121
164
  readOnlyHint: false,
122
165
  destructiveHint: false,
@@ -126,7 +169,7 @@ export function createKilnMaterialDef(library: MaterialLibrary): KilnToolDef {
126
169
  async run(raw) {
127
170
  assertMaterialJson(raw);
128
171
  const input = materialToolInput.parse(aliasMaterialId(raw));
129
- requireActionFields('kiln_material', input.action, input, REQUIREMENTS[input.action]);
172
+ requireActionFields('kiln_material', input.action, input, MATERIAL_CONTRACT[input.action]);
130
173
  switch (input.action) {
131
174
  case 'presets':
132
175
  return {
@@ -170,13 +213,14 @@ export function createKilnMaterialDef(library: MaterialLibrary): KilnToolDef {
170
213
  };
171
214
  }
172
215
  case 'get': {
173
- const revisionId = input.revisionId ?? (await onlyRevision(library, input.materialId!));
216
+ const revisionId =
217
+ input.revisionId ?? (await onlyRevision(library, input.materialId!, listCall));
174
218
  try {
175
219
  return materialResult((await library.read(input.materialId!, revisionId)).manifest);
176
220
  } catch (error) {
177
221
  // Rule 9: an unknown id names the call that lists the known ones.
178
222
  if ((error as NodeJS.ErrnoException).code === 'ENOENT')
179
- throw unknownMaterial(input.materialId!, revisionId);
223
+ throw unknownMaterial(input.materialId!, revisionId, listCall);
180
224
  throw error;
181
225
  }
182
226
  }
@@ -0,0 +1,51 @@
1
+ import { z } from 'zod';
2
+ import type { ActionRequirements } from './actions';
3
+
4
+ /** Domain action metadata shared by grouped execution and named presentations. */
5
+ export interface KilnActionContract extends ActionRequirements {
6
+ readonly fields: readonly string[];
7
+ readonly description: string;
8
+ }
9
+
10
+ export function projectActionSchemas<A extends string>(
11
+ name: string,
12
+ input: z.ZodObject,
13
+ actions: Readonly<Record<A, KilnActionContract>>,
14
+ ): Record<A, z.ZodObject> {
15
+ if (input.def.checks?.length)
16
+ throw new Error(`${name}: cross-field constraints require an explicit operation schema`);
17
+ const json = z.toJSONSchema(input, { io: 'input' });
18
+ const selector = json.properties?.action as { enum?: unknown } | undefined;
19
+ const expected = Object.keys(actions).sort();
20
+ if (
21
+ !Array.isArray(selector?.enum) ||
22
+ JSON.stringify([...selector.enum].sort()) !== JSON.stringify(expected)
23
+ )
24
+ throw new Error(`${name}: unclassified actions`);
25
+ const classified = new Set([
26
+ 'action',
27
+ ...Object.values<KilnActionContract>(actions).flatMap((contract) => [...contract.fields]),
28
+ ]);
29
+ const unclassified = Object.keys(input.shape).filter((field) => !classified.has(field));
30
+ if (unclassified.length)
31
+ throw new Error(`${name}: Unclassified fields: ${unclassified.join(', ')}`);
32
+ const schemas: [string, z.ZodObject][] = [];
33
+ for (const [action, contract] of Object.entries<KilnActionContract>(actions)) {
34
+ for (const field of contract.required)
35
+ if (!contract.fields.includes(field))
36
+ throw new Error(`${name}.${action}: unmapped required ${field}`);
37
+ for (const field of json.required ?? [])
38
+ if (field !== 'action' && !contract.fields.includes(field))
39
+ throw new Error(`${name}.${action}: would omit required ${field}`);
40
+ const shape: Record<string, z.ZodType> = {};
41
+ for (const field of contract.fields) {
42
+ if (field === 'action' || !Object.hasOwn(input.shape, field))
43
+ throw new Error(`${name}.${action}: missing or reserved field ${field}`);
44
+ classified.add(field);
45
+ const source = input.shape[field]!;
46
+ shape[field] = contract.required.includes(field) ? source.nonoptional() : source;
47
+ }
48
+ schemas.push([action, z.strictObject(shape)]);
49
+ }
50
+ return Object.fromEntries(schemas) as Record<A, z.ZodObject>;
51
+ }
@@ -0,0 +1,177 @@
1
+ import { z } from 'zod';
2
+ import type { KilnToolContext, KilnToolDef } from './registry';
3
+ import { projectActionSchemas } from './operation-contract';
4
+
5
+ /** Adapt domain operations in the engine, never in a transport-specific skin. */
6
+ export function projectOperationDefs(
7
+ defs: readonly KilnToolDef[],
8
+ context: KilnToolContext,
9
+ ): KilnToolDef[] {
10
+ return defs.flatMap((def) => {
11
+ if (
12
+ !context.assetLibrary &&
13
+ ['kiln_save', 'kiln_assets', 'kiln_present', 'kiln_export', 'kiln_import'].includes(def.name)
14
+ )
15
+ return [];
16
+ if (def.name === 'kiln_renderer') return rendererOperations(def, context);
17
+ if (def.name === 'kiln_discover') return discoveryOperations(def);
18
+ if (def.name === 'kiln_material') return materialOperations(def);
19
+ if (def.name === 'kiln_assets') return assetOperations(def, context);
20
+ return [def];
21
+ });
22
+ }
23
+
24
+ function actionTools(def: KilnToolDef) {
25
+ if (!(def.inputSchema instanceof z.ZodObject) || !def.actionContract)
26
+ throw new Error(`${def.name}: missing shared action contract`);
27
+ const schemas = projectActionSchemas(def.name, def.inputSchema, def.actionContract);
28
+ return Object.fromEntries(
29
+ Object.entries(def.actionContract).map(([action, contract]) => [
30
+ action,
31
+ {
32
+ ...def,
33
+ actionContract: undefined,
34
+ name: `${def.name}_${action.replaceAll('-', '_')}`,
35
+ description: contract.description,
36
+ inputSchema: schemas[action]!,
37
+ run: async (raw: unknown) => def.run({ ...schemas[action]!.parse(raw), action }),
38
+ } satisfies KilnToolDef,
39
+ ]),
40
+ );
41
+ }
42
+
43
+ function assetOperations(def: KilnToolDef, context: KilnToolContext): KilnToolDef[] {
44
+ const tools = actionTools(def);
45
+ const list = tools.list!;
46
+ const listSchema = list.inputSchema as z.ZodObject;
47
+ // Remove only the legacy default: omission means all collections in this operation.
48
+ const collection = (listSchema.shape.collection as z.ZodDefault<z.ZodString>)
49
+ .unwrap()
50
+ .optional()
51
+ .describe(
52
+ 'Collection ID from kiln_assets_collections; omitted searches all configured collections.',
53
+ );
54
+ const inputSchema = listSchema.extend({ collection });
55
+ const search: KilnToolDef = {
56
+ ...list,
57
+ name: 'kiln_assets_search',
58
+ description:
59
+ 'Search saved assets with pagination. Omit collection for all configured collections, or select one explicitly. Results identify exact collection, asset and revision; partial read failures are reported.',
60
+ inputSchema,
61
+ run: async (raw) => {
62
+ const input = inputSchema.parse(raw);
63
+ const { collection: _collection, ...filters } = input;
64
+ if (input.collection === undefined) return tools.catalog!.run(filters);
65
+ if (!context.assetLibrary?.collections().some((item) => item.id === input.collection))
66
+ throw new Error(
67
+ 'Unknown collection. Use kiln_assets_collections to list available destinations.',
68
+ );
69
+ const result = (await list.run(input)) as {
70
+ total: number;
71
+ nextOffset: number | null;
72
+ assets: Record<string, unknown>[];
73
+ };
74
+ return {
75
+ errors: [],
76
+ total: result.total,
77
+ nextOffset: result.nextOffset,
78
+ assets: result.assets.map((asset) => ({ ...asset, collection: input.collection })),
79
+ };
80
+ },
81
+ };
82
+ return [tools.collections!, search, tools.get!, tools.restore!];
83
+ }
84
+
85
+ function materialOperations(def: KilnToolDef): KilnToolDef[] {
86
+ const tools = actionTools(def);
87
+ const presets = tools.presets!;
88
+ const inputSchema = (presets.inputSchema as z.ZodObject).extend({
89
+ scope: z.enum(['all', 'presets', 'saved']).default('all'),
90
+ });
91
+ const search: KilnToolDef = {
92
+ ...presets,
93
+ name: 'kiln_material_search',
94
+ description:
95
+ 'Browse shipped material recipes and saved material revisions, optionally filtered by exact tag. Recipe IDs and saved material IDs are distinct. Use scope to select presets, saved or both.',
96
+ inputSchema,
97
+ run: async (raw) => {
98
+ const { scope, ...input } = inputSchema.parse(raw);
99
+ const recipes =
100
+ scope === 'saved'
101
+ ? { presets: [] }
102
+ : ((await presets.run(input)) as { presets: Record<string, unknown>[] });
103
+ const saved =
104
+ scope === 'presets'
105
+ ? { materials: [] }
106
+ : ((await tools.list!.run(input)) as { materials: Record<string, unknown>[] });
107
+ return {
108
+ ok: true,
109
+ presets: recipes.presets.map((item) => ({ ...item, kind: 'preset' })),
110
+ materials: saved.materials.map((item) => ({ ...item, kind: 'saved' })),
111
+ };
112
+ },
113
+ };
114
+ const preset = tools['create-preset']!;
115
+ const procedural = tools['create-procedural']!;
116
+ const createInput = z.strictObject({
117
+ definition: z.discriminatedUnion('kind', [
118
+ (preset.inputSchema as z.ZodObject).extend({ kind: z.literal('preset') }),
119
+ (procedural.inputSchema as z.ZodObject).extend({ kind: z.literal('procedural') }),
120
+ ]),
121
+ });
122
+ const create: KilnToolDef = {
123
+ ...preset,
124
+ name: 'kiln_material_create',
125
+ description:
126
+ 'Create and retain one immutable material from a preset or procedural definition. Presets require explicit seed, creator and license. Pin the returned materialId/revisionId for rendering. Does not fetch URLs or execute source.',
127
+ inputSchema: createInput,
128
+ run: async (raw) => {
129
+ const { kind, ...input } = createInput.parse(raw).definition;
130
+ return (kind === 'preset' ? preset : procedural).run(input);
131
+ },
132
+ };
133
+ return [search, tools.get!, create, tools.import!];
134
+ }
135
+
136
+ function rendererOperations(def: KilnToolDef, context: KilnToolContext): KilnToolDef[] {
137
+ if (!context.renderCapabilities) return [];
138
+ return ['status', ...(context.reprobeRenderer ? ['reprobe'] : [])].map((action) => {
139
+ const inputSchema = z.strictObject({});
140
+ return {
141
+ ...def,
142
+ name: `kiln_renderer_${action}`,
143
+ inputSchema,
144
+ run: async (raw: unknown) => {
145
+ inputSchema.parse(raw);
146
+ return def.run({ action });
147
+ },
148
+ };
149
+ });
150
+ }
151
+
152
+ function discoveryOperations(def: KilnToolDef): KilnToolDef[] {
153
+ const original = def.inputSchema as z.ZodObject;
154
+ const { capabilities: _capabilities, ...fields } = original.shape;
155
+ const inputSchema = z.strictObject(fields);
156
+ const empty = z.strictObject({});
157
+ return [
158
+ {
159
+ ...def,
160
+ description:
161
+ 'Discover geometry helpers, assemblies, recipes and complete input shapes. Search with modeling language or fetch up to six exact IDs. Use kiln_capabilities for current host facts.',
162
+ inputSchema,
163
+ run: async (raw) => def.run(inputSchema.parse(raw)),
164
+ },
165
+ {
166
+ ...def,
167
+ name: 'kiln_capabilities',
168
+ description:
169
+ 'Inspect declared host capabilities, execution limits and available storage. Observed rendering fidelity is returned by each render.',
170
+ inputSchema: empty,
171
+ run: async (raw) => {
172
+ empty.parse(raw);
173
+ return def.run({ capabilities: true });
174
+ },
175
+ },
176
+ ];
177
+ }
@@ -24,6 +24,8 @@ import { createKilnMaterialDef } from './materials';
24
24
  import { createKilnReviewDef, type ReviewStore } from './review';
25
25
  import { withWorkspaceContext } from './workspace';
26
26
  import { describeInputError } from './actions';
27
+ import { projectOperationDefs } from './operations';
28
+ import type { KilnActionContract } from './operation-contract';
27
29
  import {
28
30
  advancedCaptureInput,
29
31
  cameraShotInput,
@@ -92,8 +94,11 @@ import type { TextureUsage } from '../textures';
92
94
  // Tool definition contract
93
95
  // =============================================================================
94
96
 
95
- export const KILN_ASSET_WIDGET_URI = 'ui://kiln/asset-v5.html';
97
+ export { KILN_ASSET_WIDGET_URI } from '../asset-widget-uri';
98
+ import { KILN_ASSET_WIDGET_URI } from '../asset-widget-uri';
96
99
  export interface KilnToolDef {
100
+ /** Shared action ownership and requirements; never hand-maintained by transports. */
101
+ actionContract?: Readonly<Record<string, import('./operation-contract').KilnActionContract>>;
97
102
  outputSchema?: z.ZodType;
98
103
  /** Optional MCP App presentation; metadata is hidden from the language model. */
99
104
  ui?: {
@@ -2498,13 +2503,14 @@ async function readSavedRevision(
2498
2503
  collection: string,
2499
2504
  assetId: string,
2500
2505
  revisionId: string,
2506
+ listCall = `kiln_assets { action: 'list', collection: '${collection}' }`,
2501
2507
  ) {
2502
2508
  try {
2503
2509
  return await target.read(collection, assetId, revisionId);
2504
2510
  } catch (error) {
2505
2511
  if ((error as NodeJS.ErrnoException).code === 'ENOENT')
2506
2512
  throw new Error(
2507
- `Unknown asset ${assetId} revision ${revisionId} in collection ${collection}. kiln_assets { action: 'list', collection: '${collection}' } lists the saved revisions.`,
2513
+ `Unknown asset ${assetId} revision ${revisionId} in collection ${collection}. ${listCall} lists the saved revisions.`,
2508
2514
  );
2509
2515
  throw error;
2510
2516
  }
@@ -2512,12 +2518,19 @@ async function readSavedRevision(
2512
2518
 
2513
2519
  export function createKilnProgramToolRegistry(
2514
2520
  suppliedContext: KilnToolContext = {},
2521
+ ): KilnToolDef[] {
2522
+ return createProgramDefs(suppliedContext, 'grouped');
2523
+ }
2524
+
2525
+ function createProgramDefs(
2526
+ suppliedContext: KilnToolContext,
2527
+ presentation: 'grouped' | 'operations',
2515
2528
  ): KilnToolDef[] {
2516
2529
  toolRequirements(suppliedContext);
2517
2530
  const store = suppliedContext.programStore ?? new MemoryProgramStore();
2518
2531
  const context = withCaptureCache(withBuildCache({ ...suppliedContext, programStore: store }));
2519
2532
  return [
2520
- createKilnDiscoveryDef({ ...suppliedContext, programStore: store }),
2533
+ createKilnDiscoveryDef({ ...suppliedContext, programStore: store }, presentation),
2521
2534
  {
2522
2535
  name: 'kiln_renderer',
2523
2536
  description:
@@ -2564,7 +2577,9 @@ export function createKilnProgramToolRegistry(
2564
2577
  ...(context.projectStore
2565
2578
  ? [createKilnProjectDef(context.projectStore, context.projectBundleReader)]
2566
2579
  : []),
2567
- ...(context.materialLibrary ? [createKilnMaterialDef(context.materialLibrary)] : []),
2580
+ ...(context.materialLibrary
2581
+ ? [createKilnMaterialDef(context.materialLibrary, presentation)]
2582
+ : []),
2568
2583
  ...(context.reviewStore && context.assetLibrary
2569
2584
  ? [
2570
2585
  createKilnReviewDef({
@@ -2574,7 +2589,7 @@ export function createKilnProgramToolRegistry(
2574
2589
  }),
2575
2590
  ]
2576
2591
  : []),
2577
- ...createKilnAssetDefs({ ...context, programStore: store }),
2592
+ ...createKilnAssetDefs({ ...context, programStore: store }, presentation),
2578
2593
  ]
2579
2594
  .map((def) => withWorkspaceContext(def, context))
2580
2595
  .map((def) => ({
@@ -2591,6 +2606,11 @@ export function createKilnProgramToolRegistry(
2591
2606
  }));
2592
2607
  }
2593
2608
 
2609
+ /** Opt-in named domain operations; grouped local/native contracts remain unchanged. */
2610
+ export function createKilnOperationToolRegistry(context: KilnToolContext = {}): KilnToolDef[] {
2611
+ return projectOperationDefs(createProgramDefs(context, 'operations'), context);
2612
+ }
2613
+
2594
2614
  /** Canonical native workflow: shared definitions plus one host-owned terminal. */
2595
2615
  export function createKilnNativeToolRegistry(
2596
2616
  suppliedContext: KilnToolContext,
@@ -2686,6 +2706,36 @@ const assetSelector = {
2686
2706
  revisionId: z.string().regex(/^[a-z][a-z0-9_-]{0,79}$/),
2687
2707
  };
2688
2708
 
2709
+ const ASSET_CONTRACT = {
2710
+ collections: {
2711
+ fields: [],
2712
+ required: [],
2713
+ description: 'List configured asset storage collections. Collection is not project membership.',
2714
+ },
2715
+ catalog: {
2716
+ fields: ['assetId', 'query', 'offset', 'limit'],
2717
+ required: [],
2718
+ description: 'Search saved assets across configured collections with pagination.',
2719
+ },
2720
+ list: {
2721
+ fields: ['collection', 'assetId', 'query', 'offset', 'limit'],
2722
+ required: [],
2723
+ description: 'Search saved assets in one collection with pagination.',
2724
+ },
2725
+ get: {
2726
+ fields: ['collection', 'assetId', 'revisionId'],
2727
+ required: ['assetId'],
2728
+ description:
2729
+ 'Read a saved revision and its download descriptors. Omit revisionId for the newest revision.',
2730
+ },
2731
+ restore: {
2732
+ fields: ['collection', 'assetId', 'revisionId'],
2733
+ required: ['assetId'],
2734
+ description:
2735
+ 'Restore exact saved source as programRef for editing or rendering. Omit revisionId for the newest revision. Does not overwrite the saved revision. Binary-only assets have no source.',
2736
+ },
2737
+ } satisfies Record<string, KilnActionContract>;
2738
+
2689
2739
  /**
2690
2740
  * The revision a get or restore without `revisionId` means: the newest, the
2691
2741
  * one no later revision names as its parent, ties to the latest `createdAt`
@@ -2701,12 +2751,11 @@ export async function newestRevision(
2701
2751
  },
2702
2752
  collection: string,
2703
2753
  assetId: string,
2754
+ listCall = `kiln_assets { action: 'list', collection: '${collection}' }`,
2704
2755
  ): Promise<string> {
2705
2756
  const revisions = (await target.list(collection)).filter((a) => a.assetId === assetId);
2706
2757
  if (!revisions.length)
2707
- throw new Error(
2708
- `Unknown asset ${assetId} in ${collection}; kiln_assets { action: 'list', collection: '${collection}' } lists them.`,
2709
- );
2758
+ throw new Error(`Unknown asset ${assetId} in ${collection}; ${listCall} lists them.`);
2710
2759
  const parents = new Set(revisions.map((a) => a.parentRevision).filter(Boolean));
2711
2760
  const heads = revisions.filter((a) => !parents.has(a.revisionId));
2712
2761
  const newest = (heads.length ? heads : revisions).sort((a, b) =>
@@ -2797,7 +2846,23 @@ export async function buildProgramAssetDraft(
2797
2846
  }
2798
2847
 
2799
2848
  /** All asset operation schemas live here, alongside the existing tool definitions. */
2800
- export function createKilnAssetDefs(context: KilnToolContext): KilnToolDef[] {
2849
+ export function createKilnAssetDefs(
2850
+ context: KilnToolContext,
2851
+ presentation: 'grouped' | 'operations' = 'grouped',
2852
+ ): KilnToolDef[] {
2853
+ const listCall = (collection: string) =>
2854
+ presentation === 'operations'
2855
+ ? `kiln_assets_search({ collection: '${collection}' })`
2856
+ : `kiln_assets { action: 'list', collection: '${collection}' }`;
2857
+ const selector =
2858
+ presentation === 'operations'
2859
+ ? {
2860
+ ...assetSelector,
2861
+ collection: assetSelector.collection.describe(
2862
+ 'Collection ID from kiln_assets_collections; default project.',
2863
+ ),
2864
+ }
2865
+ : assetSelector;
2801
2866
  const library = () => {
2802
2867
  if (!context.assetLibrary)
2803
2868
  throw new Error(
@@ -2828,11 +2893,11 @@ export function createKilnAssetDefs(context: KilnToolContext): KilnToolDef[] {
2828
2893
  downloadUrls: await context.assetDownloadUrls?.(collection, asset.assetId, asset.revisionId),
2829
2894
  });
2830
2895
  const saveInput = z.object({
2831
- collection: assetSelector.collection,
2896
+ collection: selector.collection,
2832
2897
  programRef: z.string(),
2833
2898
  name: z.string().min(1).max(200),
2834
- assetId: assetSelector.assetId.optional(),
2835
- parentRevision: assetSelector.revisionId.optional(),
2899
+ assetId: selector.assetId.optional(),
2900
+ parentRevision: selector.revisionId.optional(),
2836
2901
  tags: z.array(z.string().max(80)).max(30).optional(),
2837
2902
  brief: z.string().max(8000).optional(),
2838
2903
  description: z.string().max(4000).optional(),
@@ -2850,16 +2915,16 @@ export function createKilnAssetDefs(context: KilnToolContext): KilnToolDef[] {
2850
2915
  });
2851
2916
  const assetsInput = z.object({
2852
2917
  action: z.enum(['collections', 'catalog', 'list', 'get', 'restore']).default('list'),
2853
- collection: assetSelector.collection,
2854
- assetId: assetSelector.assetId.optional(),
2855
- revisionId: assetSelector.revisionId
2918
+ collection: selector.collection,
2919
+ assetId: selector.assetId.optional(),
2920
+ revisionId: selector.revisionId
2856
2921
  .optional()
2857
2922
  .describe('get, restore: the saved revision; omitted, the newest.'),
2858
2923
  query: z.string().max(200).optional(),
2859
2924
  offset: z.number().int().min(0).default(0),
2860
2925
  limit: z.number().int().min(1).max(50).default(20),
2861
2926
  });
2862
- const exportInput = z.object(assetSelector);
2927
+ const exportInput = z.object(selector);
2863
2928
  const profileExportInput = exportInput.extend({
2864
2929
  profile: z
2865
2930
  .enum(['editable', 'runtime'])
@@ -2869,8 +2934,8 @@ export function createKilnAssetDefs(context: KilnToolContext): KilnToolDef[] {
2869
2934
  ),
2870
2935
  });
2871
2936
  const importInput = z.object({
2872
- ...assetSelector,
2873
- sourceCollection: assetSelector.collection,
2937
+ ...selector,
2938
+ sourceCollection: selector.collection,
2874
2939
  });
2875
2940
  return [
2876
2941
  {
@@ -2892,6 +2957,7 @@ export function createKilnAssetDefs(context: KilnToolContext): KilnToolDef[] {
2892
2957
  input.collection,
2893
2958
  input.assetId,
2894
2959
  input.parentRevision,
2960
+ listCall(input.collection),
2895
2961
  );
2896
2962
  assertSavedRequirementsAuthorized(previous.manifest, activeRequirements);
2897
2963
  }
@@ -2908,6 +2974,7 @@ export function createKilnAssetDefs(context: KilnToolContext): KilnToolDef[] {
2908
2974
  description:
2909
2975
  'Browse saved assets: collections discovers storage; catalog searches all configured collections; list searches one. Both searches paginate. get returns a build record/downloads; restore loads exact source for kiln_source/kiln_edit. Collection is not project membership. Binary-only assets cannot restore source.',
2910
2976
  inputSchema: assetsInput,
2977
+ actionContract: ASSET_CONTRACT,
2911
2978
  run: async (raw) => {
2912
2979
  const input = assetsInput.parse(raw);
2913
2980
  const activeRequirements = toolRequirements(context);
@@ -2961,13 +3028,25 @@ export function createKilnAssetDefs(context: KilnToolContext): KilnToolDef[] {
2961
3028
  })),
2962
3029
  };
2963
3030
  }
2964
- if (!input.assetId)
3031
+ if (ASSET_CONTRACT[input.action].required.some((field) => !input[field as 'assetId']))
2965
3032
  throw new Error(
2966
3033
  `kiln_assets ${input.action} requires assetId; kiln_assets { action: 'list', collection: '${input.collection}' } lists them.`,
2967
3034
  );
2968
3035
  const revisionId =
2969
- input.revisionId ?? (await newestRevision(target, input.collection, input.assetId));
2970
- const record = await readSavedRevision(target, input.collection, input.assetId, revisionId);
3036
+ input.revisionId ??
3037
+ (await newestRevision(
3038
+ target,
3039
+ input.collection,
3040
+ input.assetId!,
3041
+ listCall(input.collection),
3042
+ ));
3043
+ const record = await readSavedRevision(
3044
+ target,
3045
+ input.collection,
3046
+ input.assetId!,
3047
+ revisionId,
3048
+ listCall(input.collection),
3049
+ );
2971
3050
  if (input.action === 'get') return links(input.collection, record.manifest);
2972
3051
  const saved = assertSavedRequirementsAuthorized(record.manifest, activeRequirements);
2973
3052
  const code = record.files['source.kiln.js'];
@@ -3059,6 +3138,7 @@ export function createKilnAssetDefs(context: KilnToolContext): KilnToolDef[] {
3059
3138
  input.collection,
3060
3139
  input.assetId,
3061
3140
  input.revisionId,
3141
+ listCall(input.collection),
3062
3142
  );
3063
3143
  const { inspectDrawDiagnostics } = await import('../draw-diagnostics');
3064
3144
  if (input.profile === 'runtime') {
@@ -3102,6 +3182,7 @@ export function createKilnAssetDefs(context: KilnToolContext): KilnToolDef[] {
3102
3182
  input.sourceCollection,
3103
3183
  input.assetId,
3104
3184
  input.revisionId,
3185
+ listCall(input.sourceCollection),
3105
3186
  );
3106
3187
  await target.import(input.collection, [record]);
3107
3188
  return links(input.collection, record.manifest);
@@ -51,6 +51,8 @@ function encode(bytes: Uint8Array) {
51
51
  async function show(input: unknown) {
52
52
  const revision = ++presentationRevision;
53
53
  record = undefined;
54
+ element('stats').textContent = '';
55
+ element<HTMLSelectElement>('clips').hidden = true;
54
56
  for (const button of document.querySelectorAll<HTMLButtonElement>('button[data-download]'))
55
57
  button.disabled = true;
56
58
  try {
@@ -116,14 +118,16 @@ async function showResult(
116
118
  status.textContent = 'Drag to orbit · Scroll to zoom';
117
119
  } catch (error) {
118
120
  if (revision !== presentationRevision) return;
121
+ stage?.dispose();
122
+ stage = undefined;
119
123
  const preview = record.files['preview.png'];
120
124
  if (preview) {
121
125
  const image = new Image();
122
- image.alt = record.manifest.name;
126
+ image.alt = `${record.manifest.name} · saved preview image`;
123
127
  image.src = `data:image/png;base64,${encode(preview)}`;
124
128
  element('stage').replaceChildren(image);
125
129
  }
126
- status.textContent = `3D preview unavailable: ${String(error)}`;
130
+ status.textContent = `${preview ? 'Showing saved preview. ' : ''}3D preview unavailable: ${String(error)}`;
127
131
  }
128
132
  for (const button of document.querySelectorAll<HTMLButtonElement>('button[data-download]'))
129
133
  button.disabled = false;