@cyber-dash-tech/revela 0.16.3 → 0.17.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.
Files changed (49) hide show
  1. package/README.md +7 -5
  2. package/README.zh-CN.md +7 -5
  3. package/lib/commands/brief.ts +9 -0
  4. package/lib/commands/help.ts +5 -2
  5. package/lib/commands/init.ts +42 -27
  6. package/lib/commands/narrative.ts +26 -2
  7. package/lib/commands/research.ts +36 -20
  8. package/lib/commands/review.ts +21 -18
  9. package/lib/ctx.ts +1 -1
  10. package/lib/decks-state.ts +38 -4
  11. package/lib/edit/prompt.ts +1 -1
  12. package/lib/hook-notifications.ts +53 -0
  13. package/lib/narrative-state/render-plan.ts +114 -27
  14. package/lib/narrative-state/research-binding-eval.ts +260 -0
  15. package/lib/narrative-state/research-gaps.ts +2 -88
  16. package/lib/narrative-vault/authoring-contract.ts +127 -0
  17. package/lib/narrative-vault/authoring-guard.ts +122 -0
  18. package/lib/narrative-vault/auto-compile.ts +134 -0
  19. package/lib/narrative-vault/bootstrap.ts +63 -0
  20. package/lib/narrative-vault/cache.ts +14 -0
  21. package/lib/narrative-vault/compile-mirror.ts +45 -0
  22. package/lib/narrative-vault/compile.ts +350 -0
  23. package/lib/narrative-vault/constants.ts +6 -0
  24. package/lib/narrative-vault/diagnostic-report.ts +117 -0
  25. package/lib/narrative-vault/export.ts +71 -0
  26. package/lib/narrative-vault/frontmatter.ts +41 -0
  27. package/lib/narrative-vault/hook-targets.ts +40 -0
  28. package/lib/narrative-vault/index.ts +18 -0
  29. package/lib/narrative-vault/inventory.ts +392 -0
  30. package/lib/narrative-vault/markdown-qa.ts +237 -0
  31. package/lib/narrative-vault/markdown.ts +34 -0
  32. package/lib/narrative-vault/migration.ts +52 -0
  33. package/lib/narrative-vault/mutate.ts +361 -0
  34. package/lib/narrative-vault/paths.ts +19 -0
  35. package/lib/narrative-vault/read.ts +52 -0
  36. package/lib/narrative-vault/relations.ts +32 -0
  37. package/lib/narrative-vault/source-loader.ts +19 -0
  38. package/lib/narrative-vault/timestamp.ts +32 -0
  39. package/lib/narrative-vault/types.ts +44 -0
  40. package/lib/refine/server.ts +472 -5
  41. package/lib/refine/visual-targets.ts +295 -0
  42. package/lib/source-materials.ts +98 -0
  43. package/lib/tool-result.ts +34 -0
  44. package/package.json +2 -2
  45. package/plugin.ts +60 -22
  46. package/skill/NARRATIVE_SKILL.md +25 -10
  47. package/tools/decks.ts +363 -67
  48. package/tools/research-save.ts +3 -0
  49. package/tools/workspace-scan.ts +1 -0
@@ -0,0 +1,295 @@
1
+ import { readFileSync, statSync, writeFileSync } from "fs"
2
+
3
+ export type VisualEditKind = "image" | "text-width" | "box"
4
+
5
+ export interface VisualEditTarget {
6
+ editId: string
7
+ kind: VisualEditKind
8
+ tagName: "img" | "h1" | "h2" | "h3" | "h4" | "p" | "div" | "figure"
9
+ startOffset: number
10
+ openEndOffset: number
11
+ originalOpenTag: string
12
+ originalStyle: string
13
+ }
14
+
15
+ export interface VisualTargetResizeChange {
16
+ type: "resize"
17
+ editId: string
18
+ kind?: VisualEditKind
19
+ after: {
20
+ stylePatch?: Record<string, unknown>
21
+ width?: number
22
+ height?: number
23
+ }
24
+ }
25
+
26
+ export interface VisualTargetMoveChange {
27
+ type: "move"
28
+ editId: string
29
+ kind?: VisualEditKind
30
+ after: {
31
+ stylePatch?: Record<string, unknown>
32
+ dx?: number
33
+ dy?: number
34
+ }
35
+ }
36
+
37
+ export type VisualTargetChange = VisualTargetResizeChange | VisualTargetMoveChange
38
+
39
+ export interface ApplyVisualTargetChangesInput {
40
+ file: string
41
+ deckVersion?: string
42
+ targetDeckVersion?: string
43
+ targets: Map<string, VisualEditTarget>
44
+ changes: VisualTargetChange[]
45
+ }
46
+
47
+ export interface ApplyVisualTargetChangesResult {
48
+ ok: true
49
+ deckVersion: string
50
+ changeCount: number
51
+ }
52
+
53
+ const TEXT_TAGS = new Set(["h1", "h2", "h3", "h4", "p"])
54
+ const BOX_TAGS = new Set(["div", "figure"])
55
+ const EDIT_TAGS = new Set(["img", ...TEXT_TAGS, ...BOX_TAGS])
56
+ const MAX_DIMENSION_PX = 3840
57
+ const MIN_IMAGE_DIMENSION_PX = 24
58
+ const MIN_TEXT_WIDTH_PX = 80
59
+ const MIN_BOX_WIDTH_PX = 40
60
+ const MIN_BOX_HEIGHT_PX = 24
61
+
62
+ export function annotateVisualEditTargets(html: string): { html: string; targets: Map<string, VisualEditTarget> } {
63
+ const targets = new Map<string, VisualEditTarget>()
64
+ const insertions: Array<{ offset: number; text: string }> = []
65
+ let nextId = 1
66
+
67
+ for (const tag of scanOpeningTags(html)) {
68
+ if (!EDIT_TAGS.has(tag.tagName)) continue
69
+ if (BOX_TAGS.has(tag.tagName) && !isSafeBoxTarget(tag.openTag)) continue
70
+ const editId = `rve-${nextId++}`
71
+ const kind: VisualEditKind = tag.tagName === "img" ? "image" : BOX_TAGS.has(tag.tagName) ? "box" : "text-width"
72
+ targets.set(editId, {
73
+ editId,
74
+ kind,
75
+ tagName: tag.tagName as VisualEditTarget["tagName"],
76
+ startOffset: tag.startOffset,
77
+ openEndOffset: tag.openEndOffset,
78
+ originalOpenTag: tag.openTag,
79
+ originalStyle: attrValue(tag.openTag, "style") || "",
80
+ })
81
+ insertions.push({ offset: tag.openEndOffset - (tag.openTag.endsWith("/>") ? 2 : 1), text: ` data-revela-edit-id="${editId}" data-revela-edit-kind="${kind}"` })
82
+ }
83
+
84
+ let annotated = html
85
+ for (const insertion of insertions.reverse()) {
86
+ annotated = annotated.slice(0, insertion.offset) + insertion.text + annotated.slice(insertion.offset)
87
+ }
88
+ return { html: annotated, targets }
89
+ }
90
+
91
+ export function applyVisualTargetChanges(input: ApplyVisualTargetChangesInput): ApplyVisualTargetChangesResult {
92
+ const currentVersion = readDeckVersion(input.file).version
93
+ if (input.deckVersion && input.deckVersion !== currentVersion) throw new Error("Deck changed outside Review. Refresh Review before saving visual edits.")
94
+ if (input.targetDeckVersion && input.targetDeckVersion !== currentVersion) throw new Error("Review visual targets are stale. Refresh Review before saving visual edits.")
95
+ if (!input.changes.length) throw new Error("No visual changes to save.")
96
+
97
+ let html = readFileSync(input.file, "utf-8")
98
+ const resolved = new Map<string, { target: VisualEditTarget; patch: Record<string, string> }>()
99
+ for (const change of input.changes) {
100
+ const target = input.targets.get(change.editId)
101
+ if (!target) throw new Error("Target is no longer editable. Refresh Review and try again.")
102
+ if (change.kind && change.kind !== target.kind) throw new Error("Visual edit target kind changed. Refresh Review and try again.")
103
+ const currentOpenTag = html.slice(target.startOffset, target.openEndOffset)
104
+ if (currentOpenTag !== target.originalOpenTag) throw new Error("Target is no longer editable. Refresh Review and try again.")
105
+ const patch = normalizeStylePatch(target, change)
106
+ if (!Object.keys(patch).length) throw new Error("Visual change does not contain valid style updates.")
107
+ const existing = resolved.get(target.editId)
108
+ resolved.set(target.editId, { target, patch: { ...(existing?.patch ?? {}), ...patch } })
109
+ }
110
+
111
+ for (const { target, patch } of Array.from(resolved.values()).sort((a, b) => b.target.startOffset - a.target.startOffset)) {
112
+ const updatedOpenTag = patchOpenTagStyle(target.originalOpenTag, patch)
113
+ html = html.slice(0, target.startOffset) + updatedOpenTag + html.slice(target.openEndOffset)
114
+ }
115
+
116
+ writeFileSync(input.file, html, "utf-8")
117
+ return { ok: true, deckVersion: readDeckVersion(input.file).version, changeCount: input.changes.length }
118
+ }
119
+
120
+ export function readDeckVersion(file: string): { mtimeMs: number; size: number; version: string } {
121
+ const stat = statSync(file)
122
+ return { mtimeMs: stat.mtimeMs, size: stat.size, version: `${stat.mtimeMs}:${stat.size}` }
123
+ }
124
+
125
+ function normalizeStylePatch(target: VisualEditTarget, change: VisualTargetChange): Record<string, string> {
126
+ const input = change.after.stylePatch ?? {}
127
+ if (change.type === "move") return normalizeMovePatch(target, input)
128
+ if (change.type !== "resize") throw new Error(`Unsupported visual change type: ${(change as any).type}`)
129
+ return normalizeResizePatch(target.kind, input)
130
+ }
131
+
132
+ function normalizeResizePatch(kind: VisualEditKind, input: Record<string, unknown>): Record<string, string> {
133
+ const patch: Record<string, string> = {}
134
+ const width = typeof input.width === "string" ? normalizePxValue(input.width, kind === "image" ? MIN_IMAGE_DIMENSION_PX : kind === "box" ? MIN_BOX_WIDTH_PX : MIN_TEXT_WIDTH_PX) : null
135
+ if (width) patch.width = width
136
+ if (kind === "image" || kind === "box") {
137
+ const height = typeof input.height === "string" ? normalizePxValue(input.height, kind === "image" ? MIN_IMAGE_DIMENSION_PX : MIN_BOX_HEIGHT_PX) : null
138
+ if (height) patch.height = height
139
+ } else {
140
+ const maxWidth = typeof input["max-width"] === "string" ? normalizePxValue(input["max-width"], MIN_TEXT_WIDTH_PX) : null
141
+ if (maxWidth) patch["max-width"] = maxWidth
142
+ }
143
+ return patch
144
+ }
145
+
146
+ function normalizeMovePatch(target: VisualEditTarget, input: Record<string, unknown>): Record<string, string> {
147
+ const translate = typeof input.translate === "string" ? normalizeTranslateValue(input.translate) : null
148
+ if (translate) return { translate }
149
+ const transform = typeof input.transform === "string" ? normalizeLegacyTransformValue(input.transform) : null
150
+ return transform ? { translate: transform } : {}
151
+ }
152
+
153
+ function normalizeTranslateValue(value: string): string | null {
154
+ const direct = parseTranslateProperty(value)
155
+ if (direct) return normalizeTranslatePoint(direct)
156
+ return normalizeLegacyTransformValue(value)
157
+ }
158
+
159
+ function normalizeLegacyTransformValue(value: string): string | null {
160
+ const parsed = parseSimpleTranslate(value)
161
+ if (!parsed) return null
162
+ return normalizeTranslatePoint(parsed)
163
+ }
164
+
165
+ function normalizeTranslatePoint(parsed: { x: number; y: number }): string | null {
166
+ const { x, y } = parsed
167
+ if (Math.abs(x) > MAX_DIMENSION_PX || Math.abs(y) > MAX_DIMENSION_PX) return null
168
+ return `${Math.round(x)}px ${Math.round(y)}px`
169
+ }
170
+
171
+ function parseTranslateProperty(value: string): { x: number; y: number } | null {
172
+ const normalized = value.trim()
173
+ const match = /^(-?\d+(?:\.\d+)?)px(?:\s+|\s*,\s*)(-?\d+(?:\.\d+)?)px$/.exec(normalized)
174
+ return match ? finitePoint(Number(match[1]), Number(match[2])) : null
175
+ }
176
+
177
+ function parseSimpleTranslate(value: string): { x: number; y: number } | null {
178
+ const normalized = value.trim()
179
+ if (!normalized || normalized === "none") return { x: 0, y: 0 }
180
+ const translate = /^translate\(\s*(-?\d+(?:\.\d+)?)px(?:\s*,\s*|\s+)(-?\d+(?:\.\d+)?)px\s*\)$/.exec(normalized)
181
+ if (translate) return finitePoint(Number(translate[1]), Number(translate[2]))
182
+ const matrix = /^matrix\(\s*(-?\d+(?:\.\d+)?)\s*,\s*(-?\d+(?:\.\d+)?)\s*,\s*(-?\d+(?:\.\d+)?)\s*,\s*(-?\d+(?:\.\d+)?)\s*,\s*(-?\d+(?:\.\d+)?)\s*,\s*(-?\d+(?:\.\d+)?)\s*\)$/.exec(normalized)
183
+ if (!matrix) return null
184
+ const a = Number(matrix[1])
185
+ const b = Number(matrix[2])
186
+ const c = Number(matrix[3])
187
+ const d = Number(matrix[4])
188
+ if (a !== 1 || b !== 0 || c !== 0 || d !== 1) return null
189
+ return finitePoint(Number(matrix[5]), Number(matrix[6]))
190
+ }
191
+
192
+ function finitePoint(x: number, y: number): { x: number; y: number } | null {
193
+ return Number.isFinite(x) && Number.isFinite(y) ? { x, y } : null
194
+ }
195
+
196
+ function normalizePxValue(value: string, min: number): string | null {
197
+ const match = /^(\d+(?:\.\d+)?)px$/.exec(value.trim())
198
+ if (!match) return null
199
+ const number = Number(match[1])
200
+ if (!Number.isFinite(number) || number < min || number > MAX_DIMENSION_PX) return null
201
+ return `${Math.round(number)}px`
202
+ }
203
+
204
+ function patchOpenTagStyle(openTag: string, patch: Record<string, string>): string {
205
+ const styleMatch = /\sstyle=("([^"]*)"|'([^']*)')/i.exec(openTag)
206
+ const current = styleMatch ? parseStyle(styleMatch[2] ?? styleMatch[3] ?? "") : {}
207
+ const next = serializeStyle({ ...current, ...patch })
208
+ if (styleMatch) return openTag.slice(0, styleMatch.index) + ` style="${escapeAttr(next)}"` + openTag.slice(styleMatch.index + styleMatch[0].length)
209
+ return openTag.replace(/\s*\/?>$/, (ending) => ` style="${escapeAttr(next)}"${ending}`)
210
+ }
211
+
212
+ function parseStyle(style: string): Record<string, string> {
213
+ const result: Record<string, string> = {}
214
+ for (const part of style.split(";")) {
215
+ const index = part.indexOf(":")
216
+ if (index < 0) continue
217
+ const key = part.slice(0, index).trim().toLowerCase()
218
+ const value = part.slice(index + 1).trim()
219
+ if (key && value) result[key] = value
220
+ }
221
+ return result
222
+ }
223
+
224
+ function serializeStyle(style: Record<string, string>): string {
225
+ return Object.entries(style).map(([key, value]) => `${key}: ${value}`).join("; ")
226
+ }
227
+
228
+ function escapeAttr(value: string): string {
229
+ return value.replace(/&/g, "&amp;").replace(/"/g, "&quot;")
230
+ }
231
+
232
+ interface OpenTagRegion {
233
+ tagName: string
234
+ startOffset: number
235
+ openEndOffset: number
236
+ openTag: string
237
+ }
238
+
239
+ function scanOpeningTags(html: string): OpenTagRegion[] {
240
+ const tags: OpenTagRegion[] = []
241
+ let index = 0
242
+ while (index < html.length) {
243
+ const open = html.indexOf("<", index)
244
+ if (open < 0) break
245
+ if (html.startsWith("<!--", open)) {
246
+ const close = html.indexOf("-->", open + 4)
247
+ index = close < 0 ? html.length : close + 3
248
+ continue
249
+ }
250
+ const close = html.indexOf(">", open + 1)
251
+ if (close < 0) break
252
+ const raw = html.slice(open, close + 1)
253
+ const tagName = normalizeName(/^<\s*([a-zA-Z0-9-]+)/.exec(raw)?.[1] || "")
254
+ if (!tagName || /^<\s*[!/]/.test(raw) || /^<\s*\//.test(raw)) {
255
+ index = close + 1
256
+ continue
257
+ }
258
+ tags.push({ tagName, startOffset: open, openEndOffset: close + 1, openTag: raw })
259
+ if (tagName === "script" || tagName === "style") {
260
+ const closeTag = new RegExp(`</\\s*${tagName}\\s*>`, "ig")
261
+ closeTag.lastIndex = close + 1
262
+ const match = closeTag.exec(html)
263
+ index = match ? match.index + match[0].length : close + 1
264
+ } else {
265
+ index = close + 1
266
+ }
267
+ }
268
+ return tags
269
+ }
270
+
271
+ function attrValue(openTag: string, name: string): string | undefined {
272
+ const escaped = escapeRegExp(name)
273
+ const match = new RegExp(`\\s${escaped}=("([^"]*)"|'([^']*)')`, "i").exec(openTag)
274
+ return match ? match[2] ?? match[3] : undefined
275
+ }
276
+
277
+ function isSafeBoxTarget(openTag: string): boolean {
278
+ const tagName = normalizeName(/^<\s*([a-zA-Z0-9-]+)/.exec(openTag)?.[1] || "")
279
+ const className = attrValue(openTag, "class") || ""
280
+ const classes = className.split(/\s+/).map((item) => item.trim().toLowerCase()).filter(Boolean)
281
+ if (tagName === "div" && classes.length === 0) return false
282
+ if (/\s(?:data-chart|data-echarts|_echarts_instance_)\b/i.test(openTag)) return false
283
+ if (classes.some((name) => name === "slide" || name === "slide-canvas" || name === "deck" || name === "page")) return false
284
+ if (classes.some((name) => name.startsWith("revela-") || name.startsWith("echarts-") || name === "echart-container" || name === "echart-panel")) return false
285
+ if (classes.some((name) => /(^|-)echart($|-)/.test(name) || name === "chart-container" || name === "chart-panel")) return false
286
+ return true
287
+ }
288
+
289
+ function normalizeName(value: string | undefined): string {
290
+ return (value || "").trim().toLowerCase()
291
+ }
292
+
293
+ function escapeRegExp(value: string): string {
294
+ return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")
295
+ }
@@ -46,6 +46,103 @@ export function sourceMaterialMetadata(filePath: string, workspaceRoot: string):
46
46
  type: sourceMaterialType(resolvedFile),
47
47
  size: stat.size,
48
48
  fingerprint: computeSourceFingerprint(resolvedFile),
49
+ lastModified: new Date(stat.mtimeMs).toISOString(),
50
+ }
51
+ }
52
+
53
+ export function sourceMaterialModifiedMs(material: SourceMaterial, workspaceRoot: string): number {
54
+ if (material.lastModified) {
55
+ const parsed = Date.parse(material.lastModified)
56
+ if (Number.isFinite(parsed)) return parsed
57
+ }
58
+
59
+ try {
60
+ return statSync(ensureWorkspaceFile(material.path, workspaceRoot)).mtimeMs
61
+ } catch {
62
+ return 0
63
+ }
64
+ }
65
+
66
+ export interface SourceMaterialIngestPlan {
67
+ vaultTimestamp: string | null
68
+ vaultTimestampMs: number
69
+ addedSourceMaterials: SourceMaterial[]
70
+ changedSourceMaterials: SourceMaterial[]
71
+ newerThanVaultSourceMaterials: SourceMaterial[]
72
+ unchangedSourceMaterials: SourceMaterial[]
73
+ ingestCandidates: SourceMaterial[]
74
+ suggestedTasks: SourceMaterialIngestTask[]
75
+ }
76
+
77
+ export interface SourceMaterialIngestTask {
78
+ path: string
79
+ reason: Array<"added" | "changed" | "newer_than_vault">
80
+ materialType: string
81
+ needsExtraction: boolean
82
+ suggestedAction: "read_directly" | "extract_then_read"
83
+ note: string
84
+ }
85
+
86
+ export function classifySourceMaterialIngest(
87
+ state: DecksState,
88
+ materials: SourceMaterial[],
89
+ workspaceRoot: string,
90
+ vaultTimestampMs: number,
91
+ ): SourceMaterialIngestPlan {
92
+ const existingByPath = new Map((state.workspace.sourceMaterials ?? []).map((item) => [item.path.replace(/\\/g, "/"), item]))
93
+ const addedSourceMaterials: SourceMaterial[] = []
94
+ const changedSourceMaterials: SourceMaterial[] = []
95
+ const newerThanVaultSourceMaterials: SourceMaterial[] = []
96
+ const unchangedSourceMaterials: SourceMaterial[] = []
97
+ const ingestByPath = new Map<string, SourceMaterial>()
98
+ const reasonsByPath = new Map<string, SourceMaterialIngestTask["reason"]>()
99
+
100
+ for (const material of materials) {
101
+ const path = material.path.replace(/\\/g, "/")
102
+ const existing = existingByPath.get(path)
103
+ const added = !existing
104
+ const changed = Boolean(existing?.fingerprint && material.fingerprint && existing.fingerprint !== material.fingerprint)
105
+ const newerThanVault = sourceMaterialModifiedMs(material, workspaceRoot) > vaultTimestampMs
106
+ const reasons: SourceMaterialIngestTask["reason"] = []
107
+
108
+ if (added) addedSourceMaterials.push(material)
109
+ if (changed) changedSourceMaterials.push(material)
110
+ if (newerThanVault) newerThanVaultSourceMaterials.push(material)
111
+ if (added) reasons.push("added")
112
+ if (changed) reasons.push("changed")
113
+ if (newerThanVault) reasons.push("newer_than_vault")
114
+ if (added || changed || newerThanVault) {
115
+ ingestByPath.set(path, material)
116
+ reasonsByPath.set(path, reasons)
117
+ }
118
+ else unchangedSourceMaterials.push(material)
119
+ }
120
+ const ingestCandidates = [...ingestByPath.values()].sort((a, b) => a.path.localeCompare(b.path))
121
+
122
+ return {
123
+ vaultTimestamp: vaultTimestampMs > 0 ? new Date(vaultTimestampMs).toISOString() : null,
124
+ vaultTimestampMs,
125
+ addedSourceMaterials,
126
+ changedSourceMaterials,
127
+ newerThanVaultSourceMaterials,
128
+ unchangedSourceMaterials,
129
+ ingestCandidates,
130
+ suggestedTasks: ingestCandidates.map((material) => sourceMaterialIngestTask(material, reasonsByPath.get(material.path.replace(/\\/g, "/")) ?? [])),
131
+ }
132
+ }
133
+
134
+ function sourceMaterialIngestTask(material: SourceMaterial, reason: SourceMaterialIngestTask["reason"]): SourceMaterialIngestTask {
135
+ const materialType = (material.type || sourceMaterialType(material.path)).toLowerCase()
136
+ const needsExtraction = ["pdf", "ppt", "pptx", "doc", "docx", "xls", "xlsx"].includes(materialType)
137
+ return {
138
+ path: material.path,
139
+ reason,
140
+ materialType,
141
+ needsExtraction,
142
+ suggestedAction: needsExtraction ? "extract_then_read" : "read_directly",
143
+ note: needsExtraction
144
+ ? "Use revela-extract-document-materials before synthesizing stable narrative findings when this file is relevant."
145
+ : "Read directly when relevant and distill stable narrative findings into the Markdown vault.",
49
146
  }
50
147
  }
51
148
 
@@ -77,6 +174,7 @@ export function upsertSourceMaterial(
77
174
  path,
78
175
  type: material.type ?? existing?.type,
79
176
  status: nextStatus,
177
+ lastModified: material.lastModified ?? existing?.lastModified,
80
178
  firstSeen: existing?.firstSeen ?? material.firstSeen ?? now,
81
179
  lastChecked: now,
82
180
  }
@@ -0,0 +1,34 @@
1
+ export function appendToolResult(output: any, text: string): void {
2
+ if (!output || typeof output !== "object") return
3
+
4
+ if (typeof output.output === "string") {
5
+ output.output = appendText(output.output, text)
6
+ return
7
+ }
8
+
9
+ if (typeof output.result === "string") {
10
+ output.result = appendText(output.result, text)
11
+ return
12
+ }
13
+
14
+ if (typeof output.text === "string") {
15
+ output.text = appendText(output.text, text)
16
+ return
17
+ }
18
+
19
+ if (typeof output.message === "string") {
20
+ output.message = appendText(output.message, text)
21
+ return
22
+ }
23
+
24
+ if (Array.isArray(output.content)) {
25
+ output.content.push({ type: "text", text })
26
+ return
27
+ }
28
+
29
+ output.output = text
30
+ }
31
+
32
+ function appendText(existing: string, text: string): string {
33
+ return (existing ? `${existing}\n\n` : "") + text
34
+ }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@cyber-dash-tech/revela",
3
- "version": "0.16.3",
4
- "description": "OpenCode plugin that turns AI into an HTML slide deck generator",
3
+ "version": "0.17.0",
4
+ "description": "OpenCode plugin for trusted narrative artifacts from local sources, research, and evidence",
5
5
  "type": "module",
6
6
  "main": "./index.ts",
7
7
  "exports": {
package/plugin.ts CHANGED
@@ -72,6 +72,11 @@ import {
72
72
  hasDecksState,
73
73
  isDecksStatePath,
74
74
  } from "./lib/decks-state"
75
+ import { autoCompileNarrativeVault } from "./lib/narrative-vault/auto-compile"
76
+ import {
77
+ extractNarrativeVaultMarkdownTargetsFromPatch,
78
+ normalizeNarrativeVaultMarkdownPath,
79
+ } from "./lib/narrative-vault/hook-targets"
75
80
  import decksTool from "./tools/decks"
76
81
  import designsAuthorTool from "./tools/designs-author"
77
82
  import designsTool from "./tools/designs"
@@ -93,6 +98,8 @@ import { RESEARCH_PROMPT, RESEARCH_AGENT_SIGNATURE } from "./lib/agents/research
93
98
  import { NARRATIVE_REVIEWER_PROMPT, NARRATIVE_REVIEWER_SIGNATURE } from "./lib/agents/narrative-reviewer-prompt"
94
99
  import { extractDesignClasses } from "./lib/design/designs"
95
100
  import { log, childLog } from "./lib/log"
101
+ import { appendToolResult } from "./lib/tool-result"
102
+ import { formatArtifactQaUserNotice, formatMarkdownQaUserNotice, formatStateGateUserNotice } from "./lib/hook-notifications"
96
103
 
97
104
  // OpenCode internal agent signatures — used to skip system prompt injection
98
105
  // for built-in system agents (title, summary, compaction).
@@ -102,16 +109,6 @@ const INTERNAL_AGENT_SIGNATURES = [
102
109
  "Summarize what was done in this conversation",
103
110
  ]
104
111
 
105
- function appendToolResult(output: any, text: string): void {
106
- if (typeof output.output === "string") {
107
- output.output = (output.output ? output.output + "\n\n" : "") + text
108
- return
109
- }
110
-
111
- const existing = output.result ?? ""
112
- output.result = (existing ? existing + "\n\n" : "") + text
113
- }
114
-
115
112
  function extractEditFilePath(args: any): string {
116
113
  return args?.filePath ?? args?.file_path ?? args?.path ?? args?.file ?? ""
117
114
  }
@@ -148,7 +145,7 @@ const server: Plugin = (async (pluginCtx) => {
148
145
  const blockedDeckWrites = new Map<string, string>()
149
146
  const blockedPatches = new Map<string, string>()
150
147
 
151
- async function runPostWriteArtifactQA(filePath: string, output: any): Promise<boolean> {
148
+ async function runPostWriteArtifactQA(filePath: string, output: any, sessionID = ""): Promise<boolean> {
152
149
  if (!isDeckHtmlPath(filePath)) return true
153
150
 
154
151
  try {
@@ -161,6 +158,8 @@ const server: Plugin = (async (pluginCtx) => {
161
158
 
162
159
  const report = await runArtifactQA({ workspaceRoot, filePath, vocabulary })
163
160
  appendToolResult(output, "---\n\n" + formatArtifactQAReport(report))
161
+ const notice = formatArtifactQaUserNotice(report)
162
+ if (notice && sessionID) await sendIgnoredMessage(client, sessionID, notice)
164
163
  return report.passed
165
164
  } catch (e) {
166
165
  childLog("artifact-qa").warn("post-write artifact QA failed", {
@@ -168,10 +167,27 @@ const server: Plugin = (async (pluginCtx) => {
168
167
  error: e instanceof Error ? e.message : String(e),
169
168
  })
170
169
  appendToolResult(output, "---\n\n## Artifact QA: FAILED\n\nError running artifact QA: " + (e instanceof Error ? e.message : String(e)))
170
+ if (sessionID) await sendIgnoredMessage(client, sessionID, `**Artifact QA failed**\nFile: \`${filePath}\`\nError: ${e instanceof Error ? e.message : String(e)}`)
171
171
  return false
172
172
  }
173
173
  }
174
174
 
175
+ async function runPostWriteNarrativeVaultCompile(touched: string[], output: any, sessionID = ""): Promise<void> {
176
+ if (touched.length === 0) return
177
+
178
+ const result = autoCompileNarrativeVault(workspaceRoot, touched)
179
+ appendToolResult(output, "---\n\n" + result.markdown)
180
+ const notice = formatMarkdownQaUserNotice(result)
181
+ if (notice && sessionID) await sendIgnoredMessage(client, sessionID, notice)
182
+ if (!result.ok) {
183
+ childLog("narrative-vault").warn("auto-compile reported blockers", {
184
+ touched,
185
+ mirror: result.mirrored,
186
+ error: result.error,
187
+ })
188
+ }
189
+ }
190
+
175
191
  function extractSessionID(input: any): string {
176
192
  return input?.sessionID ?? input?.session?.id ?? input?.context?.sessionID ?? ""
177
193
  }
@@ -314,6 +330,17 @@ const server: Plugin = (async (pluginCtx) => {
314
330
  await handleHelp(send)
315
331
  throw new Error("__REVELA_STATUS_HANDLED__")
316
332
  }
333
+ if (sub === "enable") {
334
+ ctx.enabled = true
335
+ buildPrompt()
336
+ await send("Revela enabled. Workflow commands and Revela context are active.")
337
+ throw new Error("__REVELA_ENABLE_HANDLED__")
338
+ }
339
+ if (sub === "disable") {
340
+ ctx.enabled = false
341
+ await send("Revela disabled. Run `/revela enable` or any workflow command to reactivate.")
342
+ throw new Error("__REVELA_DISABLE_HANDLED__")
343
+ }
317
344
  if (sub === "make") {
318
345
  const target = args[1]?.toLowerCase() ?? ""
319
346
  const makeParam = args.slice(2).join(" ")
@@ -500,7 +527,7 @@ const server: Plugin = (async (pluginCtx) => {
500
527
  throw new Error("__REVELA_DOMAIN_USAGE_HANDLED__")
501
528
  }
502
529
  const legacyCommands = new Set([
503
- "enable", "disable", "review", "narrative", "deck", "brief", "edit", "inspect", "remember",
530
+ "review", "narrative", "deck", "brief", "edit", "inspect", "remember",
504
531
  "designs", "designs-new", "designs-edit", "designs-preview", "designs-add", "designs-rm",
505
532
  "domains", "domains-add", "domains-rm", "pdf", "pptx",
506
533
  ])
@@ -689,8 +716,6 @@ const server: Plugin = (async (pluginCtx) => {
689
716
  // - write/apply_patch: protect DECKS.json, but do not block deck HTML edits.
690
717
  "tool.execute.before": async (input, output) => {
691
718
  log.info("[hook] tool.execute.before fired", { tool: input.tool, enabled: ctx.enabled, isResearch: ctx.isResearchAgent })
692
- if (!ctx.enabled) return
693
-
694
719
  if (input.tool === "write") {
695
720
  const filePath: string = (output.args as any)?.filePath ?? ""
696
721
  if (isDecksStatePath(filePath)) {
@@ -741,6 +766,8 @@ Next step: use \`revela-decks\` with action \`init\`, \`upsertDeck\`, \`upsertSl
741
766
  }
742
767
  }
743
768
 
769
+ if (!ctx.enabled) return
770
+
744
771
  if (input.tool === "read") {
745
772
  try {
746
773
  await preRead(output.args)
@@ -760,10 +787,9 @@ Next step: use \`revela-decks\` with action \`init\`, \`upsertDeck\`, \`upsertSl
760
787
  // Also reports writes/patches blocked by the DECKS.json prewrite gate and
761
788
  // runs artifact QA before opening Refine after successful deck changes.
762
789
  "tool.execute.after": async (input, output) => {
763
- if (!ctx.enabled) return
764
-
765
790
  // ── Post-read processing ───────────────────────────────────────────
766
791
  if (input.tool === "read") {
792
+ if (!ctx.enabled) return
767
793
  try {
768
794
  await postRead(input.args, output)
769
795
  } catch (e) {
@@ -787,39 +813,51 @@ Next step: use \`revela-decks\` with action \`init\`, \`upsertDeck\`, \`upsertSl
787
813
  `${blockedReason}\n\n` +
788
814
  "Use the `revela-decks` tool or complete the DECKS.json review workflow instead."
789
815
  )
816
+ await sendIgnoredMessage(client, extractSessionID(input), formatStateGateUserNotice("write", blockedReason))
790
817
  return
791
818
  }
792
- const qaPassed = await runPostWriteArtifactQA(filePath, output)
819
+ const vaultTarget = normalizeNarrativeVaultMarkdownPath(filePath, workspaceRoot)
820
+ const sessionID = extractSessionID(input)
821
+ if (vaultTarget) await runPostWriteNarrativeVaultCompile([vaultTarget], output, sessionID)
822
+ const qaPassed = await runPostWriteArtifactQA(filePath, output, sessionID)
793
823
  if (qaPassed) ensureRefineOpenAfterDeckChange(filePath, extractSessionID(input))
794
824
  return
795
825
  }
796
826
 
797
827
  if (input.tool === "apply_patch" && blockedPatches.size > 0) {
798
828
  const [blockedPath, blockedReason] = blockedPatches.entries().next().value ?? []
829
+ if (!blockedPath || !blockedReason) return
799
830
  if (blockedPath) blockedPatches.delete(blockedPath)
800
831
  appendToolResult(
801
832
  output,
802
833
  "---\n\n**[revela prewrite gate]** Patch was blocked.\n\n" +
803
834
  `${blockedReason}\n\n` +
804
- "Use the `revela-decks` tool for controlled workspace state changes."
835
+ "Use the `revela-decks` tool for controlled workspace state changes."
805
836
  )
837
+ await sendIgnoredMessage(client, extractSessionID(input), formatStateGateUserNotice("patch", blockedReason))
806
838
  return
807
839
  }
808
840
 
809
841
  if (input.tool === "apply_patch") {
810
842
  const patchText = extractPatchTextArg(input.args as Record<string, unknown>)
843
+ const vaultTargets = patchText ? extractNarrativeVaultMarkdownTargetsFromPatch(patchText, workspaceRoot) : []
844
+ const sessionID = extractSessionID(input)
845
+ await runPostWriteNarrativeVaultCompile(vaultTargets, output, sessionID)
811
846
  const targets = patchText ? extractDeckHtmlTargetsFromPatch(patchText) : []
812
847
  for (const target of targets) {
813
- const qaPassed = await runPostWriteArtifactQA(target, output)
814
- if (qaPassed) ensureRefineOpenAfterDeckChange(target, extractSessionID(input))
848
+ const qaPassed = await runPostWriteArtifactQA(target, output, sessionID)
849
+ if (qaPassed) ensureRefineOpenAfterDeckChange(target, sessionID)
815
850
  }
816
851
  return
817
852
  }
818
853
 
819
854
  if (input.tool === "edit") {
820
855
  const filePath = extractEditFilePath(input.args)
821
- const qaPassed = await runPostWriteArtifactQA(filePath, output)
822
- if (qaPassed) ensureRefineOpenAfterDeckChange(filePath, extractSessionID(input))
856
+ const vaultTarget = normalizeNarrativeVaultMarkdownPath(filePath, workspaceRoot)
857
+ const sessionID = extractSessionID(input)
858
+ if (vaultTarget) await runPostWriteNarrativeVaultCompile([vaultTarget], output, sessionID)
859
+ const qaPassed = await runPostWriteArtifactQA(filePath, output, sessionID)
860
+ if (qaPassed) ensureRefineOpenAfterDeckChange(filePath, sessionID)
823
861
  return
824
862
  }
825
863
  },