@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.
- package/README.md +199 -8
- package/README.zh-CN.md +160 -7
- package/designs/summit/DESIGN.md +54 -51
- package/lib/media/batch-save.ts +146 -0
- package/lib/media/download.ts +68 -0
- package/lib/media/save.ts +273 -0
- package/lib/media/types.ts +54 -0
- package/lib/research/image-leads.ts +175 -0
- package/package.json +1 -1
- package/plugin.ts +6 -0
- package/skill/SKILL.md +7 -1
- package/tools/media-batch-save.ts +25 -0
- package/tools/media-save.ts +40 -0
- package/tools/research-images-list.ts +34 -0
|
@@ -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
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
|
+
})
|