@cyber-dash-tech/revela 0.17.0 → 0.17.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.
@@ -253,35 +253,105 @@ async function handleAssetSave(req: Request, session: EditSession): Promise<Resp
253
253
  const candidate = normalizeImageCandidate(body?.candidate ?? body)
254
254
  if (!candidate) return jsonResponse({ ok: false, error: "Valid image candidate is required" }, 400)
255
255
  const purpose = normalizeMediaPurpose(body?.purpose) || candidate.purpose || "illustration"
256
- const result = await saveMediaAsset({
257
- topic: session.deck,
256
+ const brief = body?.brief || `Saved from ${candidate.provider} for Review asset placement.`
257
+ const saved = await saveAssetCandidateUrls({
258
+ session,
259
+ candidate,
258
260
  id: body?.id || candidate.candidateId,
259
- type: "image",
260
261
  purpose,
261
- brief: body?.brief || `Saved from ${candidate.provider} for Review asset placement.`,
262
- status: "success",
263
- sourceUrl: candidate.imageUrl,
262
+ brief,
264
263
  alt: body?.alt || candidate.alt || candidate.title,
265
264
  notes: body?.notes,
266
- provider: candidate.provider,
267
- sourcePageUrl: candidate.sourcePageUrl,
268
- license: candidate.license,
269
- attribution: candidate.attribution,
270
- width: candidate.width,
271
- height: candidate.height,
272
- }, session.workspaceRoot)
265
+ })
273
266
 
274
267
  session.lastActiveAt = Date.now()
275
268
  scheduleIdleStop()
269
+ const result = saved.result
276
270
  if (!result.ok) return jsonResponse({ ok: false, error: result.error }, 400)
277
271
  if (result.status !== "success" || !result.path) {
278
- return jsonResponse({ ok: false, error: `Failed to save asset: ${result.status}` }, 400)
272
+ return jsonResponse({ ok: false, error: failedAssetSaveMessage(result.status, saved.failures) }, 400)
279
273
  }
280
274
  const asset = savedAssetForResult(session, result.assetId)
275
+ ?? savedAssetFallback(session, {
276
+ id: result.assetId,
277
+ path: result.path,
278
+ sourceUrl: saved.sourceUrl,
279
+ purpose,
280
+ brief,
281
+ candidate,
282
+ })
281
283
  if (!asset) return jsonResponse({ ok: false, error: "Saved asset was not found in workspace assets." }, 500)
282
284
  return jsonResponse({ ok: true, asset, result })
283
285
  }
284
286
 
287
+ async function saveAssetCandidateUrls(input: {
288
+ session: EditSession
289
+ candidate: ImageCandidate
290
+ id: string
291
+ purpose: MediaPurpose
292
+ brief: string
293
+ alt?: string
294
+ notes?: string
295
+ }): Promise<{
296
+ result: Awaited<ReturnType<typeof saveMediaAsset>>
297
+ sourceUrl?: string
298
+ failures: Array<{ url: string; status: string }>
299
+ }> {
300
+ const urls = uniqueAssetUrls([input.candidate.imageUrl, input.candidate.thumbnailUrl])
301
+ const failures: Array<{ url: string; status: string }> = []
302
+ let lastResult: Awaited<ReturnType<typeof saveMediaAsset>> | undefined
303
+
304
+ for (const sourceUrl of urls) {
305
+ const result = await saveMediaAsset({
306
+ topic: input.session.deck,
307
+ id: input.id,
308
+ type: "image",
309
+ purpose: input.purpose,
310
+ brief: input.brief,
311
+ status: "success",
312
+ sourceUrl,
313
+ alt: input.alt,
314
+ notes: input.notes,
315
+ provider: input.candidate.provider,
316
+ sourcePageUrl: input.candidate.sourcePageUrl,
317
+ license: input.candidate.license,
318
+ attribution: input.candidate.attribution,
319
+ width: input.candidate.width,
320
+ height: input.candidate.height,
321
+ }, input.session.workspaceRoot)
322
+ lastResult = result
323
+ if (result.ok && result.status === "success" && result.path) return { result, sourceUrl, failures }
324
+ failures.push({ url: sourceUrl, status: result.ok ? result.failureReason ?? result.status : result.error })
325
+ }
326
+
327
+ return {
328
+ result: lastResult ?? { ok: false, error: "No downloadable image URL was provided" },
329
+ failures,
330
+ }
331
+ }
332
+
333
+ function uniqueAssetUrls(values: Array<string | undefined>): string[] {
334
+ const seen = new Set<string>()
335
+ return values.flatMap((value) => {
336
+ const url = value?.trim()
337
+ if (!url || seen.has(url)) return []
338
+ seen.add(url)
339
+ return [url]
340
+ })
341
+ }
342
+
343
+ function failedAssetSaveMessage(status: string, failures: Array<{ url: string; status: string }>): string {
344
+ if (!failures.length) return `Failed to save asset: ${status}`
345
+ const details = failures
346
+ .map((failure) => `${shortUrl(failure.url)}: ${failure.status}`)
347
+ .join("; ")
348
+ return `Failed to save asset: ${status} (${details})`
349
+ }
350
+
351
+ function shortUrl(value: string): string {
352
+ return value.length <= 96 ? value : `${value.slice(0, 93)}...`
353
+ }
354
+
285
355
  function handleAssetList(session: EditSession): Response {
286
356
  session.lastActiveAt = Date.now()
287
357
  scheduleIdleStop()
@@ -292,6 +362,39 @@ function savedAssetForResult(session: EditSession, assetId: string): (MediaAsset
292
362
  return listSavedAssets(session).find((asset) => asset.id === assetId) ?? null
293
363
  }
294
364
 
365
+ function savedAssetFallback(
366
+ session: EditSession,
367
+ input: {
368
+ id: string
369
+ path: string | null
370
+ sourceUrl?: string
371
+ purpose: MediaPurpose
372
+ brief: string
373
+ candidate: ImageCandidate
374
+ },
375
+ ): (MediaAssetRecord & { previewUrl?: string; deckPath?: string }) | null {
376
+ if (!input.path) return null
377
+ return {
378
+ id: input.id,
379
+ type: "image",
380
+ purpose: input.purpose,
381
+ brief: input.brief,
382
+ status: "success",
383
+ path: input.path,
384
+ sourceUrl: input.sourceUrl ?? input.candidate.imageUrl,
385
+ alt: input.candidate.alt || input.candidate.title,
386
+ provider: input.candidate.provider,
387
+ sourcePageUrl: input.candidate.sourcePageUrl,
388
+ license: input.candidate.license,
389
+ attribution: input.candidate.attribution,
390
+ width: input.candidate.width,
391
+ height: input.candidate.height,
392
+ savedAt: new Date().toISOString(),
393
+ previewUrl: assetUrlForRef(input.path, session, session.workspaceRoot) ?? undefined,
394
+ deckPath: relative(dirname(session.absoluteFile), resolve(session.workspaceRoot, input.path)).replace(/\\/g, "/"),
395
+ }
396
+ }
397
+
295
398
  function listSavedAssets(session: EditSession): Array<MediaAssetRecord & { previewUrl?: string; deckPath?: string }> {
296
399
  const manifestPath = resolve(session.workspaceRoot, "assets", slugify(session.deck), "media-manifest.json")
297
400
  if (!existsSync(manifestPath)) return []
@@ -891,6 +994,7 @@ export function renderRefineShell(token: string, defaultMode: RefineMode = "edit
891
994
  .skeleton-line.long { width: 92%; }
892
995
  .asset-card.is-saving::after { content: ""; position: absolute; inset: 0; background: rgba(15,23,42,.32); }
893
996
  .asset-card.is-saving .asset-save { z-index: 1; }
997
+ .asset-card.is-saved-candidate .asset-thumb { opacity: .72; }
894
998
  @keyframes spin { to { transform: rotate(360deg); } }
895
999
  @keyframes shimmer { 0% { background-position: 200% 0; } 100% { background-position: -200% 0; } }
896
1000
  .asset-search { display: grid; grid-template-columns: minmax(0, 1fr) 118px; gap: 8px; }
@@ -913,6 +1017,7 @@ export function renderRefineShell(token: string, defaultMode: RefineMode = "edit
913
1017
  .asset-search-title span { color: #756f66; font-size: 12px; line-height: 1.35; }
914
1018
  .asset-back { width: auto; padding: 9px 11px; border-radius: 999px; background: #ebe4d8; color: #111827; box-shadow: none; }
915
1019
  .asset-save { position: absolute; left: 7px; right: 7px; bottom: 7px; width: auto; padding: 7px 8px; border-radius: 10px; font-size: 11px; background: rgba(17,24,39,.9); color: #fbfaf7; box-shadow: 0 8px 16px rgba(31,41,51,.2); opacity: .96; }
1020
+ .asset-save.saved { background: rgba(77,97,56,.94); color: #fbfaf7; cursor: default; }
916
1021
  .asset-empty { grid-column: 1 / -1; margin: 0; color: #756f66; font-size: 12px; line-height: 1.45; }
917
1022
  .edit-assets { padding: 10px; border: 1px solid #d8d2c6; border-radius: 16px; background: #f7f3ea; }
918
1023
  .edit-assets .panel { gap: 8px; }
@@ -2000,9 +2105,13 @@ export function renderRefineShell(token: string, defaultMode: RefineMode = "edit
2000
2105
  }
2001
2106
  state.assetCandidates.forEach((candidate, index) => {
2002
2107
  const card = assetCard(candidate, false, index);
2108
+ const savedAsset = savedAssetForCandidate(candidate);
2003
2109
  if (state.assetSavingIndex === index) {
2004
2110
  card.classList.add('is-saving');
2005
2111
  appendAssetSaveButton(card, 'Saving...', 'Saving to workspace', () => {}, true);
2112
+ } else if (savedAsset) {
2113
+ card.classList.add('is-saved-candidate');
2114
+ appendAssetSaveButton(card, '✅ Saved', 'Asset already saved to Local Assets', () => {}, false, 'saved');
2006
2115
  } else {
2007
2116
  appendAssetSaveButton(card, 'Save', 'Save to workspace', () => saveCandidate(index));
2008
2117
  }
@@ -2025,7 +2134,11 @@ export function renderRefineShell(token: string, defaultMode: RefineMode = "edit
2025
2134
  });
2026
2135
  const body = await res.json().catch(() => ({}));
2027
2136
  if (!res.ok || !body.ok) throw new Error(body.error || 'Failed to save asset');
2028
- await loadSavedAssets();
2137
+ const listed = await loadSavedAssets();
2138
+ if (body.asset && (!listed || !findSavedAsset(body.asset.id))) {
2139
+ mergeSavedAsset(body.asset);
2140
+ renderSavedAssets();
2141
+ }
2029
2142
  const path = body.asset && (body.asset.path || body.asset.deckPath);
2030
2143
  setStatus(path ? 'Saved to ' + path + '. Use it from Local Assets.' : 'Asset saved. Use it from Local Assets.');
2031
2144
  } catch (error) {
@@ -2043,12 +2156,36 @@ export function renderRefineShell(token: string, defaultMode: RefineMode = "edit
2043
2156
  if (!res.ok || !body.ok) throw new Error(body.error || 'Failed to list assets');
2044
2157
  state.savedAssets = Array.isArray(body.assets) ? body.assets : [];
2045
2158
  renderSavedAssets();
2159
+ return true;
2046
2160
  } catch (error) {
2047
- const message = '<p class="asset-empty">' + escapeHtml(error && error.message ? error.message : String(error)) + '</p>';
2048
- els.editSavedAssets.innerHTML = message;
2161
+ if (!state.savedAssets.length) {
2162
+ const message = '<p class="asset-empty">' + escapeHtml(error && error.message ? error.message : String(error)) + '</p>';
2163
+ els.editSavedAssets.innerHTML = message;
2164
+ }
2165
+ return false;
2049
2166
  }
2050
2167
  }
2051
2168
 
2169
+ function mergeSavedAsset(asset) {
2170
+ if (!asset || !asset.id) return;
2171
+ const next = state.savedAssets.filter((existing) => existing.id !== asset.id);
2172
+ next.unshift(asset);
2173
+ state.savedAssets = next;
2174
+ }
2175
+
2176
+ function savedAssetForCandidate(candidate) {
2177
+ const id = slugifyAssetId(candidate && candidate.candidateId);
2178
+ if (!id) return null;
2179
+ return state.savedAssets.find((asset) => asset.id === id) || null;
2180
+ }
2181
+
2182
+ function slugifyAssetId(value) {
2183
+ return String(value || '')
2184
+ .toLowerCase()
2185
+ .replace(/[^a-z0-9]+/g, '-')
2186
+ .replace(/^-+|-+$/g, '');
2187
+ }
2188
+
2052
2189
  function renderSavedAssets() {
2053
2190
  renderSavedAssetGrid(els.editSavedAssets, 'No local assets yet. Click + to search assets.');
2054
2191
  }
@@ -2108,12 +2245,12 @@ export function renderRefineShell(token: string, defaultMode: RefineMode = "edit
2108
2245
  }
2109
2246
  }
2110
2247
 
2111
- function appendAssetSaveButton(card, text, label, onClick, loading) {
2248
+ function appendAssetSaveButton(card, text, label, onClick, loading, variant) {
2112
2249
  const button = document.createElement('button');
2113
2250
  button.type = 'button';
2114
- button.className = 'asset-save';
2251
+ button.className = variant ? 'asset-save ' + variant : 'asset-save';
2115
2252
  button.innerHTML = loading ? '<span class="spinner" aria-hidden="true"></span><span>' + escapeHtml(text) + '</span>' : escapeHtml(text);
2116
- button.disabled = !!loading;
2253
+ button.disabled = !!loading || variant === 'saved';
2117
2254
  button.setAttribute('aria-label', label);
2118
2255
  button.title = label;
2119
2256
  button.addEventListener('click', onClick);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cyber-dash-tech/revela",
3
- "version": "0.17.0",
3
+ "version": "0.17.1",
4
4
  "description": "OpenCode plugin for trusted narrative artifacts from local sources, research, and evidence",
5
5
  "type": "module",
6
6
  "main": "./index.ts",
package/skill/SKILL.md CHANGED
@@ -95,7 +95,8 @@ Before writing HTML, the confirmed plan must include:
95
95
  non-structural slides assigned to each chapter.
96
96
  - One row per slide with title, purpose, narrative role, content summary, layout,
97
97
  components, primary/supporting claim ids, evidence binding ids or source
98
- summary, visual intent, and caveats/unsupported scope.
98
+ summary, `content.data.visualIntent`, `visuals[]`, and caveats/unsupported
99
+ scope.
99
100
  - A low-fidelity layout sketch for every slide when requested by the handoff
100
101
  prompt.
101
102
 
@@ -110,6 +111,10 @@ Rules for the slide plan:
110
111
  "overview of topic".
111
112
  - Every content slide must carry a distinct claim, evidence item, comparison,
112
113
  risk, or action.
114
+ - Treat `content.data.visualIntent` and `visuals[]` as required render
115
+ instructions, not optional decoration. Do not downgrade a planned metric card,
116
+ evidence table, comparison grid, risk matrix, steps view, chart, or media brief
117
+ into generic bullets unless the user revises and reconfirms the plan.
113
118
  - Normal content slides should usually contain 2-4 semantic boxes/cards unless
114
119
  intentionally using a focus layout.
115
120
  - If a chapter lacks enough substance for its allocated slides, reduce the slide
@@ -22,6 +22,18 @@ export default tool({
22
22
  claimFlow: tool.schema.string().optional(),
23
23
  flowNote: tool.schema.string().optional(),
24
24
  selectedClaim: tool.schema.string().optional(),
25
+ selectedEvidence: tool.schema.string().optional(),
26
+ evidenceList: tool.schema.string().optional(),
27
+ gap: tool.schema.string().optional(),
28
+ gaps: tool.schema.string().optional(),
29
+ noEvidence: tool.schema.string().optional(),
30
+ selectEvidencePrompt: tool.schema.string().optional(),
31
+ sourceTrace: tool.schema.string().optional(),
32
+ evidenceSource: tool.schema.string().optional(),
33
+ whyThisSupports: tool.schema.string().optional(),
34
+ linkedGaps: tool.schema.string().optional(),
35
+ selectedGap: tool.schema.string().optional(),
36
+ noLinkedGaps: tool.schema.string().optional(),
25
37
  claim: tool.schema.string().optional(),
26
38
  claimId: tool.schema.string().optional(),
27
39
  status: tool.schema.string().optional(),
@@ -51,6 +63,10 @@ export default tool({
51
63
  riskOrGapSummary: tool.schema.string().optional(),
52
64
  researchGapsSummary: tool.schema.string().optional().describe("Display-only localized summary of research gaps tied to this claim."),
53
65
  })).optional(),
66
+ researchGapCards: tool.schema.array(tool.schema.object({
67
+ gapId: tool.schema.string().describe("Existing canonical research gap id. Must match the deterministic map."),
68
+ displayQuestion: tool.schema.string().optional().describe("Display-only localized gap question. Do not change source facts, ids, targets, or gap meaning."),
69
+ })).optional(),
54
70
  relations: tool.schema.array(tool.schema.object({
55
71
  fromClaimId: tool.schema.string(),
56
72
  toClaimId: tool.schema.string(),