@cyber-dash-tech/revela 0.8.5 → 0.8.7
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/lib/commands/help.ts +1 -1
- package/lib/commands/pptx.ts +117 -17
- package/lib/commands/review.ts +10 -2
- package/lib/decks-state.ts +214 -18
- package/lib/pptx/export.ts +57 -3
- package/package.json +1 -1
- package/plugin.ts +15 -2
- package/skill/SKILL.md +19 -0
- package/tools/pptx.ts +23 -4
package/lib/commands/help.ts
CHANGED
|
@@ -42,6 +42,6 @@ export async function handleHelp(
|
|
|
42
42
|
`\`/revela designs-rm <name>\` — remove an installed design\n` +
|
|
43
43
|
`\`/revela domains-rm <name>\` — remove an installed domain\n` +
|
|
44
44
|
`\`/revela pdf <file>\` — export HTML slide deck to PDF\n` +
|
|
45
|
-
`\`/revela pptx
|
|
45
|
+
`\`/revela pptx [file] [--notes]\` — export HTML slide deck to PPTX`
|
|
46
46
|
)
|
|
47
47
|
}
|
package/lib/commands/pptx.ts
CHANGED
|
@@ -1,42 +1,124 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* lib/commands/pptx.ts
|
|
3
3
|
*
|
|
4
|
-
* Handler for `/revela pptx
|
|
4
|
+
* Handler for `/revela pptx [file_path]` — exports an HTML slide deck to PPTX.
|
|
5
5
|
*
|
|
6
6
|
* Output: same directory and base name as the input, with .pptx extension.
|
|
7
7
|
* Example: decks/my-deck.html → decks/my-deck.pptx
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
|
-
import {
|
|
10
|
+
import { existsSync, readdirSync } from "fs"
|
|
11
|
+
import { relative, resolve, sep } from "path"
|
|
12
|
+
import { hasDecksState, isDeckHtmlPath, readDecksState } from "../decks-state"
|
|
11
13
|
import { exportToPptx } from "../pptx/export"
|
|
12
|
-
|
|
14
|
+
|
|
15
|
+
export interface PptxArgs {
|
|
16
|
+
filePath: string
|
|
17
|
+
notes: boolean
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface ResolvedPptxDeck {
|
|
21
|
+
file: string
|
|
22
|
+
absoluteFile: string
|
|
23
|
+
source: "decks-state" | "fallback" | "file-path"
|
|
24
|
+
}
|
|
13
25
|
|
|
14
26
|
function formatSecs(ms: number): string {
|
|
15
27
|
return `${(ms / 1000).toFixed(1)}s`
|
|
16
28
|
}
|
|
17
29
|
|
|
18
|
-
export
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
)
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
30
|
+
export function parsePptxArgs(input: string): PptxArgs {
|
|
31
|
+
const parts = input.trim().split(/\s+/).filter(Boolean)
|
|
32
|
+
const notes = parts.includes("--notes")
|
|
33
|
+
const filePath = parts.filter((part) => part !== "--notes").join(" ").trim()
|
|
34
|
+
return { filePath, notes }
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function resolvePptxDeck(workspaceRoot: string, filePath = ""): ResolvedPptxDeck {
|
|
38
|
+
const root = resolve(workspaceRoot)
|
|
39
|
+
const explicit = filePath.trim()
|
|
40
|
+
if (explicit) {
|
|
41
|
+
const absoluteFile = resolve(root, explicit)
|
|
42
|
+
if (!existsSync(absoluteFile)) throw new Error(`Deck HTML not found: ${explicit}`)
|
|
43
|
+
if (!/\.html?$/i.test(absoluteFile)) throw new Error(`File must be an HTML file: ${explicit}`)
|
|
44
|
+
return { file: workspaceRelative(root, absoluteFile), absoluteFile, source: "file-path" }
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
if (hasDecksState(root)) {
|
|
48
|
+
const state = readDecksState(root)
|
|
49
|
+
const key = state.activeDeck || singleDeckKey(state.decks)
|
|
50
|
+
const outputPath = key ? state.decks[key]?.outputPath : undefined
|
|
51
|
+
if (outputPath && isDeckHtmlPath(outputPath)) {
|
|
52
|
+
const absoluteFile = resolve(root, outputPath)
|
|
53
|
+
if (existsSync(absoluteFile)) {
|
|
54
|
+
return { file: workspaceRelative(root, absoluteFile), absoluteFile, source: "decks-state" }
|
|
55
|
+
}
|
|
56
|
+
}
|
|
28
57
|
}
|
|
29
58
|
|
|
30
|
-
const
|
|
31
|
-
|
|
59
|
+
const htmlFiles = listDeckHtmlFiles(root)
|
|
60
|
+
if (htmlFiles.length === 0) {
|
|
61
|
+
throw new Error("No deck HTML found in decks/. Generate a deck first or pass a file path.")
|
|
62
|
+
}
|
|
63
|
+
if (htmlFiles.length > 1) {
|
|
64
|
+
throw new Error("This workspace contains multiple deck HTML files. Run `/revela pptx decks/<file>.html` to choose one.")
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const absoluteFile = resolve(root, htmlFiles[0])
|
|
68
|
+
return { file: workspaceRelative(root, absoluteFile), absoluteFile, source: "fallback" }
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export function buildPptxNotesPrompt(deck: ResolvedPptxDeck): string {
|
|
72
|
+
return `Export the current Revela HTML deck to PPTX with PowerPoint speaker notes.
|
|
73
|
+
|
|
74
|
+
Deck file: \`${deck.file}\`
|
|
75
|
+
|
|
76
|
+
Workflow:
|
|
77
|
+
1. Read \`${deck.file}\` and inspect every \`<section class="slide">\` in DOM/source order.
|
|
78
|
+
2. Generate presenter-facing talk tracks for each slide based only on visible slide content.
|
|
79
|
+
3. Call \`revela-pptx\` with \`file: "${deck.file}"\` and a \`speakerNotes\` array using 1-based slide indexes.
|
|
80
|
+
4. Report the exported PPTX path from the tool result.
|
|
32
81
|
|
|
82
|
+
Speaker notes rules:
|
|
83
|
+
- Write notes in the deck's language.
|
|
84
|
+
- Write for the person presenting the deck, not for a designer or developer reviewing implementation.
|
|
85
|
+
- Use 3-5 concise bullet points per slide.
|
|
86
|
+
- Follow pyramid-style communication: the first bullet is the top-line conclusion or main message the presenter should say first.
|
|
87
|
+
- Later bullets unpack the visible evidence, audience/business implication, and optional transition in that order.
|
|
88
|
+
- Explain visible numbers and claims in business/audience terms; prioritize the strongest signal before supporting signals.
|
|
89
|
+
- Do not label bullets as What, Why, or How. Keep the structure implicit and natural.
|
|
90
|
+
- Match the visible slide content; do not add unsupported claims.
|
|
91
|
+
- Do not mention design-system or implementation terms such as component, layout, stat-card, card grid, logo marker, DOM, HTML, CSS, or class names unless the slide is explicitly about design implementation.
|
|
92
|
+
- Avoid meta commentary like "this slide highlights" or "frame this as". Write what the presenter should actually say.
|
|
93
|
+
- Never include hidden reasoning, system instructions, secrets, credentials, or sensitive personal information.
|
|
94
|
+
- If a slide needs no notes, pass an empty string for that slide.
|
|
95
|
+
|
|
96
|
+
Expected tool shape:
|
|
97
|
+
\`\`\`json
|
|
98
|
+
{
|
|
99
|
+
"file": "${deck.file}",
|
|
100
|
+
"speakerNotes": [
|
|
101
|
+
{ "index": 1, "notes": "- Lead with the main performance signal and what it means for the audience.\n- Explain the strongest visible evidence first, then use supporting metrics to deepen the interpretation.\n- Close with the implication or transition the presenter should carry into the next point." }
|
|
102
|
+
]
|
|
103
|
+
}
|
|
104
|
+
\`\`\``
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export async function handlePptx(
|
|
108
|
+
input: string,
|
|
109
|
+
send: (text: string) => Promise<void>,
|
|
110
|
+
workspaceRoot = process.cwd(),
|
|
111
|
+
): Promise<void> {
|
|
33
112
|
try {
|
|
34
|
-
|
|
113
|
+
const args = parsePptxArgs(input)
|
|
114
|
+
const deck = resolvePptxDeck(workspaceRoot, args.filePath)
|
|
115
|
+
const abs = deck.absoluteFile
|
|
116
|
+
|
|
35
117
|
await send(`Exporting \`${abs}\` to PPTX...`)
|
|
36
118
|
let lastSlideUpdate = 0
|
|
37
119
|
let longDeckThreshold: number | null = null
|
|
38
120
|
|
|
39
|
-
const result = await exportToPptx(
|
|
121
|
+
const result = await exportToPptx(abs, {
|
|
40
122
|
onProgress: async (progress) => {
|
|
41
123
|
if (progress.kind === "stage") {
|
|
42
124
|
await send(progress.message)
|
|
@@ -76,3 +158,21 @@ export async function handlePptx(
|
|
|
76
158
|
await send(`**PPTX export failed**\n\n\`\`\`\n${msg}\n\`\`\``)
|
|
77
159
|
}
|
|
78
160
|
}
|
|
161
|
+
|
|
162
|
+
function singleDeckKey(decks: Record<string, unknown>): string | undefined {
|
|
163
|
+
const keys = Object.keys(decks)
|
|
164
|
+
return keys.length === 1 ? keys[0] : undefined
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function listDeckHtmlFiles(workspaceRoot: string): string[] {
|
|
168
|
+
const dir = resolve(workspaceRoot, "decks")
|
|
169
|
+
if (!existsSync(dir)) return []
|
|
170
|
+
return readdirSync(dir, { withFileTypes: true })
|
|
171
|
+
.filter((entry) => entry.isFile() && entry.name.toLowerCase().endsWith(".html"))
|
|
172
|
+
.map((entry) => `decks/${entry.name}`)
|
|
173
|
+
.sort((a, b) => a.localeCompare(b))
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function workspaceRelative(root: string, target: string): string {
|
|
177
|
+
return relative(root, target).split(sep).join("/")
|
|
178
|
+
}
|
package/lib/commands/review.ts
CHANGED
|
@@ -18,6 +18,7 @@ Goal:
|
|
|
18
18
|
- Preserve the deck spec for future sessions: every slide's content, layout, components, evidence, visuals, and production status.
|
|
19
19
|
- Do not write, patch, or directly edit ${DECKS_STATE_FILE}. Use the \`revela-decks\` tool for all state changes.
|
|
20
20
|
- Let \`revela-decks\` action \`review\` compute writeReadiness; do not manually set readiness to ready.
|
|
21
|
+
- Treat this as an evidence-readiness review, not only a checklist review: unsupported numbers, market sizing, recommendations, competitor comparisons, technical assertions, or investment conclusions should be made visible before writing.
|
|
21
22
|
|
|
22
23
|
Current state:
|
|
23
24
|
- ${state}
|
|
@@ -34,8 +35,8 @@ Workflow:
|
|
|
34
35
|
2. If no current deck exists but the conversation contains enough deck context, call \`revela-decks\` action \`upsertDeck\` with goal, outputPath, theme, requiredInputs, and researchPlan. Do not invent or ask for a deck key; the tool uses the workspace folder name internally.
|
|
35
36
|
3. If a user-confirmed slide plan is available, call \`revela-decks\` action \`upsertSlides\` with every slide's title, purpose, layout, components, structured content, evidence, visuals, and status.
|
|
36
37
|
4. Only set requiredInputs fields true when explicit conversation state, files read, research findings read, selected design, fetched layouts/components, or user confirmation supports them. Do not infer completion.
|
|
37
|
-
5. Call \`revela-decks\` action \`review\`. The tool computes and writes \`writeReadiness\` for the current workspace deck.
|
|
38
|
-
6. Briefly report whether the deck is ready. If blocked, list the exact blockers returned by the tool.
|
|
38
|
+
5. Call \`revela-decks\` action \`review\`. The tool computes and writes \`writeReadiness\` plus structured readiness issues for the current workspace deck.
|
|
39
|
+
6. Briefly report whether the deck is ready. If blocked, list the exact blockers returned by the tool. If warnings exist, list them after blockers as residual risks.
|
|
39
40
|
|
|
40
41
|
Minimum conditions for \`ready\`:
|
|
41
42
|
- Topic, audience, slide count, language, and visual style/design are decided.
|
|
@@ -44,9 +45,16 @@ Minimum conditions for \`ready\`:
|
|
|
44
45
|
- If research is needed, all relevant findings have been read and reflected in the slide specs.
|
|
45
46
|
- The user has confirmed the slide plan.
|
|
46
47
|
- ${DECKS_STATE_FILE} contains per-slide specs with content, layout, components, and evidence where applicable.
|
|
48
|
+
- Evidence-sensitive slide claims have compact evidence references. Numeric claims and strong recommendations should not be unsupported.
|
|
47
49
|
- The needed design layouts and components have been fetched with \`revela-designs read\`.
|
|
48
50
|
- No unresolved blockers remain.
|
|
49
51
|
|
|
52
|
+
Report format:
|
|
53
|
+
- Start with \`Ready: yes/no\`.
|
|
54
|
+
- If blocked, list each blocker with slide index/title when the tool provides it, the issue type, and the suggested next action.
|
|
55
|
+
- If warnings exist but the deck is otherwise ready, say the deck can be written but note the residual risks.
|
|
56
|
+
- Do not invent evidence or silently downgrade blockers. Use the tool result as authoritative.
|
|
57
|
+
|
|
50
58
|
Rules:
|
|
51
59
|
- Do not write or overwrite \`decks/*.html\` during review.
|
|
52
60
|
- Treat the workspace as one deck project. If the user wants another deck, tell them to use a separate workspace/folder.
|
package/lib/decks-state.ts
CHANGED
|
@@ -140,6 +140,28 @@ export interface DeckStateReadinessResult {
|
|
|
140
140
|
status?: WriteReadinessStatus
|
|
141
141
|
blocker: string
|
|
142
142
|
blockers: string[]
|
|
143
|
+
warnings: string[]
|
|
144
|
+
issues: ReadinessIssue[]
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
export type ReadinessSeverity = "blocker" | "warning"
|
|
148
|
+
|
|
149
|
+
export type ReadinessIssueType =
|
|
150
|
+
| "missing_required_input"
|
|
151
|
+
| "missing_slide_spec"
|
|
152
|
+
| "research_not_ready"
|
|
153
|
+
| "missing_evidence"
|
|
154
|
+
| "weak_evidence"
|
|
155
|
+
| "source_not_processed"
|
|
156
|
+
|
|
157
|
+
export interface ReadinessIssue {
|
|
158
|
+
type: ReadinessIssueType
|
|
159
|
+
severity: ReadinessSeverity
|
|
160
|
+
message: string
|
|
161
|
+
suggestedAction: string
|
|
162
|
+
slideIndex?: number
|
|
163
|
+
slideTitle?: string
|
|
164
|
+
claimText?: string
|
|
143
165
|
}
|
|
144
166
|
|
|
145
167
|
export function decksStatePath(workspaceRoot: string): string {
|
|
@@ -279,11 +301,20 @@ export function reviewDeckState(state: DecksState, slug?: string): { state: Deck
|
|
|
279
301
|
slug: missing,
|
|
280
302
|
blocker: `Deck ${missing} does not exist in ${DECKS_STATE_FILE}.`,
|
|
281
303
|
blockers: [`Deck ${missing} does not exist in ${DECKS_STATE_FILE}.`],
|
|
304
|
+
warnings: [],
|
|
305
|
+
issues: [{
|
|
306
|
+
type: "missing_slide_spec",
|
|
307
|
+
severity: "blocker",
|
|
308
|
+
message: `Deck ${missing} does not exist in ${DECKS_STATE_FILE}.`,
|
|
309
|
+
suggestedAction: "Create the current workspace deck spec with revela-decks upsertDeck before reviewing readiness.",
|
|
310
|
+
}],
|
|
282
311
|
},
|
|
283
312
|
}
|
|
284
313
|
}
|
|
285
314
|
|
|
286
|
-
const
|
|
315
|
+
const issues = computeDeckReadinessIssues(deck, normalized.workspace)
|
|
316
|
+
const blockers = issues.filter((issue) => issue.severity === "blocker").map((issue) => issue.message)
|
|
317
|
+
const warnings = issues.filter((issue) => issue.severity === "warning").map((issue) => issue.message)
|
|
287
318
|
deck.writeReadiness = {
|
|
288
319
|
status: blockers.length === 0 ? "ready" : "blocked",
|
|
289
320
|
blockers,
|
|
@@ -300,6 +331,8 @@ export function reviewDeckState(state: DecksState, slug?: string): { state: Deck
|
|
|
300
331
|
status: deck.writeReadiness.status,
|
|
301
332
|
blocker: blockers.join("; "),
|
|
302
333
|
blockers,
|
|
334
|
+
warnings,
|
|
335
|
+
issues,
|
|
303
336
|
},
|
|
304
337
|
}
|
|
305
338
|
}
|
|
@@ -321,18 +354,48 @@ export function evaluateDeckStateWriteReadiness(state: DecksState, filePath: str
|
|
|
321
354
|
slug: targetSlug,
|
|
322
355
|
blocker: currentDeckBlocker(normalized),
|
|
323
356
|
blockers: [currentDeckBlocker(normalized)],
|
|
357
|
+
warnings: [],
|
|
358
|
+
issues: [{
|
|
359
|
+
type: "missing_slide_spec",
|
|
360
|
+
severity: "blocker",
|
|
361
|
+
message: currentDeckBlocker(normalized),
|
|
362
|
+
suggestedAction: "Create or select the current workspace deck through revela-decks before writing deck HTML.",
|
|
363
|
+
}],
|
|
324
364
|
}
|
|
325
365
|
}
|
|
326
366
|
|
|
327
|
-
const
|
|
367
|
+
const issues = computeDeckReadinessIssues(deck, normalized.workspace)
|
|
368
|
+
const blockers = issues.filter((issue) => issue.severity === "blocker").map((issue) => issue.message)
|
|
369
|
+
const warnings = issues.filter((issue) => issue.severity === "warning").map((issue) => issue.message)
|
|
328
370
|
if (normalizeDeckPath(deck.outputPath) !== targetPath) {
|
|
329
|
-
|
|
371
|
+
const message = `Deck outputPath is ${deck.outputPath || "missing"}, not ${targetPath}`
|
|
372
|
+
blockers.unshift(message)
|
|
373
|
+
issues.unshift({
|
|
374
|
+
type: "missing_slide_spec",
|
|
375
|
+
severity: "blocker",
|
|
376
|
+
message,
|
|
377
|
+
suggestedAction: "Update deck.outputPath through revela-decks or write to the reviewed outputPath.",
|
|
378
|
+
})
|
|
330
379
|
}
|
|
331
380
|
if (deck.writeReadiness.status !== "ready") {
|
|
332
|
-
|
|
381
|
+
const message = `Deck writeReadiness is ${deck.writeReadiness.status || "missing"}, not ready`
|
|
382
|
+
blockers.unshift(message)
|
|
383
|
+
issues.unshift({
|
|
384
|
+
type: "missing_slide_spec",
|
|
385
|
+
severity: "blocker",
|
|
386
|
+
message,
|
|
387
|
+
suggestedAction: "Run /revela review and resolve all readiness blockers before writing deck HTML.",
|
|
388
|
+
})
|
|
333
389
|
}
|
|
334
390
|
if (deck.writeReadiness.blockers.length > 0) {
|
|
335
|
-
|
|
391
|
+
const message = `Deck still has readiness blockers: ${deck.writeReadiness.blockers.join("; ")}`
|
|
392
|
+
blockers.unshift(message)
|
|
393
|
+
issues.unshift({
|
|
394
|
+
type: "missing_slide_spec",
|
|
395
|
+
severity: "blocker",
|
|
396
|
+
message,
|
|
397
|
+
suggestedAction: "Resolve the stored writeReadiness blockers and rerun /revela review.",
|
|
398
|
+
})
|
|
336
399
|
}
|
|
337
400
|
|
|
338
401
|
return {
|
|
@@ -341,6 +404,8 @@ export function evaluateDeckStateWriteReadiness(state: DecksState, filePath: str
|
|
|
341
404
|
status: deck.writeReadiness.status,
|
|
342
405
|
blocker: blockers.join("; "),
|
|
343
406
|
blockers,
|
|
407
|
+
warnings,
|
|
408
|
+
issues,
|
|
344
409
|
}
|
|
345
410
|
}
|
|
346
411
|
|
|
@@ -416,30 +481,161 @@ function currentDeckBlocker(state: DecksState): string {
|
|
|
416
481
|
return `${DECKS_STATE_FILE} contains multiple deck records and no activeDeck. Select one current deck explicitly or move extra decks to separate workspaces.`
|
|
417
482
|
}
|
|
418
483
|
|
|
419
|
-
function
|
|
420
|
-
const
|
|
421
|
-
if (!deck.goal.trim())
|
|
422
|
-
if (!isDeckHtmlPath(deck.outputPath))
|
|
484
|
+
function computeDeckReadinessIssues(deck: DeckSpec, workspace: DecksState["workspace"]): ReadinessIssue[] {
|
|
485
|
+
const issues: ReadinessIssue[] = []
|
|
486
|
+
if (!deck.goal.trim()) issues.push(blockerIssue("missing_slide_spec", "Deck goal is missing", "Set the deck goal through revela-decks upsertDeck."))
|
|
487
|
+
if (!isDeckHtmlPath(deck.outputPath)) {
|
|
488
|
+
issues.push(blockerIssue(
|
|
489
|
+
"missing_slide_spec",
|
|
490
|
+
`outputPath must be decks/*.html, got ${deck.outputPath || "missing"}`,
|
|
491
|
+
"Set outputPath to the target decks/*.html file through revela-decks upsertDeck.",
|
|
492
|
+
))
|
|
493
|
+
}
|
|
423
494
|
|
|
424
495
|
for (const [key, value] of Object.entries(deck.requiredInputs) as Array<[keyof RequiredInputs, boolean]>) {
|
|
425
|
-
if (value !== true)
|
|
496
|
+
if (value !== true) {
|
|
497
|
+
issues.push(blockerIssue(
|
|
498
|
+
"missing_required_input",
|
|
499
|
+
`requiredInputs.${key} is not true`,
|
|
500
|
+
`Complete and explicitly record requiredInputs.${key} before writing the deck.`,
|
|
501
|
+
))
|
|
502
|
+
}
|
|
426
503
|
}
|
|
427
504
|
|
|
428
|
-
if (deck.slides.length === 0)
|
|
505
|
+
if (deck.slides.length === 0) issues.push(blockerIssue("missing_slide_spec", "slides are missing", "Add the confirmed slide plan through revela-decks upsertSlides."))
|
|
429
506
|
for (const slide of deck.slides) {
|
|
430
|
-
|
|
431
|
-
if (!slide.
|
|
432
|
-
if (slide.
|
|
433
|
-
if (
|
|
507
|
+
const slideRef = { slideIndex: slide.index, slideTitle: slide.title }
|
|
508
|
+
if (!slide.title.trim()) issues.push(blockerIssue("missing_slide_spec", `Slide ${slide.index} title is missing`, "Add a slide title to the slide spec.", slideRef))
|
|
509
|
+
if (!slide.layout.trim()) issues.push(blockerIssue("missing_slide_spec", `Slide ${slide.index} layout is missing`, "Fetch and record the intended design layout for this slide.", slideRef))
|
|
510
|
+
if (slide.components.length === 0) issues.push(blockerIssue("missing_slide_spec", `Slide ${slide.index} components are missing`, "Record the design components needed for this slide.", slideRef))
|
|
511
|
+
if (!hasSlideContent(slide)) issues.push(blockerIssue("missing_slide_spec", `Slide ${slide.index} content is missing`, "Add structured headline/body/bullets/data content to the slide spec.", slideRef))
|
|
512
|
+
|
|
513
|
+
const claim = findEvidenceSensitiveClaim(slide)
|
|
514
|
+
if (claim && slide.evidence.length === 0) {
|
|
515
|
+
issues.push(blockerIssue(
|
|
516
|
+
"missing_evidence",
|
|
517
|
+
`Slide ${slide.index} has an evidence-sensitive claim without evidence: ${claim}`,
|
|
518
|
+
"Add a compact evidence reference to slides[].evidence or reframe the claim as an explicit assumption/opinion.",
|
|
519
|
+
{ ...slideRef, claimText: claim },
|
|
520
|
+
))
|
|
521
|
+
} else if (claim && slide.evidence.some((item) => !hasEvidenceDetail(item))) {
|
|
522
|
+
issues.push(warningIssue(
|
|
523
|
+
"weak_evidence",
|
|
524
|
+
`Slide ${slide.index} evidence for a high-risk claim has no quote, page, or URL detail: ${claim}`,
|
|
525
|
+
"Add quote/page/url details where available so the writing agent can ground the slide more reliably.",
|
|
526
|
+
{ ...slideRef, claimText: claim },
|
|
527
|
+
))
|
|
528
|
+
}
|
|
434
529
|
}
|
|
435
530
|
|
|
436
531
|
for (const axis of deck.researchPlan) {
|
|
437
532
|
if (axis.needed && axis.status !== "done" && axis.status !== "read" && axis.status !== "skipped") {
|
|
438
|
-
|
|
533
|
+
issues.push(blockerIssue(
|
|
534
|
+
"research_not_ready",
|
|
535
|
+
`Research axis ${axis.axis || "unnamed"} is needed but ${axis.status}`,
|
|
536
|
+
"Complete, read, or explicitly skip this research axis before writing the deck.",
|
|
537
|
+
))
|
|
439
538
|
}
|
|
440
539
|
}
|
|
441
|
-
|
|
442
|
-
|
|
540
|
+
|
|
541
|
+
const hasNeededResearch = deck.researchPlan.some((axis) => axis.needed && axis.status !== "skipped")
|
|
542
|
+
for (const material of workspace.sourceMaterials ?? []) {
|
|
543
|
+
if (material.status !== "discovered") continue
|
|
544
|
+
const message = `Source material ${material.path} has been identified but not extracted, summarized, or researched`
|
|
545
|
+
if (hasNeededResearch) {
|
|
546
|
+
issues.push(blockerIssue(
|
|
547
|
+
"source_not_processed",
|
|
548
|
+
message,
|
|
549
|
+
"Extract, summarize, research, or explicitly exclude this source before writing evidence-backed slides.",
|
|
550
|
+
))
|
|
551
|
+
} else {
|
|
552
|
+
issues.push(warningIssue(
|
|
553
|
+
"source_not_processed",
|
|
554
|
+
message,
|
|
555
|
+
"Consider extracting or excluding this source if it may support the deck narrative.",
|
|
556
|
+
))
|
|
557
|
+
}
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
return issues
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
function blockerIssue(type: ReadinessIssueType, message: string, suggestedAction: string, extra: Partial<ReadinessIssue> = {}): ReadinessIssue {
|
|
564
|
+
return { type, severity: "blocker", message, suggestedAction, ...extra }
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
function warningIssue(type: ReadinessIssueType, message: string, suggestedAction: string, extra: Partial<ReadinessIssue> = {}): ReadinessIssue {
|
|
568
|
+
return { type, severity: "warning", message, suggestedAction, ...extra }
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
function findEvidenceSensitiveClaim(slide: SlideSpec): string | undefined {
|
|
572
|
+
const candidates = [
|
|
573
|
+
slide.title,
|
|
574
|
+
slide.purpose,
|
|
575
|
+
slide.content?.headline,
|
|
576
|
+
...(slide.content?.body ?? []),
|
|
577
|
+
...(slide.content?.bullets ?? []),
|
|
578
|
+
]
|
|
579
|
+
.map((item) => item?.trim())
|
|
580
|
+
.filter((item): item is string => Boolean(item))
|
|
581
|
+
|
|
582
|
+
return candidates.find(isEvidenceSensitiveClaim)
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
function isEvidenceSensitiveClaim(text: string): boolean {
|
|
586
|
+
const normalized = text.toLowerCase()
|
|
587
|
+
return hasNumericClaim(normalized) || EVIDENCE_SENSITIVE_TERMS.some((pattern) => pattern.test(normalized))
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
function hasNumericClaim(text: string): boolean {
|
|
591
|
+
return /(?:[$¥€£]\s?\d|\d+(?:\.\d+)?\s?(?:%|x|倍|万|亿|m|mn|million|b|bn|billion|k|千|年|months?|days?|users?|customers?|revenue|margin|cagr|tam|sam|som)\b|\b20\d{2}\b)/i.test(text)
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
function hasEvidenceDetail(evidence: EvidenceRef): boolean {
|
|
595
|
+
return Boolean(evidence.quote?.trim() || evidence.page?.trim() || evidence.url?.trim())
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
const EVIDENCE_SENSITIVE_TERMS = [
|
|
599
|
+
/\bmarket size\b/,
|
|
600
|
+
/\bcagr\b/,
|
|
601
|
+
/\btam\b/,
|
|
602
|
+
/\bsam\b/,
|
|
603
|
+
/\bsom\b/,
|
|
604
|
+
/\brecommend(?:ation|ed)?\b/,
|
|
605
|
+
/\bshould\b/,
|
|
606
|
+
/\bmust\b/,
|
|
607
|
+
/\bgo\/?no-go\b/,
|
|
608
|
+
/\bvs\.?\b/,
|
|
609
|
+
/\bbetter than\b/,
|
|
610
|
+
/\boutperform\b/,
|
|
611
|
+
/\bleading\b/,
|
|
612
|
+
/\bcompetitor\b/,
|
|
613
|
+
/\bmarket leader\b/,
|
|
614
|
+
/\binvest(?:ment)?\b/,
|
|
615
|
+
/\brevenue\b/,
|
|
616
|
+
/\bmargin\b/,
|
|
617
|
+
/\bcost\b/,
|
|
618
|
+
/\brisk\b/,
|
|
619
|
+
/\blatency\b/,
|
|
620
|
+
/\baccuracy\b/,
|
|
621
|
+
/\bscalable\b/,
|
|
622
|
+
/\barchitecture\b/,
|
|
623
|
+
/市场规模/,
|
|
624
|
+
/增长/,
|
|
625
|
+
/领先/,
|
|
626
|
+
/超过/,
|
|
627
|
+
/竞品/,
|
|
628
|
+
/建议/,
|
|
629
|
+
/必须/,
|
|
630
|
+
/投资/,
|
|
631
|
+
/收入/,
|
|
632
|
+
/利润/,
|
|
633
|
+
/成本/,
|
|
634
|
+
/风险/,
|
|
635
|
+
/性能/,
|
|
636
|
+
/架构/,
|
|
637
|
+
/可扩展/,
|
|
638
|
+
]
|
|
443
639
|
|
|
444
640
|
function normalizeSlides(slides: SlideSpec[]): SlideSpec[] {
|
|
445
641
|
return slides
|
package/lib/pptx/export.ts
CHANGED
|
@@ -65,6 +65,7 @@ interface SlideMeta {
|
|
|
65
65
|
index: number
|
|
66
66
|
pageNo: string | null
|
|
67
67
|
title: string | null
|
|
68
|
+
speakerNotes: string | null
|
|
68
69
|
}
|
|
69
70
|
|
|
70
71
|
interface ExportedSlide extends SlideMeta {
|
|
@@ -100,6 +101,7 @@ export interface ExportPptxProgress {
|
|
|
100
101
|
|
|
101
102
|
export interface ExportPptxOptions {
|
|
102
103
|
onProgress?: (progress: ExportPptxProgress) => void | Promise<void>
|
|
104
|
+
speakerNotes?: Array<string | null | undefined>
|
|
103
105
|
}
|
|
104
106
|
|
|
105
107
|
export interface ExportPptxTimings {
|
|
@@ -474,12 +476,21 @@ async function readSlideMeta(
|
|
|
474
476
|
.map((el) => el.textContent?.trim() ?? "")
|
|
475
477
|
.find((text) => /^\d{2}$/.test(text)) ?? null
|
|
476
478
|
const title = slide.querySelector("h1,h2,h3")?.textContent?.trim()?.slice(0, 120) ?? null
|
|
477
|
-
|
|
479
|
+
const directChildren = Array.from(slide.children)
|
|
480
|
+
const notesEl =
|
|
481
|
+
directChildren.find((el) => el.matches("template[data-revela-speaker-notes]")) ??
|
|
482
|
+
directChildren.find((el) => el.hasAttribute("data-revela-speaker-notes")) ??
|
|
483
|
+
directChildren.find((el) => el.classList.contains("speaker-notes"))
|
|
484
|
+
const notesText = notesEl instanceof HTMLTemplateElement
|
|
485
|
+
? notesEl.content.textContent
|
|
486
|
+
: notesEl?.textContent
|
|
487
|
+
const speakerNotes = notesText?.replace(/\r\n?/g, "\n").trim() || null
|
|
488
|
+
return { index, pageNo, title, speakerNotes }
|
|
478
489
|
})
|
|
479
490
|
})
|
|
480
491
|
|
|
481
492
|
return Array.from({ length: slideCount }, (_, index) => {
|
|
482
|
-
return meta[index] ?? { index, pageNo: null, title: null }
|
|
493
|
+
return meta[index] ?? { index, pageNo: null, title: null, speakerNotes: null }
|
|
483
494
|
})
|
|
484
495
|
}
|
|
485
496
|
|
|
@@ -553,6 +564,22 @@ async function exportSlidePptx(
|
|
|
553
564
|
}
|
|
554
565
|
}
|
|
555
566
|
|
|
567
|
+
function applySpeakerNotesOverride(
|
|
568
|
+
slides: SlideMeta[],
|
|
569
|
+
speakerNotes?: Array<string | null | undefined>,
|
|
570
|
+
): SlideMeta[] {
|
|
571
|
+
if (!speakerNotes) return slides
|
|
572
|
+
|
|
573
|
+
return slides.map((slide) => {
|
|
574
|
+
if (speakerNotes[slide.index] === undefined) return slide
|
|
575
|
+
const notes = speakerNotes[slide.index]
|
|
576
|
+
return {
|
|
577
|
+
...slide,
|
|
578
|
+
speakerNotes: notes?.replace(/\r\n?/g, "\n").trim() || null,
|
|
579
|
+
}
|
|
580
|
+
})
|
|
581
|
+
}
|
|
582
|
+
|
|
556
583
|
function parseXml(xml: string) {
|
|
557
584
|
return new DOMParser().parseFromString(xml, "text/xml")
|
|
558
585
|
}
|
|
@@ -703,6 +730,31 @@ function setNotesSlideNumber(files: ZipFiles, notesPath: string, number: number)
|
|
|
703
730
|
files[notesPath] = xmlToBytes(doc)
|
|
704
731
|
}
|
|
705
732
|
|
|
733
|
+
function setSpeakerNotes(files: ZipFiles, notesPath: string, notes: string | null): void {
|
|
734
|
+
if (!files[notesPath]) return
|
|
735
|
+
|
|
736
|
+
const doc = parseXml(getFileText(files, notesPath))
|
|
737
|
+
const shapes = Array.from(doc.getElementsByTagName("p:sp"))
|
|
738
|
+
const notesShape = shapes.find((shape) => {
|
|
739
|
+
return Array.from(shape.getElementsByTagName("p:ph")).some((ph) => ph.getAttribute("type") === "body")
|
|
740
|
+
})
|
|
741
|
+
const textNode = notesShape?.getElementsByTagName("a:t")[0]
|
|
742
|
+
if (!textNode) return
|
|
743
|
+
|
|
744
|
+
textNode.textContent = notes ?? ""
|
|
745
|
+
files[notesPath] = xmlToBytes(doc)
|
|
746
|
+
}
|
|
747
|
+
|
|
748
|
+
export function applySpeakerNotesToPptx(pptxBytes: Uint8Array, notesBySlide: Array<string | null | undefined>): Uint8Array {
|
|
749
|
+
const files = unzipSync(pptxBytes)
|
|
750
|
+
|
|
751
|
+
notesBySlide.forEach((notes, index) => {
|
|
752
|
+
setSpeakerNotes(files, `ppt/notesSlides/notesSlide${index + 1}.xml`, notes ?? null)
|
|
753
|
+
})
|
|
754
|
+
|
|
755
|
+
return zipSync(files)
|
|
756
|
+
}
|
|
757
|
+
|
|
706
758
|
function updateAppProperties(files: ZipFiles, slideCount: number): void {
|
|
707
759
|
const doc = parseXml(getFileText(files, "docProps/app.xml"))
|
|
708
760
|
const setText = (tag: string, value: string) => {
|
|
@@ -786,6 +838,7 @@ function mergeSingleSlidePptx(slides: ExportedSlide[]): Uint8Array {
|
|
|
786
838
|
|
|
787
839
|
setSlideName(mergedFiles, "ppt/slides/slide1.xml", "Slide 1")
|
|
788
840
|
setNotesSlideNumber(mergedFiles, "ppt/notesSlides/notesSlide1.xml", 1)
|
|
841
|
+
setSpeakerNotes(mergedFiles, "ppt/notesSlides/notesSlide1.xml", slides[0].speakerNotes)
|
|
789
842
|
|
|
790
843
|
for (let slideIdx = 1; slideIdx < slides.length; slideIdx += 1) {
|
|
791
844
|
const sourceFiles: ZipFiles = { ...unzipSync(slides[slideIdx].bytes) }
|
|
@@ -847,6 +900,7 @@ function mergeSingleSlidePptx(slides: ExportedSlide[]): Uint8Array {
|
|
|
847
900
|
setSlideName(mergedFiles, slidePath, `Slide ${slideIdx + 1}`)
|
|
848
901
|
if (notesPath && mergedFiles[notesPath]) {
|
|
849
902
|
setNotesSlideNumber(mergedFiles, notesPath, slideIdx + 1)
|
|
903
|
+
setSpeakerNotes(mergedFiles, notesPath, slides[slideIdx].speakerNotes)
|
|
850
904
|
}
|
|
851
905
|
|
|
852
906
|
const relId = `rId${nextPresentationRelId}`
|
|
@@ -940,7 +994,7 @@ export async function exportToPptx(
|
|
|
940
994
|
const failures: SlideFailure[] = []
|
|
941
995
|
|
|
942
996
|
try {
|
|
943
|
-
const slides = await readSlideMeta(page, slideCount)
|
|
997
|
+
const slides = applySpeakerNotesOverride(await readSlideMeta(page, slideCount), options?.speakerNotes)
|
|
944
998
|
await emitProgress(options, {
|
|
945
999
|
kind: "stage",
|
|
946
1000
|
message: `Deck ready. Exporting ${slides.length} slide(s) to editable PPTX parts...`,
|
package/package.json
CHANGED
package/plugin.ts
CHANGED
|
@@ -44,7 +44,7 @@ import {
|
|
|
44
44
|
handleDomainsRemove,
|
|
45
45
|
} from "./lib/commands/domains"
|
|
46
46
|
import { handlePdf } from "./lib/commands/pdf"
|
|
47
|
-
import { handlePptx } from "./lib/commands/pptx"
|
|
47
|
+
import { buildPptxNotesPrompt, handlePptx, parsePptxArgs, resolvePptxDeck } from "./lib/commands/pptx"
|
|
48
48
|
import { handleEdit } from "./lib/commands/edit"
|
|
49
49
|
import { ensureEditableDeckOpenForChange } from "./lib/edit/open"
|
|
50
50
|
import { hasLiveEditorSessionForFile } from "./lib/edit/server"
|
|
@@ -388,7 +388,20 @@ const server: Plugin = (async (pluginCtx) => {
|
|
|
388
388
|
throw new Error("__REVELA_PDF_HANDLED__")
|
|
389
389
|
}
|
|
390
390
|
if (sub === "pptx") {
|
|
391
|
-
|
|
391
|
+
const args = parsePptxArgs(param)
|
|
392
|
+
if (args.notes) {
|
|
393
|
+
try {
|
|
394
|
+
const deck = resolvePptxDeck(workspaceRoot, args.filePath)
|
|
395
|
+
output.parts.length = 0
|
|
396
|
+
output.parts.push({ type: "text", text: buildPptxNotesPrompt(deck) } as any)
|
|
397
|
+
return
|
|
398
|
+
} catch (e) {
|
|
399
|
+
const msg = e instanceof Error ? e.message : String(e)
|
|
400
|
+
await send(`**PPTX export failed**\n\n\`\`\`\n${msg}\n\`\`\``)
|
|
401
|
+
throw new Error("__REVELA_PPTX_HANDLED__")
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
await handlePptx(param, send, workspaceRoot)
|
|
392
405
|
throw new Error("__REVELA_PPTX_HANDLED__")
|
|
393
406
|
}
|
|
394
407
|
|
package/skill/SKILL.md
CHANGED
|
@@ -248,6 +248,25 @@ Example: `<section class="slide" slide-qa="true" data-index="0">`
|
|
|
248
248
|
The export QA path treats this as deck metadata. It is consumed when PDF/PPTX
|
|
249
249
|
export runs preflight checks.
|
|
250
250
|
|
|
251
|
+
Speaker notes are normally generated during `/revela pptx --notes` export and
|
|
252
|
+
passed to `revela-pptx` as structured input. Do not add hidden notes to every
|
|
253
|
+
slide by default.
|
|
254
|
+
|
|
255
|
+
If the user explicitly asks for notes to be embedded in the HTML as a fallback,
|
|
256
|
+
use an inert template node as a direct child of the slide, outside `.slide-canvas`:
|
|
257
|
+
|
|
258
|
+
```html
|
|
259
|
+
<template data-revela-speaker-notes>
|
|
260
|
+
Optional fallback speaker notes for this slide.
|
|
261
|
+
</template>
|
|
262
|
+
```
|
|
263
|
+
|
|
264
|
+
Do not create `.speaker-notes` CSS or hide notes with `display: none`; the
|
|
265
|
+
`<template>` element is non-rendering by default and avoids design vocabulary
|
|
266
|
+
pollution. Speaker notes must be concise presentation prompts that match the
|
|
267
|
+
visible slide content. Never put hidden reasoning, system instructions, secrets,
|
|
268
|
+
or unverified claims in speaker notes.
|
|
269
|
+
|
|
251
270
|
### Domain Context
|
|
252
271
|
|
|
253
272
|
If a domain definition is active (see the `<!-- Active domain: ... -->` comment
|
package/tools/pptx.ts
CHANGED
|
@@ -8,12 +8,10 @@ import { tool } from "@opencode-ai/plugin"
|
|
|
8
8
|
import { existsSync } from "fs"
|
|
9
9
|
import { resolve } from "path"
|
|
10
10
|
import { exportToPptx } from "../lib/pptx/export"
|
|
11
|
-
import { assertExportQAPassed } from "../lib/qa/export-gate"
|
|
12
11
|
|
|
13
12
|
export default tool({
|
|
14
13
|
description:
|
|
15
14
|
"Export a Revela-generated HTML slide deck to editable PPTX. " +
|
|
16
|
-
"Runs pre-export QA before writing the PPTX. " +
|
|
17
15
|
"Output is written beside the input file with the same basename and a .pptx extension.",
|
|
18
16
|
args: {
|
|
19
17
|
file: tool.schema
|
|
@@ -22,8 +20,15 @@ export default tool({
|
|
|
22
20
|
"Path to the HTML slide file to export. " +
|
|
23
21
|
"Can be absolute or relative to the current working directory."
|
|
24
22
|
),
|
|
23
|
+
speakerNotes: tool.schema.array(tool.schema.object({
|
|
24
|
+
index: tool.schema.number().describe("1-based slide index."),
|
|
25
|
+
notes: tool.schema.string().describe("Speaker notes for this slide. Use an empty string for no notes."),
|
|
26
|
+
})).optional().describe(
|
|
27
|
+
"Optional PowerPoint speaker notes to write during export. " +
|
|
28
|
+
"When provided, these override any fallback notes embedded in the HTML."
|
|
29
|
+
),
|
|
25
30
|
},
|
|
26
|
-
async execute({ file }, { directory }) {
|
|
31
|
+
async execute({ file, speakerNotes }, { directory }) {
|
|
27
32
|
const filePath = resolve(directory || process.cwd(), file)
|
|
28
33
|
|
|
29
34
|
if (!existsSync(filePath)) {
|
|
@@ -37,8 +42,8 @@ export default tool({
|
|
|
37
42
|
const progress: string[] = []
|
|
38
43
|
|
|
39
44
|
try {
|
|
40
|
-
await assertExportQAPassed(filePath)
|
|
41
45
|
const result = await exportToPptx(filePath, {
|
|
46
|
+
speakerNotes: normalizeSpeakerNotes(speakerNotes),
|
|
42
47
|
onProgress: (event) => {
|
|
43
48
|
progress.push(event.message)
|
|
44
49
|
},
|
|
@@ -49,3 +54,17 @@ export default tool({
|
|
|
49
54
|
}
|
|
50
55
|
},
|
|
51
56
|
})
|
|
57
|
+
|
|
58
|
+
function normalizeSpeakerNotes(
|
|
59
|
+
input?: Array<{ index?: number; notes?: string }>,
|
|
60
|
+
): Array<string | null | undefined> | undefined {
|
|
61
|
+
if (!input) return undefined
|
|
62
|
+
|
|
63
|
+
const notesBySlide: Array<string | null | undefined> = []
|
|
64
|
+
for (const item of input) {
|
|
65
|
+
const index = Math.floor(Number(item.index ?? 0))
|
|
66
|
+
if (index < 1) continue
|
|
67
|
+
notesBySlide[index - 1] = item.notes ?? ""
|
|
68
|
+
}
|
|
69
|
+
return notesBySlide
|
|
70
|
+
}
|