@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/index.js ADDED
@@ -0,0 +1,277 @@
1
+ /**
2
+ * @deepblend/dsh-blender-tool
3
+ *
4
+ * The **Agent-preset plane** consumer (SPEC §4.2, §5.1, §11).
5
+ *
6
+ * Registers the model-visible DeepBlend tools. This package publishes NO
7
+ * service — it only consumes `blenderStudio` from the Host composition — which
8
+ * is exactly what makes it legal to list as a row inside an agent preset: a
9
+ * preset row that provided a process-global service would collide on the second
10
+ * session (SPEC §4.4).
11
+ *
12
+ * TOOLS BY MILESTONE
13
+ * ------------------
14
+ * M0 blender_capabilities
15
+ * M1 blender_project_create, blender_project_get, blender_scene_get,
16
+ * blender_scene_patch, blender_preview_render, blender_scene_validate
17
+ * M2 blender_preview_views, blender_visual_review, blender_visual_autofix
18
+ * M3 blender_final_render, blender_export, blender_job_status,
19
+ * blender_job_cancel
20
+ * M5 blender_revision_restore, blender_asset_ingest
21
+ *
22
+ * As of M5 the SPEC §11 inventory is COMPLETE: every tool that section names is
23
+ * registered here. The rule that produced the earlier absences still holds and is why
24
+ * it took this long — a tool the model can see is a promise the runtime must keep, so
25
+ * `blender_revision_restore` waited for a suite that calls it and
26
+ * `blender_asset_ingest` waited for `asset.add` to exist in the ScenePatch vocabulary
27
+ * and for an approval plane to gate a network fetch. Everything registered here can be
28
+ * kept.
29
+ *
30
+ * `blender_capabilities` stays in this file because it is the M0 tool and its
31
+ * behaviour is pinned by the M0 acceptance suite.
32
+ *
33
+ * Owner: DeepBlend Studio — M3
34
+ * Plane: Agent preset
35
+ */
36
+
37
+ import { defineTool } from '@deepseek-ai/dsh-tools'
38
+
39
+ import { BlenderError, BlenderErrorCode } from '@deepblend/dsh-blender-contracts'
40
+
41
+ import { TOOL_OUTPUT, resolveStudio } from './shared.js'
42
+ import { apply as applySceneTools } from './tools.js'
43
+ import { applyRenderTools } from './render-tools.js'
44
+ import { applyVisualTools } from './visual-tools.js'
45
+
46
+ /** Plugin name, surfaced in loader diagnostics. */
47
+ export const name = 'deepblend-blender-tool'
48
+
49
+ /**
50
+ * `tools` is required to register at all.
51
+ *
52
+ * `blenderStudio` is deliberately NOT a hard injection, and this is a real
53
+ * trade-off worth stating. Hard-injecting it makes the row park in `waiting`
54
+ * whenever the DeepBlend Host Bundle is absent — an honest, diagnosable state,
55
+ * and the option that hides least. But it also makes the preset UNMOUNTABLE
56
+ * until the host bundle is composed: mount validation fails, and a session on
57
+ * this preset cannot start at all.
58
+ *
59
+ * The Host Bundle is a process-level composition change that must be installed
60
+ * by the operator, so it only takes effect on the next profile boot. Making the
61
+ * tools' absence a mount-time failure would mean a half-installed deployment
62
+ * cannot even open a session to diagnose itself.
63
+ *
64
+ * So the service is resolved per call from `ctx`. Every tool is always visible
65
+ * in the catalog and its description says what it does; if the host half is
66
+ * missing, calling it returns a stable `BLENDER_RUNTIME_UNAVAILABLE` error
67
+ * explaining exactly which bundle to compose. Nothing fails silently.
68
+ */
69
+ export const inject = ['tools']
70
+ /** No configuration; present so a composition row may carry `config:` later. */
71
+ export const Config = undefined
72
+
73
+ /**
74
+ * The per-job prose block, re-exported for the contract layer.
75
+ *
76
+ * This package's job is to REGISTER tools, not to publish an API, so this is the one deliberate
77
+ * exception and it is narrow: `describeJobLines` is where a job record's facts reach a model, and a
78
+ * rule reachable only through a real render is a rule nobody has checked (the same reasoning
79
+ * `host/lib/frame-ledger.js` records for `sampleFrame`). `contract/render-job.test.mjs` drives it.
80
+ */
81
+ export { describeJobLines } from './render-tools.js'
82
+
83
+ /**
84
+ * The per-review prose block, re-exported for the same reason and with the same narrow scope.
85
+ *
86
+ * `describeReviewNotes` is the other place where a fact a model acts on is turned into English, and
87
+ * it is the branchier of the two: seven independent "is this part of the review present" questions.
88
+ * Reaching any of those branches for real needs a render AND a vision-model call, so before this
89
+ * export existed the coverage reading had every finding-bearing branch dark (round 40).
90
+ */
91
+ export { describeReviewNotes } from './visual-tools.js'
92
+
93
+ /**
94
+ * The other two prose blocks of the visual plane: the repair loop's round log, and the two lines that
95
+ * describe one measured issue.
96
+ *
97
+ * `describeLoopNotes` travels with the review notes for the same reason. `describeIssueLines` is here
98
+ * because it is SHARED — the review's issue list and the loop's open-issue list both use it, and while
99
+ * it had two copies a change to one of them would have silently disagreed with the other (D126).
100
+ */
101
+ export { describeLoopNotes, describeIssueLines } from './visual-tools.js'
102
+
103
+ /**
104
+ * The four boundary helpers whose answer CHANGES when a dependency is missing.
105
+ *
106
+ * Same narrow exception as the prose blocks above, and the reason is the same: each of these has a
107
+ * branch that a fully-equipped machine never produces — an attachment store that is not composed, an
108
+ * approval service that is not composed, a value with no JSON representation crossing the harness
109
+ * boundary — and the only other way to reach one is a real deployment that lacks the service. Finding
110
+ * out what the model is told THEN is exactly what a contract test is for.
111
+ */
112
+ export { canonicalData, losslessJson, persistImage, requestApproval } from './shared.js'
113
+
114
+ /**
115
+ * The service name this plane binds to, re-exported so the cross-plane tie is CHECKABLE.
116
+ *
117
+ * The tool resolves `blenderStudio` through `ctx.get()`, and the host registers it. Two literals in two
118
+ * packages with nothing comparing them is how a rename turns into "the host bundle is missing" at
119
+ * runtime; `contract/export-usage.test.mjs` now compares them, which needs this name on the surface.
120
+ */
121
+ export { STUDIO_SERVICE } from './shared.js'
122
+
123
+ /**
124
+ * Register the DeepBlend tools for the calling agent scope.
125
+ *
126
+ * @param {import('@deepseek-ai/cordis').Context} ctx
127
+ */
128
+ export function apply(ctx) {
129
+ // Registration is fiber-scoped: Cordis disposes these registrations when the
130
+ // preset's subtree unmounts, so no manual teardown is needed (and adding one
131
+ // would risk double-disposal on reload).
132
+ ctx.tools.register(capabilitiesTool(ctx))
133
+ applySceneTools(ctx)
134
+ applyVisualTools(ctx)
135
+ applyRenderTools(ctx)
136
+ }
137
+
138
+ /**
139
+ * Render the canonical capability report as compact, model-readable text.
140
+ *
141
+ * The JSON document is appended verbatim because SPEC §11.1 requires Canonical
142
+ * JSON in the tool result: the model must be able to read exact values rather
143
+ * than a paraphrase, and downstream code must not have to parse prose.
144
+ *
145
+ * @param {Record<string, any>} value
146
+ * @returns {string}
147
+ */
148
+ function renderCapabilityText(value) {
149
+ const lines = []
150
+ const engineEntries = Object.entries(value.engines ?? {})
151
+ const available = engineEntries.filter(([, probe]) => probe.available).map(([id]) => id)
152
+
153
+ if (!value.installed) {
154
+ lines.push('Blender: NOT INSTALLED')
155
+ lines.push(`Configured path: ${value.executable?.requested ?? '<unset>'}`)
156
+ lines.push('No Blender executable could be resolved. DeepBlend cannot build or render until one is available.')
157
+ // The same sentence the settings card shows, composed once in the provider: a human and a model
158
+ // looking at the same broken install must not be told different things to do.
159
+ if (typeof value.executable?.advice === 'string' && value.executable.advice.length > 0) {
160
+ lines.push(`Fix: ${value.executable.advice}`)
161
+ }
162
+ } else {
163
+ lines.push(`Blender: ${value.version ?? 'unknown version'}`)
164
+ lines.push(`Python: ${value.pythonVersion ?? 'unknown'}`)
165
+ lines.push(`Binary: ${value.binaryPath ?? value.executable?.resolved ?? 'unknown'}`)
166
+ lines.push(`Engines: ${available.length > 0 ? available.join(', ') : 'none assignable'}`)
167
+ lines.push(
168
+ `Engine enum (diagnostic only): ${
169
+ Array.isArray(value.engineEnumItems) && value.engineEnumItems.length > 0
170
+ ? value.engineEnumItems.join(', ')
171
+ : '<empty>'
172
+ }`,
173
+ )
174
+ const gpuDevices = value.gpu?.devices ?? []
175
+ const backend = value.gpu?.preferredBackend
176
+ lines.push(
177
+ `GPU: ${
178
+ value.gpu?.available
179
+ ? `${gpuDevices.join(', ')}${backend ? ` via ${backend}` : ''}`
180
+ : 'none detected (CPU rendering)'
181
+ }`,
182
+ )
183
+ lines.push(
184
+ `Import: ${(value.formats?.import ?? []).join(', ') || 'none'}`,
185
+ )
186
+ lines.push(
187
+ `Export: ${(value.formats?.export ?? []).join(', ') || 'none'}`,
188
+ )
189
+ const smoke = value.renderSmokeTest
190
+ if (smoke) {
191
+ lines.push(
192
+ `Headless render probe: ${smoke.ok ? `ok via ${smoke.engine} (${smoke.bytes} bytes)` : `FAILED${smoke.error ? ` — ${smoke.error}` : ''}`}`,
193
+ )
194
+ }
195
+ }
196
+
197
+ const warnings = Array.isArray(value.warnings) ? value.warnings : []
198
+ if (warnings.length > 0) {
199
+ lines.push('')
200
+ lines.push(`Warnings (${warnings.length}):`)
201
+ for (const entry of warnings) lines.push(` - [${entry.code}] ${entry.message}`)
202
+ }
203
+
204
+ lines.push('')
205
+ lines.push('Canonical JSON:')
206
+ lines.push(JSON.stringify(value, null, 2))
207
+ return lines.join('\n')
208
+ }
209
+
210
+ /** The M0 capability tool, unchanged in behaviour. */
211
+ function capabilitiesTool(ctx) {
212
+ return defineTool({
213
+ name: 'blender_capabilities',
214
+ description:
215
+ 'Report what the local Blender runtime can actually do: version, Python version, which render ' +
216
+ 'engines are genuinely available, GPU compute devices, supported import/export formats, and whether ' +
217
+ 'a headless render probe succeeded. Call this before planning any modelling, material or render work, ' +
218
+ 'and again after the operator changes Blender settings. Engine availability is reported behaviorally — ' +
219
+ 'a listed engine has been verified assignable, and absence from the diagnostic engine enum does not ' +
220
+ 'mean an engine is unavailable.',
221
+ parameters: {
222
+ refresh: {
223
+ type: 'boolean',
224
+ description:
225
+ 'Re-probe Blender instead of serving the short-lived cached report. Use after changing the ' +
226
+ 'Blender path or installing a different Blender build.',
227
+ },
228
+ },
229
+ output: TOOL_OUTPUT,
230
+ /**
231
+ * @param {{ refresh?: boolean }} args
232
+ * @param {import('@deepseek-ai/dsh-tools').ToolRunContext} exec
233
+ */
234
+ async execute(args, exec) {
235
+ // Resolved per call rather than injected — see the `inject` note above.
236
+ const resolved = resolveStudio(ctx)
237
+ if (resolved.unavailable !== undefined) {
238
+ return {
239
+ ok: false,
240
+ text: `Blender capability probe unavailable.\n${resolved.unavailable.text}`,
241
+ data: resolved.unavailable.data,
242
+ }
243
+ }
244
+
245
+ try {
246
+ const data = await resolved.studio.describeCapabilities({
247
+ refresh: args?.refresh === true,
248
+ signal: exec.signal,
249
+ })
250
+ return { ok: true, text: renderCapabilityText(data), data }
251
+ } catch (cause) {
252
+ // A probe failure must still reach the model as readable text plus a
253
+ // stable code, not as an opaque tool crash (SPEC §11.1).
254
+ const error = cause instanceof BlenderError
255
+ ? cause
256
+ : new BlenderError(
257
+ BlenderErrorCode.CAPABILITY_PROBE_FAILED,
258
+ cause instanceof Error ? cause.message : String(cause),
259
+ { cause },
260
+ )
261
+ return {
262
+ ok: false,
263
+ text:
264
+ `Blender capability probe failed.\n` +
265
+ `errorCode: ${error.code}\n` +
266
+ `message: ${error.message}\n` +
267
+ (error.detail !== undefined ? `detail: ${JSON.stringify(error.detail)}\n` : ''),
268
+ data: { ok: false, errorCode: error.code, message: error.message, detail: error.detail ?? null },
269
+ }
270
+ }
271
+ },
272
+ presentCall: args =>
273
+ args?.refresh === true
274
+ ? { card: 'generic', title: 'Re-probe Blender capabilities', kind: 'other' }
275
+ : { card: 'generic', title: 'Check Blender capabilities', kind: 'read' },
276
+ })
277
+ }