@cyber-dash-tech/revela 0.17.21 → 0.17.23

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.
@@ -0,0 +1,494 @@
1
+ import { existsSync, mkdirSync, readdirSync, readFileSync, statSync, writeFileSync } from "fs"
2
+ import { basename, extname, isAbsolute, join, relative, resolve, sep } from "path"
3
+ import { extractDocumentMaterials, type DocumentMaterialsResult } from "./document-materials/extract"
4
+ import { sourceMaterialMetadata, sourceMaterialType } from "./source-materials"
5
+ import type { SourceMaterial } from "./decks-state"
6
+
7
+ export type MaterialIntakeStatus =
8
+ | "scanned"
9
+ | "extracted"
10
+ | "reviewed"
11
+ | "text_only_read"
12
+ | "skipped"
13
+ | "unsupported"
14
+ | "failed"
15
+
16
+ export interface MaterialRegistryEntry {
17
+ sourcePath: string
18
+ type: string
19
+ fingerprint?: string
20
+ size?: number
21
+ lastModified?: string
22
+ status: MaterialIntakeStatus
23
+ requiresExtraction: boolean
24
+ allowedReadPath?: string | null
25
+ extraction?: {
26
+ manifestPath?: string
27
+ textPath?: string
28
+ readViewPath?: string
29
+ cacheDir?: string
30
+ imageCount: number
31
+ tableCount: number
32
+ } | null
33
+ review?: {
34
+ reviewPath: string
35
+ reviewedAt: string
36
+ reviewedPaths: string[]
37
+ summary: string
38
+ } | null
39
+ warnings?: string[]
40
+ firstSeen: string
41
+ lastChecked: string
42
+ }
43
+
44
+ export interface MaterialRegistry {
45
+ version: 1
46
+ updatedAt: string
47
+ sources: MaterialRegistryEntry[]
48
+ }
49
+
50
+ export interface MaterialIngestTask {
51
+ path: string
52
+ materialType: string
53
+ needsExtraction: boolean
54
+ suggestedAction: "read_directly" | "extract_then_read"
55
+ status: MaterialIntakeStatus
56
+ allowedReadPath?: string | null
57
+ note: string
58
+ }
59
+
60
+ export interface PrepareLocalMaterialsInput {
61
+ workspaceRoot?: string
62
+ path?: string
63
+ maxDepth?: number
64
+ autoExtract?: boolean
65
+ }
66
+
67
+ export interface PrepareLocalMaterialsResult {
68
+ ok: true
69
+ workspaceRoot: string
70
+ registryPath: string
71
+ found: number
72
+ files: SourceMaterial[]
73
+ suggestedTasks: MaterialIngestTask[]
74
+ extractions: DocumentMaterialsResult[]
75
+ warnings: string[]
76
+ }
77
+
78
+ export interface RecordMaterialReviewInput {
79
+ workspaceRoot?: string
80
+ sourcePath: string
81
+ reviewedPaths: string[]
82
+ reviewSummary: string
83
+ narrativeDecisions: Array<{
84
+ kind: "merged" | "gap" | "ignored" | "deferred"
85
+ target?: string
86
+ rationale: string
87
+ }>
88
+ }
89
+
90
+ export interface RecordMaterialReviewResult {
91
+ ok: true
92
+ path: string
93
+ registryPath: string
94
+ sourcePath: string
95
+ }
96
+
97
+ export interface CheckMaterialIntakeInput {
98
+ workspaceRoot?: string
99
+ strictness?: "authoring" | "readiness" | "render"
100
+ }
101
+
102
+ export interface CheckMaterialIntakeResult {
103
+ ok: boolean
104
+ registryPath: string
105
+ warnings: string[]
106
+ sources: Array<MaterialRegistryEntry & { recommendedNextAction?: string }>
107
+ }
108
+
109
+ const DOC_EXTENSIONS = new Set([".pdf", ".docx", ".doc", ".xlsx", ".xls", ".pptx", ".ppt", ".csv", ".md", ".txt"])
110
+ const EXCLUDE_DIRS = new Set(["node_modules", ".git", "dist", ".opencode", "researches", "revela-narrative", "designs", "domains"])
111
+ const EXCLUDE_FILENAMES = new Set(["AGENTS.md", "DECKS.md", "README.md", "README.zh-CN.md"])
112
+ const EXTRACTION_EXTENSIONS = new Set(["pdf", "ppt", "pptx", "doc", "docx", "xls", "xlsx"])
113
+ const SUPPORTED_EXTRACTION_EXTENSIONS = new Set(["pdf", "pptx", "docx", "xlsx"])
114
+
115
+ export function materialRegistryPath(workspaceRoot: string): string {
116
+ return join(workspaceRoot, ".opencode", "revela", "material-intake", "registry.json")
117
+ }
118
+
119
+ export function readMaterialRegistry(workspaceRoot: string): MaterialRegistry {
120
+ const path = materialRegistryPath(workspaceRoot)
121
+ if (!existsSync(path)) return { version: 1, updatedAt: new Date(0).toISOString(), sources: [] }
122
+ return JSON.parse(readFileSync(path, "utf-8")) as MaterialRegistry
123
+ }
124
+
125
+ export function writeMaterialRegistry(workspaceRoot: string, registry: MaterialRegistry): string {
126
+ const path = materialRegistryPath(workspaceRoot)
127
+ mkdirSync(join(workspaceRoot, ".opencode", "revela", "material-intake"), { recursive: true })
128
+ writeFileSync(path, JSON.stringify({ ...registry, updatedAt: new Date().toISOString() }, null, 2), "utf-8")
129
+ return workspaceRelative(path, workspaceRoot)
130
+ }
131
+
132
+ export async function prepareLocalMaterials(input: PrepareLocalMaterialsInput = {}): Promise<PrepareLocalMaterialsResult> {
133
+ const workspaceRoot = root(input.workspaceRoot)
134
+ const scanRoot = scanRootFor(workspaceRoot, input.path)
135
+ const files = scanWorkspaceSources(workspaceRoot, scanRoot, input.maxDepth ?? 2)
136
+ let registry = readMaterialRegistry(workspaceRoot)
137
+ const now = new Date().toISOString()
138
+ const extractions: DocumentMaterialsResult[] = []
139
+
140
+ for (const file of files) {
141
+ registry = upsertRegistryEntry(registry, materialEntryFromSource(file, now))
142
+ }
143
+
144
+ if (input.autoExtract ?? true) {
145
+ for (const file of files) {
146
+ const type = (file.type || sourceMaterialType(file.path)).toLowerCase()
147
+ if (!EXTRACTION_EXTENSIONS.has(type)) continue
148
+ const result = await extractAndUpdateRegistry({ workspaceRoot, file: file.path }, registry)
149
+ registry = result.registry
150
+ extractions.push(result.extraction)
151
+ }
152
+ }
153
+
154
+ const registryPath = writeMaterialRegistry(workspaceRoot, registry)
155
+ return {
156
+ ok: true,
157
+ workspaceRoot,
158
+ registryPath,
159
+ found: files.length,
160
+ files,
161
+ suggestedTasks: registry.sources.map((entry) => ingestTask(entry)),
162
+ extractions,
163
+ warnings: intakeWarnings(registry.sources),
164
+ }
165
+ }
166
+
167
+ export async function extractMaterial(input: { workspaceRoot?: string; file: string }): Promise<DocumentMaterialsResult> {
168
+ const workspaceRoot = root(input.workspaceRoot)
169
+ const registry = readMaterialRegistry(workspaceRoot)
170
+ const result = await extractAndUpdateRegistry({ workspaceRoot, file: input.file }, registry)
171
+ writeMaterialRegistry(workspaceRoot, result.registry)
172
+ return result.extraction
173
+ }
174
+
175
+ export function recordMaterialReview(input: RecordMaterialReviewInput): RecordMaterialReviewResult {
176
+ const workspaceRoot = root(input.workspaceRoot)
177
+ const registry = readMaterialRegistry(workspaceRoot)
178
+ const entry = registry.sources.find((item) => item.sourcePath === normalizePath(input.sourcePath))
179
+ const sourcePath = entry?.sourcePath ?? normalizePath(input.sourcePath)
180
+ const reviewPath = writeReviewMarkdown(workspaceRoot, {
181
+ sourcePath,
182
+ fingerprint: entry?.fingerprint,
183
+ extraction: entry?.extraction ?? null,
184
+ reviewedPaths: input.reviewedPaths.map(normalizePath),
185
+ reviewSummary: input.reviewSummary,
186
+ narrativeDecisions: input.narrativeDecisions,
187
+ })
188
+ const now = new Date().toISOString()
189
+ const nextEntry: MaterialRegistryEntry = {
190
+ ...(entry ?? {
191
+ sourcePath,
192
+ type: sourceMaterialType(sourcePath),
193
+ status: "scanned",
194
+ requiresExtraction: EXTRACTION_EXTENSIONS.has(sourceMaterialType(sourcePath)),
195
+ firstSeen: now,
196
+ lastChecked: now,
197
+ }),
198
+ status: "reviewed",
199
+ review: {
200
+ reviewPath,
201
+ reviewedAt: now,
202
+ reviewedPaths: input.reviewedPaths.map(normalizePath),
203
+ summary: input.reviewSummary,
204
+ },
205
+ warnings: [],
206
+ lastChecked: now,
207
+ }
208
+ const updated = upsertRegistryEntry(registry, nextEntry)
209
+ const registryPath = writeMaterialRegistry(workspaceRoot, updated)
210
+ return { ok: true, path: reviewPath, registryPath, sourcePath }
211
+ }
212
+
213
+ export function checkMaterialIntake(input: CheckMaterialIntakeInput = {}): CheckMaterialIntakeResult {
214
+ const workspaceRoot = root(input.workspaceRoot)
215
+ const registry = readMaterialRegistry(workspaceRoot)
216
+ const sources = registry.sources.map((source) => {
217
+ const recommendedNextAction = recommendedAction(source, input.strictness ?? "authoring")
218
+ return recommendedNextAction ? { ...source, recommendedNextAction } : source
219
+ })
220
+ const warnings = intakeWarnings(registry.sources)
221
+ return {
222
+ ok: warnings.length === 0,
223
+ registryPath: workspaceRelative(materialRegistryPath(workspaceRoot), workspaceRoot),
224
+ warnings,
225
+ sources,
226
+ }
227
+ }
228
+
229
+ export function materialIntakeNoticeForCommand(input: { workspaceRoot?: string; command: string }): string | null {
230
+ const workspaceRoot = root(input.workspaceRoot)
231
+ const registry = readMaterialRegistry(workspaceRoot)
232
+ const command = input.command
233
+ const rawOfficeRead = /\b(textutil|pandoc|strings|unzip)\b/.test(command) && /\.(docx|doc|pptx|ppt|xlsx|xls|pdf)\b/i.test(command)
234
+ const matched = registry.sources.filter((entry) => entry.requiresExtraction && command.includes(entry.sourcePath))
235
+ if (!rawOfficeRead && matched.length === 0) return null
236
+
237
+ const paths = matched.length > 0
238
+ ? matched.map((entry) => entry.sourcePath)
239
+ : registry.sources.filter((entry) => entry.requiresExtraction).map((entry) => entry.sourcePath)
240
+ const unique = [...new Set(paths)].slice(0, 5)
241
+ return [
242
+ "Revela material intake notice:",
243
+ unique.length > 0
244
+ ? `Scanned source(s) require Revela extraction before narrative intake: ${unique.map((path) => `\`${path}\``).join(", ")}.`
245
+ : "This command appears to read an Office/PDF source directly.",
246
+ "Use `revela_extract_document_materials` and read the returned `read_view_path` so embedded images and manifests are considered.",
247
+ "If this is intentionally text-only, mark it as degraded intake in the init report; do not treat it as complete material review.",
248
+ ].join("\n")
249
+ }
250
+
251
+ async function extractAndUpdateRegistry(
252
+ input: { workspaceRoot: string; file: string },
253
+ registry: MaterialRegistry,
254
+ ): Promise<{ extraction: DocumentMaterialsResult; registry: MaterialRegistry }> {
255
+ const sourcePath = normalizePath(input.file)
256
+ const extraction = await extractDocumentMaterials(sourcePath, input.workspaceRoot)
257
+ const existing = registry.sources.find((entry) => entry.sourcePath === sourcePath)
258
+ const now = new Date().toISOString()
259
+ const type = extraction.type === "other" ? sourceMaterialType(sourcePath) : extraction.type
260
+ const unsupported = EXTRACTION_EXTENSIONS.has(type) && !SUPPORTED_EXTRACTION_EXTENSIONS.has(type)
261
+ const status: MaterialIntakeStatus = extraction.status === "processed"
262
+ ? "extracted"
263
+ : extraction.status === "failed"
264
+ ? "failed"
265
+ : unsupported
266
+ ? "unsupported"
267
+ : "skipped"
268
+
269
+ return {
270
+ extraction,
271
+ registry: upsertRegistryEntry(registry, {
272
+ ...(existing ?? {
273
+ sourcePath,
274
+ type,
275
+ requiresExtraction: EXTRACTION_EXTENSIONS.has(type),
276
+ firstSeen: now,
277
+ }),
278
+ sourcePath,
279
+ type,
280
+ status,
281
+ requiresExtraction: EXTRACTION_EXTENSIONS.has(type),
282
+ allowedReadPath: extraction.read_view_path ?? extraction.text_path ?? null,
283
+ extraction: extraction.status === "processed"
284
+ ? {
285
+ manifestPath: extraction.manifest_path,
286
+ textPath: extraction.text_path,
287
+ readViewPath: extraction.read_view_path,
288
+ cacheDir: extraction.cache_dir,
289
+ imageCount: extraction.images?.length ?? 0,
290
+ tableCount: extraction.tables?.length ?? 0,
291
+ }
292
+ : null,
293
+ warnings: extractionWarnings(extraction, unsupported),
294
+ lastChecked: now,
295
+ }),
296
+ }
297
+ }
298
+
299
+ function scanWorkspaceSources(workspaceRoot: string, scanRoot: string, maxDepth: number): SourceMaterial[] {
300
+ const results: SourceMaterial[] = []
301
+ scanDir(scanRoot, workspaceRoot, results, maxDepth, 0)
302
+ return results.sort((a, b) => a.path.localeCompare(b.path))
303
+ }
304
+
305
+ function scanDir(dir: string, workspaceRoot: string, results: SourceMaterial[], maxDepth: number, depth: number): void {
306
+ if (depth > maxDepth || !existsSync(dir)) return
307
+ let entries: string[]
308
+ try {
309
+ entries = readdirSync(dir)
310
+ } catch {
311
+ return
312
+ }
313
+
314
+ for (const entry of entries) {
315
+ if (entry.startsWith(".") || EXCLUDE_DIRS.has(entry)) continue
316
+ const fullPath = join(dir, entry)
317
+ let stat
318
+ try {
319
+ stat = statSync(fullPath)
320
+ } catch {
321
+ continue
322
+ }
323
+ if (stat.isDirectory()) {
324
+ scanDir(fullPath, workspaceRoot, results, maxDepth, depth + 1)
325
+ continue
326
+ }
327
+ if (!stat.isFile() || EXCLUDE_FILENAMES.has(entry) || entry.startsWith("~$")) continue
328
+ if (!DOC_EXTENSIONS.has(extname(entry).toLowerCase())) continue
329
+ results.push({ ...sourceMaterialMetadata(fullPath, workspaceRoot), status: "discovered" })
330
+ }
331
+ }
332
+
333
+ function materialEntryFromSource(source: SourceMaterial, now: string): MaterialRegistryEntry {
334
+ const type = (source.type || sourceMaterialType(source.path)).toLowerCase()
335
+ return {
336
+ sourcePath: source.path,
337
+ type,
338
+ fingerprint: source.fingerprint,
339
+ size: source.size,
340
+ lastModified: source.lastModified,
341
+ status: "scanned",
342
+ requiresExtraction: EXTRACTION_EXTENSIONS.has(type),
343
+ allowedReadPath: EXTRACTION_EXTENSIONS.has(type) ? null : source.path,
344
+ extraction: null,
345
+ review: null,
346
+ warnings: [],
347
+ firstSeen: now,
348
+ lastChecked: now,
349
+ }
350
+ }
351
+
352
+ function upsertRegistryEntry(registry: MaterialRegistry, entry: MaterialRegistryEntry): MaterialRegistry {
353
+ const sourcePath = normalizePath(entry.sourcePath)
354
+ const existingIndex = registry.sources.findIndex((item) => item.sourcePath === sourcePath)
355
+ const existing = existingIndex >= 0 ? registry.sources[existingIndex] : undefined
356
+ const unchangedFingerprint = Boolean(existing?.fingerprint && entry.fingerprint && existing.fingerprint === entry.fingerprint)
357
+ const scanRefresh = entry.status === "scanned" && unchangedFingerprint
358
+ const next: MaterialRegistryEntry = {
359
+ ...existing,
360
+ ...entry,
361
+ sourcePath,
362
+ status: scanRefresh ? existing!.status : entry.status,
363
+ allowedReadPath: scanRefresh ? existing!.allowedReadPath : entry.allowedReadPath,
364
+ extraction: scanRefresh ? existing!.extraction : entry.extraction,
365
+ firstSeen: existing?.firstSeen ?? entry.firstSeen,
366
+ review: scanRefresh ? existing!.review : entry.review === undefined ? existing?.review : entry.review,
367
+ }
368
+ const sources = [...registry.sources]
369
+ if (existingIndex >= 0) sources[existingIndex] = next
370
+ else sources.push(next)
371
+ return { version: 1, updatedAt: new Date().toISOString(), sources: sources.sort((a, b) => a.sourcePath.localeCompare(b.sourcePath)) }
372
+ }
373
+
374
+ function ingestTask(entry: MaterialRegistryEntry): MaterialIngestTask {
375
+ return {
376
+ path: entry.sourcePath,
377
+ materialType: entry.type,
378
+ needsExtraction: entry.requiresExtraction,
379
+ suggestedAction: entry.requiresExtraction ? "extract_then_read" : "read_directly",
380
+ status: entry.status,
381
+ allowedReadPath: entry.allowedReadPath ?? null,
382
+ note: entry.requiresExtraction
383
+ ? "Read the extracted read_view_path after Revela extraction; do not read the original Office/PDF source for narrative intake."
384
+ : "Read directly when relevant and record narrative meaning only after source content is actually inspected.",
385
+ }
386
+ }
387
+
388
+ function extractionWarnings(result: DocumentMaterialsResult, unsupported: boolean): string[] {
389
+ if (unsupported) return ["This source type is discovered but not supported for Revela extraction in v1."]
390
+ if (result.status === "failed") return [`Extraction failed: ${result.reason ?? "unknown error"}`]
391
+ if (result.status === "skipped") return [`Extraction skipped: ${result.reason ?? "unsupported file type"}`]
392
+ return []
393
+ }
394
+
395
+ function intakeWarnings(sources: MaterialRegistryEntry[]): string[] {
396
+ const warnings: string[] = []
397
+ for (const source of sources) {
398
+ if (!source.requiresExtraction) continue
399
+ if (source.status === "scanned") warnings.push(`${source.sourcePath} was scanned but not extracted through Revela material extraction.`)
400
+ else if (source.status === "extracted") warnings.push(`${source.sourcePath} was extracted but has no recorded material review.`)
401
+ else if (source.status === "text_only_read") warnings.push(`${source.sourcePath} was read as text-only; embedded images or structure may not have been considered.`)
402
+ else if (source.status === "unsupported") warnings.push(`${source.sourcePath} is not supported for extraction; convert it to a supported format such as .docx/.pptx/.xlsx when needed.`)
403
+ else if (source.status === "failed") warnings.push(`${source.sourcePath} extraction failed and should not be treated as complete intake.`)
404
+ }
405
+ return warnings
406
+ }
407
+
408
+ function recommendedAction(source: MaterialRegistryEntry, strictness: "authoring" | "readiness" | "render"): string | undefined {
409
+ if (!source.requiresExtraction) return undefined
410
+ if (source.status === "scanned") return "Call `revela_extract_document_materials`, then read the returned `read_view_path`."
411
+ if (source.status === "extracted") return strictness === "authoring"
412
+ ? "Read `allowedReadPath`, then call `revela_record_material_review`."
413
+ : "Record material review before treating this source as considered for narrative readiness."
414
+ if (source.status === "text_only_read") return "Use Revela extraction and review before treating this source as complete intake."
415
+ if (source.status === "unsupported") return "Convert to a supported format or keep the source as an explicit intake gap."
416
+ if (source.status === "failed") return "Fix extraction failure or record the source as an intake gap."
417
+ return undefined
418
+ }
419
+
420
+ function writeReviewMarkdown(
421
+ workspaceRoot: string,
422
+ input: {
423
+ sourcePath: string
424
+ fingerprint?: string
425
+ extraction: MaterialRegistryEntry["extraction"]
426
+ reviewedPaths: string[]
427
+ reviewSummary: string
428
+ narrativeDecisions: RecordMaterialReviewInput["narrativeDecisions"]
429
+ },
430
+ ): string {
431
+ const dir = join(workspaceRoot, "researches", "local-materials")
432
+ mkdirSync(dir, { recursive: true })
433
+ const fileName = `${slugify(input.sourcePath)}-review.md`
434
+ const path = join(dir, fileName)
435
+ const lines = [
436
+ "---",
437
+ "type: local-material-review",
438
+ `sourcePath: ${JSON.stringify(input.sourcePath)}`,
439
+ input.fingerprint ? `fingerprint: ${JSON.stringify(input.fingerprint)}` : undefined,
440
+ input.extraction?.manifestPath ? `extractionManifestPath: ${JSON.stringify(input.extraction.manifestPath)}` : undefined,
441
+ input.extraction?.textPath ? `extractionTextPath: ${JSON.stringify(input.extraction.textPath)}` : undefined,
442
+ input.extraction?.readViewPath ? `readViewPath: ${JSON.stringify(input.extraction.readViewPath)}` : undefined,
443
+ `reviewedAt: ${JSON.stringify(new Date().toISOString())}`,
444
+ "status: reviewed",
445
+ "---",
446
+ "",
447
+ "# Local Material Review",
448
+ "",
449
+ "## Review Summary",
450
+ "",
451
+ input.reviewSummary.trim(),
452
+ "",
453
+ "## Reviewed Paths",
454
+ "",
455
+ ...input.reviewedPaths.map((path) => `- ${path}`),
456
+ "",
457
+ "## Narrative Decisions",
458
+ "",
459
+ ...input.narrativeDecisions.map((decision) => `- ${decision.kind}${decision.target ? `: ${decision.target}` : ""} - ${decision.rationale}`),
460
+ "",
461
+ "## Extracted Images",
462
+ "",
463
+ input.extraction?.imageCount ? `- ${input.extraction.imageCount} extracted image(s); do not treat as interpreted evidence without explicit image review.` : "- None recorded.",
464
+ "",
465
+ ].filter((line): line is string => line !== undefined)
466
+ writeFileSync(path, lines.join("\n"), "utf-8")
467
+ return workspaceRelative(path, workspaceRoot)
468
+ }
469
+
470
+ function scanRootFor(workspaceRoot: string, path?: string): string {
471
+ if (!path) return workspaceRoot
472
+ if (isAbsolute(path)) throw new Error("path must be relative to workspace root")
473
+ const candidate = resolve(workspaceRoot, path)
474
+ const resolvedWorkspace = resolve(workspaceRoot)
475
+ if (candidate !== resolvedWorkspace && !candidate.startsWith(resolvedWorkspace + sep)) throw new Error("path must be within workspace")
476
+ return candidate
477
+ }
478
+
479
+ function root(workspaceRoot?: string): string {
480
+ return resolve(workspaceRoot || process.cwd())
481
+ }
482
+
483
+ function workspaceRelative(path: string, workspaceRoot: string): string {
484
+ return relative(resolve(workspaceRoot), resolve(path)).replace(/\\/g, "/")
485
+ }
486
+
487
+ function normalizePath(path: string): string {
488
+ return path.replace(/\\/g, "/")
489
+ }
490
+
491
+ function slugify(value: string): string {
492
+ const base = basename(value).replace(/\.[^.]+$/, "")
493
+ return base.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "") || "material"
494
+ }
@@ -6,6 +6,9 @@ import {
6
6
  activateDesign,
7
7
  createDesignDraftPackage,
8
8
  createDesignPackage,
9
+ getDesignComponent,
10
+ getDesignInventory,
11
+ getDesignLayout,
9
12
  getDesignSection,
10
13
  getDesignSkillMd,
11
14
  installDesignDraftPackage,
@@ -25,10 +28,12 @@ import { formatArtifactQaUserNotice, formatMarkdownQaUserNotice } from "../hook-
25
28
  import { readDeckPlanArtifact } from "../narrative-state/deck-plan-artifact"
26
29
  import { extractDesignClasses } from "../design/designs"
27
30
  import { recordRenderedArtifact, workspaceRelative } from "../workspace-state/rendered-artifacts"
31
+ import { checkMaterialIntake, extractMaterial, materialIntakeNoticeForCommand, prepareLocalMaterials, recordMaterialReview } from "../material-intake"
28
32
  import type { ReviewDeckOpenInput, ReviewDeckReadInput } from "./review"
29
33
  import pkg from "../../package.json"
30
34
  export { bindResearchFindings, evaluateResearchFindings, researchSave, researchTargets } from "./research"
31
35
  export { storyRead } from "./story"
36
+ export { checkMaterialIntake, extractMaterial, materialIntakeNoticeForCommand, prepareLocalMaterials, recordMaterialReview }
32
37
 
33
38
  export interface RuntimeWorkspaceInput {
34
39
  workspaceRoot?: string
@@ -57,6 +62,20 @@ export interface RuntimeDesignReadInput {
57
62
  section?: string
58
63
  }
59
64
 
65
+ export interface RuntimeDesignInventoryInput {
66
+ name?: string
67
+ }
68
+
69
+ export interface RuntimeDesignLayoutReadInput {
70
+ name?: string
71
+ layout: string | string[]
72
+ }
73
+
74
+ export interface RuntimeDesignComponentReadInput {
75
+ name?: string
76
+ component: string | string[]
77
+ }
78
+
60
79
  export interface RuntimeDesignCreateInput {
61
80
  name: string
62
81
  base?: string
@@ -210,7 +229,6 @@ export async function reviewDeckOpen(input: ReviewDeckOpenInput) {
210
229
  }
211
230
 
212
231
  export function designList() {
213
- seedBuiltinDesigns()
214
232
  return {
215
233
  ok: true,
216
234
  activeDesign: activeDesign(),
@@ -224,7 +242,6 @@ export function designList() {
224
242
  }
225
243
 
226
244
  export function designRead(input: RuntimeDesignReadInput = {}) {
227
- seedBuiltinDesigns()
228
245
  const name = input.name || activeDesign()
229
246
  if (input.section) {
230
247
  const markdown = getDesignSection(input.section, name)
@@ -244,6 +261,33 @@ export function designRead(input: RuntimeDesignReadInput = {}) {
244
261
  }
245
262
  }
246
263
 
264
+ export function designInventory(input: RuntimeDesignInventoryInput = {}) {
265
+ return {
266
+ ok: true,
267
+ ...getDesignInventory(input.name || activeDesign()),
268
+ }
269
+ }
270
+
271
+ export function designReadLayout(input: RuntimeDesignLayoutReadInput) {
272
+ const name = input.name || activeDesign()
273
+ return {
274
+ ok: true,
275
+ name,
276
+ layout: input.layout,
277
+ markdown: getDesignLayout(input.layout, name),
278
+ }
279
+ }
280
+
281
+ export function designReadComponent(input: RuntimeDesignComponentReadInput) {
282
+ const name = input.name || activeDesign()
283
+ return {
284
+ ok: true,
285
+ name,
286
+ component: input.component,
287
+ markdown: getDesignComponent(input.component, name),
288
+ }
289
+ }
290
+
247
291
  export function designCreate(input: RuntimeDesignCreateInput) {
248
292
  seedBuiltinDesigns()
249
293
  return createDesignPackage({
@@ -256,7 +300,6 @@ export function designCreate(input: RuntimeDesignCreateInput) {
256
300
  }
257
301
 
258
302
  export function designValidate(input: RuntimeNameInput) {
259
- seedBuiltinDesigns()
260
303
  return validateDesignPackage(requiredName(input, "design"))
261
304
  }
262
305
 
@@ -294,7 +337,6 @@ export interface DesignRulesReadinessResult {
294
337
  const DESIGN_RULES_MARKER_TTL_MS = 8 * 60 * 60 * 1000
295
338
 
296
339
  export function checkDesignRulesReadiness(input: RuntimeWorkspaceInput = {}): DesignRulesReadinessResult {
297
- seedBuiltinDesigns()
298
340
  const workspaceRoot = root(input.workspaceRoot)
299
341
  const design = activeDesign()
300
342
  const rules = getDesignSection("rules", design)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cyber-dash-tech/revela",
3
- "version": "0.17.21",
3
+ "version": "0.17.23",
4
4
  "description": "OpenCode plugin for trusted narrative artifacts from local sources, research, and evidence",
5
5
  "type": "module",
6
6
  "main": "./index.ts",
@@ -2,7 +2,7 @@
2
2
  "mcpServers": {
3
3
  "revela": {
4
4
  "command": "npx",
5
- "args": ["-y", "@cyber-dash-tech/revela@0.17.21", "mcp"]
5
+ "args": ["-y", "@cyber-dash-tech/revela@0.17.23", "mcp"]
6
6
  }
7
7
  }
8
8
  }
@@ -1,6 +1,16 @@
1
1
  {
2
2
  "hooks": {
3
3
  "PreToolUse": [
4
+ {
5
+ "matcher": "exec_command",
6
+ "hooks": [
7
+ {
8
+ "type": "command",
9
+ "command": "bun ${PLUGIN_ROOT}/hooks/revela_material_notice.ts",
10
+ "statusMessage": "Checking Revela material intake"
11
+ }
12
+ ]
13
+ },
4
14
  {
5
15
  "matcher": "apply_patch",
6
16
  "hooks": [
@@ -0,0 +1,58 @@
1
+ import { dirname, resolve } from "path"
2
+ import { fileURLToPath, pathToFileURL } from "url"
3
+ import { resolveRevelaRuntime } from "../mcp/runtime-resolver"
4
+ import { workspaceRootFromInput } from "./revela_post_write_notice"
5
+
6
+ export interface MaterialNoticeResult {
7
+ ok: true
8
+ messages: string[]
9
+ }
10
+
11
+ export async function runMaterialReadNotice(input: string): Promise<MaterialNoticeResult> {
12
+ const command = commandFromInput(input)
13
+ if (!command) return { ok: true, messages: [] }
14
+
15
+ const pluginRoot = resolve(process.env.PLUGIN_ROOT || dirname(dirname(fileURLToPath(import.meta.url))))
16
+ const runtime = resolveRevelaRuntime({ pluginRoot })
17
+ if (!runtime.ok || !runtime.runtimePath) return { ok: true, messages: [] }
18
+
19
+ const workspaceRoot = workspaceRootFromInput(input)
20
+ const runtimeModule = await import(pathToFileURL(runtime.runtimePath).href)
21
+ const notice = runtimeModule.materialIntakeNoticeForCommand?.({ workspaceRoot, command })
22
+ return { ok: true, messages: notice ? [notice] : [] }
23
+ }
24
+
25
+ export function commandFromInput(input: string): string | null {
26
+ try {
27
+ const parsed = JSON.parse(input)
28
+ const candidates = [
29
+ parsed.cmd,
30
+ parsed.command,
31
+ parsed.args?.cmd,
32
+ parsed.args?.command,
33
+ parsed.tool_input?.cmd,
34
+ parsed.tool_input?.command,
35
+ parsed.toolInput?.cmd,
36
+ parsed.toolInput?.command,
37
+ ]
38
+ for (const candidate of candidates) {
39
+ if (typeof candidate === "string" && candidate.trim()) return candidate
40
+ }
41
+ return null
42
+ } catch {
43
+ return input.trim() || null
44
+ }
45
+ }
46
+
47
+ if (import.meta.main) {
48
+ const input = await new Response(Bun.stdin.stream()).text()
49
+ try {
50
+ const result = await runMaterialReadNotice(input)
51
+ if (result.messages.length > 0) console.error(result.messages.join("\n\n---\n\n"))
52
+ process.exit(0)
53
+ } catch (e) {
54
+ console.error("Revela material intake notice failed to run.")
55
+ console.error(e instanceof Error ? e.message : String(e))
56
+ process.exit(0)
57
+ }
58
+ }