brainstorm-companion 1.0.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.
- package/README.md +66 -0
- package/bin/brainstorm.js +2 -0
- package/package.json +39 -0
- package/skill/SKILL.md +71 -0
- package/skill/visual-companion.md +331 -0
- package/src/cli.js +441 -0
- package/src/content-detect.js +52 -0
- package/src/mcp.js +331 -0
- package/src/server.js +624 -0
- package/src/session.js +215 -0
- package/src/templates/comparison-helper.js +277 -0
- package/src/templates/comparison.html +277 -0
- package/src/templates/frame.html +283 -0
- package/src/templates/helper.js +78 -0
- package/src/templates/waiting.html +8 -0
- package/src/ws-protocol.js +69 -0
package/src/mcp.js
ADDED
|
@@ -0,0 +1,331 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const fs = require('node:fs');
|
|
4
|
+
const path = require('node:path');
|
|
5
|
+
const { exec } = require('node:child_process');
|
|
6
|
+
const { startServer } = require('./server');
|
|
7
|
+
|
|
8
|
+
class McpServer {
|
|
9
|
+
constructor() {
|
|
10
|
+
this.sessionDir = null; // absolute path once session is started
|
|
11
|
+
this.serverInstance = null; // startServer() result
|
|
12
|
+
this.buffer = ''; // stdin buffer
|
|
13
|
+
this._pending = null; // Promise of the in-flight tool call (for serialization)
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
start() {
|
|
17
|
+
process.stdin.setEncoding('utf8');
|
|
18
|
+
process.stdin.on('data', (chunk) => {
|
|
19
|
+
this.buffer += chunk;
|
|
20
|
+
const lines = this.buffer.split('\n');
|
|
21
|
+
this.buffer = lines.pop(); // keep incomplete last line in buffer
|
|
22
|
+
for (const line of lines) {
|
|
23
|
+
if (line.trim()) this.handleMessage(line.trim());
|
|
24
|
+
}
|
|
25
|
+
});
|
|
26
|
+
process.stdin.on('end', () => this.cleanup());
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
respond(id, result) {
|
|
30
|
+
const msg = JSON.stringify({ jsonrpc: '2.0', id, result });
|
|
31
|
+
process.stdout.write(msg + '\n');
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
respondError(id, code, message) {
|
|
35
|
+
const msg = JSON.stringify({ jsonrpc: '2.0', id, error: { code, message } });
|
|
36
|
+
process.stdout.write(msg + '\n');
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
handleMessage(raw) {
|
|
40
|
+
let msg;
|
|
41
|
+
try { msg = JSON.parse(raw); } catch {
|
|
42
|
+
// Invalid JSON — ignore
|
|
43
|
+
return;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const { id, method, params } = msg;
|
|
47
|
+
|
|
48
|
+
switch (method) {
|
|
49
|
+
case 'initialize':
|
|
50
|
+
this.respond(id, {
|
|
51
|
+
protocolVersion: '2024-11-05',
|
|
52
|
+
capabilities: { tools: {} },
|
|
53
|
+
serverInfo: { name: 'brainstorm-companion', version: '1.0.0' }
|
|
54
|
+
});
|
|
55
|
+
break;
|
|
56
|
+
|
|
57
|
+
case 'notifications/initialized':
|
|
58
|
+
// Notification — no response
|
|
59
|
+
break;
|
|
60
|
+
|
|
61
|
+
case 'tools/list':
|
|
62
|
+
this.respond(id, { tools: this.getToolDefinitions() });
|
|
63
|
+
break;
|
|
64
|
+
|
|
65
|
+
case 'tools/call':
|
|
66
|
+
this.handleToolCall(id, params);
|
|
67
|
+
break;
|
|
68
|
+
|
|
69
|
+
default:
|
|
70
|
+
if (id !== undefined) {
|
|
71
|
+
this.respondError(id, -32601, `Method not found: ${method}`);
|
|
72
|
+
}
|
|
73
|
+
break;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
handleToolCall(id, params) {
|
|
78
|
+
// Serialize tool calls: each waits for the previous to finish. This ensures
|
|
79
|
+
// that brainstorm_start_session (async) responds before subsequent tools run.
|
|
80
|
+
const prev = this._pending || Promise.resolve();
|
|
81
|
+
const next = prev.then(() => {
|
|
82
|
+
const { name, arguments: args = {} } = params || {};
|
|
83
|
+
let resultOrPromise;
|
|
84
|
+
try {
|
|
85
|
+
switch (name) {
|
|
86
|
+
case 'brainstorm_start_session': resultOrPromise = this.toolStartSession(args); break;
|
|
87
|
+
case 'brainstorm_push_screen': resultOrPromise = this.toolPushScreen(args); break;
|
|
88
|
+
case 'brainstorm_read_events': resultOrPromise = this.toolReadEvents(args); break;
|
|
89
|
+
case 'brainstorm_clear_screen': resultOrPromise = this.toolClearScreen(args); break;
|
|
90
|
+
case 'brainstorm_stop_session': resultOrPromise = this.toolStopSession(args); break;
|
|
91
|
+
default:
|
|
92
|
+
this.respondError(id, -32602, `Unknown tool: ${name}`);
|
|
93
|
+
return;
|
|
94
|
+
}
|
|
95
|
+
} catch (err) {
|
|
96
|
+
this.respond(id, {
|
|
97
|
+
content: [{ type: 'text', text: `Error: ${err.message}` }],
|
|
98
|
+
isError: true
|
|
99
|
+
});
|
|
100
|
+
return;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const sendResult = (result) => {
|
|
104
|
+
this.respond(id, {
|
|
105
|
+
content: [{ type: 'text', text: typeof result === 'string' ? result : JSON.stringify(result, null, 2) }]
|
|
106
|
+
});
|
|
107
|
+
};
|
|
108
|
+
const sendError = (err) => {
|
|
109
|
+
this.respond(id, {
|
|
110
|
+
content: [{ type: 'text', text: `Error: ${err.message}` }],
|
|
111
|
+
isError: true
|
|
112
|
+
});
|
|
113
|
+
};
|
|
114
|
+
|
|
115
|
+
if (resultOrPromise && typeof resultOrPromise.then === 'function') {
|
|
116
|
+
return resultOrPromise.then(sendResult).catch(sendError);
|
|
117
|
+
} else {
|
|
118
|
+
sendResult(resultOrPromise);
|
|
119
|
+
}
|
|
120
|
+
});
|
|
121
|
+
// Track the tail of the chain; errors in earlier steps shouldn't block later ones
|
|
122
|
+
this._pending = next.catch(() => {});
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
getToolDefinitions() {
|
|
126
|
+
return [
|
|
127
|
+
{
|
|
128
|
+
name: 'brainstorm_start_session',
|
|
129
|
+
description: 'Start a brainstorm companion server and open a browser window for visual brainstorming. Returns the URL.',
|
|
130
|
+
inputSchema: {
|
|
131
|
+
type: 'object',
|
|
132
|
+
properties: {
|
|
133
|
+
project_dir: { type: 'string', description: 'Project directory for session storage' },
|
|
134
|
+
port: { type: 'number', description: 'Port to bind to (default: random)' },
|
|
135
|
+
open_browser: { type: 'boolean', description: 'Whether to open the browser (default: true)' }
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
},
|
|
139
|
+
{
|
|
140
|
+
name: 'brainstorm_push_screen',
|
|
141
|
+
description: 'Push HTML content to the brainstorm browser window. Supports comparison mode via slots.',
|
|
142
|
+
inputSchema: {
|
|
143
|
+
type: 'object',
|
|
144
|
+
properties: {
|
|
145
|
+
html: { type: 'string', description: 'HTML content to display' },
|
|
146
|
+
slot: { type: 'string', description: 'Slot for comparison mode: a, b, or c' },
|
|
147
|
+
label: { type: 'string', description: 'Label for the slot' }
|
|
148
|
+
},
|
|
149
|
+
required: ['html']
|
|
150
|
+
}
|
|
151
|
+
},
|
|
152
|
+
{
|
|
153
|
+
name: 'brainstorm_read_events',
|
|
154
|
+
description: 'Read user interaction events (clicks, preferences) from the brainstorm browser.',
|
|
155
|
+
inputSchema: {
|
|
156
|
+
type: 'object',
|
|
157
|
+
properties: {
|
|
158
|
+
clear_after_read: { type: 'boolean', description: 'Clear events after reading (default: false)' }
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
},
|
|
162
|
+
{
|
|
163
|
+
name: 'brainstorm_clear_screen',
|
|
164
|
+
description: 'Clear content from the brainstorm browser window.',
|
|
165
|
+
inputSchema: {
|
|
166
|
+
type: 'object',
|
|
167
|
+
properties: {
|
|
168
|
+
slot: { type: 'string', description: 'Clear specific slot (a, b, or c). Omit to clear all.' }
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
},
|
|
172
|
+
{
|
|
173
|
+
name: 'brainstorm_stop_session',
|
|
174
|
+
description: 'Stop the brainstorm companion server and clean up.',
|
|
175
|
+
inputSchema: { type: 'object', properties: {} }
|
|
176
|
+
}
|
|
177
|
+
];
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
// ---------------------------------------------------------------------------
|
|
181
|
+
// Tool implementations
|
|
182
|
+
// ---------------------------------------------------------------------------
|
|
183
|
+
|
|
184
|
+
toolStartSession(args) {
|
|
185
|
+
// If already started, return existing URL
|
|
186
|
+
if (this.sessionDir && this.serverInstance && this.serverInstance.url) {
|
|
187
|
+
return { url: this.serverInstance.url, session_dir: this.sessionDir };
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
const { project_dir, port = 0, open_browser = true } = args;
|
|
191
|
+
|
|
192
|
+
// Determine base directory and create session dir
|
|
193
|
+
const baseDir = project_dir
|
|
194
|
+
? path.join(project_dir, '.superpowers', 'brainstorm')
|
|
195
|
+
: '/tmp/brainstorm-companion';
|
|
196
|
+
const sessionId = `${process.pid}-${Date.now()}`;
|
|
197
|
+
const sessionDir = path.join(baseDir, sessionId);
|
|
198
|
+
fs.mkdirSync(sessionDir, { recursive: true });
|
|
199
|
+
this.sessionDir = sessionDir;
|
|
200
|
+
|
|
201
|
+
const instance = startServer({
|
|
202
|
+
screenDir: sessionDir,
|
|
203
|
+
host: '127.0.0.1',
|
|
204
|
+
port: port || 0,
|
|
205
|
+
ownerPid: process.pid,
|
|
206
|
+
logFn: (...a) => console.error(...a),
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
this.serverInstance = instance;
|
|
210
|
+
|
|
211
|
+
// The HTTP server starts asynchronously; return a Promise resolved once
|
|
212
|
+
// the 'listening' event fires and the URL is available.
|
|
213
|
+
return new Promise((resolve, reject) => {
|
|
214
|
+
instance.server.once('listening', () => {
|
|
215
|
+
const url = instance.url;
|
|
216
|
+
if (open_browser) {
|
|
217
|
+
const platform = process.platform;
|
|
218
|
+
let cmd;
|
|
219
|
+
if (platform === 'darwin') cmd = `open "${url}"`;
|
|
220
|
+
else if (platform === 'linux') cmd = `xdg-open "${url}"`;
|
|
221
|
+
else cmd = `start "${url}"`;
|
|
222
|
+
exec(cmd, (err) => {
|
|
223
|
+
if (err) console.error(`Warning: could not open browser: ${err.message}`);
|
|
224
|
+
});
|
|
225
|
+
}
|
|
226
|
+
resolve({ url, session_dir: sessionDir });
|
|
227
|
+
});
|
|
228
|
+
instance.server.once('error', (err) => {
|
|
229
|
+
reject(err);
|
|
230
|
+
});
|
|
231
|
+
});
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
toolPushScreen(args) {
|
|
235
|
+
if (!this.sessionDir) {
|
|
236
|
+
throw new Error('No active session. Call brainstorm_start_session first.');
|
|
237
|
+
}
|
|
238
|
+
const { html, slot, label } = args;
|
|
239
|
+
if (!html) throw new Error('html is required');
|
|
240
|
+
|
|
241
|
+
let filePath;
|
|
242
|
+
if (slot !== undefined) {
|
|
243
|
+
const slotDir = path.join(this.sessionDir, `slot-${slot}`);
|
|
244
|
+
fs.mkdirSync(slotDir, { recursive: true });
|
|
245
|
+
filePath = path.join(slotDir, 'current.html');
|
|
246
|
+
if (label !== undefined) {
|
|
247
|
+
fs.writeFileSync(path.join(slotDir, '.label'), String(label), 'utf8');
|
|
248
|
+
}
|
|
249
|
+
} else {
|
|
250
|
+
filePath = path.join(this.sessionDir, `screen-${Date.now()}.html`);
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
fs.writeFileSync(filePath, html, 'utf8');
|
|
254
|
+
return { path: filePath, slot: slot || null, label: label || null };
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
toolReadEvents(args) {
|
|
258
|
+
if (!this.sessionDir) {
|
|
259
|
+
return { events: [], count: 0 };
|
|
260
|
+
}
|
|
261
|
+
const { clear_after_read = false } = args;
|
|
262
|
+
const eventsPath = path.join(this.sessionDir, '.events');
|
|
263
|
+
let events = [];
|
|
264
|
+
if (fs.existsSync(eventsPath)) {
|
|
265
|
+
try {
|
|
266
|
+
const raw = fs.readFileSync(eventsPath, 'utf8');
|
|
267
|
+
events = raw
|
|
268
|
+
.split('\n')
|
|
269
|
+
.filter(line => line.trim())
|
|
270
|
+
.map(line => JSON.parse(line));
|
|
271
|
+
} catch {
|
|
272
|
+
events = [];
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
if (clear_after_read) {
|
|
276
|
+
try { fs.writeFileSync(eventsPath, '', 'utf8'); } catch { /* ignore */ }
|
|
277
|
+
}
|
|
278
|
+
return { events, count: events.length };
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
toolClearScreen(args) {
|
|
282
|
+
if (!this.sessionDir) {
|
|
283
|
+
throw new Error('No active session. Call brainstorm_start_session first.');
|
|
284
|
+
}
|
|
285
|
+
const { slot } = args;
|
|
286
|
+
if (slot) {
|
|
287
|
+
const slotFile = path.join(this.sessionDir, `slot-${slot}`, 'current.html');
|
|
288
|
+
try { fs.rmSync(slotFile); } catch { /* ignore if not found */ }
|
|
289
|
+
} else {
|
|
290
|
+
// Clear all top-level html files and slot current.html files
|
|
291
|
+
try {
|
|
292
|
+
const entries = fs.readdirSync(this.sessionDir, { withFileTypes: true });
|
|
293
|
+
for (const entry of entries) {
|
|
294
|
+
if (entry.isFile() && entry.name.endsWith('.html')) {
|
|
295
|
+
fs.rmSync(path.join(this.sessionDir, entry.name));
|
|
296
|
+
}
|
|
297
|
+
if (entry.isDirectory() && entry.name.startsWith('slot-')) {
|
|
298
|
+
const slotCurrent = path.join(this.sessionDir, entry.name, 'current.html');
|
|
299
|
+
try { fs.rmSync(slotCurrent); } catch { /* ignore */ }
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
} catch { /* ignore */ }
|
|
303
|
+
}
|
|
304
|
+
return { cleared: true };
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
toolStopSession() {
|
|
308
|
+
if (this.serverInstance) {
|
|
309
|
+
this.serverInstance.shutdown('mcp-stop');
|
|
310
|
+
this.serverInstance = null;
|
|
311
|
+
}
|
|
312
|
+
if (this.sessionDir) {
|
|
313
|
+
try { fs.rmSync(this.sessionDir, { recursive: true, force: true }); } catch { /* ignore */ }
|
|
314
|
+
this.sessionDir = null;
|
|
315
|
+
}
|
|
316
|
+
return { stopped: true };
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
cleanup() {
|
|
320
|
+
if (this.serverInstance) {
|
|
321
|
+
this.serverInstance.shutdown('stdin-end');
|
|
322
|
+
this.serverInstance = null;
|
|
323
|
+
}
|
|
324
|
+
if (this.sessionDir) {
|
|
325
|
+
try { fs.rmSync(this.sessionDir, { recursive: true, force: true }); } catch { /* ignore */ }
|
|
326
|
+
this.sessionDir = null;
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
module.exports = { McpServer };
|