@cyber-dash-tech/revela 0.3.1 → 0.4.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.
@@ -0,0 +1,273 @@
1
+ import {
2
+ copyFileSync,
3
+ existsSync,
4
+ mkdirSync,
5
+ readFileSync,
6
+ rmSync,
7
+ writeFileSync,
8
+ } from "fs"
9
+ import { dirname, join, relative, resolve, sep } from "path"
10
+ import { downloadImageFromUrl, inferImageExtension } from "./download"
11
+ import type {
12
+ MediaAssetRecord,
13
+ MediaManifest,
14
+ MediaSaveInput,
15
+ MediaSaveResult,
16
+ MediaStatus,
17
+ } from "./types"
18
+
19
+ function nowIso(): string {
20
+ return new Date().toISOString()
21
+ }
22
+
23
+ function slugify(value: string): string {
24
+ return value
25
+ .toLowerCase()
26
+ .replace(/[^a-z0-9]+/g, "-")
27
+ .replace(/^-+|-+$/g, "")
28
+ }
29
+
30
+ function ensureInsideWorkspace(pathValue: string, workspaceDir: string): string {
31
+ const abs = resolve(workspaceDir, pathValue)
32
+ const root = resolve(workspaceDir)
33
+ if (abs !== root && !abs.startsWith(root + sep)) {
34
+ throw new Error("sourcePath must be within workspace")
35
+ }
36
+ return abs
37
+ }
38
+
39
+ function readManifest(manifestPath: string, topic: string): MediaManifest {
40
+ if (!existsSync(manifestPath)) {
41
+ return { topic, updatedAt: nowIso(), assets: [] }
42
+ }
43
+
44
+ const parsed = JSON.parse(readFileSync(manifestPath, "utf-8")) as Partial<MediaManifest>
45
+ return {
46
+ topic,
47
+ updatedAt: typeof parsed.updatedAt === "string" ? parsed.updatedAt : nowIso(),
48
+ assets: Array.isArray(parsed.assets) ? parsed.assets as MediaAssetRecord[] : [],
49
+ }
50
+ }
51
+
52
+ function buildFailureRecord(
53
+ input: MediaSaveInput,
54
+ topic: string,
55
+ status: Exclude<MediaStatus, "success">,
56
+ failureReason: string,
57
+ sourcePath?: string,
58
+ ): MediaAssetRecord {
59
+ return {
60
+ id: slugify(input.id),
61
+ type: "image",
62
+ purpose: input.purpose,
63
+ brief: input.brief,
64
+ status,
65
+ path: null,
66
+ sourceUrl: input.sourceUrl,
67
+ sourcePath,
68
+ intendedSection: input.intendedSection,
69
+ alt: input.alt,
70
+ notes: input.notes,
71
+ failureReason,
72
+ savedAt: nowIso(),
73
+ }
74
+ }
75
+
76
+ function upsertAsset(manifest: MediaManifest, record: MediaAssetRecord): { manifest: MediaManifest; previous?: MediaAssetRecord } {
77
+ const assets = [...manifest.assets]
78
+ const index = assets.findIndex((asset) => asset.id === record.id)
79
+ const previous = index === -1 ? undefined : assets[index]
80
+ if (index === -1) assets.push(record)
81
+ else assets[index] = record
82
+ return {
83
+ previous,
84
+ manifest: {
85
+ topic: manifest.topic,
86
+ updatedAt: nowIso(),
87
+ assets,
88
+ },
89
+ }
90
+ }
91
+
92
+ function writeManifest(manifestPath: string, manifest: MediaManifest): void {
93
+ mkdirSync(dirname(manifestPath), { recursive: true })
94
+ writeFileSync(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`, "utf-8")
95
+ }
96
+
97
+ function saveFailureResult(
98
+ input: MediaSaveInput,
99
+ workspaceDir: string,
100
+ topic: string,
101
+ manifestPath: string,
102
+ status: Exclude<MediaStatus, "success">,
103
+ failureReason: string,
104
+ sourcePath?: string,
105
+ ): MediaSaveResult {
106
+ const manifest = readManifest(manifestPath, topic)
107
+ const existing = manifest.assets.find((asset) => asset.id === slugify(input.id))
108
+ const record = existing?.path
109
+ ? {
110
+ ...existing,
111
+ sourceUrl: input.sourceUrl ?? existing.sourceUrl,
112
+ sourcePath: sourcePath ?? existing.sourcePath,
113
+ alt: input.alt ?? existing.alt,
114
+ notes: input.notes ?? existing.notes,
115
+ failureReason,
116
+ }
117
+ : buildFailureRecord(input, topic, status, failureReason, sourcePath)
118
+ const { manifest: nextManifest, previous } = upsertAsset(manifest, record)
119
+ writeManifest(manifestPath, nextManifest)
120
+
121
+ if (!existing?.path && previous?.path) {
122
+ rmSync(join(workspaceDir, previous.path), { force: true })
123
+ }
124
+
125
+ return {
126
+ ok: true,
127
+ assetId: record.id,
128
+ status,
129
+ path: null,
130
+ manifestPath: relative(workspaceDir, manifestPath),
131
+ updated: true,
132
+ }
133
+ }
134
+
135
+ export async function saveMediaAsset(input: MediaSaveInput, workspaceDir: string): Promise<MediaSaveResult> {
136
+ try {
137
+ const topic = slugify(input.topic)
138
+ const assetId = slugify(input.id)
139
+ if (!topic) return { ok: false, error: "topic is required" }
140
+ if (!assetId) return { ok: false, error: "id is required" }
141
+ if (input.type !== "image") return { ok: false, error: "type must be 'image'" }
142
+
143
+ const topicDir = join(workspaceDir, "assets", topic)
144
+ const mediaDir = join(topicDir, "media")
145
+ const manifestPath = join(topicDir, "media-manifest.json")
146
+ mkdirSync(mediaDir, { recursive: true })
147
+
148
+ const relativeSourcePath = input.sourcePath
149
+ ? relative(workspaceDir, ensureInsideWorkspace(input.sourcePath, workspaceDir))
150
+ : undefined
151
+
152
+ if (input.status !== "success") {
153
+ if (!input.failureReason?.trim()) {
154
+ return { ok: false, error: "failureReason is required when status is not 'success'" }
155
+ }
156
+ return saveFailureResult(
157
+ input,
158
+ workspaceDir,
159
+ topic,
160
+ manifestPath,
161
+ input.status,
162
+ input.failureReason.trim(),
163
+ relativeSourcePath,
164
+ )
165
+ }
166
+
167
+ if (!input.sourcePath && !input.sourceUrl) {
168
+ return { ok: false, error: "sourcePath or sourceUrl is required when status is 'success'" }
169
+ }
170
+
171
+ let buffer: Buffer
172
+ let extension: string | null = null
173
+
174
+ if (input.sourcePath) {
175
+ const absPath = ensureInsideWorkspace(input.sourcePath, workspaceDir)
176
+ if (!existsSync(absPath)) {
177
+ return { ok: false, error: `sourcePath not found: ${input.sourcePath}` }
178
+ }
179
+ extension = inferImageExtension(null, absPath)
180
+ if (!extension) {
181
+ return { ok: false, error: `unsupported local image type: ${input.sourcePath}` }
182
+ }
183
+ const destinationPath = join(mediaDir, `${assetId}${extension}`)
184
+ const manifest = readManifest(manifestPath, topic)
185
+ const previous = manifest.assets.find((asset) => asset.id === assetId)
186
+ copyFileSync(absPath, destinationPath)
187
+ const record: MediaAssetRecord = {
188
+ id: assetId,
189
+ type: "image",
190
+ purpose: input.purpose,
191
+ brief: input.brief,
192
+ status: "success",
193
+ path: relative(workspaceDir, destinationPath),
194
+ sourceUrl: input.sourceUrl,
195
+ sourcePath: relativeSourcePath,
196
+ intendedSection: input.intendedSection,
197
+ alt: input.alt,
198
+ notes: input.notes,
199
+ savedAt: nowIso(),
200
+ }
201
+ const { manifest: nextManifest } = upsertAsset(manifest, record)
202
+ writeManifest(manifestPath, nextManifest)
203
+ if (previous?.path && previous.path !== record.path) {
204
+ rmSync(join(workspaceDir, previous.path), { force: true })
205
+ }
206
+ return {
207
+ ok: true,
208
+ assetId,
209
+ status: "success",
210
+ path: record.path,
211
+ manifestPath: relative(workspaceDir, manifestPath),
212
+ updated: true,
213
+ }
214
+ }
215
+
216
+ try {
217
+ const downloaded = await downloadImageFromUrl(input.sourceUrl!)
218
+ buffer = downloaded.buffer
219
+ extension = downloaded.extension
220
+ } catch (error) {
221
+ const message = error instanceof Error ? error.message : String(error)
222
+ if (message === "INVALID_URL") {
223
+ return saveFailureResult(input, workspaceDir, topic, manifestPath, "invalid-url", "Invalid image URL")
224
+ }
225
+ return saveFailureResult(
226
+ input,
227
+ workspaceDir,
228
+ topic,
229
+ manifestPath,
230
+ "cannot-download",
231
+ `Failed to download image: ${message}`,
232
+ )
233
+ }
234
+
235
+ const destinationPath = join(mediaDir, `${assetId}${extension}`)
236
+ const manifest = readManifest(manifestPath, topic)
237
+ const previous = manifest.assets.find((asset) => asset.id === assetId)
238
+ writeFileSync(destinationPath, new Uint8Array(buffer))
239
+ const record: MediaAssetRecord = {
240
+ id: assetId,
241
+ type: "image",
242
+ purpose: input.purpose,
243
+ brief: input.brief,
244
+ status: "success",
245
+ path: relative(workspaceDir, destinationPath),
246
+ sourceUrl: input.sourceUrl,
247
+ sourcePath: relativeSourcePath,
248
+ intendedSection: input.intendedSection,
249
+ alt: input.alt,
250
+ notes: input.notes,
251
+ savedAt: nowIso(),
252
+ }
253
+ const { manifest: nextManifest } = upsertAsset(manifest, record)
254
+ writeManifest(manifestPath, nextManifest)
255
+ if (previous?.path && previous.path !== record.path) {
256
+ rmSync(join(workspaceDir, previous.path), { force: true })
257
+ }
258
+
259
+ return {
260
+ ok: true,
261
+ assetId,
262
+ status: "success",
263
+ path: record.path,
264
+ manifestPath: relative(workspaceDir, manifestPath),
265
+ updated: true,
266
+ }
267
+ } catch (error) {
268
+ return {
269
+ ok: false,
270
+ error: error instanceof Error ? error.message : String(error),
271
+ }
272
+ }
273
+ }
@@ -0,0 +1,54 @@
1
+ export type MediaPurpose = "hero" | "illustration" | "portrait" | "logo" | "screenshot"
2
+
3
+ export type MediaStatus = "success" | "cannot-download" | "invalid-url" | "cannot-generate"
4
+
5
+ export interface MediaSaveInput {
6
+ topic: string
7
+ id: string
8
+ type: "image"
9
+ purpose: MediaPurpose
10
+ brief: string
11
+ status: MediaStatus
12
+ intendedSection?: string
13
+ sourcePath?: string
14
+ sourceUrl?: string
15
+ alt?: string
16
+ notes?: string
17
+ failureReason?: string
18
+ }
19
+
20
+ export interface MediaAssetRecord {
21
+ id: string
22
+ type: "image"
23
+ purpose: MediaPurpose
24
+ brief: string
25
+ status: MediaStatus
26
+ path: string | null
27
+ sourceUrl?: string
28
+ sourcePath?: string
29
+ intendedSection?: string
30
+ alt?: string
31
+ notes?: string
32
+ failureReason?: string
33
+ savedAt: string
34
+ }
35
+
36
+ export interface MediaManifest {
37
+ topic: string
38
+ updatedAt: string
39
+ assets: MediaAssetRecord[]
40
+ }
41
+
42
+ export type MediaSaveResult =
43
+ | {
44
+ ok: true
45
+ assetId: string
46
+ status: MediaStatus
47
+ path: string | null
48
+ manifestPath: string
49
+ updated: boolean
50
+ }
51
+ | {
52
+ ok: false
53
+ error: string
54
+ }
@@ -0,0 +1,175 @@
1
+ import { existsSync, readdirSync, readFileSync } from "fs"
2
+ import { basename, join } from "path"
3
+
4
+ export type ResearchImageUse = "logo" | "portrait" | "screenshot" | "unknown"
5
+
6
+ export interface ResearchImageLead {
7
+ candidateId: string
8
+ topic: string
9
+ axis: string
10
+ sourceFile: string
11
+ description: string
12
+ url: string
13
+ alt: string
14
+ use: ResearchImageUse
15
+ line: number
16
+ valid: boolean
17
+ warnings: string[]
18
+ }
19
+
20
+ export interface ListResearchImageLeadOptions {
21
+ uses?: string[]
22
+ axis?: string[]
23
+ }
24
+
25
+ export interface ResearchImageLeadListResult {
26
+ topic: string
27
+ items: ResearchImageLead[]
28
+ warnings: string[]
29
+ }
30
+
31
+ const LEAD_LINE_RE = /^-\s*(.+?):\s*(\S+)(?:\s*\|\s*Alt:\s*(.*?))?(?:\s*\|\s*Use:\s*(.*?))?\s*$/
32
+
33
+ function slugify(value: string): string {
34
+ return value
35
+ .toLowerCase()
36
+ .replace(/[^a-z0-9]+/g, "-")
37
+ .replace(/^-+|-+$/g, "")
38
+ }
39
+
40
+ function normalizeUse(value: string | undefined): ResearchImageUse {
41
+ const normalized = (value ?? "").trim().toLowerCase()
42
+ if (normalized === "logo" || normalized === "portrait" || normalized === "screenshot") {
43
+ return normalized
44
+ }
45
+ return "unknown"
46
+ }
47
+
48
+ function normalizeAxisFilter(values: string[] | undefined): string[] | undefined {
49
+ if (!values?.length) return undefined
50
+ const normalized = values
51
+ .map((value) => slugify(value))
52
+ .filter(Boolean)
53
+ return normalized.length ? normalized : undefined
54
+ }
55
+
56
+ function normalizeUseFilter(values: string[] | undefined): ResearchImageUse[] | undefined {
57
+ if (!values?.length) return undefined
58
+ const normalized = values
59
+ .map((value) => normalizeUse(value))
60
+ return normalized.length ? normalized : undefined
61
+ }
62
+
63
+ function isHttpUrl(value: string): boolean {
64
+ try {
65
+ const url = new URL(value)
66
+ return url.protocol === "http:" || url.protocol === "https:"
67
+ } catch {
68
+ return false
69
+ }
70
+ }
71
+
72
+ function shouldIncludeLead(lead: ResearchImageLead, options?: ListResearchImageLeadOptions): boolean {
73
+ const uses = normalizeUseFilter(options?.uses)
74
+ const axis = normalizeAxisFilter(options?.axis)
75
+ if (uses?.length && !uses.includes(lead.use)) return false
76
+ if (axis?.length && !axis.includes(lead.axis)) return false
77
+ return true
78
+ }
79
+
80
+ function parseImageSection(
81
+ topic: string,
82
+ axis: string,
83
+ sourceFile: string,
84
+ content: string,
85
+ ): { items: ResearchImageLead[]; warnings: string[] } {
86
+ const lines = content.split(/\r?\n/)
87
+ const items: ResearchImageLead[] = []
88
+ const warnings: string[] = []
89
+ let inImagesSection = false
90
+ let candidateIndex = 0
91
+
92
+ for (let index = 0; index < lines.length; index++) {
93
+ const line = lines[index]
94
+ const trimmed = line.trim()
95
+
96
+ if (/^##\s+Images\s*$/i.test(trimmed)) {
97
+ inImagesSection = true
98
+ continue
99
+ }
100
+
101
+ if (inImagesSection && /^##\s+/.test(trimmed)) {
102
+ break
103
+ }
104
+
105
+ if (!inImagesSection || !trimmed.startsWith("-")) continue
106
+ if (trimmed === "- None") continue
107
+
108
+ const match = trimmed.match(LEAD_LINE_RE)
109
+ if (!match) {
110
+ warnings.push(`${sourceFile}:${index + 1} could not parse image lead`)
111
+ continue
112
+ }
113
+
114
+ candidateIndex += 1
115
+ const description = match[1].trim()
116
+ const url = match[2].trim()
117
+ const alt = (match[3] ?? "").trim()
118
+ const use = normalizeUse(match[4])
119
+ const itemWarnings: string[] = []
120
+ const valid = isHttpUrl(url)
121
+
122
+ if (!valid) itemWarnings.push("invalid-url")
123
+ if (use === "unknown") itemWarnings.push("unknown-use")
124
+
125
+ items.push({
126
+ candidateId: `${axis}:${candidateIndex}`,
127
+ topic,
128
+ axis,
129
+ sourceFile,
130
+ description,
131
+ url,
132
+ alt,
133
+ use,
134
+ line: index + 1,
135
+ valid,
136
+ warnings: itemWarnings,
137
+ })
138
+ }
139
+
140
+ return { items, warnings }
141
+ }
142
+
143
+ export function listResearchImageLeads(
144
+ topic: string,
145
+ workspaceDir: string,
146
+ options?: ListResearchImageLeadOptions,
147
+ ): ResearchImageLeadListResult {
148
+ const topicSlug = slugify(topic)
149
+ const researchDir = join(workspaceDir, "researches", topicSlug)
150
+ if (!existsSync(researchDir)) {
151
+ return { topic: topicSlug, items: [], warnings: [] }
152
+ }
153
+
154
+ const items: ResearchImageLead[] = []
155
+ const warnings: string[] = []
156
+ const entries = readdirSync(researchDir)
157
+ .filter((entry) => entry.endsWith(".md"))
158
+ .sort()
159
+
160
+ for (const entry of entries) {
161
+ const filePath = join(researchDir, entry)
162
+ const axis = basename(entry, ".md")
163
+ const sourceFile = `researches/${topicSlug}/${entry}`
164
+ const content = readFileSync(filePath, "utf-8")
165
+ const parsed = parseImageSection(topicSlug, axis, sourceFile, content)
166
+
167
+ for (const item of parsed.items) {
168
+ if (shouldIncludeLead(item, options)) items.push(item)
169
+ }
170
+
171
+ warnings.push(...parsed.warnings)
172
+ }
173
+
174
+ return { topic: topicSlug, items, warnings }
175
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cyber-dash-tech/revela",
3
- "version": "0.3.1",
3
+ "version": "0.4.0",
4
4
  "description": "OpenCode plugin that turns AI into an HTML slide deck generator",
5
5
  "type": "module",
6
6
  "main": "./index.ts",
package/plugin.ts CHANGED
@@ -47,6 +47,9 @@ import { handlePdf } from "./lib/commands/pdf"
47
47
  import { handlePptx } from "./lib/commands/pptx"
48
48
  import designsTool from "./tools/designs"
49
49
  import domainsTool from "./tools/domains"
50
+ import mediaBatchSaveTool from "./tools/media-batch-save"
51
+ import mediaSaveTool from "./tools/media-save"
52
+ import researchImagesListTool from "./tools/research-images-list"
50
53
  import researchSaveTool from "./tools/research-save"
51
54
  import workspaceScanTool from "./tools/workspace-scan"
52
55
  import extractDocumentMaterialsTool from "./tools/extract-document-materials"
@@ -229,6 +232,9 @@ const server: Plugin = (async (pluginCtx) => {
229
232
  tool: {
230
233
  "revela-designs": designsTool,
231
234
  "revela-domains": domainsTool,
235
+ "revela-media-batch-save": mediaBatchSaveTool,
236
+ "revela-media-save": mediaSaveTool,
237
+ "revela-research-images-list": researchImagesListTool,
232
238
  "revela-research-save": researchSaveTool,
233
239
  "revela-workspace-scan": workspaceScanTool,
234
240
  "revela-extract-document-materials": extractDocumentMaterialsTool,
package/skill/SKILL.md CHANGED
@@ -427,6 +427,12 @@ element selector list, and `window.getEditedHTML()` definition.
427
427
 
428
428
  ### Image Rules
429
429
 
430
+ - When research findings contain image leads that should appear in the final deck,
431
+ first call `revela-research-images-list` to inspect structured candidates from
432
+ `researches/{slug}/*.md`. When multiple images are needed, prefer
433
+ `revela-media-batch-save` to save the selected candidates in one call. Use
434
+ `revela-media-save` for one-off cases. Then reference the returned local file
435
+ path in HTML. Do not use remote image URLs directly in final slides.
430
436
  - Use direct file paths (`src="assets/logo.png"`) in HTML — not base64
431
437
  - Always use the **original** file path in HTML `<img src>` for full-quality rendering
432
438
  - Never repeat the same image on multiple slides (logos: title + closing only)
@@ -493,4 +499,4 @@ The active design name is in the HTML comment at the top of this prompt:
493
499
  The active design's complete visual specification — Component Library, Layout
494
500
  Primitives, Composition Guide, and Data Visualization rules — is injected
495
501
  below after the `---` separator. This is your sole visual reference for
496
- generating slides.
502
+ generating slides.
@@ -0,0 +1,25 @@
1
+ import { tool } from "@opencode-ai/plugin"
2
+ import { batchSaveMediaAssets } from "../lib/media/batch-save"
3
+
4
+ export default tool({
5
+ description:
6
+ "Save a selected batch of research-found image leads into workspace assets and update the media manifest. " +
7
+ "Use this after the primary agent has chosen multiple images from researches/{topic}/*.md.",
8
+ args: {
9
+ topic: tool.schema.string().describe("Topic slug shared by one presentation, e.g. 'ev-market'."),
10
+ items: tool.schema.array(tool.schema.object({
11
+ candidateId: tool.schema.string().describe("Stable candidate id returned by revela-research-images-list."),
12
+ description: tool.schema.string().describe("Candidate description from research findings."),
13
+ url: tool.schema.string().describe("Remote image URL to save."),
14
+ alt: tool.schema.string().optional().describe("Optional alt text."),
15
+ use: tool.schema.enum(["logo", "portrait", "screenshot"]).describe("Structured image use from research findings."),
16
+ sourceFile: tool.schema.string().optional().describe("Optional source research file path."),
17
+ intendedSection: tool.schema.string().optional().describe("Optional section override for this one item."),
18
+ })).describe("Explicitly selected image leads to save. This tool does not auto-select candidates."),
19
+ intendedSection: tool.schema.string().optional().describe("Optional default narrative section for all items in this batch."),
20
+ },
21
+ async execute(args, context) {
22
+ const workspaceDir = context.directory ?? process.cwd()
23
+ return JSON.stringify(await batchSaveMediaAssets(args, workspaceDir), null, 2)
24
+ },
25
+ })
@@ -0,0 +1,40 @@
1
+ import { tool } from "@opencode-ai/plugin"
2
+ import { saveMediaAsset } from "../lib/media/save"
3
+
4
+ export default tool({
5
+ description:
6
+ "Save one image asset into the workspace assets/ directory and update a media manifest. " +
7
+ "Supports either a sourceUrl or a sourcePath. Records both success and failure states. " +
8
+ "Use this when a research-found image or an existing local image should become a formal project asset.",
9
+ args: {
10
+ topic: tool.schema.string().describe("Topic slug shared by one presentation, e.g. 'ev-market'."),
11
+ id: tool.schema.string().describe("Stable asset id within the topic, e.g. 'tesla-logo-01'."),
12
+ type: tool.schema.enum(["image"]).describe("Asset type. Stage 1 only supports 'image'."),
13
+ purpose: tool.schema
14
+ .enum(["hero", "illustration", "portrait", "logo", "screenshot"])
15
+ .describe("Image purpose in the deck."),
16
+ brief: tool.schema.string().describe("One-sentence reason this image is needed."),
17
+ status: tool.schema
18
+ .enum(["success", "cannot-download", "invalid-url", "cannot-generate"])
19
+ .describe("'success' saves an image asset; other statuses record a failed attempt in the manifest."),
20
+ intendedSection: tool.schema.string().optional().describe("Optional narrative section such as 'market-overview'."),
21
+ sourcePath: tool.schema
22
+ .string()
23
+ .optional()
24
+ .describe("Optional local image path, relative to the workspace root. Preferred when both sourcePath and sourceUrl are present."),
25
+ sourceUrl: tool.schema
26
+ .string()
27
+ .optional()
28
+ .describe("Optional remote image URL to download when sourcePath is not provided."),
29
+ alt: tool.schema.string().optional().describe("Optional alt text for the image."),
30
+ notes: tool.schema.string().optional().describe("Optional usage notes for future slide generation."),
31
+ failureReason: tool.schema
32
+ .string()
33
+ .optional()
34
+ .describe("Required when status is not 'success'. Briefly explain why the image is unavailable."),
35
+ },
36
+ async execute(args, context) {
37
+ const workspaceDir = context.directory ?? process.cwd()
38
+ return JSON.stringify(await saveMediaAsset(args, workspaceDir), null, 2)
39
+ },
40
+ })
@@ -0,0 +1,34 @@
1
+ import { tool } from "@opencode-ai/plugin"
2
+ import { listResearchImageLeads } from "../lib/research/image-leads"
3
+
4
+ export default tool({
5
+ description:
6
+ "List structured image leads from researches/{topic}/*.md. " +
7
+ "Parses ## Images sections and returns candidate image records for the primary agent to review.",
8
+ args: {
9
+ topic: tool.schema.string().describe("Topic slug shared by one presentation, e.g. 'ev-market'."),
10
+ uses: tool.schema
11
+ .array(tool.schema.enum(["logo", "portrait", "screenshot", "unknown"]))
12
+ .optional()
13
+ .describe("Optional use filter, e.g. ['logo', 'portrait', 'screenshot']"),
14
+ axis: tool.schema
15
+ .array(tool.schema.string())
16
+ .optional()
17
+ .describe("Optional axis filter, e.g. ['tesla-profile', 'market-data']"),
18
+ },
19
+ async execute(args, context) {
20
+ const workspaceDir = context.directory ?? process.cwd()
21
+ const result = listResearchImageLeads(args.topic, workspaceDir, {
22
+ uses: args.uses,
23
+ axis: args.axis,
24
+ })
25
+
26
+ return JSON.stringify({
27
+ ok: true,
28
+ topic: result.topic,
29
+ count: result.items.length,
30
+ items: result.items,
31
+ warnings: result.warnings,
32
+ }, null, 2)
33
+ },
34
+ })