@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.
- package/README.md +7 -5
- package/README.zh-CN.md +7 -5
- package/lib/commands/brief.ts +9 -0
- package/lib/commands/help.ts +5 -2
- package/lib/commands/init.ts +42 -27
- package/lib/commands/narrative.ts +26 -2
- package/lib/commands/research.ts +36 -20
- package/lib/commands/review.ts +21 -18
- package/lib/ctx.ts +1 -1
- package/lib/decks-state.ts +38 -4
- package/lib/edit/prompt.ts +1 -1
- package/lib/hook-notifications.ts +53 -0
- package/lib/narrative-state/render-plan.ts +114 -27
- package/lib/narrative-state/research-binding-eval.ts +260 -0
- package/lib/narrative-state/research-gaps.ts +2 -88
- package/lib/narrative-vault/authoring-contract.ts +127 -0
- package/lib/narrative-vault/authoring-guard.ts +122 -0
- package/lib/narrative-vault/auto-compile.ts +134 -0
- package/lib/narrative-vault/bootstrap.ts +63 -0
- package/lib/narrative-vault/cache.ts +14 -0
- package/lib/narrative-vault/compile-mirror.ts +45 -0
- package/lib/narrative-vault/compile.ts +350 -0
- package/lib/narrative-vault/constants.ts +6 -0
- package/lib/narrative-vault/diagnostic-report.ts +117 -0
- package/lib/narrative-vault/export.ts +71 -0
- package/lib/narrative-vault/frontmatter.ts +41 -0
- package/lib/narrative-vault/hook-targets.ts +40 -0
- package/lib/narrative-vault/index.ts +18 -0
- package/lib/narrative-vault/inventory.ts +392 -0
- package/lib/narrative-vault/markdown-qa.ts +237 -0
- package/lib/narrative-vault/markdown.ts +34 -0
- package/lib/narrative-vault/migration.ts +52 -0
- package/lib/narrative-vault/mutate.ts +361 -0
- package/lib/narrative-vault/paths.ts +19 -0
- package/lib/narrative-vault/read.ts +52 -0
- package/lib/narrative-vault/relations.ts +32 -0
- package/lib/narrative-vault/source-loader.ts +19 -0
- package/lib/narrative-vault/timestamp.ts +32 -0
- package/lib/narrative-vault/types.ts +44 -0
- package/lib/refine/server.ts +472 -5
- package/lib/refine/visual-targets.ts +295 -0
- package/lib/source-materials.ts +98 -0
- package/lib/tool-result.ts +34 -0
- package/package.json +2 -2
- package/plugin.ts +60 -22
- package/skill/NARRATIVE_SKILL.md +25 -10
- package/tools/decks.ts +363 -67
- package/tools/research-save.ts +3 -0
- 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, "&").replace(/"/g, """)
|
|
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
|
+
}
|
package/lib/source-materials.ts
CHANGED
|
@@ -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.
|
|
4
|
-
"description": "OpenCode plugin
|
|
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
|
-
"
|
|
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
|
|
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
|
-
|
|
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,
|
|
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
|
|
822
|
-
|
|
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
|
},
|