@cyber-dash-tech/revela 0.3.1 → 0.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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.1",
4
4
  "description": "OpenCode plugin that turns AI into an HTML slide deck generator",
5
5
  "type": "module",
6
6
  "main": "./index.ts",
@@ -13,8 +13,12 @@
13
13
  "tools/",
14
14
  "skill/",
15
15
  "designs/aurora/DESIGN.md",
16
+ "designs/starter/DESIGN.md",
17
+ "designs/starter/preview.html",
16
18
  "designs/summit/DESIGN.md",
19
+ "designs/summit/preview.html",
17
20
  "designs/monet/DESIGN.md",
21
+ "designs/monet/preview.html",
18
22
  "domains/general/INDUSTRY.md",
19
23
  "domains/deeptech-investment/INDUSTRY.md",
20
24
  "domains/consulting/INDUSTRY.md",
package/plugin.ts CHANGED
@@ -45,8 +45,19 @@ import {
45
45
  } from "./lib/commands/domains"
46
46
  import { handlePdf } from "./lib/commands/pdf"
47
47
  import { handlePptx } from "./lib/commands/pptx"
48
+ import { handleDesignsPreview } from "./lib/commands/designs-preview"
49
+ import {
50
+ parseDesignsNewArgs,
51
+ buildDesignsNewPrompt,
52
+ parseDesignsEditArgs,
53
+ buildDesignsEditPrompt,
54
+ } from "./lib/commands/designs-new"
55
+ import designsAuthorTool from "./tools/designs-author"
48
56
  import designsTool from "./tools/designs"
49
57
  import domainsTool from "./tools/domains"
58
+ import mediaBatchSaveTool from "./tools/media-batch-save"
59
+ import mediaSaveTool from "./tools/media-save"
60
+ import researchImagesListTool from "./tools/research-images-list"
50
61
  import researchSaveTool from "./tools/research-save"
51
62
  import workspaceScanTool from "./tools/workspace-scan"
52
63
  import extractDocumentMaterialsTool from "./tools/extract-document-materials"
@@ -200,6 +211,36 @@ const server: Plugin = (async (pluginCtx) => {
200
211
  await handleDesignsAdd(param, send)
201
212
  throw new Error("__REVELA_DESIGNS_ADD_HANDLED__")
202
213
  }
214
+ if (sub === "designs-new") {
215
+ const parsed = parseDesignsNewArgs(param)
216
+ if (!parsed.ok) {
217
+ await send(parsed.error)
218
+ throw new Error("__REVELA_DESIGNS_NEW_USAGE_HANDLED__")
219
+ }
220
+ output.parts.length = 0
221
+ output.parts.push({
222
+ type: "text",
223
+ text: buildDesignsNewPrompt({ name: parsed.name, base: parsed.base }),
224
+ } as any)
225
+ return
226
+ }
227
+ if (sub === "designs-edit") {
228
+ const parsed = parseDesignsEditArgs(param)
229
+ if (!parsed.ok) {
230
+ await send(parsed.error)
231
+ throw new Error("__REVELA_DESIGNS_EDIT_USAGE_HANDLED__")
232
+ }
233
+ output.parts.length = 0
234
+ output.parts.push({
235
+ type: "text",
236
+ text: buildDesignsEditPrompt({ name: parsed.name }),
237
+ } as any)
238
+ return
239
+ }
240
+ if (sub === "designs-preview") {
241
+ await handleDesignsPreview(param, send)
242
+ throw new Error("__REVELA_DESIGNS_PREVIEW_HANDLED__")
243
+ }
203
244
  if (sub === "domains-add") {
204
245
  await handleDomainsAdd(param, send)
205
246
  throw new Error("__REVELA_DOMAINS_ADD_HANDLED__")
@@ -228,7 +269,11 @@ const server: Plugin = (async (pluginCtx) => {
228
269
  // ── LLM tools: designs, domains, research, document materials, qa ─────
229
270
  tool: {
230
271
  "revela-designs": designsTool,
272
+ "revela-designs-author": designsAuthorTool,
231
273
  "revela-domains": domainsTool,
274
+ "revela-media-batch-save": mediaBatchSaveTool,
275
+ "revela-media-save": mediaSaveTool,
276
+ "revela-research-images-list": researchImagesListTool,
232
277
  "revela-research-save": researchSaveTool,
233
278
  "revela-workspace-scan": workspaceScanTool,
234
279
  "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,62 @@
1
+ import { tool } from "@opencode-ai/plugin"
2
+ import { createDesignPackage, validateDesignPackage } from "../lib/design/designs"
3
+
4
+ export default tool({
5
+ description:
6
+ "Create and validate local Revela design packages generated by the AI design authoring workflow. " +
7
+ "Use action 'create' to save DESIGN.md and preview.html into ~/.config/revela/designs/{name}/. " +
8
+ "Use action 'validate' to check a generated design package before asking the user to activate it.",
9
+ args: {
10
+ action: tool.schema
11
+ .enum(["create", "validate"])
12
+ .describe("Operation to perform"),
13
+ name: tool.schema
14
+ .string()
15
+ .optional()
16
+ .describe("Design name in kebab-case. Required for create and validate."),
17
+ base: tool.schema
18
+ .string()
19
+ .optional()
20
+ .describe("Base design used as structural scaffold, e.g. 'summit'. Recorded in the result only."),
21
+ designMd: tool.schema
22
+ .string()
23
+ .optional()
24
+ .describe("Complete DESIGN.md content. Required for create."),
25
+ previewHtml: tool.schema
26
+ .string()
27
+ .optional()
28
+ .describe("Complete preview.html content. Required for create."),
29
+ overwrite: tool.schema
30
+ .boolean()
31
+ .optional()
32
+ .describe("Whether to replace an existing local design package. Defaults to false."),
33
+ },
34
+ async execute(args) {
35
+ try {
36
+ if (!args.name) return JSON.stringify({ error: "name is required" })
37
+
38
+ switch (args.action) {
39
+ case "create": {
40
+ if (!args.designMd) return JSON.stringify({ error: "designMd is required for create" })
41
+ if (!args.previewHtml) return JSON.stringify({ error: "previewHtml is required for create" })
42
+ const result = createDesignPackage({
43
+ name: args.name,
44
+ base: args.base,
45
+ designMd: args.designMd,
46
+ previewHtml: args.previewHtml,
47
+ overwrite: args.overwrite ?? false,
48
+ })
49
+ return JSON.stringify(result, null, 2)
50
+ }
51
+ case "validate": {
52
+ return JSON.stringify(validateDesignPackage(args.name), null, 2)
53
+ }
54
+ default: {
55
+ return JSON.stringify({ error: `Unknown action: ${args.action}` })
56
+ }
57
+ }
58
+ } catch (e: any) {
59
+ return JSON.stringify({ error: e.message || String(e) })
60
+ }
61
+ },
62
+ })
@@ -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
+ })