@cyber-dash-tech/revela 0.1.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 (42) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +239 -0
  3. package/README.zh-CN.md +270 -0
  4. package/designs/default/DESIGN.md +1100 -0
  5. package/designs/editorial-ribbon/DESIGN.md +1092 -0
  6. package/designs/minimal/DESIGN.md +1079 -0
  7. package/domains/consulting/INDUSTRY.md +230 -0
  8. package/domains/deeptech-investment/INDUSTRY.md +160 -0
  9. package/domains/general/INDUSTRY.md +6 -0
  10. package/index.ts +1 -0
  11. package/lib/agents/research-prompt.ts +129 -0
  12. package/lib/commands/designs.ts +59 -0
  13. package/lib/commands/disable.ts +14 -0
  14. package/lib/commands/domains.ts +59 -0
  15. package/lib/commands/enable.ts +48 -0
  16. package/lib/commands/help.ts +35 -0
  17. package/lib/config.ts +65 -0
  18. package/lib/ctx.ts +27 -0
  19. package/lib/design/designs.ts +389 -0
  20. package/lib/domain/domains.ts +258 -0
  21. package/lib/frontmatter.ts +63 -0
  22. package/lib/log.ts +35 -0
  23. package/lib/prompt-builder.ts +194 -0
  24. package/lib/qa/checks.ts +594 -0
  25. package/lib/qa/index.ts +38 -0
  26. package/lib/qa/measure.ts +287 -0
  27. package/lib/read-hooks/extractors/docx.ts +16 -0
  28. package/lib/read-hooks/extractors/pdf.ts +19 -0
  29. package/lib/read-hooks/extractors/pptx.ts +53 -0
  30. package/lib/read-hooks/extractors/xlsx.ts +81 -0
  31. package/lib/read-hooks/image/compress.ts +36 -0
  32. package/lib/read-hooks/index.ts +12 -0
  33. package/lib/read-hooks/post-read.ts +74 -0
  34. package/lib/read-hooks/pre-read.ts +51 -0
  35. package/package.json +65 -0
  36. package/plugin.ts +365 -0
  37. package/skill/SKILL.md +676 -0
  38. package/tools/designs.ts +126 -0
  39. package/tools/domains.ts +73 -0
  40. package/tools/qa.ts +61 -0
  41. package/tools/research-save.ts +96 -0
  42. package/tools/workspace-scan.ts +154 -0
@@ -0,0 +1,126 @@
1
+ import { tool } from "@opencode-ai/plugin"
2
+ import {
3
+ listDesigns,
4
+ activeDesign,
5
+ activateDesign,
6
+ installDesign,
7
+ removeDesign,
8
+ parseDesignSections,
9
+ generateComponentIndex,
10
+ getDesignSection,
11
+ getDesignComponent,
12
+ } from "../lib/design/designs"
13
+ import { buildPrompt } from "../lib/prompt-builder"
14
+ import { existsSync, readFileSync } from "fs"
15
+ import { join } from "path"
16
+ import { DESIGNS_DIR } from "../lib/config"
17
+ import { parseFrontmatter } from "../lib/frontmatter"
18
+
19
+ export default tool({
20
+ description:
21
+ "Manage revela visual design templates. " +
22
+ "Use action 'list' to show all installed designs with names and descriptions. " +
23
+ "Use action 'activate' to switch to a different design (requires name). " +
24
+ "Use action 'install' to add a new design from a URL, local path, or github:user/repo shorthand (requires source). " +
25
+ "Use action 'remove' to uninstall a design (requires name). " +
26
+ "Use action 'read' to fetch on-demand design content: pass component (comma-separated names) to get full CSS/HTML for specific components, or section ('charts' | 'guide' | 'global' | 'layouts' | 'components') to get an entire section. Pass neither to get the Component Index table. " +
27
+ "After activating a new design, the system prompt is automatically rebuilt.",
28
+ args: {
29
+ action: tool.schema
30
+ .enum(["list", "activate", "install", "remove", "read"])
31
+ .describe("Operation to perform"),
32
+ name: tool.schema
33
+ .string()
34
+ .optional()
35
+ .describe("Design name — required for activate and remove; optional for read (defaults to active design)"),
36
+ source: tool.schema
37
+ .string()
38
+ .optional()
39
+ .describe(
40
+ "Install source — URL, local path, github:user/repo. Required for install."
41
+ ),
42
+ component: tool.schema
43
+ .string()
44
+ .optional()
45
+ .describe(
46
+ "For action 'read': comma-separated component name(s) to fetch (e.g. 'card', 'card,stat-card')"
47
+ ),
48
+ section: tool.schema
49
+ .string()
50
+ .optional()
51
+ .describe(
52
+ "For action 'read': section name to fetch — 'charts', 'guide', 'global', 'layouts', or 'components'"
53
+ ),
54
+ },
55
+ async execute(args) {
56
+ try {
57
+ switch (args.action) {
58
+ case "list": {
59
+ const designs = listDesigns()
60
+ const current = activeDesign()
61
+ return JSON.stringify(
62
+ designs.map((d) => ({
63
+ name: d.name,
64
+ description: d.description,
65
+ author: d.author,
66
+ version: d.version,
67
+ active: d.name === current,
68
+ })),
69
+ null,
70
+ 2,
71
+ )
72
+ }
73
+ case "activate": {
74
+ if (!args.name) return JSON.stringify({ error: "name is required for activate" })
75
+ activateDesign(args.name)
76
+ buildPrompt()
77
+ return JSON.stringify({ ok: true })
78
+ }
79
+ case "install": {
80
+ if (!args.source) return JSON.stringify({ error: "source is required for install" })
81
+ const installed = await installDesign(args.source, args.name)
82
+ return JSON.stringify({ ok: true, name: installed })
83
+ }
84
+ case "remove": {
85
+ if (!args.name) return JSON.stringify({ error: "name is required for remove" })
86
+ removeDesign(args.name)
87
+ return JSON.stringify({ ok: true })
88
+ }
89
+ case "read": {
90
+ const designName = args.name || activeDesign()
91
+
92
+ // Read raw body to check for markers
93
+ const mdPath = join(DESIGNS_DIR, designName, "DESIGN.md")
94
+ if (!existsSync(mdPath)) {
95
+ return JSON.stringify({ error: `Design '${designName}' is not installed` })
96
+ }
97
+ const raw = readFileSync(mdPath, "utf-8")
98
+ const { body } = parseFrontmatter(raw)
99
+ const { components, hasMarkers } = parseDesignSections(body)
100
+
101
+ if (!hasMarkers) {
102
+ // No markers — return full body
103
+ return body
104
+ }
105
+
106
+ // Specific component(s) requested
107
+ if (args.component) {
108
+ return getDesignComponent(args.component, designName)
109
+ }
110
+
111
+ // Specific section requested
112
+ if (args.section) {
113
+ return getDesignSection(args.section, designName)
114
+ }
115
+
116
+ // Default: return Component Index
117
+ return generateComponentIndex(components)
118
+ }
119
+ default:
120
+ return JSON.stringify({ error: `Unknown action: ${args.action}` })
121
+ }
122
+ } catch (e: any) {
123
+ return JSON.stringify({ error: e.message || String(e) })
124
+ }
125
+ },
126
+ })
@@ -0,0 +1,73 @@
1
+ import { tool } from "@opencode-ai/plugin"
2
+ import {
3
+ listDomains,
4
+ activeDomain,
5
+ activateDomain,
6
+ installDomain,
7
+ removeDomain,
8
+ } from "../lib/domain/domains"
9
+ import { buildPrompt } from "../lib/prompt-builder"
10
+
11
+ export default tool({
12
+ description:
13
+ "Manage revela domain definitions (industry/topic specializations). " +
14
+ "Use action 'list' to show all installed domains with names and descriptions. " +
15
+ "Use action 'activate' to switch to a different domain (requires name). " +
16
+ "Use action 'install' to add a new domain from a URL, local path, or github:user/repo shorthand (requires source). " +
17
+ "Use action 'remove' to uninstall a domain — 'general' cannot be removed (requires name). " +
18
+ "After activating a new domain, the system prompt is automatically rebuilt.",
19
+ args: {
20
+ action: tool.schema
21
+ .enum(["list", "activate", "install", "remove"])
22
+ .describe("Operation to perform"),
23
+ name: tool.schema
24
+ .string()
25
+ .optional()
26
+ .describe("Domain name — required for activate and remove"),
27
+ source: tool.schema
28
+ .string()
29
+ .optional()
30
+ .describe("Install source — URL, local path, github:user/repo. Required for install."),
31
+ },
32
+ async execute(args) {
33
+ try {
34
+ switch (args.action) {
35
+ case "list": {
36
+ const domains = listDomains()
37
+ const current = activeDomain()
38
+ return JSON.stringify(
39
+ domains.map((d) => ({
40
+ name: d.name,
41
+ description: d.description,
42
+ author: d.author,
43
+ version: d.version,
44
+ active: d.name === current,
45
+ })),
46
+ null,
47
+ 2,
48
+ )
49
+ }
50
+ case "activate": {
51
+ if (!args.name) return JSON.stringify({ error: "name is required for activate" })
52
+ activateDomain(args.name)
53
+ buildPrompt()
54
+ return JSON.stringify({ ok: true })
55
+ }
56
+ case "install": {
57
+ if (!args.source) return JSON.stringify({ error: "source is required for install" })
58
+ const installed = await installDomain(args.source, args.name)
59
+ return JSON.stringify({ ok: true, name: installed })
60
+ }
61
+ case "remove": {
62
+ if (!args.name) return JSON.stringify({ error: "name is required for remove" })
63
+ removeDomain(args.name)
64
+ return JSON.stringify({ ok: true })
65
+ }
66
+ default:
67
+ return JSON.stringify({ error: `Unknown action: ${args.action}` })
68
+ }
69
+ } catch (e: any) {
70
+ return JSON.stringify({ error: e.message || String(e) })
71
+ }
72
+ },
73
+ })
package/tools/qa.ts ADDED
@@ -0,0 +1,61 @@
1
+ /**
2
+ * tools/qa.ts
3
+ *
4
+ * revela-qa — Layout quality assurance tool for generated slide HTML files.
5
+ *
6
+ * Exposed to the LLM so it can run layout checks after writing a slides file.
7
+ * Also called automatically by the tool.execute.after hook in plugin.ts
8
+ * when the LLM writes a file matching slides/*.html.
9
+ */
10
+
11
+ import { tool } from "@opencode-ai/plugin"
12
+ import { resolve } from "path"
13
+ import { existsSync } from "fs"
14
+ import { runQA, formatReport } from "../lib/qa"
15
+
16
+ export default tool({
17
+ description:
18
+ "Run layout quality checks on a generated slide HTML file. " +
19
+ "Opens the file in a headless browser and measures actual rendered geometry. " +
20
+ "Checks for: canvas underfill (too much empty space), bottom whitespace, " +
21
+ "left-right column asymmetry, element overflow, and card height variance. " +
22
+ "Returns a structured report with specific issues and fix instructions. " +
23
+ "Call this after writing or editing any slides/*.html file to verify layout quality.",
24
+ args: {
25
+ file: tool.schema
26
+ .string()
27
+ .describe(
28
+ "Path to the HTML slide file to check. " +
29
+ "Can be absolute or relative to the current working directory."
30
+ ),
31
+ },
32
+ async execute({ file }, { directory }) {
33
+ // Resolve path relative to working directory
34
+ const filePath = resolve(directory || process.cwd(), file)
35
+
36
+ if (!existsSync(filePath)) {
37
+ return `Error: File not found: ${filePath}`
38
+ }
39
+
40
+ if (!filePath.endsWith(".html")) {
41
+ return `Error: File must be an HTML file: ${filePath}`
42
+ }
43
+
44
+ try {
45
+ const report = await runQA(filePath)
46
+ const formatted = formatReport(report)
47
+
48
+ // Prepend a compact JSON summary for programmatic use if needed
49
+ const jsonSummary = JSON.stringify({
50
+ totalIssues: report.totalIssues,
51
+ errors: report.errorCount,
52
+ warnings: report.warningCount,
53
+ slidesWithIssues: report.slides.filter((s) => s.issues.length > 0).map((s) => s.index + 1),
54
+ })
55
+
56
+ return `<!-- QA Summary: ${jsonSummary} -->\n\n${formatted}`
57
+ } catch (err: any) {
58
+ return `Error running layout QA: ${err?.message ?? String(err)}\n\nMake sure Chrome is installed at /Applications/Google Chrome.app`
59
+ }
60
+ },
61
+ })
@@ -0,0 +1,96 @@
1
+ import { tool } from "@opencode-ai/plugin"
2
+ import { mkdirSync, writeFileSync } from "fs"
3
+ import { join } from "path"
4
+
5
+ /**
6
+ * Format today's date as YYYY-MM-DD
7
+ */
8
+ function today(): string {
9
+ return new Date().toISOString().slice(0, 10)
10
+ }
11
+
12
+ /**
13
+ * Sanitize a slug: lowercase, alphanumeric + hyphens only.
14
+ */
15
+ function slugify(s: string): string {
16
+ return s
17
+ .toLowerCase()
18
+ .replace(/[^a-z0-9]+/g, "-")
19
+ .replace(/^-+|-+$/g, "")
20
+ }
21
+
22
+ /**
23
+ * Build YAML frontmatter string.
24
+ */
25
+ function buildFrontmatter(topic: string, axis: string, sources: string[]): string {
26
+ const lines = [
27
+ "---",
28
+ `topic: ${topic}`,
29
+ `axis: ${axis}`,
30
+ `date: ${today()}`,
31
+ ]
32
+ if (sources.length > 0) {
33
+ lines.push("sources:")
34
+ for (const s of sources) {
35
+ lines.push(` - "${s.replace(/"/g, '\\"')}"`)
36
+ }
37
+ }
38
+ lines.push("---")
39
+ return lines.join("\n")
40
+ }
41
+
42
+ export default tool({
43
+ description:
44
+ "Save a research findings file to the workspace researches/ directory. " +
45
+ "Creates researches/{topic}/{filename}.md with YAML frontmatter. " +
46
+ "Each research axis gets its own file (e.g. 'market-data', 'catl-profile'). " +
47
+ "Content should use ## Data / ## Cases / ## Images / ## Gaps sections.",
48
+ args: {
49
+ topic: tool.schema
50
+ .string()
51
+ .describe(
52
+ "Topic slug in kebab-case, e.g. 'ev-battery-market' or 'saas-competitive-analysis'. " +
53
+ "All files for the same presentation share the same topic slug.",
54
+ ),
55
+ filename: tool.schema
56
+ .string()
57
+ .describe(
58
+ "Axis name without extension, e.g. 'market-data', 'catl-profile', 'tech-trends'. " +
59
+ "Each parallel research agent uses a unique axis name.",
60
+ ),
61
+ content: tool.schema
62
+ .string()
63
+ .describe(
64
+ "Structured markdown findings. Use these sections (omit empty ones):\n" +
65
+ "## Data — key stats and data points, each with [Source: url]\n" +
66
+ "## Cases — company/entity profiles, 1-2 sentences each with [Source: url]\n" +
67
+ "## Images — image URLs: '{description}: {url} | Alt: {text} | Use: logo|screenshot|portrait'\n" +
68
+ "## Gaps — topics not found or insufficiently covered",
69
+ ),
70
+ sources: tool.schema
71
+ .array(tool.schema.string())
72
+ .optional()
73
+ .describe("Source URLs or filenames for YAML frontmatter, e.g. ['https://example.com/report', 'data.xlsx']"),
74
+ },
75
+ async execute(args, context) {
76
+ try {
77
+ const topicSlug = slugify(args.topic || "research")
78
+ const fileSlug = slugify(args.filename || "findings")
79
+ const workspaceDir = context.directory ?? process.cwd()
80
+ const topicDir = join(workspaceDir, "researches", topicSlug)
81
+
82
+ mkdirSync(topicDir, { recursive: true })
83
+
84
+ const frontmatter = buildFrontmatter(args.topic, fileSlug, args.sources ?? [])
85
+ const fileContent = `${frontmatter}\n\n${args.content ?? ""}\n`
86
+ const filePath = join(topicDir, `${fileSlug}.md`)
87
+ const relPath = `researches/${topicSlug}/${fileSlug}.md`
88
+
89
+ writeFileSync(filePath, fileContent, "utf-8")
90
+
91
+ return JSON.stringify({ ok: true, path: relPath })
92
+ } catch (e: any) {
93
+ return JSON.stringify({ error: e.message || String(e) })
94
+ }
95
+ },
96
+ })
@@ -0,0 +1,154 @@
1
+ import { tool } from "@opencode-ai/plugin"
2
+ import { readdirSync, statSync, existsSync } from "fs"
3
+ import { join, relative, extname } from "path"
4
+
5
+ const DOC_EXTENSIONS = new Set([
6
+ ".pdf", ".docx", ".doc", ".xlsx", ".xls",
7
+ ".pptx", ".ppt", ".csv", ".md", ".txt",
8
+ ])
9
+
10
+ // Directories to exclude from scanning
11
+ const EXCLUDE_DIRS = new Set([
12
+ "node_modules", ".git", "dist", ".opencode",
13
+ "researches", // Exclude revela's own research output
14
+ "designs", "domains", // Exclude revela plugin assets
15
+ ])
16
+
17
+ type FileEntry = {
18
+ path: string
19
+ type: string
20
+ size: string
21
+ }
22
+
23
+ /**
24
+ * Format bytes into a human-readable size string.
25
+ */
26
+ function formatSize(bytes: number): string {
27
+ if (bytes < 1024) return `${bytes} B`
28
+ if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`
29
+ return `${(bytes / (1024 * 1024)).toFixed(1)} MB`
30
+ }
31
+
32
+ /**
33
+ * Map extension to a friendly type label.
34
+ */
35
+ function typeLabel(ext: string): string {
36
+ const map: Record<string, string> = {
37
+ ".pdf": "PDF",
38
+ ".docx": "Word",
39
+ ".doc": "Word",
40
+ ".xlsx": "Excel",
41
+ ".xls": "Excel",
42
+ ".pptx": "PowerPoint",
43
+ ".ppt": "PowerPoint",
44
+ ".csv": "CSV",
45
+ ".md": "Markdown",
46
+ ".txt": "Text",
47
+ }
48
+ return map[ext] ?? ext.slice(1).toUpperCase()
49
+ }
50
+
51
+ /**
52
+ * Recursively scan a directory for document files.
53
+ */
54
+ function scanDir(dir: string, rootDir: string, results: FileEntry[], maxDepth: number, depth: number): void {
55
+ if (depth > maxDepth) return
56
+ if (!existsSync(dir)) return
57
+
58
+ let entries: string[]
59
+ try {
60
+ entries = readdirSync(dir)
61
+ } catch {
62
+ return
63
+ }
64
+
65
+ for (const entry of entries) {
66
+ // Skip hidden files/dirs and excluded dirs
67
+ if (entry.startsWith(".")) continue
68
+ if (EXCLUDE_DIRS.has(entry)) continue
69
+
70
+ const fullPath = join(dir, entry)
71
+ let stat
72
+ try {
73
+ stat = statSync(fullPath)
74
+ } catch {
75
+ continue
76
+ }
77
+
78
+ if (stat.isDirectory()) {
79
+ scanDir(fullPath, rootDir, results, maxDepth, depth + 1)
80
+ } else if (stat.isFile()) {
81
+ const ext = extname(entry).toLowerCase()
82
+ if (DOC_EXTENSIONS.has(ext)) {
83
+ results.push({
84
+ path: relative(rootDir, fullPath),
85
+ type: typeLabel(ext),
86
+ size: formatSize(stat.size),
87
+ })
88
+ }
89
+ }
90
+ }
91
+ }
92
+
93
+ export default tool({
94
+ description:
95
+ "Scan the current workspace for document and data files that can be used as research input. " +
96
+ "Returns a structured list of all found files with their type and size. " +
97
+ "Searches for: PDF, Word (docx/doc), Excel (xlsx/xls), PowerPoint (pptx/ppt), CSV, Markdown, and text files. " +
98
+ "Excludes node_modules, .git, dist, and the researches/ output directory. " +
99
+ "Use this as the first step before reading workspace documents.",
100
+ args: {
101
+ path: tool.schema
102
+ .string()
103
+ .optional()
104
+ .describe(
105
+ "Optional subdirectory to scan (relative to workspace root). " +
106
+ "If omitted, scans the entire workspace.",
107
+ ),
108
+ max_depth: tool.schema
109
+ .number()
110
+ .optional()
111
+ .describe("Maximum directory depth to recurse. Defaults to 6."),
112
+ },
113
+ async execute(args, context) {
114
+ try {
115
+ const workspaceDir = context.directory ?? process.cwd()
116
+ const scanRoot = args.path ? join(workspaceDir, args.path) : workspaceDir
117
+ const maxDepth = args.max_depth ?? 6
118
+
119
+ if (!existsSync(scanRoot)) {
120
+ return JSON.stringify({ error: `Path not found: ${args.path}` })
121
+ }
122
+
123
+ const results: FileEntry[] = []
124
+ scanDir(scanRoot, workspaceDir, results, maxDepth, 0)
125
+
126
+ if (results.length === 0) {
127
+ return JSON.stringify({
128
+ found: 0,
129
+ message: "No document files found in workspace.",
130
+ files: [],
131
+ })
132
+ }
133
+
134
+ // Sort by type then path for readability
135
+ results.sort((a, b) => a.type.localeCompare(b.type) || a.path.localeCompare(b.path))
136
+
137
+ // Build markdown table
138
+ const tableRows = results.map((f) => `| ${f.path} | ${f.type} | ${f.size} |`)
139
+ const table = [
140
+ "| File | Type | Size |",
141
+ "|------|------|------|",
142
+ ...tableRows,
143
+ ].join("\n")
144
+
145
+ return JSON.stringify({
146
+ found: results.length,
147
+ table,
148
+ files: results,
149
+ })
150
+ } catch (e: any) {
151
+ return JSON.stringify({ error: e.message || String(e) })
152
+ }
153
+ },
154
+ })