@deepblend/dsh-blender-tool 0.1.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/lib/tools.js ADDED
@@ -0,0 +1,988 @@
1
+ /**
2
+ * M1 model-visible tools: the batch SceneSpec loop.
3
+ *
4
+ * Plane: Agent preset (SPEC §4.2, §4.3). This module registers tools and
5
+ * publishes NOTHING — it consumes `blenderStudio` from the Host composition.
6
+ *
7
+ * WHICH TOOLS EXIST, AND WHY ONLY THESE
8
+ * -------------------------------------
9
+ * SPEC §11 lists twelve tools for the finished product. M1 registers six, plus
10
+ * the M0 `blender_capabilities`, and the rule for the gap is the M0 rule:
11
+ * **a tool the model can see is a promise the runtime must keep.** So
12
+ * `blender_final_render`, `blender_export`, `blender_asset_ingest`,
13
+ * `blender_job_status` and `blender_job_cancel` are deliberately absent rather
14
+ * than registered-and-throwing: their host services arrive in M3, and a model
15
+ * that can see `blender_final_render` would reasonably plan around it.
16
+ *
17
+ * WHAT THE TOOL DESCRIPTIONS ARE DOING
18
+ * ------------------------------------
19
+ * They are the API documentation the model actually reads, so they carry the
20
+ * facts a caller cannot discover from the schema: that a write needs the current
21
+ * `baseRevision`, that a stale one is refused rather than merged, that an
22
+ * identical retry is safe, and — most importantly — that `preview_render` needs
23
+ * a `.blend` checkpoint, which is why a patch that should be visible must set
24
+ * `saveCheckpoint`.
25
+ *
26
+ * Owner: DeepBlend Studio — M1
27
+ */
28
+
29
+ import { defineTool } from '@deepseek-ai/dsh-tools'
30
+
31
+ import {
32
+ SCENE_OPERATION_NAMES,
33
+ BlenderErrorCode,
34
+ BlenderWarningCode,
35
+ warning,
36
+ } from '@deepblend/dsh-blender-contracts'
37
+
38
+ import {
39
+ TOOL_OUTPUT,
40
+ canonicalCall,
41
+ definedFields,
42
+ describeRevision,
43
+ renderFailure,
44
+ renderSuccess,
45
+ requestApproval,
46
+ resolveStudio,
47
+ } from './shared.js'
48
+
49
+ /** A short parameter description of a SceneSpec engine key. */
50
+ const ENGINE_KEY_DESCRIPTION =
51
+ 'Render engine: "cycles" (path traced — correct metal, glass and shadows; the default), ' +
52
+ '"eevee" (fast rasterised preview), or "workbench" (solid shading, no materials). ' +
53
+ 'If the installed Blender cannot run the requested engine it is downgraded and a warning is returned.'
54
+
55
+ /** Shared shape for the `sceneSpec` parameter of project_create. */
56
+ const SCENE_SPEC_PARAMETER = {
57
+ type: 'object',
58
+ additionalProperties: true,
59
+ description:
60
+ 'A complete SceneSpec v1 document to seed the project with. Omit it to start from a minimal, ' +
61
+ 'immediately renderable scaffold (one cube, one camera, one area light, both render profiles), ' +
62
+ 'then build the scene with blender_scene_patch — that route is usually better, because each patch ' +
63
+ 'is a reviewable revision instead of one large opaque document. ' +
64
+ 'Top-level keys: schemaVersion, project, assets[], materials[], entities[], lights[], cameras[], ' +
65
+ 'shots[], animationTracks[], renderProfiles{preview,final}. ' +
66
+ 'A generator entity is `{id,type:"generator",generator:{shape,size|radius|depth|majorRadius|minorRadius,bevel?},' +
67
+ 'materialId,transform:{location,rotationEuler,scale}}` where shape is one of ' +
68
+ 'cube, rounded_box, uv_sphere, cylinder, cone, plane, torus. Material parameters use Blender 5 socket ' +
69
+ 'names: baseColor [r,g,b,a], metallic, roughness, ior, alpha, emissionColor, emissionStrength, ' +
70
+ 'coatWeight, transmissionWeight. A camera aims itself at `targetEntityId` (or `targetPoint`) and needs ' +
71
+ '`transform.location` for framing.',
72
+ }
73
+
74
+ /**
75
+ * Register every M1 DeepBlend tool for the calling agent scope.
76
+ *
77
+ * Registration is fiber-scoped: Cordis disposes it when the preset's subtree
78
+ * unmounts, so no manual teardown is needed — and adding one would risk
79
+ * double-disposal on reload.
80
+ *
81
+ * @param {import('@deepseek-ai/cordis').Context} ctx
82
+ */
83
+ export function apply(ctx) {
84
+ ctx.tools.register(projectCreate(ctx))
85
+ ctx.tools.register(projectGet(ctx))
86
+ ctx.tools.register(sceneGet(ctx))
87
+ ctx.tools.register(scenePatch(ctx))
88
+ ctx.tools.register(previewRender(ctx))
89
+ ctx.tools.register(sceneValidate(ctx))
90
+ ctx.tools.register(revisionRestore(ctx))
91
+ ctx.tools.register(assetIngest(ctx))
92
+ }
93
+
94
+ // ---------------------------------------------------------------------------
95
+ // project_create
96
+ // ---------------------------------------------------------------------------
97
+
98
+ function projectCreate(ctx) {
99
+ return defineTool({
100
+ name: 'blender_project_create',
101
+ description:
102
+ 'Create a DeepBlend 3D project and commit its first revision. Returns the projectId and the initial ' +
103
+ 'revision id — keep BOTH, because every later write must name the project and the revision it read. ' +
104
+ 'Give it the operator\'s brief as `goal`: it is stored with the project and never parsed, so write it ' +
105
+ 'in full. Omit `sceneSpec` to start from a minimal renderable scaffold and build up with ' +
106
+ 'blender_scene_patch; pass one only when you already know the whole scene. ' +
107
+ 'Set saveCheckpoint:false to skip the Blender compile and store the SceneSpec alone. The revision then ' +
108
+ 'has no .blend of its own, and the first render of it compiles one from the spec — which costs a few ' +
109
+ 'extra seconds on that render instead of on the commit, and is exactly why the SceneSpec, not the ' +
110
+ '.blend, is the source of truth.',
111
+ parameters: {
112
+ title: {
113
+ type: 'string',
114
+ required: true,
115
+ description:
116
+ 'Human-readable project title. Also the source of the project id (slugified), so keep it short ' +
117
+ 'and distinctive — "watch commercial" becomes projectId "watch-commercial".',
118
+ },
119
+ goal: {
120
+ type: 'string',
121
+ description:
122
+ 'The operator\'s natural-language brief, stored verbatim for audit. It never affects compilation.',
123
+ },
124
+ sceneSpec: SCENE_SPEC_PARAMETER,
125
+ projectId: {
126
+ type: 'string',
127
+ description:
128
+ 'Optional explicit project id (lowercase letters, digits, dashes). Omit to derive it from the title; ' +
129
+ 'a collision gets a numeric suffix rather than an error.',
130
+ },
131
+ saveCheckpoint: {
132
+ type: 'boolean',
133
+ description:
134
+ 'Compile the scene in Blender and store `<revision>/scene.blend` as a checkpoint. Default true. ' +
135
+ 'A preview can only be rendered from a checkpoint, so leave this on unless you are only reshaping the spec.',
136
+ },
137
+ renderPreview: {
138
+ type: 'boolean',
139
+ description:
140
+ 'Also render the preview profile now, so the first revision already has a viewable image. Default false.',
141
+ },
142
+ },
143
+ output: TOOL_OUTPUT,
144
+ async execute(args, exec) {
145
+ const resolved = resolveStudio(ctx)
146
+ if (resolved.unavailable !== undefined) return { ok: false, ...resolved.unavailable }
147
+ try {
148
+ const { data, canonicalWarnings } = await canonicalCall(resolved.studio.createProject({
149
+ ...definedFields({
150
+ title: args.title,
151
+ goal: args.goal,
152
+ sceneSpec: args.sceneSpec,
153
+ projectId: args.projectId,
154
+ saveCheckpoint: args.saveCheckpoint,
155
+ }),
156
+ renderPreview: args.renderPreview === true,
157
+ signal: exec.signal,
158
+ }), warning)
159
+ const notes = [
160
+ `Project: ${data.projectId} "${data.title}"`,
161
+ `Revision: ${describeRevision(data.revision)}`,
162
+ `Digest: ${data.revision.digest}`,
163
+ ]
164
+ if (data.revision.checkpoint === null) {
165
+ notes.push('No checkpoint was saved, so this revision cannot be previewed until a later revision saves one.')
166
+ }
167
+ if (Array.isArray(data.warnings) && data.warnings.length > 0) {
168
+ notes.push('')
169
+ notes.push('Compiler notes (these describe decisions made on your behalf):')
170
+ for (const entry of data.warnings) notes.push(` - ${entry.message}`)
171
+ }
172
+ return {
173
+ ok: true,
174
+ text: renderSuccess(`Created project "${data.projectId}" at revision ${data.revision.revision}.`, data, {
175
+ notes,
176
+ warnings: canonicalWarnings,
177
+ }),
178
+ data,
179
+ }
180
+ } catch (cause) {
181
+ const failure = renderFailure(cause, 'PROJECT_CREATE_FAILED')
182
+ return { ok: false, ...failure }
183
+ }
184
+ },
185
+ presentCall: args => ({
186
+ card: 'generic',
187
+ title: `Create project "${args?.title ?? 'project'}"`,
188
+ kind: 'edit',
189
+ }),
190
+ })
191
+ }
192
+
193
+ // ---------------------------------------------------------------------------
194
+ // project_get
195
+ // ---------------------------------------------------------------------------
196
+
197
+ function projectGet(ctx) {
198
+ return defineTool({
199
+ name: 'blender_project_get',
200
+ description:
201
+ 'Read a project: its current revision, the scene digest, and the full revision history with each ' +
202
+ 'revision\'s summary, digest, checkpoint and previews. Use this to see how a scene evolved, and to ' +
203
+ 'find a revision id you want to restore. It returns digests and summaries, not full SceneSpecs — ' +
204
+ 'call blender_scene_get with full:true when you need the document itself.',
205
+ parameters: {
206
+ projectId: { type: 'string', required: true, description: 'The project id returned by blender_project_create.' },
207
+ revision: {
208
+ type: 'string',
209
+ description: 'Read a specific revision (e.g. "r0003") instead of the current one. Read-only; ' +
210
+ 'the project\'s current revision does not change.',
211
+ },
212
+ },
213
+ output: TOOL_OUTPUT,
214
+ async execute(args, exec) {
215
+ const resolved = resolveStudio(ctx)
216
+ if (resolved.unavailable !== undefined) return { ok: false, ...resolved.unavailable }
217
+ try {
218
+ const { data, canonicalWarnings } = await canonicalCall(
219
+ resolved.studio.getProject(args.projectId, definedFields({ revision: args.revision })), warning,
220
+ )
221
+ const scene = data.scene ?? {}
222
+ const notes = [
223
+ `Project: ${data.projectId} "${data.title}"`,
224
+ `Current: ${data.currentRevision} (${data.revisionCount} revision${data.revisionCount === 1 ? '' : 's'})`,
225
+ `Scene: ${JSON.stringify(scene.counts ?? {})}`,
226
+ `Frame range: ${scene.project?.frameStart}..${scene.project?.frameEnd} at ${scene.project?.fps} fps`,
227
+ '',
228
+ 'Revisions:',
229
+ ]
230
+ for (const entry of data.revisions ?? []) {
231
+ const marker = entry.isCurrent ? ' <- current' : ''
232
+ notes.push(
233
+ ` ${entry.revision} ${entry.kind ?? '?'}${entry.checkpoint === null ? ' (no checkpoint)' : ''}` +
234
+ `${(entry.previews ?? []).length > 0 ? ` (${entry.previews.length} preview)` : ''}${marker}`,
235
+ )
236
+ if (entry.summary) notes.push(` ${entry.summary}`)
237
+ }
238
+ if (args.revision !== undefined && args.revision !== data.currentRevision) {
239
+ notes.push('')
240
+ notes.push(`Read revision ${args.revision}; the project still points at ${data.currentRevision}.`)
241
+ }
242
+ return {
243
+ ok: true,
244
+ text: renderSuccess(`Project "${data.projectId}" at ${data.currentRevision}.`, data, { notes, warnings: canonicalWarnings }),
245
+ data,
246
+ }
247
+ } catch (cause) {
248
+ const failure = renderFailure(cause, 'PROJECT_READ_FAILED')
249
+ return { ok: false, ...failure }
250
+ }
251
+ },
252
+ presentCall: args => ({ card: 'generic', title: `Read project "${args?.projectId ?? ''}"`, kind: 'read' }),
253
+ })
254
+ }
255
+
256
+ // ---------------------------------------------------------------------------
257
+ // scene_get
258
+ // ---------------------------------------------------------------------------
259
+
260
+ function sceneGet(ctx) {
261
+ return defineTool({
262
+ name: 'blender_scene_get',
263
+ description:
264
+ 'Read a project\'s scene. By default this returns a compact SceneDigest — the revision and digest, ' +
265
+ 'frame range and fps, entity/material/light/camera counts, every entity with its shape, material ' +
266
+ 'location and visibility, camera parameters, animation tracks and the render profiles. That is ' +
267
+ 'usually everything you need to decide what to change. Pass full:true to also get the complete ' +
268
+ 'SceneSpec document, which you should do only when you actually need exact field values. ' +
269
+ 'ALWAYS use the revision this returns as `baseRevision` for your next blender_scene_patch.',
270
+ parameters: {
271
+ projectId: { type: 'string', required: true, description: 'The project id.' },
272
+ revision: { type: 'string', description: 'Read a specific revision instead of the current one.' },
273
+ full: {
274
+ type: 'boolean',
275
+ description: 'Also return the complete SceneSpec and its compiled form. Costs a lot of context — ' +
276
+ 'use it when you need exact values, not to browse.',
277
+ },
278
+ },
279
+ output: TOOL_OUTPUT,
280
+ async execute(args, exec) {
281
+ const resolved = resolveStudio(ctx)
282
+ if (resolved.unavailable !== undefined) return { ok: false, ...resolved.unavailable }
283
+ try {
284
+ const { data, canonicalWarnings } = await canonicalCall(resolved.studio.getScene(args.projectId, {
285
+ ...definedFields({ revision: args.revision }),
286
+ full: args.full === true,
287
+ }), warning)
288
+ const counts = data.counts ?? {}
289
+ const notes = [
290
+ `Revision ${data.revision}${data.isCurrent ? ' (current)' : ' (NOT current — patches against it will be refused)'}`,
291
+ `Digest: ${data.digest}`,
292
+ `Title: ${data.project?.title}`,
293
+ `Frame range: ${data.project?.frameStart}..${data.project?.frameEnd} at ${data.project?.fps} fps, ${data.project?.aspectRatio}`,
294
+ `Counts: ${JSON.stringify(counts)}`,
295
+ '',
296
+ 'Entities:',
297
+ ]
298
+ for (const entity of data.entities ?? []) {
299
+ notes.push(
300
+ ` ${entity.id.padEnd(22)} ${String(entity.type).padEnd(15)} ${String(entity.shape ?? '-').padEnd(12)}` +
301
+ ` material=${entity.materialId ?? '<default>'} at [${entity.location.join(', ')}]` +
302
+ `${entity.visible === false ? ' HIDDEN' : ''}${entity.locked ? ' locked' : ''}`,
303
+ )
304
+ }
305
+ notes.push('')
306
+ notes.push('Cameras:')
307
+ for (const camera of data.cameras ?? []) {
308
+ notes.push(
309
+ ` ${camera.id.padEnd(18)} ${camera.lens}mm at [${camera.location.join(', ')}]` +
310
+ ` aiming at ${camera.targetEntityId ?? (camera.targetPoint ? `point [${camera.targetPoint.join(', ')}]` : 'authored rotation')}`,
311
+ )
312
+ }
313
+ notes.push('')
314
+ notes.push('Lights:')
315
+ for (const light of data.lights ?? []) {
316
+ notes.push(` ${light.id.padEnd(18)} ${light.type.padEnd(8)} energy=${light.energy} at [${light.location.join(', ')}]`)
317
+ }
318
+ if ((data.animationTracks ?? []).length > 0) {
319
+ notes.push('')
320
+ notes.push('Animation:')
321
+ for (const track of data.animationTracks) {
322
+ notes.push(
323
+ ` ${track.id.padEnd(20)} ${track.targetEntityId}.${track.property} ` +
324
+ `${track.keyframeCount} keyframes over frames ${track.frameRange?.[0]}..${track.frameRange?.[1]}`,
325
+ )
326
+ }
327
+ }
328
+ notes.push('')
329
+ notes.push(`Render profiles: ${JSON.stringify(data.renderProfiles)}`)
330
+ if ((data.previews ?? []).length > 0) {
331
+ notes.push(`Previews: ${data.previews.map(entry => `${entry.path} (camera ${entry.cameraId}, frame ${entry.frame})`).join('; ')}`)
332
+ }
333
+ if (data.checkpoint !== null) notes.push(`Checkpoint: ${data.checkpoint}`)
334
+ return {
335
+ ok: true,
336
+ text: renderSuccess(`Scene ${data.revision} of project "${args.projectId}".`, data, { notes, warnings: canonicalWarnings }),
337
+ data,
338
+ }
339
+ } catch (cause) {
340
+ const failure = renderFailure(cause, 'SCENE_READ_FAILED')
341
+ return { ok: false, ...failure }
342
+ }
343
+ },
344
+ presentCall: args => ({
345
+ card: 'generic',
346
+ title: args?.full === true ? `Read full scene of "${args?.projectId ?? ''}"` : `Read scene of "${args?.projectId ?? ''}"`,
347
+ kind: 'read',
348
+ }),
349
+ })
350
+ }
351
+
352
+ // ---------------------------------------------------------------------------
353
+ // scene_patch
354
+ // ---------------------------------------------------------------------------
355
+
356
+ const OPERATION_SUMMARY = [
357
+ 'entity.transform.update {entityId, location?, rotationEuler?, scale?} — only supplied components change',
358
+ 'entity.visibility.set {entityId, visible}',
359
+ 'entity.tags.set {entityId, tags} — replaces the whole tag list; [] clears it',
360
+ 'entity.add {entity} — full entity object',
361
+ 'entity.remove {entityId}',
362
+ 'entity.material.set {entityId, materialId|null} — null restores the default material',
363
+ 'material.add {material} — {id, shader, parameters?}',
364
+ 'material.parameter.update {materialId, parameter, value} — parameter is a Blender 5 socket name',
365
+ 'light.add {light}',
366
+ 'light.update {lightId, energy?, color?, size?, transform?, spotSize?, spotBlend?, angle?}',
367
+ 'light.remove {lightId}',
368
+ 'camera.add {camera}',
369
+ 'camera.update {cameraId, lens?, transform?, targetEntityId?, targetPoint?, clipping?, fStop?}',
370
+ 'camera.remove {cameraId}',
371
+ 'animation.track.set {track} — {id, targetEntityId, targetKind?, property, keyframes[]}',
372
+ 'animation.track.remove {trackId}',
373
+ 'shot.set {shot} — {id, cameraId, frameRange?, description?}',
374
+ 'shot.remove {shotId}',
375
+ 'project.frameRange.set {frameStart, frameEnd, fps?}',
376
+ 'render.profile.set {profileName, profile} — profileName is "preview" or "final"',
377
+ 'world.set {world} — {color?, strength?}; the environment behind the product',
378
+ 'asset.add {asset} — {id, type, path, sha256, license?}; the file must be ingested first',
379
+ 'asset.remove {assetId} — removing the last one makes the key disappear, not an empty list',
380
+ 'material.texture.set {materialId, texture} — {type: noise|wave|voronoi, scale, …}, or null to remove it',
381
+ ].join('\n ')
382
+
383
+ function scenePatch(ctx) {
384
+ return defineTool({
385
+ name: 'blender_scene_patch',
386
+ description:
387
+ 'Modify a scene and commit the result as one new immutable revision. This is the ONLY way to change a ' +
388
+ 'scene — there is no free-form edit. Read the scene first with blender_scene_get and pass the revision ' +
389
+ 'it returned as `baseRevision`; if the project has moved on since, the patch is REFUSED rather than ' +
390
+ 'merged, and you should re-read and re-apply. Operations apply in order and all-or-nothing: if any one ' +
391
+ 'fails, no revision is created and the current revision is untouched, so a failed patch is always safe ' +
392
+ 'to correct and retry. Retrying an identical patch is also safe — it returns the original outcome ' +
393
+ 'instead of committing twice.\n\n' +
394
+ 'Set saveCheckpoint (default true) unless you are only reshaping the spec: a preview can only be ' +
395
+ 'rendered from a revision that has a .blend checkpoint.\n\n' +
396
+ 'Operations (each object needs an "op" key):\n ' + OPERATION_SUMMARY,
397
+ parameters: {
398
+ projectId: { type: 'string', required: true, description: 'The project id.' },
399
+ baseRevision: {
400
+ type: 'string',
401
+ required: true,
402
+ description:
403
+ 'The revision you read before computing this patch, e.g. "r0002". A stale value is refused — ' +
404
+ 'this is what stops a concurrent edit from silently discarding your work, and it is also how ' +
405
+ 'you find out that you need to re-read.',
406
+ },
407
+ operations: {
408
+ type: 'array',
409
+ required: true,
410
+ items: { type: 'object', additionalProperties: true },
411
+ description:
412
+ 'Ordered list of operations. Each needs an "op" key naming the operation (see the tool description). ' +
413
+ 'Later operations see the result of earlier ones, so a patch can remove a camera and then the shot ' +
414
+ 'that used it — in that order.',
415
+ },
416
+ note: {
417
+ type: 'string',
418
+ description:
419
+ 'One line explaining WHY this change was made. It is stored in the revision history and is what a ' +
420
+ 'human reads when reviewing how the scene evolved — write it as prose, not as a repeat of the operations.',
421
+ },
422
+ actor: { type: 'string', description: 'Who asked for this change, for the audit trail (a session id, a stage name).' },
423
+ stage: {
424
+ type: 'string',
425
+ description: 'The orchestrator stage that produced this patch (BRIEF, BUILD, VISUAL_REVIEW, PATCH, ...).',
426
+ },
427
+ idempotencyKey: {
428
+ type: 'string',
429
+ description:
430
+ 'Optional. Identifies THIS logical intent. Leave it unset and one is derived from the project, the ' +
431
+ 'base revision and the operations — so an accidental retry is already safe. Set it explicitly only ' +
432
+ 'when you deliberately want to apply the same operations again as a new revision.',
433
+ },
434
+ saveCheckpoint: {
435
+ type: 'boolean',
436
+ description:
437
+ 'Compile the patched scene in Blender and store `<revision>/scene.blend`. Default true. ' +
438
+ 'Turn it off only for a spec-only change you do not intend to look at.',
439
+ },
440
+ renderPreview: {
441
+ type: 'boolean',
442
+ description: 'Also render the preview profile as part of this commit, so the revision arrives with an image. Default false.',
443
+ },
444
+ },
445
+ output: TOOL_OUTPUT,
446
+ async execute(args, exec) {
447
+ const resolved = resolveStudio(ctx)
448
+ if (resolved.unavailable !== undefined) return { ok: false, ...resolved.unavailable }
449
+ if (!Array.isArray(args.operations) || args.operations.length === 0) {
450
+ return {
451
+ ok: false,
452
+ text:
453
+ 'DeepBlend call failed.\nerrorCode: SCENE_PATCH_INVALID\n' +
454
+ 'message: a ScenePatch needs at least one operation.\n' +
455
+ `supported operations:\n ${SCENE_OPERATION_NAMES.join('\n ')}`,
456
+ data: { ok: false, errorCode: 'SCENE_PATCH_INVALID', message: 'operations must be a non-empty array', detail: null },
457
+ }
458
+ }
459
+ try {
460
+ // `definedFields` is load-bearing here, not tidiness: forwarding
461
+ // `note: undefined` puts an own `note` key on the patch document, and the
462
+ // host's schema validation rejects that as a wrong TYPE. A correct call
463
+ // with an omitted optional argument was being refused for exactly this.
464
+ const { data, canonicalWarnings } = await canonicalCall(resolved.studio.applyScenePatch({
465
+ ...definedFields({
466
+ projectId: args.projectId,
467
+ baseRevision: args.baseRevision,
468
+ operations: args.operations,
469
+ note: args.note,
470
+ actor: args.actor,
471
+ stage: args.stage,
472
+ idempotencyKey: args.idempotencyKey,
473
+ saveCheckpoint: args.saveCheckpoint,
474
+ }),
475
+ renderPreview: args.renderPreview === true,
476
+ signal: exec.signal,
477
+ }), warning)
478
+ const notes = [
479
+ `Revision: ${describeRevision(data)}`,
480
+ `Digest: ${data.revision ? data.revision : data.digest}`,
481
+ `Summary: ${data.summary ?? '(none)'}`,
482
+ ]
483
+ if (data.idempotentReplay === true) {
484
+ notes.unshift(
485
+ 'This exact patch had already been applied, so the original outcome was returned and nothing was ' +
486
+ 'committed again. This is the idempotency guard working, not an error.',
487
+ )
488
+ }
489
+ if (data.checkpoint !== null && data.checkpoint !== undefined) {
490
+ notes.push(`Checkpoint: ${data.checkpoint}`)
491
+ } else {
492
+ notes.push('No checkpoint was saved, so this revision cannot be previewed as-is.')
493
+ }
494
+ if ((data.previews ?? []).length > 0) {
495
+ for (const preview of data.previews) {
496
+ notes.push(`Preview: ${preview.path} (camera ${preview.cameraId}, frame ${preview.frame}, ${preview.width}x${preview.height})`)
497
+ }
498
+ }
499
+ const scene = data.scene ?? {}
500
+ if (scene.counts) notes.push(`Scene now: ${JSON.stringify(scene.counts)}`)
501
+ return {
502
+ ok: true,
503
+ text: renderSuccess(
504
+ `${data.idempotentReplay === true ? 'Reused' : 'Committed'} revision ${data.revision}.`,
505
+ data,
506
+ { notes, warnings: [...(data.warnings ?? []), ...canonicalWarnings] },
507
+ ),
508
+ data,
509
+ }
510
+ } catch (cause) {
511
+ const failure = renderFailure(cause, 'SCENE_PATCH_FAILED')
512
+ return { ok: false, ...failure }
513
+ }
514
+ },
515
+ presentCall: args => ({
516
+ card: 'generic',
517
+ title: `Patch scene of "${args?.projectId ?? ''}" (${Array.isArray(args?.operations) ? args.operations.length : 0} ops)`,
518
+ kind: 'edit',
519
+ }),
520
+ })
521
+ }
522
+
523
+ // ---------------------------------------------------------------------------
524
+ // preview_render
525
+ // ---------------------------------------------------------------------------
526
+
527
+ function previewRender(ctx) {
528
+ return defineTool({
529
+ name: 'blender_preview_render',
530
+ description:
531
+ 'Render one preview frame of a revision from a camera, and store the PNG inside that revision. ' +
532
+ 'Rendering does NOT create a revision — a preview observes a scene, it does not change it — so it is ' +
533
+ 'always safe to call. A preview needs a .blend checkpoint: if the revision has one it is opened ' +
534
+ 'directly; if it does not, the revision is compiled from its SceneSpec into a scratch directory first ' +
535
+ '(slower, and reported as a warning). Prefer committing patches with saveCheckpoint:true so previews ' +
536
+ 'stay cheap. Returns the project-relative path of the image, its dimensions, the engine actually used, ' +
537
+ 'and the frame that was rendered.',
538
+ parameters: {
539
+ projectId: { type: 'string', required: true, description: 'The project id.' },
540
+ revision: { type: 'string', description: 'Revision to render. Defaults to the current one.' },
541
+ cameraId: {
542
+ type: 'string',
543
+ description: 'Camera to render from. Defaults to the project\'s first camera — name one when the scene has several.',
544
+ },
545
+ frame: {
546
+ type: 'integer',
547
+ description:
548
+ 'Frame to render. Defaults to the MIDDLE of the project\'s frame range, which is usually more ' +
549
+ 'informative than frame 1 for an animated scene.',
550
+ },
551
+ samples: {
552
+ type: 'integer',
553
+ description:
554
+ 'Override the preview profile\'s sample count. Lower is faster and noisier. A value above the ' +
555
+ 'profile\'s maxSamplesBudget is reduced and reported rather than honoured.',
556
+ },
557
+ width: { type: 'integer', description: 'Override preview width in pixels.' },
558
+ height: { type: 'integer', description: 'Override preview height in pixels.' },
559
+ },
560
+ output: TOOL_OUTPUT,
561
+ async execute(args, exec) {
562
+ const resolved = resolveStudio(ctx)
563
+ if (resolved.unavailable !== undefined) return { ok: false, ...resolved.unavailable }
564
+ try {
565
+ const { data, canonicalWarnings } = await canonicalCall(resolved.studio.renderPreview({
566
+ ...definedFields({
567
+ projectId: args.projectId,
568
+ revision: args.revision,
569
+ cameraId: args.cameraId,
570
+ frame: args.frame,
571
+ samples: args.samples,
572
+ width: args.width,
573
+ height: args.height,
574
+ }),
575
+ signal: exec.signal,
576
+ }), warning)
577
+ const notes = [
578
+ `Revision: ${data.revision}`,
579
+ `Engine: ${data.profile?.blenderEngine ?? data.profile?.engine}`,
580
+ `Samples: ${data.profile?.samples ?? 'engine default'}`,
581
+ ]
582
+ for (const artifact of data.artifacts ?? []) {
583
+ notes.push(
584
+ `Image: ${artifact.path} (camera ${artifact.cameraId}, frame ${artifact.frame}, ` +
585
+ `${artifact.width}x${artifact.height}, ${artifact.bytes} bytes, sha256 ${String(artifact.sha256).slice(0, 16)}…)`,
586
+ )
587
+ }
588
+ notes.push('')
589
+ notes.push('The path is relative to the project directory. Open it to see what the scene actually looks like.')
590
+ return {
591
+ ok: true,
592
+ text: renderSuccess(`Rendered a preview of ${data.revision}.`, data, { notes, warnings: [...(data.warnings ?? []), ...canonicalWarnings] }),
593
+ data,
594
+ }
595
+ } catch (cause) {
596
+ const failure = renderFailure(cause, 'PREVIEW_RENDER_FAILED')
597
+ return { ok: false, ...failure }
598
+ }
599
+ },
600
+ presentCall: args => ({
601
+ card: 'generic',
602
+ title: `Render preview of "${args?.projectId ?? ''}"${args?.cameraId ? ` from ${args.cameraId}` : ''}`,
603
+ kind: 'other',
604
+ }),
605
+ })
606
+ }
607
+
608
+ // ---------------------------------------------------------------------------
609
+ // scene_validate
610
+ // ---------------------------------------------------------------------------
611
+
612
+ function sceneValidate(ctx) {
613
+ return defineTool({
614
+ name: 'blender_scene_validate',
615
+ description:
616
+ 'Check a revision for problems BEFORE you spend a revision on a change. Reports structural and semantic ' +
617
+ 'errors (dangling references, duplicate ids, an unrenderable scene), the technical report Blender ' +
618
+ 'produced when the revision was compiled (object and geometry counts, camera framing, whether the ' +
619
+ 'subject is inside the frame), and the decisions the compiler made on your behalf. ' +
620
+ 'Pass `patch` to ALSO dry-run a ScenePatch: it is applied to an in-memory copy and validated without ' +
621
+ 'committing anything, which is the cheapest way to find out whether a patch will be accepted. ' +
622
+ 'ok:true means there are no errors — notices are expected and are listed so you can see what the ' +
623
+ 'compiler decided.',
624
+ parameters: {
625
+ projectId: { type: 'string', required: true, description: 'The project id.' },
626
+ revision: { type: 'string', description: 'Revision to validate. Defaults to the current one.' },
627
+ patch: {
628
+ type: 'object',
629
+ additionalProperties: true,
630
+ description:
631
+ 'Optional ScenePatch to dry-run against the validated revision: ' +
632
+ '{baseRevision, operations:[...], plus optional projectId}. Nothing is committed. ' +
633
+ 'Its baseRevision should equal the revision being validated.',
634
+ },
635
+ },
636
+ output: TOOL_OUTPUT,
637
+ async execute(args, exec) {
638
+ const resolved = resolveStudio(ctx)
639
+ if (resolved.unavailable !== undefined) return { ok: false, ...resolved.unavailable }
640
+ try {
641
+ const { data, canonicalWarnings } = await canonicalCall(resolved.studio.validateScene({
642
+ projectId: args.projectId,
643
+ revision: args.revision,
644
+ patch: args.patch === undefined ? undefined : { projectId: args.projectId, ...args.patch },
645
+ signal: exec.signal,
646
+ }), warning)
647
+ const notes = [
648
+ `Revision: ${data.revision}`,
649
+ `Result: ${data.ok ? 'VALID' : `INVALID — ${data.errorCount} error(s)`}`,
650
+ `Digest: ${data.digest}`,
651
+ ]
652
+ if (data.technical !== null && data.technical !== undefined) {
653
+ const technical = data.technical
654
+ notes.push(
655
+ `Technical: engine ${technical.engine}, frames ${technical.frameRange?.[0]}..${technical.frameRange?.[1]} ` +
656
+ `at ${technical.fps} fps, active camera ${technical.activeCamera}`,
657
+ )
658
+ if (technical.counts) notes.push(`Counts: ${JSON.stringify(technical.counts)}`)
659
+ if (technical.geometry) {
660
+ notes.push(
661
+ `Geometry: ${technical.geometry.totalVertices} vertices, ${technical.geometry.totalPolygons} polygons` +
662
+ `${(technical.geometry.degenerateObjects ?? []).length > 0 ? `, DEGENERATE: ${technical.geometry.degenerateObjects.join(', ')}` : ''}`,
663
+ )
664
+ }
665
+ } else {
666
+ notes.push('Technical: this revision has no recorded technical report (it was committed without a checkpoint).')
667
+ }
668
+ if (data.errorCount > 0) {
669
+ notes.push('')
670
+ notes.push('Errors:')
671
+ for (const issue of data.errors) notes.push(` - [${issue.code}] ${issue.path ?? ''} ${issue.message}`)
672
+ }
673
+ if (data.noticeCount > 0) {
674
+ notes.push('')
675
+ notes.push('Notices (decisions the compiler made):')
676
+ for (const issue of data.notices) notes.push(` - [${issue.code}] ${issue.message}`)
677
+ }
678
+ return {
679
+ ok: true,
680
+ text: renderSuccess(
681
+ data.ok
682
+ ? `Revision ${data.revision} validates.`
683
+ : `Revision ${data.revision} has ${data.errorCount} error(s).`,
684
+ data,
685
+ { notes, warnings: canonicalWarnings },
686
+ ),
687
+ data,
688
+ }
689
+ } catch (cause) {
690
+ const failure = renderFailure(cause, 'SCENE_VALIDATE_FAILED')
691
+ return { ok: false, ...failure }
692
+ }
693
+ },
694
+ presentCall: args => ({
695
+ card: 'generic',
696
+ title: args?.patch === undefined
697
+ ? `Validate "${args?.projectId ?? ''}"`
698
+ : `Dry-run a patch against "${args?.projectId ?? ''}"`,
699
+ kind: 'read',
700
+ }),
701
+ })
702
+ }
703
+
704
+ // ---------------------------------------------------------------------------
705
+ // revision_restore
706
+ // ---------------------------------------------------------------------------
707
+
708
+ /**
709
+ * Put the project back on an earlier revision.
710
+ *
711
+ * WHY THIS TOOL EXISTS, AND WHY IT WAS MISSING
712
+ * --------------------------------------------
713
+ * SPEC §11 lists `blender_revision_restore` among the model-visible tools, with
714
+ * "需确认" as its permission. It was never implemented — while two of this
715
+ * repository's own documents told a user to call it:
716
+ * `README.md` ("可直接重放或 `blender_revision_restore` 回退") and
717
+ * `milestone-status.md` §10B ("需要回退时:`blender_revision_restore {…}`").
718
+ * Both were written by an earlier session that assumed the tool followed from the
719
+ * facade method. The facade method did exist and did work — the workbench's own
720
+ * Revisions panel calls it — so nothing failed, and nothing noticed.
721
+ *
722
+ * That is the whole shape of the defect: a promise in prose, a working
723
+ * implementation one layer down, and no line of code that had to agree with
724
+ * either. `composition/tool-plane-m3.e2e.mjs` now calls this tool, which is the
725
+ * line of code that would have caught it.
726
+ *
727
+ * WHAT IT IS NOT
728
+ * --------------
729
+ * It is not destructive. Restore MOVES THE POINTER to a revision that already
730
+ * exists; every later revision stays in the history, and the revision it left is
731
+ * still there to come back to. That is why the guard below is an explicit
732
+ * `confirm` rather than an approval prompt: the expensive and irreversible
733
+ * operations are the renders, and SPEC's approval plane (§15.1, Q7) is about
734
+ * those. A caller who has to write `confirm: true` has stated an intent; a caller
735
+ * who forgot to read the history has not.
736
+ *
737
+ * @param {import('@deepseek-ai/cordis').Context} ctx
738
+ */
739
+ function revisionRestore(ctx) {
740
+ return defineTool({
741
+ name: 'blender_revision_restore',
742
+ description:
743
+ 'Move a project back to an earlier revision. Read blender_project_get first and pass the exact ' +
744
+ 'revision id from its history — this does not take an index, a timestamp or "the previous one". ' +
745
+ 'Nothing is deleted: the revisions after the target stay in the history, and the revision you ' +
746
+ 'leave is still there to restore again. Requires confirm:true, because the scene the model is ' +
747
+ 'reasoning about changes underneath it — after a restore, re-read with blender_scene_get before ' +
748
+ 'proposing any further patch, or the baseRevision you pass will be a conflict.',
749
+ parameters: {
750
+ projectId: { type: 'string', required: true, description: 'The project id.' },
751
+ revision: {
752
+ type: 'string',
753
+ required: true,
754
+ description: 'The revision to move the project to, exactly as blender_project_get reports it (e.g. "r0003").',
755
+ },
756
+ confirm: {
757
+ type: 'boolean',
758
+ required: true,
759
+ description: 'Must be true. A restore changes which revision later patches must be based on, so it is ' +
760
+ 'never something to do as a side effect of another call.',
761
+ },
762
+ },
763
+ output: TOOL_OUTPUT,
764
+ async execute(args, exec) {
765
+ const resolved = resolveStudio(ctx)
766
+ if (resolved.unavailable !== undefined) return { ok: false, ...resolved.unavailable }
767
+
768
+ // NOTE ON THE ABSENT `confirm` CHECK. An earlier version of this tool ran a
769
+ // hand-written refusal when `args.confirm !== true`. It was unreachable: the
770
+ // parameter is declared `required`, so the harness rejects a call that omits it
771
+ // with `INVALID_ARGS` before `execute` is entered — measured while writing this
772
+ // suite, and it is why the branch is gone. A guard that cannot fire reads like
773
+ // protection and is not, which is the same defect as a test that cannot fail.
774
+ // The confirmation this tool offers IS the schema requirement, and
775
+ // `composition/tool-plane-m3.e2e.mjs` asserts both halves: that it is declared,
776
+ // and that omitting it never reaches the host.
777
+ try {
778
+ const { data, canonicalWarnings } = await canonicalCall(
779
+ resolved.studio.restoreRevision(definedFields({
780
+ projectId: args.projectId,
781
+ revision: args.revision,
782
+ })),
783
+ warning,
784
+ )
785
+
786
+ // `restored:false` is a success, not a no-op to hide: the target was
787
+ // already current, and saying so beats reporting a change that did not
788
+ // happen (D54's rule, one layer down).
789
+ const alreadyThere = data.restored !== true
790
+ const notes = alreadyThere
791
+ ? [`${args.revision} was already the current revision; nothing moved.`]
792
+ : [
793
+ `Moved ${data.from} -> ${data.revision}`,
794
+ 'The revisions in between are still in the history — this only moved the pointer.',
795
+ 'Re-read with blender_scene_get before the next patch: its baseRevision must now be ' +
796
+ `${data.revision}.`,
797
+ ]
798
+
799
+ return {
800
+ ok: true,
801
+ text: renderSuccess(
802
+ alreadyThere
803
+ ? `Project "${data.projectId}" is already on ${data.revision}.`
804
+ : `Project "${data.projectId}" is now on ${data.revision} (was ${data.from}).`,
805
+ data,
806
+ { notes, warnings: canonicalWarnings },
807
+ ),
808
+ data,
809
+ }
810
+ } catch (cause) {
811
+ const failure = renderFailure(cause, 'REVISION_RESTORE_FAILED')
812
+ return { ok: false, ...failure }
813
+ }
814
+ },
815
+ presentCall: args => ({
816
+ card: 'generic',
817
+ title: `Restore "${args?.projectId ?? ''}" to ${args?.revision ?? '?'}`,
818
+ kind: 'edit',
819
+ }),
820
+ })
821
+ }
822
+
823
+ // ---------------------------------------------------------------------------
824
+ // asset_ingest
825
+ // ---------------------------------------------------------------------------
826
+
827
+ /**
828
+ * Bring a model file into a project so a scene can declare it.
829
+ *
830
+ * THE LAST SPEC §11 TOOL, AND WHY IT NEEDED TWO HALVES
831
+ * ----------------------------------------------------
832
+ * SPEC §11 lists `blender_asset_ingest` with the permission "本地自动,网络需审批", and
833
+ * SPEC §13's project tree has carried `assets/{raw,normalized,textures,manifest.json}`
834
+ * since M1. The CONSUMER half was built then: the SceneSpec validates an `asset`
835
+ * (id, type, project-relative path, sha256), `asset-instance` entities reference one,
836
+ * and the compiler imports each with the right operator and classifies the result
837
+ * behaviourally (D10).
838
+ *
839
+ * What was missing was the producer, and the reason it could not have been written
840
+ * earlier is worth stating: `assets` had no patch operation. A scene could declare
841
+ * assets only if the document that CREATED the project already had them, so an ingest
842
+ * would have had nothing to attach its result to. M5 added `asset.add` and
843
+ * `asset.remove` to the vocabulary, and this tool is the other half of that feature.
844
+ *
845
+ * WHY IT DOES NOT COMMIT A REVISION
846
+ * ---------------------------------
847
+ * It puts bytes on disk and returns the descriptor; the model then declares that
848
+ * descriptor with `blender_scene_patch {op: "asset.add", …}`. Keeping those separate
849
+ * means the scene still changes through exactly one path — so an ingest that is never
850
+ * declared leaves an unreferenced file rather than a scene nobody validated.
851
+ *
852
+ * @param {import('@deepseek-ai/cordis').Context} ctx
853
+ */
854
+ function assetIngest(ctx) {
855
+ return defineTool({
856
+ name: 'blender_asset_ingest',
857
+ description:
858
+ 'Bring a 3D model file into a project, so a scene can instantiate it. Give EITHER sourcePath (a ' +
859
+ 'file already on this machine) OR sourceUrl (an http/https address); a local file is imported ' +
860
+ 'directly, a remote one needs the operator\'s approval first, so it will pause and ask. ' +
861
+ '\n\nThis does NOT change the scene. It copies the bytes into the project\'s assets/raw/, records ' +
862
+ 'them in assets/manifest.json, and returns an assetId, a project-relative path and a sha256. ' +
863
+ 'Declare it with blender_scene_patch {op: "asset.add", asset: {...}} and then add an entity of ' +
864
+ 'type "asset-instance" with that assetId — those two steps are what put it in the scene, and ' +
865
+ 'blender_scene_validate will tell you if the format cannot be imported by this Blender build. ' +
866
+ '\n\nSupported formats: glb, gltf, fbx, obj, usd, blend. The size ceiling is the deployment\'s ' +
867
+ 'assetMaxBytes (SPEC §15).',
868
+ parameters: {
869
+ projectId: { type: 'string', required: true, description: 'The project to bring the asset into.' },
870
+ sourcePath: {
871
+ type: 'string',
872
+ description: 'Absolute path to a local file. No approval needed. Use this whenever the file is ' +
873
+ 'already on the machine — it is faster and it does not leave the machine.',
874
+ },
875
+ sourceUrl: {
876
+ type: 'string',
877
+ description: 'An http or https URL. Requires the operator\'s approval, which this tool asks for ' +
878
+ 'automatically; the call pauses until they answer.',
879
+ },
880
+ assetId: {
881
+ type: 'string',
882
+ description: 'Id to record the asset under. Defaults to a slug of the file name. Must start with a ' +
883
+ 'letter and use only letters, digits, ".", "_" and "-".',
884
+ },
885
+ type: {
886
+ type: 'string',
887
+ description: 'Asset type, when the file extension does not say it. One of glb, gltf, fbx, obj, usd, blend.',
888
+ },
889
+ license: {
890
+ type: 'string',
891
+ description: 'Licence string recorded with the asset. Worth setting for anything downloaded: an ' +
892
+ 'asset whose provenance is unrecorded is one nobody can safely ship.',
893
+ },
894
+ },
895
+ output: TOOL_OUTPUT,
896
+ async execute(args, exec) {
897
+ const resolved = resolveStudio(ctx)
898
+ if (resolved.unavailable !== undefined) return { ok: false, ...resolved.unavailable }
899
+
900
+ const ingestRequest = {
901
+ projectId: args.projectId,
902
+ sourcePath: args.sourcePath,
903
+ sourceUrl: args.sourceUrl,
904
+ assetId: args.assetId,
905
+ type: args.type,
906
+ // PASSED THROUGH, because the parameter exists and its description promises it is recorded. It used
907
+ // to be declared here and dropped on the floor: the model set a licence, the manifest had none, and
908
+ // nothing anywhere said so.
909
+ license: args.license,
910
+ }
911
+ if (ingestRequest.sourcePath === undefined && ingestRequest.sourceUrl === undefined) {
912
+ return {
913
+ ok: false,
914
+ text: 'Give either sourcePath (a local file) or sourceUrl (a remote one); neither was supplied.',
915
+ data: { errorCode: 'ASSET_REQUEST_INVALID' },
916
+ }
917
+ }
918
+
919
+ try {
920
+ let result
921
+ try {
922
+ result = await canonicalCall(resolved.studio.ingestAsset(definedFields(ingestRequest)), warning)
923
+ } catch (cause) {
924
+ // The same split the render gate uses, for the same reason: the HOST decides
925
+ // whether a grant is needed, because every caller passes through it, and the
926
+ // TOOL is the only place that can ask — `ctx.approval.request` needs a live
927
+ // agent and an open turn.
928
+ if (cause?.code !== BlenderErrorCode.ASSET_APPROVAL_REQUIRED) throw cause
929
+
930
+ const approval = await requestApproval(ctx, exec, {
931
+ toolName: 'blender_asset_ingest',
932
+ refusalCode: 'ASSET_APPROVAL_REFUSED',
933
+ detail: { sourceUrl: cause.detail?.sourceUrl ?? null },
934
+ reason:
935
+ `Download ${cause.detail?.sourceUrl ?? 'a remote file'} into a DeepBlend project. ` +
936
+ `This reaches off this machine and may write up to ${cause.detail?.maxBytes ?? '?'} bytes ` +
937
+ 'into the project\'s assets/.',
938
+ refusal:
939
+ 'Nothing was downloaded and nothing was written. Options: ask the operator again, or use ' +
940
+ 'sourcePath with a file that is already on this machine, which needs no approval.',
941
+ })
942
+ if (approval.granted !== true) return { ok: false, ...approval.refusal }
943
+
944
+ result = await canonicalCall(
945
+ resolved.studio.ingestAsset(definedFields({ ...ingestRequest, approved: true })),
946
+ warning,
947
+ )
948
+ }
949
+
950
+ const { data, canonicalWarnings } = result
951
+ return {
952
+ ok: true,
953
+ text: renderSuccess(
954
+ `Ingested ${data.type} asset "${data.assetId}" (${data.bytes} bytes) at ${data.path}.`,
955
+ data,
956
+ {
957
+ notes: [
958
+ data.source?.kind === 'url'
959
+ ? `Downloaded from ${data.source.url}`
960
+ : `Copied from ${data.source?.path}`,
961
+ `sha256: ${data.sha256}`,
962
+ '',
963
+ 'The scene has NOT changed. Declare it, then instantiate it:',
964
+ ` 1. blender_scene_patch {op: "asset.add", asset: {id: "${data.assetId}", type: "${data.type}", ` +
965
+ `path: "${data.path}", sha256: "${data.sha256}"}}`,
966
+ ` 2. blender_scene_patch {op: "entity.add", entity: {id: "<a name>", type: "asset-instance", ` +
967
+ `assetId: "${data.assetId}", transform: {…}}}`,
968
+ 'blender_scene_validate then reports whether this Blender build can import it.',
969
+ ],
970
+ warnings: canonicalWarnings,
971
+ },
972
+ ),
973
+ data,
974
+ }
975
+ } catch (cause) {
976
+ const failure = renderFailure(cause, 'ASSET_INGEST_FAILED')
977
+ return { ok: false, ...failure }
978
+ }
979
+ },
980
+ presentCall: args => ({
981
+ card: 'generic',
982
+ title: args?.sourceUrl !== undefined
983
+ ? `Ingest asset from ${args.sourceUrl}`
984
+ : `Ingest asset "${args?.sourcePath ?? ''}"`,
985
+ kind: 'edit',
986
+ }),
987
+ })
988
+ }