@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,258 @@
1
+ /**
2
+ * DomainManager — manage revela domain definitions (formerly "industries").
3
+ *
4
+ * Domains are stored in ~/.config/revela/domains/<name>/.
5
+ * Each domain directory contains DOMAIN.md (required).
6
+ *
7
+ * Built-in domains are shipped with the npm package under domains/ and seeded
8
+ * to the config directory on first run.
9
+ *
10
+ * NOTE: For backward compatibility, the .md files inside each domain directory
11
+ * are still named INDUSTRY.md. The config key `activeIndustry` is used as
12
+ * fallback for `activeDomain`.
13
+ */
14
+
15
+ import {
16
+ cpSync,
17
+ existsSync,
18
+ mkdirSync,
19
+ readdirSync,
20
+ readFileSync,
21
+ rmSync,
22
+ statSync,
23
+ writeFileSync,
24
+ } from "fs"
25
+ import { join, resolve, basename } from "path"
26
+ import { tmpdir } from "os"
27
+ import { parseFrontmatter } from "../frontmatter"
28
+ import {
29
+ DOMAINS_DIR,
30
+ DEFAULT_DOMAIN,
31
+ loadConfig,
32
+ saveConfig,
33
+ } from "../config"
34
+ import { childLog } from "../log"
35
+
36
+ const domainLog = childLog("domains")
37
+
38
+ // Seed directory: built-in domains shipped with this package.
39
+ const SEED_DIR = resolve(__dirname, "../..", "domains")
40
+
41
+ /** The markdown filename inside each domain directory. */
42
+ const DOMAIN_FILE = "INDUSTRY.md"
43
+
44
+ export interface DomainInfo {
45
+ name: string
46
+ description: string
47
+ author: string
48
+ version: string
49
+ skillText: string
50
+ }
51
+
52
+ // ---------------------------------------------------------------------------
53
+ // Seed
54
+ // ---------------------------------------------------------------------------
55
+
56
+ /**
57
+ * Copy built-in domains from the package to ~/.config/revela/domains/.
58
+ * Always overwrites to keep bundled domains up to date.
59
+ * User-created domains (not in the seed directory) are never touched.
60
+ */
61
+ export function seedBuiltinDomains(): void {
62
+ if (!existsSync(SEED_DIR)) return
63
+ mkdirSync(DOMAINS_DIR, { recursive: true })
64
+
65
+ for (const entry of readdirSync(SEED_DIR)) {
66
+ const src = join(SEED_DIR, entry)
67
+ if (!statSync(src).isDirectory()) continue
68
+ if (!existsSync(join(src, DOMAIN_FILE))) continue
69
+
70
+ const dst = join(DOMAINS_DIR, entry)
71
+ mkdirSync(dst, { recursive: true })
72
+ cpSync(src, dst, { recursive: true, force: true })
73
+ }
74
+ }
75
+
76
+ // ---------------------------------------------------------------------------
77
+ // Parse
78
+ // ---------------------------------------------------------------------------
79
+
80
+ /** Parse an INDUSTRY.md file into DomainInfo. Returns null on any error. */
81
+ export function parseDomainFile(filePath: string): DomainInfo | null {
82
+ try {
83
+ const text = readFileSync(filePath, "utf-8")
84
+ const { meta, body } = parseFrontmatter(text)
85
+ return {
86
+ name: meta.name || basename(join(filePath, "..")),
87
+ description: meta.description || "",
88
+ author: meta.author || "unknown",
89
+ version: meta.version || "0.0.0",
90
+ skillText: body,
91
+ }
92
+ } catch (e) {
93
+ domainLog.warn("failed to parse domain file — skipping", {
94
+ filePath,
95
+ error: e instanceof Error ? e.message : String(e),
96
+ })
97
+ return null
98
+ }
99
+ }
100
+
101
+ // ---------------------------------------------------------------------------
102
+ // Public API
103
+ // ---------------------------------------------------------------------------
104
+
105
+ /** List all installed domains, sorted by name. */
106
+ export function listDomains(): DomainInfo[] {
107
+ if (!existsSync(DOMAINS_DIR)) return []
108
+ const results: DomainInfo[] = []
109
+
110
+ for (const entry of readdirSync(DOMAINS_DIR).sort()) {
111
+ const dir = join(DOMAINS_DIR, entry)
112
+ if (!statSync(dir).isDirectory()) continue
113
+ const mdPath = join(dir, DOMAIN_FILE)
114
+ if (!existsSync(mdPath)) continue
115
+ const info = parseDomainFile(mdPath)
116
+ if (info) results.push(info)
117
+ }
118
+ return results
119
+ }
120
+
121
+ /** Get the name of the currently active domain. */
122
+ export function activeDomain(): string {
123
+ const cfg = loadConfig()
124
+ return cfg.activeDomain || cfg.activeIndustry || DEFAULT_DOMAIN
125
+ }
126
+
127
+ /** Set the active domain. Throws if domain is not installed. */
128
+ export function activateDomain(name: string): void {
129
+ if (!domainExists(name)) {
130
+ throw new Error(`Domain '${name}' is not installed`)
131
+ }
132
+ const cfg = loadConfig()
133
+ cfg.activeDomain = name
134
+ saveConfig(cfg)
135
+ }
136
+
137
+ /** Get the skill text body from a domain's INDUSTRY.md. */
138
+ export function getDomainSkillMd(name?: string): string {
139
+ const domainName = name || activeDomain()
140
+ const mdPath = join(DOMAINS_DIR, domainName, DOMAIN_FILE)
141
+ if (!existsSync(mdPath)) {
142
+ throw new Error(`Domain '${domainName}' is not installed`)
143
+ }
144
+ const info = parseDomainFile(mdPath)
145
+ if (!info) {
146
+ throw new Error(`Failed to parse ${DOMAIN_FILE} for '${domainName}'`)
147
+ }
148
+ return info.skillText
149
+ }
150
+
151
+ /** Remove an installed domain. Throws if not found or is the protected default. */
152
+ export function removeDomain(name: string): void {
153
+ if (name === DEFAULT_DOMAIN) {
154
+ throw new Error(`Cannot remove the built-in '${DEFAULT_DOMAIN}' domain`)
155
+ }
156
+ const dir = join(DOMAINS_DIR, name)
157
+ if (!existsSync(dir)) {
158
+ throw new Error(`Domain '${name}' is not installed`)
159
+ }
160
+ rmSync(dir, { recursive: true, force: true })
161
+ // Reset active domain if it was the removed one
162
+ if (activeDomain() === name) {
163
+ activateDomain(DEFAULT_DOMAIN)
164
+ }
165
+ }
166
+
167
+ /**
168
+ * Install a domain from a source.
169
+ *
170
+ * Supported sources:
171
+ * - Local path (starts with `./` or `/` or exists on disk)
172
+ * - URL (starts with `http://` or `https://`) — downloads zip
173
+ * - GitHub shorthand `github:user/repo` — converted to zip URL
174
+ *
175
+ * Returns the installed domain name.
176
+ */
177
+ export async function installDomain(
178
+ source: string,
179
+ name?: string,
180
+ ): Promise<string> {
181
+ if (source.startsWith("http://") || source.startsWith("https://")) {
182
+ return installFromUrl(source, name)
183
+ }
184
+ if (source.startsWith("github:")) {
185
+ const repo = source.slice("github:".length)
186
+ const url = `https://github.com/${repo}/archive/refs/heads/main.zip`
187
+ return installFromUrl(url, name)
188
+ }
189
+ // Local path
190
+ return installFromPath(source, name)
191
+ }
192
+
193
+ // ---------------------------------------------------------------------------
194
+ // Private helpers
195
+ // ---------------------------------------------------------------------------
196
+
197
+ function domainExists(name: string): boolean {
198
+ const dir = join(DOMAINS_DIR, name)
199
+ return existsSync(dir) && existsSync(join(dir, DOMAIN_FILE))
200
+ }
201
+
202
+ function installFromPath(srcPath: string, name?: string): string {
203
+ const resolved = resolve(srcPath)
204
+ if (!existsSync(resolved)) {
205
+ throw new Error(`Path does not exist: ${resolved}`)
206
+ }
207
+ if (!existsSync(join(resolved, DOMAIN_FILE))) {
208
+ throw new Error(`No ${DOMAIN_FILE} found in ${resolved}`)
209
+ }
210
+ const info = parseDomainFile(join(resolved, DOMAIN_FILE))
211
+ const domainName = name || info?.name || basename(resolved)
212
+ const target = join(DOMAINS_DIR, domainName)
213
+
214
+ mkdirSync(DOMAINS_DIR, { recursive: true })
215
+ if (existsSync(target)) {
216
+ rmSync(target, { recursive: true, force: true })
217
+ }
218
+ cpSync(resolved, target, { recursive: true })
219
+ return domainName
220
+ }
221
+
222
+ async function installFromUrl(url: string, name?: string): Promise<string> {
223
+ const tmp = join(tmpdir(), `revela-domain-${Date.now()}`)
224
+ mkdirSync(tmp, { recursive: true })
225
+
226
+ try {
227
+ const zipPath = join(tmp, "domain.zip")
228
+ const response = await fetch(url)
229
+ if (!response.ok) {
230
+ throw new Error(`Failed to download: ${response.status} ${response.statusText}`)
231
+ }
232
+ const buffer = new Uint8Array(await response.arrayBuffer())
233
+ writeFileSync(zipPath, buffer)
234
+
235
+ const extractDir = join(tmp, "extracted")
236
+ mkdirSync(extractDir)
237
+
238
+ const proc = Bun.spawnSync(["unzip", "-q", "-o", zipPath, "-d", extractDir])
239
+ if (proc.exitCode !== 0) {
240
+ throw new Error(`Failed to extract zip: ${proc.stderr.toString()}`)
241
+ }
242
+
243
+ const candidates = [extractDir]
244
+ for (const entry of readdirSync(extractDir)) {
245
+ const p = join(extractDir, entry)
246
+ if (statSync(p).isDirectory()) candidates.push(p)
247
+ }
248
+
249
+ for (const candidate of candidates) {
250
+ if (existsSync(join(candidate, DOMAIN_FILE))) {
251
+ return installFromPath(candidate, name)
252
+ }
253
+ }
254
+ throw new Error(`No ${DOMAIN_FILE} found inside the downloaded zip`)
255
+ } finally {
256
+ rmSync(tmp, { recursive: true, force: true })
257
+ }
258
+ }
@@ -0,0 +1,63 @@
1
+ /**
2
+ * Minimal YAML frontmatter parser.
3
+ *
4
+ * Handles the simple `key: value` format used by DESIGN.md / DOMAIN.md files.
5
+ * No dependency on external YAML libraries.
6
+ */
7
+
8
+ export interface Frontmatter {
9
+ /** Key-value pairs from the YAML block between `---` fences. */
10
+ meta: Record<string, string>
11
+ /** Everything after the closing `---`, trimmed. */
12
+ body: string
13
+ }
14
+
15
+ /**
16
+ * Parse a markdown file with optional YAML frontmatter.
17
+ *
18
+ * Format:
19
+ * ```
20
+ * ---
21
+ * key1: value1
22
+ * key2: value2
23
+ * ---
24
+ *
25
+ * Body text...
26
+ * ```
27
+ *
28
+ * If the file does not start with `---`, the entire content is returned as body
29
+ * with an empty meta object.
30
+ */
31
+ export function parseFrontmatter(text: string): Frontmatter {
32
+ const lines = text.split("\n")
33
+
34
+ if (!lines.length || lines[0].trim() !== "---") {
35
+ return { meta: {}, body: text.trim() }
36
+ }
37
+
38
+ const meta: Record<string, string> = {}
39
+ let endIdx = -1
40
+
41
+ for (let i = 1; i < lines.length; i++) {
42
+ if (lines[i].trim() === "---") {
43
+ endIdx = i
44
+ break
45
+ }
46
+ const colonPos = lines[i].indexOf(":")
47
+ if (colonPos !== -1) {
48
+ const key = lines[i].slice(0, colonPos).trim()
49
+ const value = lines[i].slice(colonPos + 1).trim()
50
+ if (key) {
51
+ meta[key] = value
52
+ }
53
+ }
54
+ }
55
+
56
+ if (endIdx === -1) {
57
+ // No closing ---, treat entire content as body
58
+ return { meta: {}, body: text.trim() }
59
+ }
60
+
61
+ const body = lines.slice(endIdx + 1).join("\n").trim()
62
+ return { meta, body }
63
+ }
package/lib/log.ts ADDED
@@ -0,0 +1,35 @@
1
+ import { Logger } from "tslog"
2
+
3
+ /**
4
+ * Revela structured logger (tslog).
5
+ *
6
+ * Log levels:
7
+ * 0 = silly, 1 = trace, 2 = debug, 3 = info, 4 = warn, 5 = error, 6 = fatal
8
+ *
9
+ * Set REVELA_DEBUG=1 to enable debug-level output (minLevel 2).
10
+ * Default minLevel is 3 (info) in production.
11
+ */
12
+ const minLevel = process.env.REVELA_DEBUG === "1" ? 2 : 3
13
+
14
+ export const log = new Logger({
15
+ name: "revela",
16
+ minLevel,
17
+ type: "json",
18
+ hideLogPositionForProduction: true,
19
+ overwrite: {
20
+ transportJSON: (logObj: unknown) => {
21
+ process.stderr.write(JSON.stringify(logObj) + "\n")
22
+ },
23
+ },
24
+ })
25
+
26
+ /**
27
+ * Create a child logger for a specific sub-module.
28
+ *
29
+ * @example
30
+ * const qaLog = childLog("qa")
31
+ * qaLog.info("measuring slides", { file: htmlPath })
32
+ */
33
+ export function childLog(name: string) {
34
+ return log.getSubLogger({ name })
35
+ }
@@ -0,0 +1,194 @@
1
+ /**
2
+ * Prompt builder — assembles the three-layer system prompt.
3
+ *
4
+ * Layer 1: SKILL.md — core protocol (conversation flow, HTML rules, quality)
5
+ * Layer 2: DOMAIN.md — domain knowledge (report structure, terminology)
6
+ * Layer 3: DESIGN.md — visual style (colors, fonts, animations, layout)
7
+ *
8
+ * When the active DESIGN.md has @section markers, only the global section,
9
+ * layouts section, and a generated component index are injected into the
10
+ * system prompt. Components, charts, and guide sections are available on
11
+ * demand via the revela-designs tool read action.
12
+ *
13
+ * When no markers are present (third-party designs), the full DESIGN.md body
14
+ * is injected unchanged (backward-compatible fallback).
15
+ *
16
+ * The combined prompt is written to ~/.config/revela/_active-prompt.md
17
+ * and referenced by agents via `{file:~/.config/revela/_active-prompt.md}`.
18
+ */
19
+
20
+ import { existsSync, readFileSync, writeFileSync, mkdirSync } from "fs"
21
+ import { join, resolve } from "path"
22
+ import {
23
+ CONFIG_DIR,
24
+ DESIGNS_DIR,
25
+ ACTIVE_PROMPT_FILE,
26
+ } from "./config"
27
+ import {
28
+ activeDesign,
29
+ getDesignSkillMd,
30
+ parseDesignSections,
31
+ generateComponentIndex,
32
+ } from "./design/designs"
33
+ import { activeDomain, getDomainSkillMd } from "./domain/domains"
34
+ import { parseFrontmatter } from "./frontmatter"
35
+ import { SLIDE_TYPES, type SlideType } from "./qa/checks"
36
+ import { childLog } from "./log"
37
+
38
+ const promptLog = childLog("prompt-builder")
39
+
40
+ /** Path to SKILL.md shipped with this package. */
41
+ const SKILL_MD_PATH = resolve(__dirname, "..", "skill", "SKILL.md")
42
+
43
+ /**
44
+ * Human-readable descriptions for each slide type.
45
+ * Used to generate the data-slide-type table injected into SKILL.md.
46
+ * Kept here (not in checks.ts) — these are prose for the AI, not QA logic.
47
+ */
48
+ const SLIDE_TYPE_DESCRIPTIONS: Record<SlideType, string> = {
49
+ cover: "Title / opening slide",
50
+ toc: "Table of contents",
51
+ content: "Regular content slides (default)",
52
+ summary: "Key takeaways slide",
53
+ closing: "Thank-you / Q&A / contact slide",
54
+ divider: "Section-break / transition slide",
55
+ "thank-you": "Alias for `closing`",
56
+ }
57
+
58
+ /**
59
+ * Build the combined system prompt and write it to _active-prompt.md.
60
+ *
61
+ * @param designName - Override design (defaults to active)
62
+ * @param domainName - Override domain (defaults to active)
63
+ * @returns The path to the written file.
64
+ */
65
+ export function buildPrompt(designName?: string, domainName?: string): string {
66
+ const design = designName || activeDesign()
67
+ const domain = domainName || activeDomain()
68
+
69
+ // Layer 1 — SKILL.md (with dynamic slide-type table injected)
70
+ let coreSkill = readFileSync(SKILL_MD_PATH, "utf-8")
71
+ coreSkill = injectSlideTypes(coreSkill)
72
+
73
+ // Check for preview.html
74
+ const designDir = join(DESIGNS_DIR, design)
75
+ const hasPreview = existsSync(join(designDir, "preview.html"))
76
+ const previewLine = hasPreview
77
+ ? "<!-- - preview.html — canonical visual reference (read this before generating slides) -->"
78
+ : "<!-- - (no preview.html for this design) -->"
79
+
80
+ // Layer 2 — DOMAIN.md skill text (may be empty for "general")
81
+ let domainSkill = ""
82
+ try {
83
+ domainSkill = getDomainSkillMd(domain)
84
+ } catch (e) {
85
+ // Domain not installed or empty — proceed without domain layer
86
+ promptLog.warn("domain skill not found — building without domain layer", {
87
+ domain,
88
+ error: e instanceof Error ? e.message : String(e),
89
+ })
90
+ }
91
+
92
+ // Layer 3 — DESIGN.md: marker-aware or full-text fallback
93
+ const designSkill = buildDesignLayer(design)
94
+
95
+ // Assemble header
96
+ const header =
97
+ `<!-- Active design: ${design} -->\n` +
98
+ `<!-- Active domain: ${domain} -->\n` +
99
+ `<!-- Design files: ${designDir}/ -->\n` +
100
+ `<!-- - DESIGN.md — metadata + style instructions (injected below) -->\n` +
101
+ `${previewLine}\n\n`
102
+
103
+ // Three-layer concatenation: Header → SKILL → Domain → Design
104
+ const parts = [header, coreSkill]
105
+ if (domainSkill) {
106
+ parts.push(`\n\n---\n\n${domainSkill}`)
107
+ }
108
+ parts.push(`\n\n---\n\n${designSkill}`)
109
+
110
+ const prompt = parts.join("")
111
+
112
+ // Write to _active-prompt.md
113
+ mkdirSync(CONFIG_DIR, { recursive: true })
114
+ writeFileSync(ACTIVE_PROMPT_FILE, prompt, "utf-8")
115
+ promptLog.info("prompt rebuilt", { design, domain, bytes: prompt.length })
116
+
117
+ return ACTIVE_PROMPT_FILE
118
+ }
119
+
120
+ /**
121
+ * Build the design layer text.
122
+ *
123
+ * If the DESIGN.md has markers:
124
+ * - Always include @section:global
125
+ * - Always include @section:layouts (layout primitives — always needed)
126
+ * - Include a generated Component Index table (lightweight catalog)
127
+ * - Omit @section:components detail, @section:charts, @section:guide
128
+ * (available on demand via revela-designs tool)
129
+ *
130
+ * If no markers: return the full DESIGN.md body unchanged.
131
+ */
132
+ function buildDesignLayer(designName: string): string {
133
+ const mdPath = join(DESIGNS_DIR, designName, "DESIGN.md")
134
+ if (!existsSync(mdPath)) {
135
+ throw new Error(`Design '${designName}' is not installed`)
136
+ }
137
+
138
+ const raw = readFileSync(mdPath, "utf-8")
139
+ const { body } = parseFrontmatter(raw)
140
+ const { sections, components, hasMarkers } = parseDesignSections(body)
141
+
142
+ if (!hasMarkers) {
143
+ // Backward-compatible: full text injection
144
+ return body
145
+ }
146
+
147
+ const layerParts: string[] = []
148
+
149
+ // 1. Global section (colors, typography, CSS, JS class, HTML structure)
150
+ if (sections["global"]) {
151
+ layerParts.push(sections["global"])
152
+ }
153
+
154
+ // 2. Component Index — compact catalog
155
+ const index = generateComponentIndex(components)
156
+ if (index) {
157
+ layerParts.push(index)
158
+ }
159
+
160
+ // 3. Layouts section — always resident (needed for every slide)
161
+ if (sections["layouts"]) {
162
+ layerParts.push(sections["layouts"])
163
+ }
164
+
165
+ // 4. On-demand note
166
+ layerParts.push(
167
+ [
168
+ "### On-Demand Design Sections",
169
+ "",
170
+ "The following design sections are available on demand. Fetch them with",
171
+ "the `revela-designs` tool (`action: \"read\"`) before using them:",
172
+ "",
173
+ "| Section | Fetch with |",
174
+ "|---|---|",
175
+ "| Component CSS/HTML details | `component: \"<name>\"` (see Component Index above) |",
176
+ "| Data Visualization (ECharts) | `section: \"charts\"` |",
177
+ "| Composition Guide & Do/Don't | `section: \"guide\"` |",
178
+ ].join("\n"),
179
+ )
180
+
181
+ return layerParts.join("\n\n---\n\n")
182
+ }
183
+
184
+ /**
185
+ * Replace the <!-- @slide-types --> placeholder in SKILL.md with a generated
186
+ * markdown table built from SLIDE_TYPES (single source of truth in checks.ts).
187
+ */
188
+ function injectSlideTypes(skillMd: string): string {
189
+ const rows = SLIDE_TYPES.map(
190
+ (t) => `| \`${t}\` | ${SLIDE_TYPE_DESCRIPTIONS[t]} |`,
191
+ )
192
+ const table = ["| Value | Use for |", "|-------|---------|", ...rows].join("\n")
193
+ return skillMd.replace("<!-- @slide-types -->", table)
194
+ }