catalyst-os 2.0.3 → 3.0.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.
@@ -86,6 +86,27 @@ function copyFile(src, dest) {
86
86
  return false;
87
87
  }
88
88
 
89
+ /**
90
+ * Ensure the consuming project's .gitignore protects local secrets.
91
+ * If `.catalyst/` (or the secrets file) is already ignored, do nothing.
92
+ */
93
+ function ensureGitignore(cwd) {
94
+ const guard = '.catalyst/secrets.local.yaml';
95
+ const gitignorePath = path.join(cwd, '.gitignore');
96
+ let content = '';
97
+ if (fs.existsSync(gitignorePath)) {
98
+ content = fs.readFileSync(gitignorePath, 'utf8');
99
+ const lines = content.split(/\r?\n/).map((l) => l.trim());
100
+ const covered = lines.some(
101
+ (l) => l === guard || l === '/.catalyst' || l === '.catalyst' || l === '.catalyst/'
102
+ );
103
+ if (covered) return;
104
+ }
105
+ const block = `${content && !content.endsWith('\n') ? '\n' : ''}\n# Catalyst OS local secrets (never commit)\n${guard}\n`;
106
+ fs.appendFileSync(gitignorePath, block);
107
+ console.log(` ${green}✓${reset} Added ${guard} to .gitignore`);
108
+ }
109
+
89
110
  /**
90
111
  * Install catalyst-os to current directory
91
112
  */
@@ -142,6 +163,14 @@ function install() {
142
163
  }
143
164
  }
144
165
 
166
+ // Install voice runtime (zero-dependency local meeting daemon for /meet-spec)
167
+ const voiceSrc = path.join(src, '.catalyst', 'voice');
168
+ const voiceDest = path.join(catalystDir, 'voice');
169
+ if (fs.existsSync(voiceSrc)) {
170
+ copyDir(voiceSrc, voiceDest);
171
+ console.log(` ${green}✓${reset} Installed .catalyst/voice/`);
172
+ }
173
+
145
174
  // Create specs directory if it doesn't exist
146
175
  const specsDir = path.join(catalystDir, 'specs');
147
176
  if (!fs.existsSync(specsDir)) {
@@ -149,6 +178,23 @@ function install() {
149
178
  console.log(` ${green}✓${reset} Created .catalyst/specs/`);
150
179
  }
151
180
 
181
+ // Create a local secrets file (git-ignored) if it doesn't exist yet.
182
+ // Optional — only needed for voice meetings (/meet-spec).
183
+ const secretsPath = path.join(catalystDir, 'secrets.local.yaml');
184
+ if (!fs.existsSync(secretsPath)) {
185
+ fs.writeFileSync(
186
+ secretsPath,
187
+ '# Catalyst OS — Local Secrets (git-ignored, never commit)\n' +
188
+ '# Optional. Only needed for /meet-spec voice meetings.\n' +
189
+ '# Get a key at https://elevenlabs.io (you pay for your own usage).\n' +
190
+ 'elevenlabs_api_key: ""\n'
191
+ );
192
+ console.log(` ${green}✓${reset} Created .catalyst/secrets.local.yaml`);
193
+ }
194
+
195
+ // Safety belt: ensure the local secrets file can never be committed.
196
+ ensureGitignore(cwd);
197
+
152
198
  // Install AGENTS.md
153
199
  const agentsMdSrc = path.join(src, 'AGENTS.md');
154
200
  const agentsMdDest = path.join(cwd, 'AGENTS.md');
@@ -33,3 +33,19 @@ git:
33
33
  red_phase: "test({scope}): write failing tests for {spec}"
34
34
  green_phase: "feat({scope}): implement {spec}"
35
35
 
36
+ # Voice meetings (/meet-spec) — optional.
37
+ # See .catalyst/voice/README.md for setup.
38
+ voice:
39
+ enabled: false # set true to enable /meet-spec
40
+ language: "en" # meeting/conversation language (e.g. "tr"); artifacts stay English
41
+
42
+ # mode: diy = browser speech + local `claude -p` brain. FREE, no API key, needs Chrome.
43
+ # bundled = ElevenLabs Conversational AI. Nicer voice, per-minute cost (BYOK).
44
+ mode: diy
45
+
46
+ # --- bundled mode only (ignored in diy) ---
47
+ # The ElevenLabs API key does NOT go here — it lives in .catalyst/secrets.local.yaml.
48
+ provider: elevenlabs
49
+ voice_id: "" # optional ElevenLabs voice id
50
+ agent_id: "" # ElevenLabs Conversational AI agent id
51
+
@@ -46,7 +46,7 @@ files:
46
46
  owner: arbiter
47
47
  purpose: Test results, quality checks
48
48
  created: audit-spec
49
- updated_by: [arbiter, enforcer, sentinel, inquisitor, watcher]
49
+ updated_by: [arbiter, enforcer, sentinel, inquisitor, watcher, curator]
50
50
 
51
51
  assets/:
52
52
  owner: any
@@ -0,0 +1,78 @@
1
+ # Catalyst Voice Runtime
2
+
3
+ Local browser meeting room for `/meet-spec`. **Zero npm dependencies** — Node stdlib
4
+ only. Requires **Node >= 18** and the `claude` CLI on your PATH.
5
+
6
+ Two modes, set by `voice.mode` in `.catalyst/main/project-config.yaml`:
7
+
8
+ | | **diy** (default) | **bundled** |
9
+ |---|---|---|
10
+ | Speech | Browser Web Speech API | ElevenLabs Conversational AI |
11
+ | Brain | Local `claude -p` | ElevenLabs agent (Claude LLM) |
12
+ | Voice quality | Robotic (browser TTS) | Natural |
13
+ | Cost | **Free** (uses your Claude) | ~$0.10–$1/min (ElevenLabs, BYOK) |
14
+ | Setup | None — just Chrome | API key + agent (see below) |
15
+
16
+ Both modes give the agent full read access to the codebase, docs, and web search, and
17
+ both write the same `meeting.md` artifact. **Artifacts are always in English**, whatever
18
+ language the meeting is spoken in (set `voice.language`, e.g. `"tr"`).
19
+
20
+ ## DIY mode (default — nothing to set up)
21
+
22
+ ```yaml
23
+ voice:
24
+ enabled: true
25
+ language: "tr" # or "en", etc.
26
+ mode: diy
27
+ ```
28
+
29
+ Open the room (via `/meet-spec`), click **Start meeting**, and talk. Chrome transcribes
30
+ your speech, `claude -p` answers from the real repo, and the browser speaks the reply.
31
+ You can also **type** in the text box at any time (great when speech is misheard). Needs
32
+ Chrome/Chromium for speech; in other browsers, the text box still works.
33
+
34
+ ## Bundled mode (optional — nicer voice, paid)
35
+
36
+ 1. **Get an ElevenLabs API key** at https://elevenlabs.io. Conversational AI is billed
37
+ **per minute** (~1,000 credits/min on credit plans), so prefer a plan with included
38
+ agent-minutes. Paste the key into `.catalyst/secrets.local.yaml`:
39
+ ```yaml
40
+ elevenlabs_api_key: "your-key-here"
41
+ ```
42
+ 2. **Create a Conversational AI agent** (dashboard): LLM = Claude; a spec-shaping
43
+ interviewer system prompt; a **client tool** named exactly **`investigate_codebase`**
44
+ with one **string** parameter **`query`**. For non-English, enable the **language
45
+ override** (Security/Overrides → Language) and use a multilingual TTS model
46
+ (`eleven_flash_v2_5`).
47
+ 3. **Point Catalyst at it**:
48
+ ```yaml
49
+ voice:
50
+ enabled: true
51
+ language: "tr"
52
+ mode: bundled
53
+ agent_id: "your-agent-id"
54
+ ```
55
+ The key is read server-side by `meet-server.js` and never reaches the browser, argv,
56
+ or env. Signed URLs are minted server-side; client tools run in the browser so they
57
+ reach `localhost` — nothing is exposed to the internet.
58
+
59
+ ## How it works
60
+
61
+ ```
62
+ diy: Browser (Web Speech) ──text──► meet-server /turn ──► claude -p (repo) ──► reply ──► browser TTS
63
+ bundled: Browser (ElevenLabs SDK) ◄─voice─► ElevenLabs agent
64
+ └─ investigate_codebase ─► meet-server /tool ─► claude -p (repo)
65
+ ```
66
+
67
+ Clicking **End meeting** sends the transcript to the daemon, which folds it into
68
+ `.catalyst/meetings/YYYY-MM-DD-{slug}/meeting.md` via one `claude -p` pass, then shuts down.
69
+
70
+ ## Run it manually
71
+
72
+ ```bash
73
+ PORT=4399 \
74
+ MEETING_DIR=".catalyst/meetings/2026-01-01-test" \
75
+ MEETING_TOPIC="Test meeting" \
76
+ node .catalyst/voice/meet-server.js
77
+ # then open http://localhost:4399
78
+ ```
@@ -0,0 +1,61 @@
1
+ /**
2
+ * Meeting artifact generation for /meet-spec.
3
+ * Writes meeting.md (canonical, for /catalyze-spec) and meeting.html (rendered, for humans).
4
+ */
5
+ const fs = require('fs');
6
+ const path = require('path');
7
+
8
+ function esc(s) {
9
+ return String(s).replace(/[&<>]/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;' }[c]));
10
+ }
11
+
12
+ // One-pass transcript summarizer (bundled mode, or when no live notes were produced).
13
+ const SUMMARY_PROMPT = (topic, transcript) =>
14
+ `You are summarizing a spec-shaping meeting. Convert the transcript below into clean ` +
15
+ `Markdown that another command (/catalyze-spec) will use as the feature request.\n\n` +
16
+ `Write the notes in ENGLISH regardless of the language spoken in the meeting.\n\n` +
17
+ `Output EXACTLY these sections, nothing before or after:\n\n` +
18
+ `# Meeting: ${topic}\n\n## Summary\n(one paragraph)\n\n## Decisions\n(bullet list of ` +
19
+ `decisions reached; "None recorded." if none)\n\n## Action Items\n(bullet list of ` +
20
+ `requirements / things to build)\n\n## Open Questions\n(bullet list of unresolved ` +
21
+ `questions)\n\n## Transcript\n(the full transcript, lightly cleaned)\n\n` +
22
+ `--- TRANSCRIPT ---\n${transcript}`;
23
+
24
+ // notesHtml is the browser-rendered notes (DIY); falls back to <pre> if absent.
25
+ function writeArtifacts({ projectRoot, meetingDir, topic, notesMd, notesHtml, transcriptText, findings }) {
26
+ const dir = path.join(projectRoot, meetingDir);
27
+ fs.mkdirSync(dir, { recursive: true });
28
+
29
+ const research = findings && findings.length
30
+ ? '\n\n## Research Findings\n\n' + findings.map((f) => `### ${f.query}\n\n${f.result}`).join('\n\n')
31
+ : '';
32
+ const notesBody = notesMd.startsWith('#') ? notesMd : `# Meeting: ${topic}\n\n${notesMd}`;
33
+ const md = `${notesBody}\n\n## Transcript\n\n${transcriptText}\n${research}`;
34
+ fs.writeFileSync(path.join(dir, 'meeting.md'), md);
35
+
36
+ const notesSection = notesHtml || `<pre>${esc(notesMd)}</pre>`;
37
+ const transcriptHtml = transcriptText.split('\n').map((l) => `<p>${esc(l)}</p>`).join('\n');
38
+ const html = `<!doctype html>
39
+ <html lang="en"><head><meta charset="utf-8" />
40
+ <title>Meeting: ${esc(topic)}</title>
41
+ <style>
42
+ body{font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;max-width:820px;
43
+ margin:40px auto;padding:0 20px;line-height:1.6;color:#1a1a24}
44
+ h1{font-size:24px} h2{font-size:18px;margin-top:28px;border-bottom:1px solid #eee;padding-bottom:4px}
45
+ .notes{background:#faf9ff;border:1px solid #ece8ff;border-radius:10px;padding:18px 22px}
46
+ .transcript p{margin:2px 0;color:#444;font-size:14px}
47
+ code{background:#f3f1fa;padding:2px 5px;border-radius:4px}
48
+ </style></head>
49
+ <body>
50
+ <h1>Meeting: ${esc(topic)}</h1>
51
+ <section class="notes">${notesSection}</section>
52
+ <h2>Transcript</h2>
53
+ <div class="transcript">${transcriptHtml}</div>
54
+ </body></html>
55
+ `;
56
+ fs.writeFileSync(path.join(dir, 'meeting.html'), html);
57
+
58
+ return { mdPath: path.join(meetingDir, 'meeting.md'), htmlPath: path.join(meetingDir, 'meeting.html') };
59
+ }
60
+
61
+ module.exports = { writeArtifacts, SUMMARY_PROMPT };
@@ -0,0 +1,293 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Local voice-meeting daemon for /meet-spec.
4
+ *
5
+ * Zero npm dependencies: Node stdlib + global fetch (Node >= 18) + child_process.
6
+ *
7
+ * Two modes (project-config voice.mode):
8
+ * diy — browser Web Speech (free) + local `claude -p` brain. No ElevenLabs, no cost.
9
+ * bundled — ElevenLabs Conversational AI (better voice, per-minute cost).
10
+ *
11
+ * Responsibilities:
12
+ * GET / serve the meeting room page (diy or bundled)
13
+ * GET /config non-secret config for the browser (mode, agentId, language, topic)
14
+ * GET /signed-url bundled: mint an ElevenLabs signed URL (key stays server-side)
15
+ * POST /turn diy: one conversational turn via headless `claude -p`
16
+ * POST /tool bundled: live codebase investigation via headless `claude -p`
17
+ * POST /end fold transcript (+ findings) into meeting.md, then shut down
18
+ *
19
+ * The ElevenLabs API key (bundled only) is read from .catalyst/secrets.local.yaml and
20
+ * never leaves this process (not the browser, not argv, not the environment).
21
+ */
22
+
23
+ const http = require('http');
24
+ const fs = require('fs');
25
+ const path = require('path');
26
+ const { spawn } = require('child_process');
27
+ const { writeArtifacts, SUMMARY_PROMPT } = require('./artifact');
28
+
29
+ const PROJECT_ROOT = process.cwd();
30
+ const PORT = parseInt(process.env.PORT || '4399', 10);
31
+ const MEETING_DIR = process.env.MEETING_DIR || '.catalyst/meetings/untitled';
32
+ const MEETING_TOPIC = process.env.MEETING_TOPIC || 'Untitled meeting';
33
+
34
+ // Read-only tool whitelist for headless claude runs. Non-listed tools are
35
+ // auto-denied in -p mode, so there are no interactive permission hangs.
36
+ const CLAUDE_TOOLS = 'Read,Grep,Glob,Task,WebSearch,WebFetch';
37
+
38
+ // --- Config / secrets (minimal YAML reads, no parser dependency) ---
39
+
40
+ function readScalar(relPath, key) {
41
+ try {
42
+ const txt = fs.readFileSync(path.join(PROJECT_ROOT, relPath), 'utf8');
43
+ const m = txt.match(new RegExp('^\\s*' + key + ':\\s*["\']?([^"\'\\n]*)["\']?', 'm'));
44
+ return m ? m[1].trim() : '';
45
+ } catch {
46
+ return '';
47
+ }
48
+ }
49
+
50
+ const API_KEY = readScalar('.catalyst/secrets.local.yaml', 'elevenlabs_api_key');
51
+ const AGENT_ID = readScalar('.catalyst/main/project-config.yaml', 'agent_id');
52
+ const VOICE_ID = readScalar('.catalyst/main/project-config.yaml', 'voice_id');
53
+ const LANGUAGE = readScalar('.catalyst/main/project-config.yaml', 'language') || 'en';
54
+ // bundled = ElevenLabs Conversational AI; diy = browser speech + local claude -p (free).
55
+ const MODE = readScalar('.catalyst/main/project-config.yaml', 'mode') || 'bundled';
56
+ const HTML_PATH = path.join(__dirname, MODE === 'diy' ? 'meeting-diy.html' : 'meeting.html');
57
+
58
+ // In DIY mode the "agent" is claude -p itself, driven turn by turn.
59
+ const PERSONA =
60
+ `You are Catalyst, a spec-shaping interviewer for this software project. Hold a focused, ` +
61
+ `friendly spoken meeting that turns a rough idea into a clear feature specification. ` +
62
+ `Speak in ${LANGUAGE === 'tr' ? 'Turkish' : "the user's language"}. Ask ONE question at a ` +
63
+ `time and keep each spoken turn short (1-3 sentences). You have full read access to this ` +
64
+ `repository, its documentation, and web search — use them to ground your questions and ` +
65
+ `answers; never guess about the code. Drive toward: the problem, scope, concrete ` +
66
+ `requirements, key decisions, and action items. Be patient: if the user is brief or silent, ` +
67
+ `wait — never nag with "are you there?".\n\n` +
68
+ `OUTPUT FORMAT (strict, every turn): first your spoken words ONLY (no markdown). Then a ` +
69
+ `line containing exactly ===NOTES=== . Then the running meeting notes in Markdown, IN ` +
70
+ `ENGLISH, cumulative (carry forward everything so far and refine it), with these sections:\n` +
71
+ `## Summary\n## Decisions\n## Action Items\n## Open Questions\n` +
72
+ `The spoken part is for text-to-speech; the notes part is never spoken.`;
73
+
74
+ const NOTES_DELIM = '===NOTES===';
75
+
76
+ // Live investigation findings, folded into the artifact at the end.
77
+ const findings = [];
78
+ // DIY conversation session (claude -p --resume keeps context across turns).
79
+ let sessionId = null;
80
+ // Latest running notes (Markdown) maintained across DIY turns.
81
+ let latestNotes = '';
82
+
83
+ // --- Headless claude bridge ---
84
+
85
+ function runClaude(prompt, timeoutMs) {
86
+ return new Promise((resolve) => {
87
+ let child;
88
+ try {
89
+ child = spawn('claude', ['-p', '--allowedTools', CLAUDE_TOOLS], {
90
+ cwd: PROJECT_ROOT,
91
+ });
92
+ } catch (e) {
93
+ return resolve(`(could not start claude: ${e.message})`);
94
+ }
95
+ let out = '';
96
+ let err = '';
97
+ const timer = setTimeout(() => {
98
+ child.kill('SIGTERM');
99
+ resolve(out.trim() || '(investigation timed out)');
100
+ }, timeoutMs);
101
+
102
+ child.stdout.on('data', (d) => (out += d));
103
+ child.stderr.on('data', (d) => (err += d));
104
+ child.on('error', (e) => {
105
+ clearTimeout(timer);
106
+ resolve(`(claude not available: ${e.message})`);
107
+ });
108
+ child.on('close', () => {
109
+ clearTimeout(timer);
110
+ resolve(out.trim() || err.trim() || '(no output)');
111
+ });
112
+
113
+ child.stdin.write(prompt);
114
+ child.stdin.end();
115
+ });
116
+ }
117
+
118
+ // One conversational turn for DIY mode. Uses --resume to keep context (and the
119
+ // codebase warm) across turns; the persona is sent only on the first turn.
120
+ function claudeTurn(userText) {
121
+ return new Promise((resolve) => {
122
+ const args = ['-p', '--output-format', 'json', '--allowedTools', CLAUDE_TOOLS];
123
+ if (sessionId) args.push('--resume', sessionId);
124
+ const prompt = sessionId
125
+ ? userText
126
+ : `${PERSONA}\n\nThe meeting topic is: ${MEETING_TOPIC}\n\nUser: ${userText}`;
127
+ let child;
128
+ try {
129
+ child = spawn('claude', args, { cwd: PROJECT_ROOT });
130
+ } catch (e) {
131
+ return resolve({ reply: `(claude unavailable: ${e.message})`, notes: latestNotes });
132
+ }
133
+ let out = '';
134
+ let err = '';
135
+ const timer = setTimeout(() => {
136
+ child.kill('SIGTERM');
137
+ resolve(splitTurn(out) || { reply: '(timed out)', notes: latestNotes });
138
+ }, 150000);
139
+ child.stdout.on('data', (d) => (out += d));
140
+ child.stderr.on('data', (d) => (err += d));
141
+ child.on('error', (e) => {
142
+ clearTimeout(timer);
143
+ resolve({ reply: `(claude unavailable: ${e.message})`, notes: latestNotes });
144
+ });
145
+ child.on('close', () => {
146
+ clearTimeout(timer);
147
+ let result = out.trim();
148
+ try {
149
+ const j = JSON.parse(out);
150
+ result = (j.result || '').trim();
151
+ if (j.session_id) sessionId = j.session_id;
152
+ } catch {
153
+ /* non-JSON output: fall back to raw text */
154
+ }
155
+ resolve(splitTurn(result) || { reply: err.trim() || '(no response)', notes: latestNotes });
156
+ });
157
+ child.stdin.write(prompt);
158
+ child.stdin.end();
159
+ });
160
+ }
161
+
162
+ // Split a turn's result into spoken reply + running notes. Updates latestNotes.
163
+ // If the delimiter is missing, treat everything as the reply and keep prior notes.
164
+ function splitTurn(result) {
165
+ if (!result) return null;
166
+ const i = result.indexOf(NOTES_DELIM);
167
+ if (i < 0) return { reply: result.trim(), notes: latestNotes };
168
+ const reply = result.slice(0, i).trim();
169
+ const notes = result.slice(i + NOTES_DELIM.length).trim();
170
+ if (notes) latestNotes = notes;
171
+ return { reply: reply || '(no response)', notes: latestNotes };
172
+ }
173
+
174
+ // --- HTTP helpers ---
175
+
176
+ function sendJson(res, code, obj) {
177
+ const body = JSON.stringify(obj);
178
+ res.writeHead(code, { 'Content-Type': 'application/json' });
179
+ res.end(body);
180
+ }
181
+
182
+ function readBody(req) {
183
+ return new Promise((resolve) => {
184
+ let data = '';
185
+ req.on('data', (c) => (data += c));
186
+ req.on('end', () => {
187
+ try {
188
+ resolve(data ? JSON.parse(data) : {});
189
+ } catch {
190
+ resolve({});
191
+ }
192
+ });
193
+ });
194
+ }
195
+
196
+ // --- Server ---
197
+
198
+ const server = http.createServer(async (req, res) => {
199
+ const url = (req.url || '/').split('?')[0];
200
+
201
+ if (req.method === 'GET' && url === '/') {
202
+ fs.readFile(HTML_PATH, (e, buf) => {
203
+ if (e) {
204
+ res.writeHead(500);
205
+ res.end('meeting.html not found');
206
+ } else {
207
+ res.writeHead(200, { 'Content-Type': 'text/html' });
208
+ res.end(buf);
209
+ }
210
+ });
211
+ return;
212
+ }
213
+
214
+ if (req.method === 'GET' && url === '/config') {
215
+ return sendJson(res, 200, {
216
+ mode: MODE,
217
+ agentId: AGENT_ID,
218
+ voiceId: VOICE_ID,
219
+ topic: MEETING_TOPIC,
220
+ language: LANGUAGE,
221
+ });
222
+ }
223
+
224
+ if (req.method === 'GET' && url === '/signed-url') {
225
+ if (!API_KEY || !AGENT_ID) {
226
+ return sendJson(res, 200, { signedUrl: null });
227
+ }
228
+ try {
229
+ const r = await fetch(
230
+ `https://api.elevenlabs.io/v1/convai/conversation/get-signed-url?agent_id=${AGENT_ID}`,
231
+ { headers: { 'xi-api-key': API_KEY } }
232
+ );
233
+ const j = await r.json();
234
+ return sendJson(res, 200, { signedUrl: j.signed_url || null });
235
+ } catch (e) {
236
+ return sendJson(res, 200, { signedUrl: null, error: e.message });
237
+ }
238
+ }
239
+
240
+ if (req.method === 'POST' && url === '/tool') {
241
+ const { query } = await readBody(req);
242
+ if (!query) return sendJson(res, 400, { result: '(empty query)' });
243
+ const prompt =
244
+ `You are assisting a live spec meeting. Investigate this codebase and answer ` +
245
+ `concisely (max ~150 words), citing files where useful. Question: ${query}`;
246
+ const result = await runClaude(prompt, 120000);
247
+ findings.push({ query, result });
248
+ return sendJson(res, 200, { result });
249
+ }
250
+
251
+ if (req.method === 'POST' && url === '/turn') {
252
+ const { text } = await readBody(req);
253
+ if (!text) return sendJson(res, 400, { reply: '', notes: latestNotes });
254
+ const { reply, notes } = await claudeTurn(text);
255
+ return sendJson(res, 200, { reply, notes });
256
+ }
257
+
258
+ if (req.method === 'POST' && url === '/end') {
259
+ const { transcript, notesMd, notesHtml } = await readBody(req);
260
+ const text = (transcript || '').trim() || '(empty transcript)';
261
+ // Prefer the notes maintained live during the meeting; otherwise summarize the
262
+ // transcript in one pass (bundled mode, or if no notes were produced).
263
+ let notes = (notesMd || latestNotes || '').trim();
264
+ if (!notes) notes = await runClaude(SUMMARY_PROMPT(MEETING_TOPIC, text), 180000);
265
+ const { mdPath, htmlPath } = writeArtifacts({
266
+ projectRoot: PROJECT_ROOT,
267
+ meetingDir: MEETING_DIR,
268
+ topic: MEETING_TOPIC,
269
+ notesMd: notes,
270
+ notesHtml,
271
+ transcriptText: text,
272
+ findings,
273
+ });
274
+ sendJson(res, 200, { path: mdPath, htmlPath });
275
+ console.log(`\n ✓ Meeting artifacts written:\n ${mdPath}\n ${htmlPath}`);
276
+ setTimeout(() => server.close(() => process.exit(0)), 500);
277
+ return;
278
+ }
279
+
280
+ res.writeHead(404);
281
+ res.end('not found');
282
+ });
283
+
284
+ server.listen(PORT, () => {
285
+ console.log(`\n Catalyst meeting room: http://localhost:${PORT}`);
286
+ console.log(` Topic: ${MEETING_TOPIC} · Mode: ${MODE} · Language: ${LANGUAGE}`);
287
+ if (MODE === 'diy') {
288
+ console.log(' DIY mode: browser speech + local claude -p (no ElevenLabs, no cost).');
289
+ } else {
290
+ if (!API_KEY) console.log(' ⚠ No elevenlabs_api_key in .catalyst/secrets.local.yaml');
291
+ if (!AGENT_ID) console.log(' ⚠ No voice.agent_id in project-config.yaml');
292
+ }
293
+ });