acuvo-code 0.2.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.
Files changed (154) hide show
  1. package/CHANGELOG.md +328 -0
  2. package/ENTERPRISE.md +927 -0
  3. package/LICENSE +120 -0
  4. package/README.md +1245 -0
  5. package/ROADMAP.md +556 -0
  6. package/bin/acuvo-mcp.mjs +208 -0
  7. package/bin/acuvo.mjs +3108 -0
  8. package/lib/acceptance-consent.mjs +168 -0
  9. package/lib/acceptance.mjs +859 -0
  10. package/lib/account.mjs +226 -0
  11. package/lib/acuvo-dir.mjs +72 -0
  12. package/lib/acuvo-models.mjs +141 -0
  13. package/lib/apply-patch.mjs +570 -0
  14. package/lib/ask-user.mjs +173 -0
  15. package/lib/audit.mjs +530 -0
  16. package/lib/auto-lease.mjs +174 -0
  17. package/lib/background.mjs +842 -0
  18. package/lib/best-of.mjs +334 -0
  19. package/lib/board.mjs +232 -0
  20. package/lib/breaker.mjs +93 -0
  21. package/lib/budget.mjs +1375 -0
  22. package/lib/builtin-skills.mjs +135 -0
  23. package/lib/cache-floor.mjs +204 -0
  24. package/lib/chain.mjs +303 -0
  25. package/lib/changed-paths.mjs +84 -0
  26. package/lib/chat.mjs +434 -0
  27. package/lib/checkpoint.mjs +637 -0
  28. package/lib/child-lifetime.mjs +71 -0
  29. package/lib/cli-args.mjs +1255 -0
  30. package/lib/code-review.mjs +1382 -0
  31. package/lib/colour.mjs +82 -0
  32. package/lib/command.mjs +2847 -0
  33. package/lib/compact.mjs +1151 -0
  34. package/lib/completion.mjs +515 -0
  35. package/lib/creative-engines.mjs +779 -0
  36. package/lib/db-inspect.mjs +1624 -0
  37. package/lib/delete.mjs +111 -0
  38. package/lib/design-loop.mjs +570 -0
  39. package/lib/diff-preview.mjs +1044 -0
  40. package/lib/doctor.mjs +2139 -0
  41. package/lib/dropped.mjs +216 -0
  42. package/lib/edit-diagnostics.mjs +277 -0
  43. package/lib/edit.mjs +460 -0
  44. package/lib/env-file.mjs +250 -0
  45. package/lib/escalate.mjs +702 -0
  46. package/lib/evaluate.mjs +284 -0
  47. package/lib/fetch-text.mjs +952 -0
  48. package/lib/fleet-budget.mjs +256 -0
  49. package/lib/gh.mjs +1536 -0
  50. package/lib/git.mjs +1341 -0
  51. package/lib/github.mjs +261 -0
  52. package/lib/h2.mjs +194 -0
  53. package/lib/handoff.mjs +417 -0
  54. package/lib/hooks.mjs +626 -0
  55. package/lib/http-probe.mjs +907 -0
  56. package/lib/image-director.mjs +322 -0
  57. package/lib/image-edit.mjs +522 -0
  58. package/lib/imagegen.mjs +998 -0
  59. package/lib/interrupt.mjs +234 -0
  60. package/lib/learned.mjs +353 -0
  61. package/lib/lease-watch.mjs +115 -0
  62. package/lib/lease.mjs +868 -0
  63. package/lib/localize.mjs +834 -0
  64. package/lib/log-tail.mjs +1052 -0
  65. package/lib/login.mjs +157 -0
  66. package/lib/lsp.mjs +1613 -0
  67. package/lib/mcp-consent.mjs +377 -0
  68. package/lib/mcp-defaults.mjs +780 -0
  69. package/lib/mcp-server.mjs +1343 -0
  70. package/lib/mcp.mjs +1263 -0
  71. package/lib/media.mjs +1283 -0
  72. package/lib/memory-workspace.mjs +179 -0
  73. package/lib/model-json.mjs +132 -0
  74. package/lib/model-tier.mjs +171 -0
  75. package/lib/model.mjs +1445 -0
  76. package/lib/parallel.mjs +144 -0
  77. package/lib/perchance.mjs +210 -0
  78. package/lib/plan-coherence.mjs +1461 -0
  79. package/lib/plan-ledger.mjs +981 -0
  80. package/lib/plan.mjs +461 -0
  81. package/lib/policy.mjs +783 -0
  82. package/lib/prefix-order.mjs +38 -0
  83. package/lib/project-memory.mjs +127 -0
  84. package/lib/prompt.mjs +109 -0
  85. package/lib/python.mjs +862 -0
  86. package/lib/rcfile.mjs +853 -0
  87. package/lib/read-window.mjs +743 -0
  88. package/lib/refute-tools.mjs +34 -0
  89. package/lib/refute.mjs +806 -0
  90. package/lib/repl-driver.mjs +264 -0
  91. package/lib/repl.mjs +324 -0
  92. package/lib/replay.mjs +1218 -0
  93. package/lib/repo-map.mjs +1101 -0
  94. package/lib/report.mjs +419 -0
  95. package/lib/search-rank.mjs +99 -0
  96. package/lib/search.mjs +659 -0
  97. package/lib/secret-paths.mjs +54 -0
  98. package/lib/session.mjs +1017 -0
  99. package/lib/skills.mjs +703 -0
  100. package/lib/slash.mjs +356 -0
  101. package/lib/spawn-argv.mjs +1151 -0
  102. package/lib/spend.mjs +250 -0
  103. package/lib/steer.mjs +280 -0
  104. package/lib/stream.mjs +253 -0
  105. package/lib/stuck.mjs +712 -0
  106. package/lib/subagent.mjs +749 -0
  107. package/lib/terminal-graphics.mjs +171 -0
  108. package/lib/tool-prefix.mjs +226 -0
  109. package/lib/tool-shortlist.mjs +162 -0
  110. package/lib/tools.mjs +2333 -0
  111. package/lib/tsserver.mjs +423 -0
  112. package/lib/turn.mjs +5672 -0
  113. package/lib/untrusted-block.mjs +271 -0
  114. package/lib/verify-claim.mjs +299 -0
  115. package/lib/vision.mjs +330 -0
  116. package/lib/voice-task.mjs +561 -0
  117. package/lib/warm-provider.mjs +255 -0
  118. package/lib/websearch.mjs +401 -0
  119. package/lib/workspace.mjs +928 -0
  120. package/lib/write-approval.mjs +235 -0
  121. package/lib/write-many.mjs +162 -0
  122. package/package.json +62 -0
  123. package/scripts/bundle.mjs +768 -0
  124. package/scripts/cache-floor.mjs +176 -0
  125. package/scripts/machine.mjs +226 -0
  126. package/scripts/test.mjs +139 -0
  127. package/skills/accessibility.md +87 -0
  128. package/skills/acuvo-design-system.md +123 -0
  129. package/skills/animation.md +84 -0
  130. package/skills/api-design.md +82 -0
  131. package/skills/auth-and-sessions.md +78 -0
  132. package/skills/build-with-a-framework.md +101 -0
  133. package/skills/colour-and-contrast.md +112 -0
  134. package/skills/creative-engines.md +81 -0
  135. package/skills/css-layout.md +85 -0
  136. package/skills/data-and-charts.md +77 -0
  137. package/skills/debugging.md +76 -0
  138. package/skills/designing-by-looking.md +84 -0
  139. package/skills/error-handling.md +78 -0
  140. package/skills/forms-and-validation.md +93 -0
  141. package/skills/nextjs-app-router.md +75 -0
  142. package/skills/page-composition.md +103 -0
  143. package/skills/performance.md +77 -0
  144. package/skills/plan-before-building.md +52 -0
  145. package/skills/planning-and-delegating.md +72 -0
  146. package/skills/refactoring.md +70 -0
  147. package/skills/security-basics.md +76 -0
  148. package/skills/state-management.md +73 -0
  149. package/skills/supabase-multitenant.md +72 -0
  150. package/skills/typescript-strict.md +90 -0
  151. package/skills/typography.md +135 -0
  152. package/skills/verify-your-own-work.md +62 -0
  153. package/skills/web-app-quality.md +62 -0
  154. package/skills/working-in-the-background.md +64 -0
package/lib/vision.mjs ADDED
@@ -0,0 +1,330 @@
1
+ /**
2
+ * ── ⭐⭐ EYES — THE SENSE THE AGENT NEVER HAD ────────────────────────────────
3
+ *
4
+ * This CLI could MAKE a picture and could not LOOK at one. Everything good that
5
+ * came out of this codebase this week came from someone looking: a watermark
6
+ * that rendered as "ΛACUVO", a banner printing a 100-character absolute path, a
7
+ * change list printed twice, six generated images judged good enough to ship.
8
+ * A model that cannot see can verify that code RAN. It can never verify that
9
+ * the result was GOOD — and for anything with a screen, those are different
10
+ * questions with different answers.
11
+ *
12
+ * ── ⚠️⚠️ THE CODER MODEL CANNOT SEE, AND THIS IS NOT A PASSTHROUGH ─────────
13
+ *
14
+ * The default model here is `deepseek-v4-flash`, which is text-only. Attaching
15
+ * an image to it does not fail loudly — it either drops the attachment or,
16
+ * far worse, answers the question anyway from the filename and the surrounding
17
+ * conversation, in fluent confident prose. A hallucinated "the layout looks
18
+ * clean and balanced" about a page nobody rendered is strictly worse than "I
19
+ * cannot see it": it ENDS the investigation with a false all-clear, which is
20
+ * exactly the bug `see_page` had to be fixed for once already.
21
+ *
22
+ * ⭐ So this makes its own call, to a model chosen because it can actually see.
23
+ * `qwen3.7-flash` is measured at **$0.042/M** — a 1024×1024 image is ~1,400
24
+ * tokens, so a look costs about **$0.00006**. Cheap enough that "check before
25
+ * you claim" stops being a budget decision.
26
+ *
27
+ * ⚠️ AND IT ABSTAINS RATHER THAN GUESSES. Every failure path returns ok:false
28
+ * with a reason. The one behaviour this module must never have is producing a
29
+ * plausible sentence about an image it did not receive.
30
+ */
31
+
32
+ import { readFileSync, statSync } from 'node:fs';
33
+
34
+ import { resolveInWorkspace } from './workspace.mjs';
35
+ import { chargeEstimate } from './budget.mjs';
36
+
37
+ export const DEFAULT_VISION_MODEL = 'qwen/qwen3.7-flash';
38
+ export const VISION_TIMEOUT_MS = 60_000;
39
+
40
+ /**
41
+ * ⭐ $0.042 per million — the measured price of `qwen3.7-flash`, and the same
42
+ * number this file's header quotes. It exists as a constant now because it has
43
+ * to price the calls the provider does not price for us; see `chargeEstimate`
44
+ * at the bottom of `readImage`.
45
+ */
46
+ export const VISION_USD_PER_MILLION_TOKENS = 0.042;
47
+
48
+ /**
49
+ * ⚠️ A CEILING IN BYTES, BECAUSE WE CANNOT RESIZE. There is no image library
50
+ * here (zero dependencies is the product), so an oversized screenshot cannot be
51
+ * scaled down on the way out — it can only be sent whole or refused. Refusing
52
+ * with the number and a way forward beats silently sending 40MB.
53
+ */
54
+ export const MAX_IMAGE_BYTES = 8 * 1024 * 1024;
55
+ export const PIXELS_PER_TOKEN = 750;
56
+ export const MAX_LOOKS_PER_PROCESS = 12;
57
+
58
+ let looksThisProcess = 0;
59
+
60
+ /** Test seam — the per-run cap must not leak between test files. */
61
+ export function resetVisionState() {
62
+ looksThisProcess = 0;
63
+ }
64
+
65
+ /**
66
+ * ⚠️ SNIFFED FROM THE BYTES, NOT THE EXTENSION. A `.png` that is really a JPEG
67
+ * is common (every "save as" dialog produces some), and declaring the wrong
68
+ * mime type to the API is a 400 that reads like the image being rejected on its
69
+ * merits rather than on its label.
70
+ */
71
+ export function sniffImage(buf) {
72
+ if (!buf || buf.length < 12) return null;
73
+ const b = buf;
74
+ if (b[0] === 0x89 && b[1] === 0x50 && b[2] === 0x4e && b[3] === 0x47) return 'image/png';
75
+ if (b[0] === 0xff && b[1] === 0xd8 && b[2] === 0xff) return 'image/jpeg';
76
+ if (b[0] === 0x47 && b[1] === 0x49 && b[2] === 0x46) return 'image/gif';
77
+ if (b.slice(0, 4).toString('latin1') === 'RIFF' && b.slice(8, 12).toString('latin1') === 'WEBP') return 'image/webp';
78
+ return null;
79
+ }
80
+
81
+ /**
82
+ * Width and height without decoding the image.
83
+ *
84
+ * ⭐ THIS EXISTS SO THE COST CAN BE STATED RATHER THAN GUESSED. Vision billing
85
+ * is per pixel, so "this look will cost about $0.00006" is only honest if the
86
+ * dimensions are read from the file. Returns null when the format hides them,
87
+ * and the caller then says "unknown" instead of inventing a number.
88
+ */
89
+ export function imageSize(buf) {
90
+ const mime = sniffImage(buf);
91
+ try {
92
+ if (mime === 'image/png') {
93
+ // IHDR is always the first chunk: width at byte 16, height at 20.
94
+ return { width: buf.readUInt32BE(16), height: buf.readUInt32BE(20) };
95
+ }
96
+ if (mime === 'image/gif') {
97
+ return { width: buf.readUInt16LE(6), height: buf.readUInt16LE(8) };
98
+ }
99
+ if (mime === 'image/jpeg') {
100
+ /**
101
+ * ⚠️ JPEG HIDES ITS SIZE BEHIND A SEGMENT WALK. There is no fixed offset:
102
+ * the dimensions live in whichever SOF marker appears, after any number of
103
+ * EXIF/comment segments of arbitrary length. Guessing an offset is how you
104
+ * report a 6000×4000 photo as 2×19029.
105
+ */
106
+ let i = 2;
107
+ while (i + 9 < buf.length) {
108
+ if (buf[i] !== 0xff) { i += 1; continue; }
109
+ const marker = buf[i + 1];
110
+ // SOF0..SOF15, excluding the non-frame markers C4 (DHT), C8, CC.
111
+ if (marker >= 0xc0 && marker <= 0xcf && marker !== 0xc4 && marker !== 0xc8 && marker !== 0xcc) {
112
+ return { height: buf.readUInt16BE(i + 5), width: buf.readUInt16BE(i + 7) };
113
+ }
114
+ i += 2 + buf.readUInt16BE(i + 2);
115
+ }
116
+ return null;
117
+ }
118
+ if (mime === 'image/webp') {
119
+ // Only the simple VP8X form carries dimensions at a fixed offset.
120
+ if (buf.slice(12, 16).toString('latin1') === 'VP8X') {
121
+ return {
122
+ width: 1 + (buf[24] | (buf[25] << 8) | (buf[26] << 16)),
123
+ height: 1 + (buf[27] | (buf[28] << 8) | (buf[29] << 16)),
124
+ };
125
+ }
126
+ return null;
127
+ }
128
+ } catch {
129
+ return null;
130
+ }
131
+ return null;
132
+ }
133
+
134
+ export function estimateLookTokens(size) {
135
+ if (!size?.width || !size?.height) return null;
136
+ return Math.ceil((size.width * size.height) / PIXELS_PER_TOKEN);
137
+ }
138
+
139
+ const DEFAULT_QUESTION = [
140
+ 'Describe this image precisely and factually.',
141
+ 'State what is actually visible — layout, text (quote it exactly), colours, objects, and anything that looks wrong,',
142
+ 'broken, misaligned, garbled or misspelled.',
143
+ 'Do not speculate about anything you cannot see, and do not be polite about defects.',
144
+ ].join(' ');
145
+
146
+ /**
147
+ * Look at an image file in the workspace and answer a question about it.
148
+ *
149
+ * ⚠️ EVERY FAILURE ABSTAINS. `ok:false` with a reason, never a sentence that
150
+ * could be mistaken for an observation.
151
+ */
152
+ export async function readImage(params = {}) {
153
+ const root = params.root ?? params.executor?.root;
154
+ const path = String(params.path ?? '').trim();
155
+ const question = String(params.question ?? '').trim() || DEFAULT_QUESTION;
156
+ const apiKey = params.apiKey ?? process.env.OPENROUTER_API_KEY;
157
+ const model = params.model ?? process.env.ACUVO_VISION_MODEL ?? DEFAULT_VISION_MODEL;
158
+ const fetchImpl = params.fetchImpl ?? fetch;
159
+
160
+ if (!path) return { ok: false, error: 'read_image needs a `path` to an image file in the workspace.' };
161
+ if (!root) return { ok: false, error: 'read_image has no workspace root to resolve the path against.' };
162
+ if (!apiKey) {
163
+ return {
164
+ ok: false,
165
+ error: 'no OPENROUTER_API_KEY, so there is nothing that can look at this image. Do not describe it from its filename — say you could not see it.',
166
+ };
167
+ }
168
+ if (looksThisProcess >= MAX_LOOKS_PER_PROCESS) {
169
+ return {
170
+ ok: false,
171
+ error: `this run has already looked at ${MAX_LOOKS_PER_PROCESS} images, which is the cap. Looking again at the same thing will not tell you something new — act on what you saw.`,
172
+ };
173
+ }
174
+
175
+ const resolved = resolveInWorkspace(root, path, 'read');
176
+ if (!resolved.ok) return { ok: false, error: resolved.reason };
177
+
178
+ let stat;
179
+ try {
180
+ stat = statSync(resolved.absolute);
181
+ } catch {
182
+ return { ok: false, error: `no such file: ${path}. Check the path with list_dir — an image you just generated is written under the name the tool reported, not the one you asked for.` };
183
+ }
184
+ if (stat.size > MAX_IMAGE_BYTES) {
185
+ return {
186
+ ok: false,
187
+ error: `${path} is ${(stat.size / 1024 / 1024).toFixed(1)}MB, over the ${MAX_IMAGE_BYTES / 1024 / 1024}MB limit. This agent has no image library and cannot resize it — render or export a smaller version and look at that.`,
188
+ };
189
+ }
190
+
191
+ const bytes = readFileSync(resolved.absolute);
192
+ const mime = sniffImage(bytes);
193
+ if (!mime) {
194
+ /**
195
+ * ⚠️ NAMED BY WHAT IT IS, NOT BY WHAT IT IS NOT. "unsupported format" sends
196
+ * the model round the same loop; saying the first bytes do not match any
197
+ * image format tells it the file is probably HTML, an error page, or empty.
198
+ */
199
+ return {
200
+ ok: false,
201
+ error: `${path} does not begin with PNG, JPEG, GIF or WEBP magic bytes, so it is not an image this can look at — it may be an error page, a text file, or a truncated download. Read the first bytes with read_file if you need to know which.`,
202
+ };
203
+ }
204
+
205
+ const size = imageSize(bytes);
206
+ const tokens = estimateLookTokens(size);
207
+ looksThisProcess += 1;
208
+
209
+ let res;
210
+ try {
211
+ res = await fetchImpl('https://openrouter.ai/api/v1/chat/completions', {
212
+ method: 'POST',
213
+ headers: { authorization: `Bearer ${apiKey}`, 'content-type': 'application/json' },
214
+ body: JSON.stringify({
215
+ model,
216
+ max_tokens: 900,
217
+ /**
218
+ * ⚠️ REASONING OFF. Measured on this account: a reasoning model charges
219
+ * its thinking budget against max_tokens and can spend ALL of it —
220
+ * 15,999 reasoning tokens and an empty reply. An empty reply from a
221
+ * vision model is indistinguishable from "I saw nothing".
222
+ */
223
+ reasoning: { enabled: false },
224
+ messages: [{
225
+ role: 'user',
226
+ content: [
227
+ { type: 'text', text: question },
228
+ { type: 'image_url', image_url: { url: `data:${mime};base64,${bytes.toString('base64')}` } },
229
+ ],
230
+ }],
231
+ }),
232
+ signal: AbortSignal.timeout(VISION_TIMEOUT_MS),
233
+ });
234
+ } catch (err) {
235
+ const why = err?.name === 'TimeoutError' ? `no answer in ${VISION_TIMEOUT_MS / 1000}s` : String(err?.message || err);
236
+ return { ok: false, error: `could not reach the vision model (${why}). Say you were unable to look at the image rather than describing it.` };
237
+ }
238
+
239
+ if (!res.ok) {
240
+ const snippet = await res.text().catch(() => '');
241
+ return { ok: false, error: `the vision model returned HTTP ${res.status}${snippet ? `: ${String(snippet).slice(0, 200)}` : ''}` };
242
+ }
243
+
244
+ let json;
245
+ try {
246
+ json = await res.json();
247
+ } catch {
248
+ return { ok: false, error: 'the vision model returned a body that was not JSON' };
249
+ }
250
+
251
+ const text = json?.choices?.[0]?.message?.content;
252
+ /**
253
+ * ⚠️⚠️ AN EMPTY REPLY IS A FAILURE, NOT AN EMPTY IMAGE. This is the same 200
254
+ * that meant failure in model.mjs, media.mjs and the image engine — four
255
+ * places now. `res.ok` answers a question about the HTTP conversation, never
256
+ * about whether the work happened.
257
+ */
258
+ if (typeof text !== 'string' || text.trim() === '') {
259
+ return { ok: false, error: 'the vision model answered with nothing at all, so the image was not read. Do not describe it.' };
260
+ }
261
+
262
+ /**
263
+ * ── ⚠️⚠️ WHEN THE PROVIDER DOES NOT PRICE IT, IT WAS FREE. IT IS NOT. ──────
264
+ *
265
+ * `tools.mjs:1481` charges this call back only when `costUsd` is a finite
266
+ * number greater than zero, and `costUsd` is `json.usage.cost ?? null` —
267
+ * OpenRouter does not always return one. So a paid vision call on a quiet
268
+ * reporting day cost the governor nothing: the same "unknown priced as free"
269
+ * failure `budget.mjs` calls the worst one available, in the one tool whose
270
+ * only other bound is a COUNT.
271
+ *
272
+ * ⭐ IT IS CHARGED AS AN ESTIMATE, IN THE ESTIMATE LEDGER, NOT SMUGGLED INTO
273
+ * `costUsd`. Filling `costUsd` with a guess would make the run report a
274
+ * reported figure it never received, and `report()` would stop saying the
275
+ * total is approximate — trading a number that is too low for one that lies
276
+ * about its own provenance. The ledger keeps the two kinds apart.
277
+ */
278
+ const reported = json?.usage?.cost;
279
+ const reportedIsReal = typeof reported === 'number' && Number.isFinite(reported);
280
+ if (!reportedIsReal) {
281
+ const outTokens = Number(json?.usage?.completion_tokens) || 900;
282
+ const inTokens = Number(json?.usage?.prompt_tokens) || (tokens ?? 1400);
283
+ chargeEstimate({
284
+ kind: 'vision',
285
+ verb: 'read_image',
286
+ usd: ((inTokens + outTokens) / 1e6) * VISION_USD_PER_MILLION_TOKENS,
287
+ basis: `${inTokens + outTokens} tokens at the published ${VISION_USD_PER_MILLION_TOKENS}/M for ${model} — the provider reported no cost`,
288
+ });
289
+ }
290
+
291
+ return {
292
+ ok: true,
293
+ path,
294
+ text: text.trim(),
295
+ model,
296
+ mime,
297
+ ...(size ? { width: size.width, height: size.height } : {}),
298
+ ...(tokens ? { approxImageTokens: tokens } : {}),
299
+ costUsd: reportedIsReal ? reported : null,
300
+ };
301
+ }
302
+
303
+ export function visionToolSchemas() {
304
+ return [
305
+ {
306
+ type: 'function',
307
+ function: {
308
+ name: 'read_image',
309
+ description: [
310
+ 'LOOK at an image file in the workspace and get back a factual description.',
311
+ 'Use it on anything visual you produced or were given: a screenshot, a generated image, a chart, a photo, a design mock.',
312
+ 'You cannot see images yourself — this makes a separate call to a model that can, so it is the ONLY way',
313
+ 'to know what is actually in a picture.',
314
+ 'Ask a specific question when you have one ("is the heading text spelled correctly?", "does the button overlap the image?");',
315
+ 'with no question it describes the image and names anything that looks broken.',
316
+ 'If it cannot see the image it says so — in that case tell the user you could not look, and never describe the image from its filename.',
317
+ `At most ${MAX_LOOKS_PER_PROCESS} looks per run.`,
318
+ ].join(' '),
319
+ parameters: {
320
+ type: 'object',
321
+ properties: {
322
+ path: { type: 'string', description: 'Path to the image inside the workspace, e.g. "shot.png" or "public/hero.jpg".' },
323
+ question: { type: 'string', description: 'What you want to know about it. Leave out for a general description with defects called out.' },
324
+ },
325
+ required: ['path'],
326
+ },
327
+ },
328
+ },
329
+ ];
330
+ }