@geraldmaron/construct 1.1.0 → 1.1.1

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/lib/demo.mjs ADDED
@@ -0,0 +1,245 @@
1
+ /**
2
+ * lib/demo.mjs — `construct demo` command, produces a reproducible terminal
3
+ * recording of a Construct workflow from a `.tape` script, rendering to
4
+ * GIF/MP4/WebM via VHS, degrading to asciinema, and finally to emitting the
5
+ * `.tape` source + install hint when no recorder is installed.
6
+ *
7
+ * Tool evaluation (2026-06), against: reproducible-from-code, headless CI
8
+ * render, distribution formats, license:
9
+ * - VHS (charm.sh, MIT): records a `.tape` script (declarative keystrokes +
10
+ * timing) to GIF/MP4/WebM headlessly via an embedded ttyd + ffmpeg. The
11
+ * `.tape` is deterministic and diffable — the source of truth lives in the
12
+ * repo, the artifact regenerates in CI. Chosen as PRIMARY: reproducible,
13
+ * embeddable in README/docs, multiple output formats.
14
+ * - asciinema (asciinema.org, GPL-3.0): records a terminal session to a
15
+ * `.cast` JSON. Reproducible and lightweight but interactive-capture by
16
+ * nature and GIF export needs the separate `agg` tool. Chosen as FALLBACK
17
+ * when VHS is absent; we drive it non-interactively via `asciinema rec -c`.
18
+ * - Playwright (dashboard): out of scope here — it is a dev-only browser
19
+ * driver for the web UI, not a terminal recorder, and lives with the
20
+ * dashboard tests rather than this command.
21
+ *
22
+ * Degradation contract: when neither vhs nor asciinema is on PATH the command
23
+ * still succeeds (exit 0) by writing the `.tape` SOURCE plus an install hint.
24
+ * No bundled binaries, no npm dependencies; recorders are detected at runtime,
25
+ * mirroring lib/runtime/whisper-bootstrap.mjs.
26
+ *
27
+ * Output: .cx/demos/<name>-<ts>.<ext> (recording) and the `.tape` source.
28
+ */
29
+ import { existsSync, mkdirSync, writeFileSync } from 'node:fs';
30
+ import { spawnSync } from 'node:child_process';
31
+ import { join, resolve, dirname } from 'node:path';
32
+
33
+ const FORMATS = ['gif', 'mp4', 'webm'];
34
+
35
+ function timestamp() {
36
+ return new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19);
37
+ }
38
+
39
+ function which(bin) {
40
+ const result = spawnSync(process.platform === 'win32' ? 'where' : 'which', [bin], { encoding: 'utf8' });
41
+ if (result.status !== 0) return null;
42
+ return result.stdout.trim().split('\n')[0] || null;
43
+ }
44
+
45
+ // Recorder detection follows the optional-external-tool pattern: probe PATH,
46
+ // return a structured descriptor, never throw. VHS is preferred for its
47
+ // declarative `.tape` model and multi-format output; asciinema is the fallback.
48
+
49
+ export function locateRecorder() {
50
+ const vhs = which('vhs');
51
+ if (vhs) return { engine: 'vhs', binary: vhs };
52
+ const asciinema = which('asciinema');
53
+ if (asciinema) return { engine: 'asciinema', binary: asciinema };
54
+ return null;
55
+ }
56
+
57
+ export function installHint() {
58
+ if (process.platform === 'darwin') return 'brew install vhs (or: brew install asciinema)';
59
+ if (process.platform === 'linux') return 'See https://github.com/charmbracelet/vhs#installation (or: pip install asciinema)';
60
+ return 'See https://github.com/charmbracelet/vhs#installation';
61
+ }
62
+
63
+ // Built-in workflow tapes. Each is a VHS `.tape`: declarative Type/Enter/Sleep
64
+ // directives that record a real walkthrough. Commands are illustrative and
65
+ // safe to replay; tapes are the source of truth, regenerated in CI.
66
+
67
+ const TAPES = {
68
+ quickstart: (out) => `# construct demo: quickstart — plan then build
69
+ Output ${out}
70
+ Set FontSize 18
71
+ Set Width 1100
72
+ Set Height 600
73
+ Set Theme "Dracula"
74
+ Set TypingSpeed 60ms
75
+
76
+ Type "construct plan 'add a rate limiter to the API'"
77
+ Sleep 500ms
78
+ Enter
79
+ Sleep 2s
80
+
81
+ Type "construct build"
82
+ Sleep 500ms
83
+ Enter
84
+ Sleep 2s
85
+
86
+ Type "construct ship status"
87
+ Sleep 500ms
88
+ Enter
89
+ Sleep 2s
90
+ `,
91
+ diagram: (out) => `# construct demo: diagram — render an architecture diagram
92
+ Output ${out}
93
+ Set FontSize 18
94
+ Set Width 1100
95
+ Set Height 600
96
+ Set Theme "Dracula"
97
+ Set TypingSpeed 60ms
98
+
99
+ Type "construct diagram 'web app: client -> api -> db'"
100
+ Sleep 500ms
101
+ Enter
102
+ Sleep 2s
103
+ `,
104
+ };
105
+
106
+ export const TAPE_NAMES = Object.keys(TAPES);
107
+
108
+ export function generateTape(name, outputArtifact) {
109
+ const builder = TAPES[name];
110
+ if (!builder) return null;
111
+ return builder(outputArtifact);
112
+ }
113
+
114
+ // VHS reads the Output directive from the .tape, so the artifact path is baked
115
+ // into the source before rendering. The render is fully headless.
116
+
117
+ function renderWithVhs(binary, tapePath) {
118
+ return spawnSync(binary, [tapePath], { encoding: 'utf8', timeout: 180_000 });
119
+ }
120
+
121
+ // asciinema can't replay a VHS .tape, so for the fallback we extract the
122
+ // `Type`/`Enter` lines into a single shell command string and capture it
123
+ // non-interactively into a .cast file. The .tape source is still written so
124
+ // the canonical, VHS-renderable form is preserved.
125
+
126
+ function tapeToShellCommand(tapeSource) {
127
+ const commands = [];
128
+ let pending = '';
129
+ for (const line of tapeSource.split('\n')) {
130
+ const typeMatch = line.match(/^Type\s+"(.*)"\s*$/);
131
+ if (typeMatch) { pending = typeMatch[1]; continue; }
132
+ if (/^Enter\s*$/.test(line) && pending) { commands.push(pending); pending = ''; }
133
+ }
134
+ return commands.join(' && ');
135
+ }
136
+
137
+ function renderWithAsciinema(binary, tapeSource, outPath) {
138
+ const command = tapeToShellCommand(tapeSource) || 'echo "construct demo"';
139
+ return spawnSync(binary, ['rec', '--overwrite', '-c', command, outPath], { encoding: 'utf8', timeout: 180_000 });
140
+ }
141
+
142
+ export function printHelp() {
143
+ console.log(`Usage: construct demo <name> [options]
144
+
145
+ Produces a reproducible terminal recording of a Construct workflow from a
146
+ .tape script. Renders to GIF/MP4/WebM via VHS (primary) or a .cast via
147
+ asciinema (fallback). When neither recorder is installed, writes the .tape
148
+ SOURCE plus an install hint and exits 0.
149
+
150
+ Tapes:
151
+ ${TAPE_NAMES.join('\n ')}
152
+
153
+ Options:
154
+ --format <f> gif (default) | mp4 | webm (VHS only)
155
+ --out <path> Output path (default: .cx/demos/<name>-<ts>.<ext>)
156
+ --source-only Always write the .tape source; skip recording
157
+ -h, --help Show this message
158
+
159
+ Recorders (detected at runtime, never bundled):
160
+ VHS ${process.platform === 'darwin' ? 'brew install vhs' : 'https://github.com/charmbracelet/vhs#installation'}
161
+ asciinema ${process.platform === 'darwin' ? 'brew install asciinema' : 'pip install asciinema'}
162
+
163
+ Examples:
164
+ construct demo quickstart
165
+ construct demo diagram --format mp4
166
+ construct demo quickstart --source-only
167
+ `);
168
+ }
169
+
170
+ function parseArgs(argv) {
171
+ const options = { format: 'gif', out: null, sourceOnly: false, name: null };
172
+ const positional = [];
173
+ for (let i = 0; i < argv.length; i += 1) {
174
+ const arg = argv[i];
175
+ if (arg === '--help' || arg === '-h') { options.help = true; continue; }
176
+ if (arg === '--source-only') { options.sourceOnly = true; continue; }
177
+ if (arg === '--format') { options.format = argv[++i]; continue; }
178
+ if (arg.startsWith('--format=')) { options.format = arg.slice(9); continue; }
179
+ if (arg === '--out') { options.out = argv[++i]; continue; }
180
+ if (arg.startsWith('--out=')) { options.out = arg.slice(6); continue; }
181
+ positional.push(arg);
182
+ }
183
+ options.name = positional[0] || null;
184
+ return options;
185
+ }
186
+
187
+ export async function runDemoCli(argv = [], { cwd = process.cwd() } = {}) {
188
+ const options = parseArgs(argv);
189
+ if (options.help || !options.name) {
190
+ printHelp();
191
+ if (!options.help && !options.name) process.exit(1);
192
+ return;
193
+ }
194
+ if (!TAPE_NAMES.includes(options.name)) {
195
+ console.error(`Unknown demo: ${options.name}. Valid: ${TAPE_NAMES.join(', ')}`);
196
+ process.exit(1);
197
+ }
198
+ if (!FORMATS.includes(options.format)) {
199
+ console.error(`Unknown format: ${options.format}. Valid: ${FORMATS.join(', ')}`);
200
+ process.exit(1);
201
+ }
202
+
203
+ const recorder = options.sourceOnly ? null : locateRecorder();
204
+
205
+ const dir = options.out ? dirname(resolve(cwd, options.out)) : join(cwd, '.cx', 'demos');
206
+ if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
207
+
208
+ const baseName = options.out
209
+ ? resolve(cwd, options.out).replace(/\.[^./]+$/, '')
210
+ : join(dir, `${options.name}-${timestamp()}`);
211
+
212
+ // asciinema emits .cast; VHS emits the requested image format. The Output
213
+ // directive baked into the tape must match the artifact VHS will write.
214
+
215
+ const artifactExt = recorder?.engine === 'asciinema' ? 'cast' : options.format;
216
+ const artifactPath = `${baseName}.${artifactExt}`;
217
+ const tapeSource = generateTape(options.name, artifactPath);
218
+ const tapePath = `${baseName}.tape`;
219
+ writeFileSync(tapePath, tapeSource, 'utf8');
220
+
221
+ if (!recorder) {
222
+ console.log(`Demo tape (${options.name}) written to:`);
223
+ console.log(` ${tapePath}`);
224
+ console.log(`\nNo terminal recorder found. To produce a recording, install one:`);
225
+ console.log(` ${installHint()}`);
226
+ console.log(`\nThen: vhs "${tapePath}"`);
227
+ return;
228
+ }
229
+
230
+ const render = recorder.engine === 'vhs'
231
+ ? renderWithVhs(recorder.binary, tapePath)
232
+ : renderWithAsciinema(recorder.binary, tapeSource, artifactPath);
233
+
234
+ if (render.status === 0 && existsSync(artifactPath)) {
235
+ console.log(`Demo (${options.name}) recorded via ${recorder.engine} to:`);
236
+ console.log(` ${artifactPath}`);
237
+ console.log(`Tape: ${tapePath}`);
238
+ return;
239
+ }
240
+
241
+ console.log(`Demo tape written to:`);
242
+ console.log(` ${tapePath}`);
243
+ console.log(`\nRecorder ${recorder.engine} failed (exit ${render.status}); tape preserved.`);
244
+ if (render.stderr) console.log(` ${render.stderr.trim().split('\n').slice(0, 3).join('\n ')}`);
245
+ }
@@ -0,0 +1,300 @@
1
+ /**
2
+ * lib/diagram.mjs — `construct diagram` command, generates code-driven
3
+ * architecture/flow diagrams from a natural-language description and renders
4
+ * them to SVG/PNG via an external diagram binary, degrading to source-only
5
+ * output when no renderer is installed.
6
+ *
7
+ * Tool evaluation (2026-06), against: visual distinctiveness, reproducible
8
+ * from code, headless Node/CI SVG+PNG render, theming, license:
9
+ * - D2 (terrastruct, MPL-2.0): single self-contained Go binary, distinctive
10
+ * sketch look plus first-class themes, headless `d2 in.d2 out.svg|png`,
11
+ * deterministic from a text file. Chosen as PRIMARY for visual
12
+ * distinctiveness and one-binary install.
13
+ * - Graphviz `dot` (EPL-1.0): ubiquitous, almost always already present on
14
+ * CI images and dev machines, headless `dot -Tsvg|-Tpng`. Chosen as
15
+ * FALLBACK for reach when D2 is absent.
16
+ * - Mermaid: renders natively in many viewers but headless image export
17
+ * needs mermaid-cli, which pulls a headless-Chromium npm tree — disallowed
18
+ * by ADR-0001 (zero-npm-core). Used only as the SOURCE we emit when no
19
+ * binary is present, reusing lib/wireframe.mjs's Mermaid generators so the
20
+ * description still yields a viewable diagram in GitHub/Notion/OpenCode.
21
+ *
22
+ * Degradation contract: when neither D2 nor dot is on PATH the command still
23
+ * succeeds (exit 0) by writing the diagram SOURCE (.d2 by default, or .mmd
24
+ * Mermaid for diagram kinds wireframe.mjs already models) plus an install
25
+ * hint. No bundled binaries, no npm dependencies; renderers are detected at
26
+ * runtime, mirroring lib/runtime/whisper-bootstrap.mjs.
27
+ *
28
+ * Output: .cx/diagrams/<slug>-<ts>.<ext>.
29
+ */
30
+ import { existsSync, mkdirSync, writeFileSync } from 'node:fs';
31
+ import { spawnSync } from 'node:child_process';
32
+ import { join, resolve, dirname } from 'node:path';
33
+
34
+ import { generateWireframe } from './wireframe.mjs';
35
+
36
+ const TYPES = ['architecture', 'flow', 'sequence', 'state', 'er', 'class'];
37
+ const FORMATS = ['svg', 'png'];
38
+
39
+ function slugify(text) {
40
+ return String(text || '')
41
+ .toLowerCase()
42
+ .replace(/[^a-z0-9]+/g, '-')
43
+ .replace(/^-+|-+$/g, '')
44
+ .slice(0, 60) || 'diagram';
45
+ }
46
+
47
+ function timestamp() {
48
+ return new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19);
49
+ }
50
+
51
+ function which(bin) {
52
+ const result = spawnSync(process.platform === 'win32' ? 'where' : 'which', [bin], { encoding: 'utf8' });
53
+ if (result.status !== 0) return null;
54
+ return result.stdout.trim().split('\n')[0] || null;
55
+ }
56
+
57
+ // Renderer detection follows the optional-external-tool pattern: probe PATH,
58
+ // return a structured descriptor, never throw. D2 is preferred for its
59
+ // distinctive look; dot is the ubiquitous fallback.
60
+
61
+ export function locateRenderer() {
62
+ const d2 = which('d2');
63
+ if (d2) return { engine: 'd2', binary: d2, sourceExt: 'd2' };
64
+ const dot = which('dot');
65
+ if (dot) return { engine: 'dot', binary: dot, sourceExt: 'dot' };
66
+ return null;
67
+ }
68
+
69
+ export function installHint() {
70
+ if (process.platform === 'darwin') return 'brew install d2 (or: brew install graphviz)';
71
+ if (process.platform === 'linux') return 'curl -fsSL https://d2lang.com/install.sh | sh (or install graphviz via your distro)';
72
+ return 'See https://d2lang.com/tour/install or https://graphviz.org/download/';
73
+ }
74
+
75
+ function inferType(description, explicit) {
76
+ if (explicit) return explicit;
77
+ const text = String(description || '').toLowerCase();
78
+ if (/\b(sequence|handshake|request|response|api call)\b/.test(text)) return 'sequence';
79
+ if (/\b(state|status|transition|machine|lifecycle)\b/.test(text)) return 'state';
80
+ if (/\b(entity|schema|database|table|relationship|foreign key)\b/.test(text)) return 'er';
81
+ if (/\b(class|inheritance|interface)\b/.test(text)) return 'class';
82
+ if (/\b(flow|funnel|step|decision|process)\b/.test(text)) return 'flow';
83
+ return 'architecture';
84
+ }
85
+
86
+ // Parse `a -> b -> c` (or `a → b → c`) chains out of the description so a
87
+ // terse architecture prompt produces a real edge graph rather than a single
88
+ // node. Falls back to treating the whole description as one node.
89
+
90
+ function parseNodes(description) {
91
+ const text = String(description || '');
92
+ const arrowSplit = text.split(/\s*(?:->|→|=>)\s*/).map((s) => s.trim()).filter(Boolean);
93
+ if (arrowSplit.length >= 2) {
94
+ const stripped = arrowSplit.map((s) => s.replace(/^[a-z0-9\s]*:\s*/i, '').trim() || s);
95
+ return stripped.map((label) => label.split(/[,/]/)[0].trim()).filter(Boolean);
96
+ }
97
+ return [];
98
+ }
99
+
100
+ function nodeId(label, index) {
101
+ const id = String(label).toLowerCase().replace(/[^a-z0-9]+/g, '_').replace(/^_+|_+$/g, '');
102
+ return id || `n${index}`;
103
+ }
104
+
105
+ function d2Source(description, nodes) {
106
+ const header = `# ${description}\n# D2 source — render: d2 this.d2 out.svg (https://d2lang.com)\n`;
107
+ if (nodes.length >= 2) {
108
+ const lines = nodes.map((label, i) => `${nodeId(label, i)}: ${label}`);
109
+ for (let i = 0; i < nodes.length - 1; i += 1) {
110
+ lines.push(`${nodeId(nodes[i], i)} -> ${nodeId(nodes[i + 1], i + 1)}`);
111
+ }
112
+ return `${header}${lines.join('\n')}\n`;
113
+ }
114
+ return `${header}node: ${description || 'Component'}\n`;
115
+ }
116
+
117
+ function dotSource(description, nodes) {
118
+ const header = `// ${description}\n// Graphviz DOT source — render: dot -Tsvg this.dot -o out.svg\n`;
119
+ const body = [];
120
+ if (nodes.length >= 2) {
121
+ nodes.forEach((label, i) => body.push(` ${nodeId(label, i)} [label="${label}"];`));
122
+ for (let i = 0; i < nodes.length - 1; i += 1) {
123
+ body.push(` ${nodeId(nodes[i], i)} -> ${nodeId(nodes[i + 1], i + 1)};`);
124
+ }
125
+ } else {
126
+ body.push(` node [label="${description || 'Component'}"];`);
127
+ }
128
+ return `${header}digraph G {\n rankdir=LR;\n${body.join('\n')}\n}\n`;
129
+ }
130
+
131
+ // For diagram kinds wireframe.mjs already models (flow/sequence/state/er) we
132
+ // reuse its Mermaid generators when emitting source-only output, so the
133
+ // description maps to a richer diagram than a bare D2 chain.
134
+
135
+ const WIREFRAME_KINDS = { flow: 'flow', sequence: 'sequence', state: 'state', er: 'er' };
136
+
137
+ export function generateDiagram({ description = '', type = null, theme = null } = {}) {
138
+ const kind = inferType(description, type);
139
+ const nodes = parseNodes(description);
140
+
141
+ let source;
142
+ let engineHint;
143
+ if (nodes.length >= 2 || kind === 'architecture' || kind === 'class') {
144
+ source = d2Source(description, nodes);
145
+ engineHint = 'd2';
146
+ } else {
147
+ const wf = generateWireframe({ description, type: WIREFRAME_KINDS[kind] || 'flow' });
148
+ source = wf.content;
149
+ engineHint = 'mermaid';
150
+ }
151
+ return { kind, nodes, source, engineHint, theme };
152
+ }
153
+
154
+ // Render via the detected binary. D2 themes map to numeric ids; an invalid
155
+ // theme name is ignored rather than failing the render. Returns the render
156
+ // result so the caller can fall back to source-only on any non-zero exit.
157
+
158
+ function renderWithD2(binary, sourcePath, outPath, { format, theme }) {
159
+ const args = [];
160
+ if (theme) {
161
+ const themeId = D2_THEMES[theme.toLowerCase()];
162
+ if (themeId != null) args.push('--theme', String(themeId));
163
+ }
164
+ args.push(sourcePath, outPath);
165
+ return spawnSync(binary, args, { encoding: 'utf8', timeout: 60_000 });
166
+ }
167
+
168
+ const D2_THEMES = {
169
+ neutral: 0,
170
+ 'neutral-grey': 1,
171
+ 'flagship-terrastruct': 3,
172
+ 'cool-classics': 4,
173
+ 'mixed-berry-blue': 5,
174
+ 'grape-soda': 6,
175
+ aubergine: 7,
176
+ 'colorblind-clear': 8,
177
+ 'vanilla-nitro-cola': 100,
178
+ 'orange-creamsicle': 101,
179
+ sketch: 200,
180
+ };
181
+
182
+ function renderWithDot(binary, sourcePath, outPath, { format }) {
183
+ return spawnSync(binary, [`-T${format}`, sourcePath, '-o', outPath], { encoding: 'utf8', timeout: 60_000 });
184
+ }
185
+
186
+ export function printHelp() {
187
+ console.log(`Usage: construct diagram <description> [options]
188
+
189
+ Generates a code-driven diagram from a description and renders it to SVG/PNG
190
+ via D2 (primary) or Graphviz dot (fallback). When neither renderer is
191
+ installed, writes the diagram SOURCE plus an install hint and exits 0.
192
+
193
+ Options:
194
+ --type <t> architecture (default) | flow | sequence | state | er | class
195
+ --format <f> svg (default) | png
196
+ --theme <name> D2 theme name (e.g. neutral, sketch, cool-classics)
197
+ --out <path> Output path (default: .cx/diagrams/<slug>-<ts>.<ext>)
198
+ --source-only Always write the source file; skip rendering
199
+ -h, --help Show this message
200
+
201
+ Renderers (detected at runtime, never bundled):
202
+ D2 ${process.platform === 'darwin' ? 'brew install d2' : 'https://d2lang.com/tour/install'}
203
+ dot ${process.platform === 'darwin' ? 'brew install graphviz' : 'https://graphviz.org/download/'}
204
+
205
+ Examples:
206
+ construct diagram "web app: client -> api -> db"
207
+ construct diagram "auth flow" --type flow
208
+ construct diagram "client -> api -> db" --format png --theme sketch
209
+ `);
210
+ }
211
+
212
+ function parseArgs(argv) {
213
+ const options = { type: null, format: 'svg', theme: null, out: null, sourceOnly: false, description: [] };
214
+ for (let i = 0; i < argv.length; i += 1) {
215
+ const arg = argv[i];
216
+ if (arg === '--help' || arg === '-h') { options.help = true; continue; }
217
+ if (arg === '--source-only') { options.sourceOnly = true; continue; }
218
+ if (arg === '--type') { options.type = argv[++i]; continue; }
219
+ if (arg.startsWith('--type=')) { options.type = arg.slice(7); continue; }
220
+ if (arg === '--format') { options.format = argv[++i]; continue; }
221
+ if (arg.startsWith('--format=')) { options.format = arg.slice(9); continue; }
222
+ if (arg === '--theme') { options.theme = argv[++i]; continue; }
223
+ if (arg.startsWith('--theme=')) { options.theme = arg.slice(8); continue; }
224
+ if (arg === '--out') { options.out = argv[++i]; continue; }
225
+ if (arg.startsWith('--out=')) { options.out = arg.slice(6); continue; }
226
+ options.description.push(arg);
227
+ }
228
+ options.description = options.description.join(' ').trim();
229
+ return options;
230
+ }
231
+
232
+ export async function runDiagramCli(argv = [], { cwd = process.cwd() } = {}) {
233
+ const options = parseArgs(argv);
234
+ if (options.help || !options.description) {
235
+ printHelp();
236
+ if (!options.help && !options.description) process.exit(1);
237
+ return;
238
+ }
239
+ if (options.type && !TYPES.includes(options.type)) {
240
+ console.error(`Unknown type: ${options.type}. Valid: ${TYPES.join(', ')}`);
241
+ process.exit(1);
242
+ }
243
+ if (!FORMATS.includes(options.format)) {
244
+ console.error(`Unknown format: ${options.format}. Valid: ${FORMATS.join(', ')}`);
245
+ process.exit(1);
246
+ }
247
+
248
+ const result = generateDiagram({ description: options.description, type: options.type, theme: options.theme });
249
+ const renderer = options.sourceOnly ? null : locateRenderer();
250
+
251
+ const slug = slugify(options.description);
252
+ const dir = options.out ? dirname(resolve(cwd, options.out)) : join(cwd, '.cx', 'diagrams');
253
+ if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
254
+
255
+ // Source is always written. When the source is Mermaid (reused wireframe
256
+ // output) the .d2/.dot renderers can't consume it, so we keep the source
257
+ // extension honest and skip the binary render in that case.
258
+
259
+ const renderableSource = result.engineHint !== 'mermaid';
260
+ const sourceExt = result.engineHint === 'mermaid' ? 'md' : (renderer ? renderer.sourceExt : 'd2');
261
+ const baseName = options.out
262
+ ? resolve(cwd, options.out).replace(/\.[^./]+$/, '')
263
+ : join(dir, `${slug}-${timestamp()}`);
264
+ const sourcePath = `${baseName}.${sourceExt}`;
265
+
266
+ let source = result.source;
267
+ if (renderer && renderableSource) {
268
+ source = renderer.engine === 'dot' ? dotSource(options.description, result.nodes) : d2Source(options.description, result.nodes);
269
+ }
270
+ writeFileSync(sourcePath, source, 'utf8');
271
+
272
+ if (!renderer || !renderableSource) {
273
+ console.log(`Diagram source (${result.kind}, ${result.engineHint}) written to:`);
274
+ console.log(` ${sourcePath}`);
275
+ if (!renderer) {
276
+ console.log(`\nNo diagram renderer found. To render SVG/PNG, install one:`);
277
+ console.log(` ${installHint()}`);
278
+ } else {
279
+ console.log(`\nThis diagram kind renders as Mermaid; paste into any Mermaid-aware viewer (GitHub, Notion, OpenCode).`);
280
+ }
281
+ return;
282
+ }
283
+
284
+ const outPath = `${baseName}.${options.format}`;
285
+ const render = renderer.engine === 'd2'
286
+ ? renderWithD2(renderer.binary, sourcePath, outPath, { format: options.format, theme: options.theme })
287
+ : renderWithDot(renderer.binary, sourcePath, outPath, { format: options.format });
288
+
289
+ if (render.status === 0 && existsSync(outPath)) {
290
+ console.log(`Diagram (${result.kind}) rendered via ${renderer.engine} to:`);
291
+ console.log(` ${outPath}`);
292
+ console.log(`Source: ${sourcePath}`);
293
+ return;
294
+ }
295
+
296
+ console.log(`Diagram source written to:`);
297
+ console.log(` ${sourcePath}`);
298
+ console.log(`\nRenderer ${renderer.engine} failed (exit ${render.status}); source preserved.`);
299
+ if (render.stderr) console.log(` ${render.stderr.trim().split('\n').slice(0, 3).join('\n ')}`);
300
+ }
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * lib/doctor/index.mjs — `construct-doctor` daemon main loop.
3
3
  *
4
- * Long-running Node process spawned by `construct up`. Holds the L0 watchers
4
+ * Long-running Node process spawned by `construct dev`. Holds the L0 watchers
5
5
  * for resource pressure, service health, disk + log rotation, and cost. Each
6
6
  * watcher ticks on its own interval; the loop is bounded so a slow watcher
7
7
  * never starves the others. Termination via SIGTERM/SIGINT triggers a clean
@@ -2,7 +2,7 @@
2
2
  * lib/doctor/watchers/process-pressure.mjs — continuous process pressure guard.
3
3
  *
4
4
  * Runs the runtime-pressure cleanup on a recurring tick so stale Construct
5
- * helpers don't accumulate between `construct up` invocations. Any killed
5
+ * helpers don't accumulate between `construct dev` invocations. Any killed
6
6
  * process is recorded; if the same service-class has been killed >=2 times
7
7
  * in the last hour, escalates so cx-sre can investigate the underlying churn.
8
8
  *
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * lib/doctor/watchers/service-health.mjs — continuous service probes.
3
3
  *
4
- * Probes the services `construct up` starts (postgres, dashboard,
4
+ * Probes the services `construct dev` starts (postgres, dashboard,
5
5
  * cm, opencode). On 2 consecutive probe failures, attempts a restart of
6
6
  * docker-backed services (postgres). On 2 consecutive failed
7
7
  * restarts, escalates `service.down` for cx-sre. Non-docker services log +
@@ -698,6 +698,13 @@ export function extractDocumentText(filePath, { maxChars = null } = {}) {
698
698
  throw new Error(`Unsupported document type: ${extension || 'unknown'}`);
699
699
  }
700
700
 
701
+ return buildExtractionResult(resolvedPath, extension, extracted, maxChars);
702
+ }
703
+
704
+ // Shared envelope construction so the sync, async, and Node-native paths emit
705
+ // an identical result shape (callers route on extractionMethod and droppedInfo).
706
+
707
+ function buildExtractionResult(resolvedPath, extension, extracted, maxChars) {
701
708
  const text = extracted.text;
702
709
  const limit = Number.isFinite(Number(maxChars)) && Number(maxChars) > 0
703
710
  ? Math.min(Number(maxChars), 200_000)
@@ -714,13 +721,54 @@ export function extractDocumentText(filePath, { maxChars = null } = {}) {
714
721
  droppedInfo: extracted.droppedInfo ?? [],
715
722
  structured: extracted.structured ?? null,
716
723
  };
724
+
717
725
  // Surface eml-specific metadata so callers (intake, ingest) can route
718
726
  // attachment filenames and oversize-skip markers without re-parsing.
727
+
719
728
  if (extracted.attachments) result.attachments = extracted.attachments;
720
729
  if (extracted.skipped) result.skipped = extracted.skipped;
721
730
  return result;
722
731
  }
723
732
 
733
+ // Node-native extraction for the everyday adapter path: unpdf (PDF) and mammoth
734
+ // (DOCX) are optional pure-JS deps, so plain documents ingest with no Python venv
735
+ // and no system binary. Missing dep, an empty parse, or any other type delegates
736
+ // to the sync system-binary extractor — the choice degrades, never fails hard.
737
+
738
+ async function extractWithUnpdf(filePath) {
739
+ const { extractText, getDocumentProxy } = await import('unpdf');
740
+ const proxy = await getDocumentProxy(new Uint8Array(readFileSync(filePath)));
741
+ const { text } = await extractText(proxy, { mergePages: true });
742
+ return normalizeText(typeof text === 'string' ? text : (Array.isArray(text) ? text.join('\n') : ''));
743
+ }
744
+
745
+ async function extractWithMammoth(filePath) {
746
+ const mammoth = (await import('mammoth')).default;
747
+ const { value } = await mammoth.extractRawText({ path: filePath });
748
+ return normalizeText(value || '');
749
+ }
750
+
751
+ export async function extractDocumentTextNodeNative(filePath, { maxChars = null } = {}) {
752
+ const resolvedPath = resolve(filePath);
753
+ if (!existsSync(resolvedPath)) throw new Error(`File not found: ${resolvedPath}`);
754
+ const extension = extname(resolvedPath).toLowerCase();
755
+
756
+ const nodeBackend = extension === '.pdf' ? { fn: extractWithUnpdf, method: 'unpdf' }
757
+ : extension === '.docx' ? { fn: extractWithMammoth, method: 'mammoth' }
758
+ : null;
759
+
760
+ if (nodeBackend) {
761
+ try {
762
+ const text = await nodeBackend.fn(resolvedPath);
763
+ if (text) {
764
+ return buildExtractionResult(resolvedPath, extension, { text, method: nodeBackend.method, droppedInfo: [], structured: null }, maxChars);
765
+ }
766
+ } catch { /* optional dep absent or parse failure — fall through to the sync extractor */ }
767
+ }
768
+
769
+ return extractDocumentText(resolvedPath, { maxChars });
770
+ }
771
+
724
772
  // Async high-fidelity extraction path. Routes PDF/Office/HTML through the
725
773
  // docling Python sidecar (layout-aware markdown) and audio/video through
726
774
  // whisper.cpp (Metal-accelerated on macOS). Text/transcript/calendar/email
@@ -9,7 +9,7 @@
9
9
  */
10
10
  import { existsSync, mkdirSync, readdirSync, statSync, writeFileSync } from 'node:fs';
11
11
  import { basename, dirname, extname, isAbsolute, join, parse, relative, resolve, sep } from 'node:path';
12
- import { extractDocumentText, extractDocumentTextAsync, extractDocumentMetadata, isExtractableDocumentPath } from './document-extract.mjs';
12
+ import { extractDocumentText, extractDocumentTextAsync, extractDocumentTextNodeNative, extractDocumentMetadata, isExtractableDocumentPath } from './document-extract.mjs';
13
13
  import { syncFileStateToSql } from './storage/sync.mjs';
14
14
  import { stampFrontmatter } from './doc-stamp.mjs';
15
15
  import { KNOWLEDGE_ROOT, KNOWLEDGE_SUBDIRS } from './knowledge/layout.mjs';
@@ -207,10 +207,15 @@ export async function extractWithDoclingFallback(sourcePath, {
207
207
  }
208
208
  }
209
209
 
210
+ // The default adapter path prefers the Node-native extractor (unpdf/mammoth) so
211
+ // plain PDF/DOCX ingest needs no Python venv; it self-degrades to the sync
212
+ // system-binary extractor when the optional dep is absent. High-fidelity stays
213
+ // on docling, reserved for scanned/layout-critical and audio/video.
214
+
210
215
  function extractViaAdapter(sourcePath, { highFidelity, maxChars }) {
211
216
  return highFidelity
212
217
  ? extractWithDoclingFallback(sourcePath, { maxChars })
213
- : extractDocumentText(sourcePath, { maxChars });
218
+ : extractDocumentTextNodeNative(sourcePath, { maxChars });
214
219
  }
215
220
 
216
221
  async function extractWithStrategy(sourcePath, { strategy, fallback, model, provider, highFidelity, maxChars, env }) {