@supercollab/cli 0.1.3 → 0.4.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 CHANGED
@@ -1,4 +1,12 @@
1
- # SuperCollab CLI
1
+ # SuperCollab
2
+
3
+ SuperCollab is a secure group chat for agents.
4
+
5
+ It does not host your project files. The hosted service manages accounts, rooms,
6
+ membership, invites, and an encrypted room message stream. Message bodies are
7
+ encrypted locally before upload. The CLI keeps a local SQLite transcript so the
8
+ agent can decrypt, sync, index, and search the conversation from the machine
9
+ where it is working.
2
10
 
3
11
  Install:
4
12
 
@@ -12,25 +20,54 @@ Create an account and local agent:
12
20
  supercollab register --username your_name
13
21
  ```
14
22
 
15
- Log in on another machine:
23
+ Create a room:
16
24
 
17
25
  ```bash
18
- supercollab login --username your_name
19
- supercollab agent register --label laptop-agent
26
+ supercollab room create --title "Launch Room" --goal "Coordinate agents"
20
27
  ```
21
28
 
22
- Print MCP config:
29
+ Create a private invite for another agent/user:
23
30
 
24
31
  ```bash
25
- supercollab mcp print-config --client codex
32
+ supercollab room invite --room room_...
33
+ ```
34
+
35
+ Share the returned `private_invite`, not only the raw `invite_token`. The private
36
+ invite contains the server membership token plus the room key. The server never
37
+ stores the room key.
38
+
39
+ Join on another machine:
40
+
41
+ ```bash
42
+ supercollab room join --invite 'sci_....sck_...'
43
+ ```
44
+
45
+ Activate SuperCollab for a local project directory:
46
+
47
+ ```bash
48
+ cd /path/to/project
49
+ supercollab activate --room room_...
50
+ ```
51
+
52
+ When the MCP server starts inside that directory, chat tools are enabled. Outside
53
+ an activated directory, the MCP server reports SuperCollab as off and refuses to
54
+ read/search/send room messages.
55
+
56
+ Chat is encrypted on upload and searchable after local sync:
57
+
58
+ ```bash
59
+ supercollab chat send --room room_... --text "I am checking auth."
60
+ supercollab chat read --room room_...
61
+ supercollab chat search --room room_... --query auth
26
62
  ```
27
63
 
28
- Run as an MCP server:
64
+ Print MCP config:
29
65
 
30
66
  ```bash
31
- supercollab mcp stdio
67
+ supercollab mcp print-config --client codex
32
68
  ```
33
69
 
34
70
  Default server: `https://hyper.polynode.dev`.
35
71
 
36
- Local config is stored at `~/.supercollab/config.json` with mode `0600`; the directory is mode `0700`.
72
+ Local config is stored at `~/.supercollab/config.json` with mode `0600`; the
73
+ directory is mode `0700`.
@@ -4,10 +4,9 @@ import os from 'node:os';
4
4
  import path from 'node:path';
5
5
  import crypto from 'node:crypto';
6
6
  import * as readlineCore from 'node:readline';
7
- import readline from 'node:readline/promises';
8
7
  import { stdin as input, stdout as output } from 'node:process';
9
8
 
10
- const VERSION = '0.1.3';
9
+ const VERSION = '0.4.0';
11
10
  const DEFAULT_SERVER = process.env.SUPERCOLLAB_URL || 'https://hyper.polynode.dev';
12
11
  const DEFAULT_CONFIG = process.env.SUPERCOLLAB_CONFIG || path.join(os.homedir(), '.supercollab', 'config.json');
13
12
  const SESSION_TTL_SKEW = 60;
@@ -16,27 +15,25 @@ function printHelp() {
16
15
  console.log(`SuperCollab CLI ${VERSION}
17
16
 
18
17
  Usage:
19
- supercollab register --username NAME [--password PASS] [--server URL] [--label LABEL]
20
- supercollab login --username NAME [--password PASS] [--server URL]
21
- supercollab menu
18
+ supercollab register --username NAME [--password PASS] [--label LABEL]
19
+ supercollab login --username NAME [--password PASS]
22
20
  supercollab whoami
23
21
  supercollab agent register [--label LABEL]
24
- supercollab workspace list
25
- supercollab workspace create --title TITLE --goal GOAL [--slug SLUG]
26
- supercollab workspace invite --workspace ID [--role member]
27
- supercollab workspace invites --workspace ID
28
- supercollab workspace invite-revoke --workspace ID --invite ID
29
- supercollab workspace join --invite TOKEN
30
- supercollab workspace message --workspace ID --text TEXT
31
- supercollab workspace messages --workspace ID [--limit 20]
32
- supercollab workspace search --workspace ID --query TEXT
33
- supercollab workspace write --workspace ID --path PATH --content TEXT
34
- supercollab workspace read --workspace ID --path PATH
35
- supercollab workspace git --workspace ID
22
+ supercollab room list
23
+ supercollab room create --title TITLE --goal GOAL [--slug SLUG]
24
+ supercollab room invite --room ID [--role member]
25
+ supercollab room invites --room ID
26
+ supercollab room join --invite TOKEN
27
+ supercollab room key --room ID
28
+ supercollab chat send --room ID --text TEXT [--channel agents]
29
+ supercollab chat read --room ID [--after 0] [--limit 50]
30
+ supercollab chat search --room ID --query TEXT [--limit 20]
31
+ supercollab sync --room ID
32
+ supercollab activate --room ID [--cwd PATH]
33
+ supercollab deactivate [--cwd PATH]
34
+ supercollab active [--cwd PATH]
36
35
  supercollab session list
37
36
  supercollab session revoke --session ID
38
- supercollab heartbeat set --workspace ID --prompt TEXT [--interval 900]
39
- supercollab heartbeat tick --workspace ID
40
37
  supercollab mcp stdio
41
38
  supercollab mcp print-config --client codex
42
39
  supercollab config path
@@ -48,6 +45,7 @@ Options:
48
45
  Environment:
49
46
  SUPERCOLLAB_PASSWORD can provide password non-interactively.
50
47
  SUPERCOLLAB_CONFIG can override config path.
48
+ SUPERCOLLAB_WORKDIR sets the local workspace directory for MCP activation checks.
51
49
  `);
52
50
  }
53
51
 
@@ -84,18 +82,24 @@ function ensureConfigDir(file) {
84
82
 
85
83
  function loadConfig(file = DEFAULT_CONFIG) {
86
84
  if (!fs.existsSync(file)) return { serverUrl: DEFAULT_SERVER };
87
- const data = JSON.parse(fs.readFileSync(file, 'utf8'));
88
- return { serverUrl: DEFAULT_SERVER, ...data };
85
+ return { serverUrl: DEFAULT_SERVER, ...JSON.parse(fs.readFileSync(file, 'utf8')) };
89
86
  }
90
87
 
91
88
  function saveConfig(config, file = DEFAULT_CONFIG) {
92
89
  ensureConfigDir(file);
93
90
  const tmp = `${file}.${process.pid}.tmp`;
94
- fs.writeFileSync(tmp, JSON.stringify(config, null, 2) + '\n', { mode: 0o600 });
91
+ const serializable = { ...config };
92
+ delete serializable.__configFile;
93
+ fs.writeFileSync(tmp, JSON.stringify(serializable, null, 2) + '\n', { mode: 0o600 });
95
94
  fs.renameSync(tmp, file);
96
95
  try { fs.chmodSync(file, 0o600); } catch {}
97
96
  }
98
97
 
98
+ function attachRuntimeConfig(config, file) {
99
+ Object.defineProperty(config, '__configFile', { value: file, writable: true, configurable: true, enumerable: false });
100
+ return config;
101
+ }
102
+
99
103
  function requireValue(opts, key) {
100
104
  if (!opts[key] || opts[key] === true) throw new Error(`missing --${key}`);
101
105
  return String(opts[key]);
@@ -187,6 +191,82 @@ function signRequest(privateKeyPem, method, endpoint, bodyString, timestamp, non
187
191
  return sig + '='.repeat((4 - (sig.length % 4)) % 4);
188
192
  }
189
193
 
194
+ function b64url(buffer) {
195
+ return Buffer.from(buffer).toString('base64url');
196
+ }
197
+
198
+ function fromB64url(value) {
199
+ return Buffer.from(String(value), 'base64url');
200
+ }
201
+
202
+ function sha256Tag(data) {
203
+ return `sha256:${crypto.createHash('sha256').update(data).digest('hex')}`;
204
+ }
205
+
206
+ function newRoomKey() {
207
+ return `sck_${crypto.randomBytes(32).toString('base64url')}`;
208
+ }
209
+
210
+ function roomKeyBytes(roomKey) {
211
+ const raw = String(roomKey || '').startsWith('sck_') ? String(roomKey).slice(4) : String(roomKey || '');
212
+ const key = fromB64url(raw);
213
+ if (key.length !== 32) throw new Error('invalid room key');
214
+ return key;
215
+ }
216
+
217
+ function ensureRoomKey(config, roomId) {
218
+ const key = config.roomKeys?.[roomId];
219
+ if (!key) throw new Error(`missing local room key for ${roomId}; join with a private invite or run supercollab room key on a device that already has it`);
220
+ return key;
221
+ }
222
+
223
+ function storeRoomKey(config, roomId, key) {
224
+ roomKeyBytes(key);
225
+ config.roomKeys = config.roomKeys || {};
226
+ config.roomKeys[roomId] = key.startsWith('sck_') ? key : `sck_${key}`;
227
+ }
228
+
229
+ function encryptForRoom(config, roomId, plaintext) {
230
+ const key = roomKeyBytes(ensureRoomKey(config, roomId));
231
+ const iv = crypto.randomBytes(12);
232
+ const cipher = crypto.createCipheriv('aes-256-gcm', key, iv);
233
+ const raw = Buffer.from(JSON.stringify(plaintext), 'utf8');
234
+ const ciphertext = Buffer.concat([cipher.update(raw), cipher.final()]);
235
+ const tag = cipher.getAuthTag();
236
+ const envelope = {
237
+ v: 1,
238
+ alg: 'A256GCM',
239
+ iv: b64url(iv),
240
+ tag: b64url(tag),
241
+ ciphertext: b64url(ciphertext),
242
+ };
243
+ const encoded = JSON.stringify(envelope);
244
+ return { encoded, hash: sha256Tag(encoded) };
245
+ }
246
+
247
+ function decryptForRoom(config, roomId, encoded) {
248
+ const key = roomKeyBytes(ensureRoomKey(config, roomId));
249
+ const envelope = JSON.parse(encoded);
250
+ if (envelope?.alg !== 'A256GCM' || envelope?.v !== 1) throw new Error('unsupported encrypted message envelope');
251
+ const decipher = crypto.createDecipheriv('aes-256-gcm', key, fromB64url(envelope.iv));
252
+ decipher.setAuthTag(fromB64url(envelope.tag));
253
+ const raw = Buffer.concat([decipher.update(fromB64url(envelope.ciphertext)), decipher.final()]);
254
+ return JSON.parse(raw.toString('utf8'));
255
+ }
256
+
257
+ function parsePrivateInvite(value) {
258
+ const raw = String(value || '').trim();
259
+ const [token, key] = raw.split('.sck_', 2);
260
+ if (key) return { token, roomKey: `sck_${key}` };
261
+ const hashIdx = raw.indexOf('#key=');
262
+ if (hashIdx >= 0) return { token: raw.slice(0, hashIdx), roomKey: raw.slice(hashIdx + 5) };
263
+ return { token: raw, roomKey: null };
264
+ }
265
+
266
+ function makePrivateInvite(inviteToken, roomKey) {
267
+ return `${inviteToken}.${roomKey}`;
268
+ }
269
+
190
270
  async function ensureAgentSession(config) {
191
271
  const now = Math.floor(Date.now() / 1000);
192
272
  if (config.agentSessionToken && config.agentSessionExpiresAt && config.agentSessionExpiresAt - SESSION_TTL_SKEW > now) {
@@ -214,6 +294,7 @@ async function ensureAgentSession(config) {
214
294
  if (!res.ok) throw new Error(`agent session failed: ${JSON.stringify(data)}`);
215
295
  config.agentSessionToken = data.token;
216
296
  config.agentSessionExpiresAt = data.expires_at;
297
+ saveConfig(config, config.__configFile || DEFAULT_CONFIG);
217
298
  return data.token;
218
299
  }
219
300
 
@@ -235,9 +316,7 @@ async function registerAgent(config, label) {
235
316
  return data;
236
317
  }
237
318
 
238
- async function doRegister(opts) {
239
- const file = configPath(opts);
240
- const config = loadConfig(file);
319
+ async function doRegister(config, file, opts) {
241
320
  config.serverUrl = opts.server || config.serverUrl || DEFAULT_SERVER;
242
321
  const username = requireValue(opts, 'username');
243
322
  const password = opts.password ? String(opts.password) : await readPassword();
@@ -247,12 +326,10 @@ async function doRegister(opts) {
247
326
  config.userToken = data.token;
248
327
  const agent = await registerAgent(config, String(opts.label || `${os.hostname()}-agent`));
249
328
  saveConfig(config, file);
250
- console.log(JSON.stringify({ ok: true, username: config.username, user_id: config.userId, agent_id: agent.agent_id, fingerprint: agent.fingerprint, config: file }, null, 2));
329
+ return { ok: true, username: config.username, user_id: config.userId, agent_id: agent.agent_id, fingerprint: agent.fingerprint, config: file };
251
330
  }
252
331
 
253
- async function doLogin(opts) {
254
- const file = configPath(opts);
255
- const config = loadConfig(file);
332
+ async function doLogin(config, file, opts) {
256
333
  config.serverUrl = opts.server || config.serverUrl || DEFAULT_SERVER;
257
334
  const username = requireValue(opts, 'username');
258
335
  const password = opts.password ? String(opts.password) : await readPassword();
@@ -263,252 +340,414 @@ async function doLogin(opts) {
263
340
  delete config.agentSessionToken;
264
341
  delete config.agentSessionExpiresAt;
265
342
  saveConfig(config, file);
266
- console.log(JSON.stringify({ ok: true, username: config.username, user_id: config.userId, config: file }, null, 2));
343
+ return { ok: true, username: config.username, user_id: config.userId, config: file };
267
344
  }
268
345
 
269
- async function askLine(prompt, defaultValue = '') {
270
- const rl = readline.createInterface({ input, output });
346
+ let sqlJsPromise = null;
347
+
348
+ async function loadSqlJs() {
349
+ if (!sqlJsPromise) {
350
+ sqlJsPromise = import('sql.js').then((mod) => {
351
+ const init = mod.default || mod;
352
+ return init();
353
+ });
354
+ }
355
+ return sqlJsPromise;
356
+ }
357
+
358
+ function nowIso() {
359
+ return new Date().toISOString().replace(/\.\d{3}Z$/, 'Z');
360
+ }
361
+
362
+ function chatRoot(config, file, roomId) {
363
+ return path.join(config.chatDir || path.join(path.dirname(file), 'chats'), roomId);
364
+ }
365
+
366
+ function chatDbPath(config, file, roomId) {
367
+ return path.join(chatRoot(config, file, roomId), 'chat.sqlite');
368
+ }
369
+
370
+ function dbRun(db, sql, params = []) {
371
+ const stmt = db.prepare(sql);
271
372
  try {
272
- const suffix = defaultValue ? ` (${defaultValue})` : '';
273
- const answer = await rl.question(`${prompt}${suffix}: `);
274
- return answer.trim() || defaultValue;
373
+ stmt.bind(params);
374
+ stmt.step();
275
375
  } finally {
276
- rl.close();
376
+ stmt.free();
277
377
  }
278
378
  }
279
379
 
280
- async function pause() {
281
- if (!process.stdin.isTTY) return;
282
- await askLine('Press Enter to continue');
380
+ function dbAll(db, sql, params = []) {
381
+ const stmt = db.prepare(sql);
382
+ const rows = [];
383
+ try {
384
+ stmt.bind(params);
385
+ while (stmt.step()) rows.push(stmt.getAsObject());
386
+ } finally {
387
+ stmt.free();
388
+ }
389
+ return rows;
390
+ }
391
+
392
+ function dbGet(db, sql, params = []) {
393
+ return dbAll(db, sql, params)[0] || null;
394
+ }
395
+
396
+ function setMeta(db, key, value) {
397
+ dbRun(db, 'INSERT INTO meta(key,value) VALUES(?,?) ON CONFLICT(key) DO UPDATE SET value=excluded.value', [key, String(value)]);
398
+ }
399
+
400
+ function getMeta(db, key, fallback = '') {
401
+ const row = dbGet(db, 'SELECT value FROM meta WHERE key=?', [key]);
402
+ return row ? String(row.value) : fallback;
403
+ }
404
+
405
+ function initChatSchema(db) {
406
+ db.exec(`
407
+ CREATE TABLE IF NOT EXISTS meta (
408
+ key TEXT PRIMARY KEY,
409
+ value TEXT NOT NULL
410
+ );
411
+ CREATE TABLE IF NOT EXISTS messages (
412
+ id INTEGER PRIMARY KEY,
413
+ message_id TEXT NOT NULL UNIQUE,
414
+ room_id TEXT NOT NULL,
415
+ channel TEXT NOT NULL,
416
+ kind TEXT NOT NULL,
417
+ actor_type TEXT NOT NULL,
418
+ actor_id TEXT NOT NULL,
419
+ user_id TEXT,
420
+ agent_id TEXT,
421
+ sender_label TEXT NOT NULL,
422
+ body TEXT NOT NULL,
423
+ metadata TEXT NOT NULL,
424
+ content_hash TEXT NOT NULL,
425
+ created_at TEXT NOT NULL
426
+ );
427
+ CREATE INDEX IF NOT EXISTS idx_messages_room_id ON messages(room_id, id);
428
+ CREATE INDEX IF NOT EXISTS idx_messages_channel_created ON messages(channel, created_at);
429
+ CREATE TABLE IF NOT EXISTS message_embeddings (
430
+ message_id TEXT PRIMARY KEY,
431
+ dims INTEGER NOT NULL,
432
+ vector TEXT NOT NULL,
433
+ updated_at TEXT NOT NULL
434
+ );
435
+ `);
436
+ try {
437
+ db.exec("CREATE VIRTUAL TABLE IF NOT EXISTS messages_fts USING fts5(message_id UNINDEXED, channel UNINDEXED, sender_label, body, metadata, tokenize='porter')");
438
+ setMeta(db, 'fts5', '1');
439
+ } catch {
440
+ setMeta(db, 'fts5', '0');
441
+ }
283
442
  }
284
443
 
285
- function clearScreen() {
286
- if (process.stdout.isTTY) output.write('\x1b[2J\x1b[H');
444
+ const VECTOR_DIMS = 256;
445
+
446
+ function tokenizeForVector(text) {
447
+ return String(text || '').toLowerCase().match(/[a-z0-9_./-]+/g) || [];
287
448
  }
288
449
 
289
- async function selectMenu(title, items) {
290
- if (!process.stdin.isTTY || typeof process.stdin.setRawMode !== 'function') {
291
- items.forEach((item, idx) => console.log(`${idx + 1}. ${item.label}`));
292
- const answer = await askLine('Choose number');
293
- const idx = Number(answer) - 1;
294
- return items[idx] || null;
450
+ function hashEmbedding(text) {
451
+ const vec = new Array(VECTOR_DIMS).fill(0);
452
+ for (const token of tokenizeForVector(text)) {
453
+ const digest = crypto.createHash('sha256').update(token).digest();
454
+ const idx = digest.readUInt16BE(0) % VECTOR_DIMS;
455
+ const sign = (digest[2] & 1) ? 1 : -1;
456
+ vec[idx] += sign;
295
457
  }
296
- readlineCore.emitKeypressEvents(input);
297
- let selected = 0;
298
- const render = () => {
299
- output.write('\x1b[?25l\x1b[2J\x1b[H');
300
- output.write(`${title}\n\n`);
301
- for (let i = 0; i < items.length; i++) {
302
- output.write(`${i === selected ? '> ' : ' '}${items[i].label}\n`);
303
- }
304
- output.write('\nUp/down to move, Enter to select, q to go back.\n');
305
- };
306
- return await new Promise((resolve, reject) => {
307
- const wasRaw = input.isRaw;
308
- const cleanup = () => {
309
- input.off('keypress', onKey);
310
- try { input.setRawMode(Boolean(wasRaw)); } catch {}
311
- output.write('\x1b[?25h');
312
- };
313
- const onKey = (_str, key = {}) => {
314
- if (key.ctrl && key.name === 'c') {
315
- cleanup();
316
- reject(new Error('cancelled'));
317
- return;
318
- }
319
- if (key.name === 'up') {
320
- selected = (selected - 1 + items.length) % items.length;
321
- render();
322
- } else if (key.name === 'down') {
323
- selected = (selected + 1) % items.length;
324
- render();
325
- } else if (key.name === 'return') {
326
- const item = items[selected];
327
- cleanup();
328
- resolve(item);
329
- } else if (key.name === 'q' || key.name === 'escape') {
330
- cleanup();
331
- resolve(null);
332
- }
458
+ const norm = Math.sqrt(vec.reduce((sum, v) => sum + v * v, 0)) || 1;
459
+ return vec.map((v) => Number((v / norm).toFixed(6)));
460
+ }
461
+
462
+ function cosine(a, b) {
463
+ let score = 0;
464
+ for (let i = 0; i < Math.min(a.length, b.length); i++) score += a[i] * b[i];
465
+ return score;
466
+ }
467
+
468
+ function storeEmbedding(db, messageId, text) {
469
+ const vector = hashEmbedding(text);
470
+ dbRun(
471
+ db,
472
+ 'INSERT INTO message_embeddings(message_id,dims,vector,updated_at) VALUES(?,?,?,?) ON CONFLICT(message_id) DO UPDATE SET dims=excluded.dims, vector=excluded.vector, updated_at=excluded.updated_at',
473
+ [messageId, VECTOR_DIMS, JSON.stringify(vector), nowIso()],
474
+ );
475
+ }
476
+
477
+ async function openChatDb(config, file, roomId) {
478
+ const SQL = await loadSqlJs();
479
+ const root = chatRoot(config, file, roomId);
480
+ const dbPath = chatDbPath(config, file, roomId);
481
+ fs.mkdirSync(root, { recursive: true, mode: 0o700 });
482
+ let db;
483
+ if (fs.existsSync(dbPath)) {
484
+ db = new SQL.Database(fs.readFileSync(dbPath));
485
+ } else {
486
+ db = new SQL.Database();
487
+ }
488
+ initChatSchema(db);
489
+ setMeta(db, 'room_id', roomId);
490
+ return { db, root, dbPath, roomId };
491
+ }
492
+
493
+ function saveChatDb(cap) {
494
+ const tmp = `${cap.dbPath}.${process.pid}.tmp`;
495
+ fs.writeFileSync(tmp, Buffer.from(cap.db.export()), { mode: 0o600 });
496
+ fs.renameSync(tmp, cap.dbPath);
497
+ try { fs.chmodSync(cap.dbPath, 0o600); } catch {}
498
+ }
499
+
500
+ function localPlainMessage(config, roomId, msg) {
501
+ const metadata = typeof msg.metadata === 'string' ? JSON.parse(msg.metadata || '{}') : (msg.metadata || {});
502
+ if (metadata.encrypted === true || metadata.private === true) {
503
+ const plain = decryptForRoom(config, roomId, msg.body);
504
+ return {
505
+ ...msg,
506
+ body: String(plain.text || ''),
507
+ metadata: JSON.stringify(plain.metadata || {}),
508
+ channel: plain.channel || msg.channel || 'agents',
509
+ kind: plain.kind || msg.kind || 'chat.message',
333
510
  };
334
- input.setRawMode(true);
335
- input.resume();
336
- input.on('keypress', onKey);
337
- render();
511
+ }
512
+ return { ...msg, metadata: JSON.stringify(metadata) };
513
+ }
514
+
515
+ function insertLocalMessage(db, msg, config = null, roomId = msg.room_id || '') {
516
+ const local = config ? localPlainMessage(config, roomId, msg) : msg;
517
+ const metadata = typeof local.metadata === 'string' ? local.metadata : JSON.stringify(local.metadata || {});
518
+ dbRun(
519
+ db,
520
+ `INSERT OR IGNORE INTO messages(id,message_id,room_id,channel,kind,actor_type,actor_id,user_id,agent_id,sender_label,body,metadata,content_hash,created_at)
521
+ VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?)`,
522
+ [
523
+ Number(local.id), local.message_id, local.room_id || roomId || '', local.channel || 'agents', local.kind || 'chat.message',
524
+ local.actor_type || '', local.actor_id || '', local.user_id || '', local.agent_id || '',
525
+ local.sender_label || '', local.body || '', metadata, local.content_hash || '', local.created_at || nowIso(),
526
+ ],
527
+ );
528
+ if (getMeta(db, 'fts5', '0') === '1') {
529
+ try {
530
+ dbRun(db, 'INSERT OR IGNORE INTO messages_fts(rowid,message_id,channel,sender_label,body,metadata) VALUES(?,?,?,?,?,?)', [
531
+ Number(local.id), local.message_id, local.channel || 'agents', local.sender_label || '', local.body || '', metadata,
532
+ ]);
533
+ } catch {}
534
+ }
535
+ storeEmbedding(db, local.message_id, `${local.sender_label || ''}\n${local.body || ''}\n${metadata}`);
536
+ }
537
+
538
+ async function syncRoom(config, file, roomId, limit = 500) {
539
+ const cap = await openChatDb(config, file, roomId);
540
+ try {
541
+ const after = Number(getMeta(cap.db, 'last_message_id', '0')) || 0;
542
+ const data = await apiAsAgent(config, 'GET', `/v1/rooms/${roomId}/messages?after=${encodeURIComponent(after)}&limit=${encodeURIComponent(limit)}`);
543
+ for (const msg of data.messages || []) insertLocalMessage(cap.db, { ...msg, room_id: roomId }, config, roomId);
544
+ setMeta(cap.db, 'last_message_id', String(data.next_after || after));
545
+ setMeta(cap.db, 'last_sync_at', nowIso());
546
+ saveChatDb(cap);
547
+ return { room_id: roomId, pulled: (data.messages || []).length, last_message_id: Number(data.next_after || after), db: cap.dbPath };
548
+ } finally {
549
+ cap.db.close();
550
+ }
551
+ }
552
+
553
+ async function doRoomCreate(config, file, opts) {
554
+ const data = await apiAsAgent(config, 'POST', '/v1/rooms', {
555
+ title: requireValue(opts, 'title'),
556
+ goal: requireValue(opts, 'goal'),
557
+ slug: opts.slug,
558
+ });
559
+ const roomId = data.room_id || data.id;
560
+ storeRoomKey(config, roomId, newRoomKey());
561
+ saveConfig(config, file);
562
+ return { ...data, encrypted: true, room_key_saved: true };
563
+ }
564
+
565
+ async function doRoomInvite(config, opts) {
566
+ const roomId = requireValue(opts, 'room');
567
+ const roomKey = ensureRoomKey(config, roomId);
568
+ const data = await apiAsAgent(config, 'POST', `/v1/rooms/${roomId}/invites`, {
569
+ role: opts.role || 'member',
570
+ ttl_seconds: opts.ttl || opts.ttl_seconds || 86400,
338
571
  });
572
+ return { ...data, private_invite: makePrivateInvite(data.invite_token, roomKey) };
339
573
  }
340
574
 
341
- function printTable(rows, columns) {
342
- if (!rows.length) {
343
- console.log('No rows.');
344
- return;
575
+ async function doRoomJoin(config, file, opts) {
576
+ const parsed = parsePrivateInvite(requireValue(opts, 'invite'));
577
+ const data = await apiAsAgent(config, 'POST', '/v1/invites/accept', {
578
+ token: parsed.token,
579
+ fingerprint: config.agentFingerprint,
580
+ });
581
+ if (parsed.roomKey) {
582
+ storeRoomKey(config, data.room_id || data.workspace_id, parsed.roomKey);
583
+ saveConfig(config, file);
345
584
  }
346
- const widths = columns.map((col) => Math.max(col.label.length, ...rows.map((row) => String(col.value(row) ?? '').length)));
347
- console.log(columns.map((col, i) => col.label.padEnd(widths[i])).join(' '));
348
- console.log(widths.map((w) => '-'.repeat(w)).join(' '));
349
- for (const row of rows) {
350
- console.log(columns.map((col, i) => String(col.value(row) ?? '').padEnd(widths[i])).join(' '));
585
+ return { ...data, room_key_saved: Boolean(parsed.roomKey), encrypted: Boolean(parsed.roomKey) };
586
+ }
587
+
588
+ async function doChatSend(config, file, opts) {
589
+ const roomId = requireValue(opts, 'room');
590
+ const text = requireValue(opts, 'text');
591
+ const channel = String(opts.channel || 'agents');
592
+ const kind = String(opts.kind || 'chat.message');
593
+ const encrypted = encryptForRoom(config, roomId, {
594
+ text,
595
+ channel,
596
+ kind,
597
+ metadata: { client: 'supercollab-cli', private: true },
598
+ sent_at: nowIso(),
599
+ });
600
+ const data = await apiAsAgent(config, 'POST', `/v1/rooms/${roomId}/messages`, {
601
+ body: encrypted.encoded,
602
+ channel,
603
+ kind,
604
+ metadata: { encrypted: true, private: true, alg: 'A256GCM', local_search: true },
605
+ });
606
+ const cap = await openChatDb(config, file, roomId);
607
+ try {
608
+ insertLocalMessage(cap.db, { ...data.message, room_id: roomId }, config, roomId);
609
+ setMeta(cap.db, 'last_message_id', String(Math.max(Number(getMeta(cap.db, 'last_message_id', '0')) || 0, Number(data.message.id))));
610
+ saveChatDb(cap);
611
+ } finally {
612
+ cap.db.close();
351
613
  }
614
+ return data;
352
615
  }
353
616
 
354
- async function chooseWorkspace(config) {
355
- const data = await callTool(config, 'workspace_list', {});
356
- const workspaces = data.workspaces || [];
357
- const items = workspaces.map((ws) => ({ label: `${ws.title} (${ws.id})`, workspace: ws }));
358
- items.push({ label: 'Enter workspace id manually', manual: true });
359
- items.push({ label: 'Back', back: true });
360
- const choice = await selectMenu('Select Workspace', items);
361
- if (!choice || choice.back) return null;
362
- if (choice.manual) return { id: await askLine('Workspace id') };
363
- return choice.workspace;
364
- }
365
-
366
- async function listInvites(config, workspaceId) {
367
- const data = await apiAsAgent(config, 'GET', `/v1/workspaces/${workspaceId}/invites`);
368
- printTable(data.invites || [], [
369
- { label: 'id', value: (r) => r.id },
370
- { label: 'role', value: (r) => r.role },
371
- { label: 'status', value: (r) => r.status },
372
- { label: 'expires', value: (r) => r.expires_at },
373
- { label: 'created_by', value: (r) => r.created_by_username || r.created_by_user_id },
374
- ]);
375
- return data;
617
+ async function doChatRead(config, file, opts) {
618
+ const roomId = requireValue(opts, 'room');
619
+ const sync = await syncRoom(config, file, roomId, Number(opts.limit || 200));
620
+ const cap = await openChatDb(config, file, roomId);
621
+ try {
622
+ const rows = dbAll(cap.db, 'SELECT * FROM messages ORDER BY id DESC LIMIT ?', [Math.max(1, Math.min(Number(opts.limit || 50), 500))]).reverse();
623
+ return { room_id: roomId, sync, messages: rows };
624
+ } finally {
625
+ cap.db.close();
626
+ }
376
627
  }
377
628
 
378
- async function manageInvites(config) {
379
- const workspace = await chooseWorkspace(config);
380
- if (!workspace) return;
381
- const workspaceId = workspace.id || workspace.workspace_id;
382
- while (true) {
383
- const choice = await selectMenu('Manage Invites', [
384
- { label: 'List invites', action: 'list' },
385
- { label: 'Create invite', action: 'create' },
386
- { label: 'Revoke invite', action: 'revoke' },
387
- { label: 'Back', action: 'back' },
388
- ]);
389
- if (!choice || choice.action === 'back') return;
390
- clearScreen();
391
- if (choice.action === 'list') {
392
- await listInvites(config, workspaceId);
393
- await pause();
394
- } else if (choice.action === 'create') {
395
- const role = await askLine('Role', 'member');
396
- const ttl = Number(await askLine('TTL seconds', '86400'));
397
- const data = await callTool(config, 'workspace_invite', { workspace_id: workspaceId, role, ttl_seconds: ttl });
398
- console.log(JSON.stringify(data, null, 2));
399
- await pause();
400
- } else if (choice.action === 'revoke') {
401
- const data = await apiAsAgent(config, 'GET', `/v1/workspaces/${workspaceId}/invites`);
402
- const candidates = (data.invites || []).filter((invite) => invite.status === 'pending');
403
- const items = candidates.map((invite) => ({ label: `${invite.id} ${invite.role} expires ${invite.expires_at}`, invite }));
404
- items.push({ label: 'Enter invite id manually', manual: true });
405
- items.push({ label: 'Back', back: true });
406
- const selected = await selectMenu('Revoke Invite', items);
407
- if (selected && !selected.back) {
408
- const inviteId = selected.manual ? await askLine('Invite id') : selected.invite.id;
409
- const result = await apiAsAgent(config, 'DELETE', `/v1/workspaces/${workspaceId}/invites/${inviteId}`);
410
- console.log(JSON.stringify(result, null, 2));
411
- await pause();
629
+ function ftsQuery(value) {
630
+ const terms = String(value || '').match(/[A-Za-z0-9_./-]+/g) || [];
631
+ return terms.slice(0, 12).join(' OR ');
632
+ }
633
+
634
+ async function doChatSearch(config, file, opts) {
635
+ const roomId = requireValue(opts, 'room');
636
+ const query = requireValue(opts, 'query');
637
+ await syncRoom(config, file, roomId, 500);
638
+ const cap = await openChatDb(config, file, roomId);
639
+ try {
640
+ const maxResults = Math.max(1, Math.min(Number(opts.limit || 20), 100));
641
+ let rows = [];
642
+ if (getMeta(cap.db, 'fts5', '0') === '1') {
643
+ const q = ftsQuery(query);
644
+ if (q) {
645
+ try {
646
+ rows = dbAll(
647
+ cap.db,
648
+ `SELECT m.*, bm25(messages_fts) AS score
649
+ FROM messages_fts JOIN messages m ON m.id=messages_fts.rowid
650
+ WHERE messages_fts MATCH ?
651
+ ORDER BY score LIMIT ?`,
652
+ [q, maxResults],
653
+ );
654
+ } catch {
655
+ rows = [];
656
+ }
412
657
  }
413
658
  }
659
+ if (!rows.length) {
660
+ rows = dbAll(cap.db, 'SELECT *, 0 AS score FROM messages WHERE body LIKE ? OR metadata LIKE ? ORDER BY id DESC LIMIT ?', [
661
+ `%${query}%`, `%${query}%`, maxResults,
662
+ ]);
663
+ }
664
+ const seen = new Set(rows.map((row) => row.message_id));
665
+ const qvec = hashEmbedding(query);
666
+ const vectorRows = dbAll(
667
+ cap.db,
668
+ `SELECT m.*, e.vector
669
+ FROM message_embeddings e JOIN messages m ON m.message_id=e.message_id
670
+ ORDER BY m.id DESC LIMIT 1000`,
671
+ )
672
+ .map((row) => {
673
+ let score = 0;
674
+ try { score = cosine(qvec, JSON.parse(row.vector)); } catch {}
675
+ const { vector, ...clean } = row;
676
+ return { ...clean, vector_score: score };
677
+ })
678
+ .filter((row) => row.vector_score > 0 && !seen.has(row.message_id))
679
+ .sort((a, b) => b.vector_score - a.vector_score)
680
+ .slice(0, Math.max(0, maxResults - rows.length));
681
+ return { room_id: roomId, query, search: { local_only: true, fts: true, vector: 'hash-256' }, results: [...rows, ...vectorRows] };
682
+ } finally {
683
+ cap.db.close();
414
684
  }
415
685
  }
416
686
 
417
- async function listSessions(config) {
418
- const data = await api(config, 'GET', '/v1/agent-sessions', undefined, config.userToken);
419
- printTable(data.sessions || [], [
420
- { label: 'session', value: (r) => r.session_id },
421
- { label: 'agent', value: (r) => r.label || r.agent_id },
422
- { label: 'active', value: (r) => r.active ? 'yes' : 'no' },
423
- { label: 'expires', value: (r) => r.expires_at },
424
- { label: 'revoked', value: (r) => r.revoked_at || '' },
425
- ]);
426
- return data;
687
+ function normalizeCwd(value) {
688
+ return path.resolve(value || process.env.SUPERCOLLAB_WORKDIR || process.cwd());
427
689
  }
428
690
 
429
- async function revokeSession(config, file, sessionId) {
430
- const result = await api(config, 'DELETE', `/v1/agent-sessions/${sessionId}`, undefined, config.userToken);
431
- delete config.agentSessionToken;
432
- delete config.agentSessionExpiresAt;
433
- saveConfig(config, file);
434
- return result;
435
- }
436
-
437
- async function manageSessions(config, file) {
438
- while (true) {
439
- const choice = await selectMenu('Manage Sessions', [
440
- { label: 'List sessions', action: 'list' },
441
- { label: 'Revoke session', action: 'revoke' },
442
- { label: 'Back', action: 'back' },
443
- ]);
444
- if (!choice || choice.action === 'back') return;
445
- clearScreen();
446
- if (choice.action === 'list') {
447
- await listSessions(config);
448
- await pause();
449
- } else if (choice.action === 'revoke') {
450
- const data = await api(config, 'GET', '/v1/agent-sessions', undefined, config.userToken);
451
- const candidates = (data.sessions || []).filter((session) => session.active);
452
- const items = candidates.map((session) => ({ label: `${session.session_id} ${session.label} expires ${session.expires_at}`, session }));
453
- items.push({ label: 'Enter session id manually', manual: true });
454
- items.push({ label: 'Back', back: true });
455
- const selected = await selectMenu('Revoke Session', items);
456
- if (selected && !selected.back) {
457
- const sessionId = selected.manual ? await askLine('Session id') : selected.session.session_id;
458
- console.log(JSON.stringify(await revokeSession(config, file, sessionId), null, 2));
459
- await pause();
460
- }
691
+ function activationFor(config, cwd = normalizeCwd()) {
692
+ const activations = config.activations || {};
693
+ let best = null;
694
+ for (const [root, activation] of Object.entries(activations)) {
695
+ if (!activation?.enabled) continue;
696
+ const abs = path.resolve(root);
697
+ if (cwd === abs || cwd.startsWith(abs + path.sep)) {
698
+ if (!best || abs.length > best.cwd.length) best = { cwd: abs, ...activation };
461
699
  }
462
700
  }
701
+ return best;
463
702
  }
464
703
 
465
- async function runMenu(opts) {
466
- const file = configPath(opts);
467
- const config = loadConfig(file);
468
- config.serverUrl = opts.server || config.serverUrl || DEFAULT_SERVER;
469
- while (true) {
470
- const label = config.username ? `SuperCollab - ${config.username}` : 'SuperCollab';
471
- const choice = await selectMenu(label, [
472
- { label: 'Who am I', action: 'whoami' },
473
- { label: 'List workspaces', action: 'workspaces' },
474
- { label: 'Create workspace', action: 'create_workspace' },
475
- { label: 'Manage invites', action: 'invites' },
476
- { label: 'Manage sessions', action: 'sessions' },
477
- { label: 'Print Codex MCP config', action: 'mcp_config' },
478
- { label: 'Exit', action: 'exit' },
479
- ]);
480
- if (!choice || choice.action === 'exit') {
481
- clearScreen();
482
- return;
483
- }
484
- clearScreen();
485
- if (choice.action === 'whoami') {
486
- console.log(JSON.stringify(await api(config, 'GET', '/v1/me'), null, 2));
487
- await pause();
488
- } else if (choice.action === 'workspaces') {
489
- const data = await callTool(config, 'workspace_list', {});
490
- printTable(data.workspaces || [], [
491
- { label: 'id', value: (r) => r.id },
492
- { label: 'role', value: (r) => r.role },
493
- { label: 'title', value: (r) => r.title },
494
- ]);
495
- await pause();
496
- } else if (choice.action === 'create_workspace') {
497
- const title = await askLine('Title');
498
- const goal = await askLine('Goal');
499
- const slug = await askLine('Slug', '');
500
- const data = await callTool(config, 'workspace_create', { title, goal, slug: slug || undefined });
501
- console.log(JSON.stringify(data, null, 2));
502
- await pause();
503
- } else if (choice.action === 'invites') {
504
- await manageInvites(config);
505
- } else if (choice.action === 'sessions') {
506
- await manageSessions(config, file);
507
- } else if (choice.action === 'mcp_config') {
508
- printCodexConfig(opts);
509
- await pause();
510
- }
704
+ function agentInstructions(active) {
705
+ if (!active) {
706
+ return 'SuperCollab is OFF for this local workspace. Do not send, read, or search SuperCollab room messages. Ask the user to run `supercollab activate --room ROOM_ID` from the project directory if they want this agent to join a room.';
511
707
  }
708
+ return `SuperCollab is ACTIVE for this local workspace. You are participating in room ${active.roomId}. Use the SuperCollab chat tools to post concise progress, decisions, blockers, and local-workspace change summaries. Do not paste secrets. Treat the room as the shared agent-to-agent coordination log and searchable memory surface.`;
709
+ }
710
+
711
+ function activate(config, file, opts) {
712
+ const roomId = requireValue(opts, 'room');
713
+ ensureRoomKey(config, roomId);
714
+ const cwd = normalizeCwd(opts.cwd);
715
+ config.activations = config.activations || {};
716
+ config.activations[cwd] = { roomId, enabled: true, activatedAt: nowIso() };
717
+ saveConfig(config, file);
718
+ return { ok: true, cwd, room_id: roomId, instructions: agentInstructions({ roomId, cwd }) };
719
+ }
720
+
721
+ function deactivate(config, file, opts) {
722
+ const cwd = normalizeCwd(opts.cwd);
723
+ if (config.activations?.[cwd]) {
724
+ config.activations[cwd].enabled = false;
725
+ config.activations[cwd].deactivatedAt = nowIso();
726
+ }
727
+ saveConfig(config, file);
728
+ return { ok: true, cwd, active: false, instructions: agentInstructions(null) };
729
+ }
730
+
731
+ async function activeStatus(config, file, opts = {}) {
732
+ const cwd = normalizeCwd(opts.cwd);
733
+ const active = activationFor(config, cwd);
734
+ return {
735
+ active: Boolean(active),
736
+ cwd,
737
+ room_id: active?.roomId || null,
738
+ activation_root: active?.cwd || null,
739
+ config: file,
740
+ instructions: agentInstructions(active),
741
+ };
742
+ }
743
+
744
+ function requireActiveRoom(config, args = {}) {
745
+ const active = activationFor(config);
746
+ if (!active) throw new Error(agentInstructions(null));
747
+ if (args.room_id && args.room_id !== active.roomId) {
748
+ throw new Error(`SuperCollab is active for ${active.roomId}, not ${args.room_id}. Switch rooms with supercollab activate.`);
749
+ }
750
+ return active.roomId;
512
751
  }
513
752
 
514
753
  function toolSchema(name, description, properties = {}, required = []) {
@@ -518,49 +757,42 @@ function toolSchema(name, description, properties = {}, required = []) {
518
757
  function mcpTools() {
519
758
  const s = { type: 'string' };
520
759
  return [
521
- toolSchema('supercollab_status', 'Check SuperCollab server and authenticated actor status.'),
522
- toolSchema('workspace_list', 'List workspaces visible to this agent.'),
523
- toolSchema('workspace_create', 'Create a goal-scoped workspace.', { title: s, goal: s, slug: s }, ['title', 'goal']),
524
- toolSchema('workspace_invite', 'Create a short-lived invite token.', { workspace_id: s, role: s, ttl_seconds: { type: 'integer' } }, ['workspace_id']),
525
- toolSchema('workspace_join', 'Accept an invite token.', { invite_token: s, fingerprint: s }, ['invite_token']),
526
- toolSchema('workspace_message_send', 'Send a workspace message.', { workspace_id: s, text: s }, ['workspace_id', 'text']),
527
- toolSchema('workspace_messages_read', 'Read workspace events/messages.', { workspace_id: s, limit: { type: 'integer' } }, ['workspace_id']),
528
- toolSchema('workspace_file_list', 'List workspace files.', { workspace_id: s, path: s }, ['workspace_id']),
529
- toolSchema('workspace_file_read', 'Read a workspace file.', { workspace_id: s, path: s }, ['workspace_id', 'path']),
530
- toolSchema('workspace_file_write', 'Write a workspace file and commit it.', { workspace_id: s, path: s, content: s, commit_message: s }, ['workspace_id', 'path', 'content']),
531
- toolSchema('workspace_git_status', 'Show workspace Git status and recent commits.', { workspace_id: s }, ['workspace_id']),
532
- toolSchema('workspace_task_create', 'Create a task.', { workspace_id: s, title: s, body: s, owner: s }, ['workspace_id', 'title']),
533
- toolSchema('workspace_decision_record', 'Record a decision.', { workspace_id: s, title: s, decision: s, rationale: s }, ['workspace_id', 'title', 'decision']),
534
- toolSchema('workspace_memory_reindex', 'Rebuild workspace search index.', { workspace_id: s }, ['workspace_id']),
535
- toolSchema('workspace_memory_search', 'Search workspace memory.', { workspace_id: s, query: s, limit: { type: 'integer' } }, ['workspace_id', 'query']),
536
- toolSchema('workspace_heartbeat_get', 'Read heartbeat config.', { workspace_id: s }, ['workspace_id']),
537
- toolSchema('workspace_heartbeat_set', 'Set heartbeat config.', { workspace_id: s, prompt: s, interval_seconds: { type: 'integer' }, enabled: { type: 'boolean' } }, ['workspace_id', 'prompt']),
538
- toolSchema('workspace_heartbeat_tick', 'Emit heartbeat now.', { workspace_id: s }, ['workspace_id']),
760
+ toolSchema('supercollab_status', 'Check whether SuperCollab is active for this local workspace.'),
761
+ toolSchema('room_list', 'List rooms visible to this agent.'),
762
+ toolSchema('room_create', 'Create a new agent chat room.', { title: s, goal: s, slug: s }, ['title', 'goal']),
763
+ toolSchema('room_invite', 'Create an invite token for a room.', { room_id: s, role: s, ttl_seconds: { type: 'integer' } }, ['room_id']),
764
+ toolSchema('room_join', 'Accept a room invite token.', { invite_token: s, fingerprint: s }, ['invite_token']),
765
+ toolSchema('chat_send', 'Send a message to the active agent chat room.', { text: s, channel: s, kind: s }, ['text']),
766
+ toolSchema('chat_read', 'Sync and read recent messages from the active room.', { limit: { type: 'integer' } }),
767
+ toolSchema('chat_search', 'Sync and search the active room transcript.', { query: s, limit: { type: 'integer' } }, ['query']),
768
+ toolSchema('chat_sync', 'Sync the active room transcript into local SQLite.'),
539
769
  ];
540
770
  }
541
771
 
542
772
  async function callTool(config, name, args) {
543
- if (name === 'supercollab_status') return { health: await api(config, 'GET', '/health'), me: await apiAsAgent(config, 'GET', '/v1/me') };
544
- if (name === 'workspace_list') return apiAsAgent(config, 'GET', '/v1/workspaces');
545
- if (name === 'workspace_create') return apiAsAgent(config, 'POST', '/v1/workspaces', { title: args.title, goal: args.goal, slug: args.slug });
546
- if (name === 'workspace_invite') return apiAsAgent(config, 'POST', `/v1/workspaces/${args.workspace_id}/invites`, { role: args.role || 'member', ttl_seconds: args.ttl_seconds || 86400 });
547
- if (name === 'workspace_join') return apiAsAgent(config, 'POST', '/v1/invites/accept', { token: args.invite_token, fingerprint: args.fingerprint || config.agentFingerprint });
548
- if (name === 'workspace_message_send') return apiAsAgent(config, 'POST', `/v1/workspaces/${args.workspace_id}/messages`, { text: args.text });
549
- if (name === 'workspace_messages_read') return apiAsAgent(config, 'GET', `/v1/workspaces/${args.workspace_id}/messages?limit=${encodeURIComponent(args.limit || 50)}`);
550
- if (name === 'workspace_file_list') return apiAsAgent(config, 'GET', `/v1/workspaces/${args.workspace_id}/files?path=${encodeURIComponent(args.path || '')}`);
551
- if (name === 'workspace_file_read') return apiAsAgent(config, 'GET', `/v1/workspaces/${args.workspace_id}/files/read?path=${encodeURIComponent(args.path)}`);
552
- if (name === 'workspace_file_write') return apiAsAgent(config, 'POST', `/v1/workspaces/${args.workspace_id}/files/write`, { path: args.path, content: args.content, commit_message: args.commit_message });
553
- if (name === 'workspace_git_status') return apiAsAgent(config, 'GET', `/v1/workspaces/${args.workspace_id}/git/status`);
554
- if (name === 'workspace_task_create') return apiAsAgent(config, 'POST', `/v1/workspaces/${args.workspace_id}/tasks`, { title: args.title, body: args.body || '', owner: args.owner || 'unassigned' });
555
- if (name === 'workspace_decision_record') return apiAsAgent(config, 'POST', `/v1/workspaces/${args.workspace_id}/decisions`, { title: args.title, decision: args.decision, rationale: args.rationale || '' });
556
- if (name === 'workspace_memory_reindex') return apiAsAgent(config, 'POST', `/v1/workspaces/${args.workspace_id}/memory/reindex`, {});
557
- if (name === 'workspace_memory_search') return apiAsAgent(config, 'GET', `/v1/workspaces/${args.workspace_id}/memory/search?q=${encodeURIComponent(args.query)}&limit=${encodeURIComponent(args.limit || 8)}`);
558
- if (name === 'workspace_heartbeat_get') return apiAsAgent(config, 'GET', `/v1/workspaces/${args.workspace_id}/heartbeat`);
559
- if (name === 'workspace_heartbeat_set') return apiAsAgent(config, 'PUT', `/v1/workspaces/${args.workspace_id}/heartbeat`, { prompt: args.prompt, interval_seconds: args.interval_seconds || 900, enabled: args.enabled !== false });
560
- if (name === 'workspace_heartbeat_tick') return apiAsAgent(config, 'POST', `/v1/workspaces/${args.workspace_id}/heartbeat/tick`, {});
773
+ const file = config.__configFile || DEFAULT_CONFIG;
774
+ if (name === 'supercollab_status') return activeStatus(config, file, {});
775
+ if (name === 'room_list') return apiAsAgent(config, 'GET', '/v1/rooms');
776
+ if (name === 'room_create') return doRoomCreate(config, file, { title: args.title, goal: args.goal, slug: args.slug });
777
+ if (name === 'room_invite') return doRoomInvite(config, { room: args.room_id, role: args.role || 'member', ttl_seconds: args.ttl_seconds || 86400 });
778
+ if (name === 'room_join') return doRoomJoin(config, file, { invite: args.invite_token });
779
+ if (name === 'chat_send') return doChatSend(config, file, { room: requireActiveRoom(config, args), text: args.text, channel: args.channel || 'agents', kind: args.kind || 'chat.message' });
780
+ if (name === 'chat_read') return doChatRead(config, file, { room: requireActiveRoom(config, args), limit: args.limit || 50 });
781
+ if (name === 'chat_search') return doChatSearch(config, file, { room: requireActiveRoom(config, args), query: args.query, limit: args.limit || 20 });
782
+ if (name === 'chat_sync') return syncRoom(config, file, requireActiveRoom(config, args));
561
783
  throw new Error(`unknown tool: ${name}`);
562
784
  }
563
785
 
786
+ function mcpPrompts(config) {
787
+ const active = activationFor(config);
788
+ return [{
789
+ name: 'supercollab_workspace_context',
790
+ description: 'Current SuperCollab activation state and agent instructions for this local workspace.',
791
+ arguments: [],
792
+ messages: [{ role: 'user', content: { type: 'text', text: agentInstructions(active) } }],
793
+ }];
794
+ }
795
+
564
796
  function writeRpc(payload) {
565
797
  const raw = Buffer.from(JSON.stringify(payload));
566
798
  process.stdout.write(`Content-Length: ${raw.length}\r\n\r\n`);
@@ -568,7 +800,9 @@ function writeRpc(payload) {
568
800
  }
569
801
 
570
802
  async function runMcp(opts) {
571
- const config = loadConfig(configPath(opts));
803
+ const file = configPath(opts);
804
+ const config = attachRuntimeConfig(loadConfig(file), file);
805
+ config.serverUrl = opts.server || config.serverUrl || DEFAULT_SERVER;
572
806
  let buffer = Buffer.alloc(0);
573
807
  for await (const chunk of process.stdin) {
574
808
  buffer = Buffer.concat([buffer, chunk]);
@@ -588,12 +822,24 @@ async function runMcp(opts) {
588
822
  try {
589
823
  let result;
590
824
  if (msg.method === 'initialize') {
591
- result = { protocolVersion: '2024-11-05', capabilities: { tools: {} }, serverInfo: { name: 'supercollab', version: VERSION } };
825
+ const active = activationFor(config);
826
+ result = {
827
+ protocolVersion: '2024-11-05',
828
+ capabilities: { tools: {}, prompts: {} },
829
+ serverInfo: { name: 'supercollab', version: VERSION },
830
+ instructions: agentInstructions(active),
831
+ };
592
832
  } else if (msg.method === 'tools/list') {
593
833
  result = { tools: mcpTools() };
594
834
  } else if (msg.method === 'tools/call') {
595
835
  const data = await callTool(config, msg.params.name, msg.params.arguments || {});
596
836
  result = { content: [{ type: 'text', text: JSON.stringify(data, null, 2) }] };
837
+ } else if (msg.method === 'prompts/list') {
838
+ result = { prompts: mcpPrompts(config).map(({ messages, ...p }) => p) };
839
+ } else if (msg.method === 'prompts/get') {
840
+ const prompt = mcpPrompts(config).find((p) => p.name === msg.params.name);
841
+ if (!prompt) throw new Error(`unknown prompt: ${msg.params.name}`);
842
+ result = { description: prompt.description, messages: prompt.messages };
597
843
  } else if (msg.method && msg.method.startsWith('notifications/')) {
598
844
  continue;
599
845
  } else {
@@ -617,12 +863,11 @@ async function main() {
617
863
  if (opts.help || positionals.length === 0) { printHelp(); return; }
618
864
  const [cmd, sub] = positionals;
619
865
  const file = configPath(opts);
620
- const config = loadConfig(file);
866
+ const config = attachRuntimeConfig(loadConfig(file), file);
621
867
  config.serverUrl = opts.server || config.serverUrl || DEFAULT_SERVER;
622
868
 
623
- if (cmd === 'register') return doRegister(opts);
624
- if (cmd === 'login') return doLogin(opts);
625
- if (cmd === 'menu') return runMenu(opts);
869
+ if (cmd === 'register') return console.log(JSON.stringify(await doRegister(config, file, opts), null, 2));
870
+ if (cmd === 'login') return console.log(JSON.stringify(await doLogin(config, file, opts), null, 2));
626
871
  if (cmd === 'whoami') return console.log(JSON.stringify(await api(config, 'GET', '/v1/me'), null, 2));
627
872
  if (cmd === 'config' && sub === 'path') return console.log(file);
628
873
  if (cmd === 'agent' && sub === 'register') {
@@ -630,30 +875,29 @@ async function main() {
630
875
  saveConfig(config, file);
631
876
  return console.log(JSON.stringify({ ok: true, agent_id: data.agent_id, fingerprint: data.fingerprint, config: file }, null, 2));
632
877
  }
633
- if (cmd === 'mcp' && sub === 'stdio') return runMcp(opts);
634
- if (cmd === 'mcp' && sub === 'print-config') return printCodexConfig(opts);
635
- if (cmd === 'session') {
636
- if (sub === 'list') return console.log(JSON.stringify(await api(config, 'GET', '/v1/agent-sessions', undefined, config.userToken), null, 2));
637
- if (sub === 'revoke') return console.log(JSON.stringify(await revokeSession(config, file, requireValue(opts, 'session')), null, 2));
878
+ if (cmd === 'room') {
879
+ if (sub === 'list') return console.log(JSON.stringify(await apiAsAgent(config, 'GET', '/v1/rooms'), null, 2));
880
+ if (sub === 'create') return console.log(JSON.stringify(await doRoomCreate(config, file, opts), null, 2));
881
+ if (sub === 'invite') return console.log(JSON.stringify(await doRoomInvite(config, opts), null, 2));
882
+ if (sub === 'invites') return console.log(JSON.stringify(await apiAsAgent(config, 'GET', `/v1/rooms/${requireValue(opts, 'room')}/invites`), null, 2));
883
+ if (sub === 'join') return console.log(JSON.stringify(await doRoomJoin(config, file, opts), null, 2));
884
+ if (sub === 'key') return console.log(ensureRoomKey(config, requireValue(opts, 'room')));
638
885
  }
639
- if (cmd === 'workspace') {
640
- if (sub === 'list') return console.log(JSON.stringify(await callTool(config, 'workspace_list', {}), null, 2));
641
- if (sub === 'create') return console.log(JSON.stringify(await callTool(config, 'workspace_create', { title: requireValue(opts, 'title'), goal: requireValue(opts, 'goal'), slug: opts.slug }), null, 2));
642
- if (sub === 'invite') return console.log(JSON.stringify(await callTool(config, 'workspace_invite', { workspace_id: requireValue(opts, 'workspace'), role: opts.role || 'member', ttl_seconds: opts.ttl || 86400 }), null, 2));
643
- if (sub === 'invites') return console.log(JSON.stringify(await apiAsAgent(config, 'GET', `/v1/workspaces/${requireValue(opts, 'workspace')}/invites`), null, 2));
644
- if (sub === 'invite-revoke') return console.log(JSON.stringify(await apiAsAgent(config, 'DELETE', `/v1/workspaces/${requireValue(opts, 'workspace')}/invites/${requireValue(opts, 'invite')}`), null, 2));
645
- if (sub === 'join') return console.log(JSON.stringify(await callTool(config, 'workspace_join', { invite_token: requireValue(opts, 'invite') }), null, 2));
646
- if (sub === 'message') return console.log(JSON.stringify(await callTool(config, 'workspace_message_send', { workspace_id: requireValue(opts, 'workspace'), text: requireValue(opts, 'text') }), null, 2));
647
- if (sub === 'messages') return console.log(JSON.stringify(await callTool(config, 'workspace_messages_read', { workspace_id: requireValue(opts, 'workspace'), limit: opts.limit || 20 }), null, 2));
648
- if (sub === 'search') return console.log(JSON.stringify(await callTool(config, 'workspace_memory_search', { workspace_id: requireValue(opts, 'workspace'), query: requireValue(opts, 'query'), limit: opts.limit || 8 }), null, 2));
649
- if (sub === 'write') return console.log(JSON.stringify(await callTool(config, 'workspace_file_write', { workspace_id: requireValue(opts, 'workspace'), path: requireValue(opts, 'path'), content: requireValue(opts, 'content'), commit_message: opts.message }), null, 2));
650
- if (sub === 'read') return console.log(JSON.stringify(await callTool(config, 'workspace_file_read', { workspace_id: requireValue(opts, 'workspace'), path: requireValue(opts, 'path') }), null, 2));
651
- if (sub === 'git') return console.log(JSON.stringify(await callTool(config, 'workspace_git_status', { workspace_id: requireValue(opts, 'workspace') }), null, 2));
886
+ if (cmd === 'chat') {
887
+ if (sub === 'send') return console.log(JSON.stringify(await doChatSend(config, file, opts), null, 2));
888
+ if (sub === 'read') return console.log(JSON.stringify(await doChatRead(config, file, opts), null, 2));
889
+ if (sub === 'search') return console.log(JSON.stringify(await doChatSearch(config, file, opts), null, 2));
652
890
  }
653
- if (cmd === 'heartbeat') {
654
- if (sub === 'set') return console.log(JSON.stringify(await callTool(config, 'workspace_heartbeat_set', { workspace_id: requireValue(opts, 'workspace'), prompt: requireValue(opts, 'prompt'), interval_seconds: opts.interval || 900, enabled: opts.enabled !== 'false' }), null, 2));
655
- if (sub === 'tick') return console.log(JSON.stringify(await callTool(config, 'workspace_heartbeat_tick', { workspace_id: requireValue(opts, 'workspace') }), null, 2));
891
+ if (cmd === 'sync') return console.log(JSON.stringify(await syncRoom(config, file, requireValue(opts, 'room')), null, 2));
892
+ if (cmd === 'activate') return console.log(JSON.stringify(activate(config, file, opts), null, 2));
893
+ if (cmd === 'deactivate') return console.log(JSON.stringify(deactivate(config, file, opts), null, 2));
894
+ if (cmd === 'active') return console.log(JSON.stringify(await activeStatus(config, file, opts), null, 2));
895
+ if (cmd === 'session') {
896
+ if (sub === 'list') return console.log(JSON.stringify(await api(config, 'GET', '/v1/agent-sessions', undefined, config.userToken), null, 2));
897
+ if (sub === 'revoke') return console.log(JSON.stringify(await api(config, 'DELETE', `/v1/agent-sessions/${requireValue(opts, 'session')}`, undefined, config.userToken), null, 2));
656
898
  }
899
+ if (cmd === 'mcp' && sub === 'stdio') return runMcp(opts);
900
+ if (cmd === 'mcp' && sub === 'print-config') return printCodexConfig(opts);
657
901
  throw new Error(`unknown command: ${positionals.join(' ')}`);
658
902
  }
659
903
 
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@supercollab/cli",
3
- "version": "0.1.3",
4
- "description": "SuperCollab CLI and MCP bridge for secure agent collaboration workspaces.",
3
+ "version": "0.4.0",
4
+ "description": "SuperCollab CLI and MCP bridge for encrypted local-search agent group chat.",
5
5
  "type": "module",
6
6
  "bin": {
7
7
  "supercollab": "./bin/supercollab.js"
@@ -13,11 +13,14 @@
13
13
  "engines": {
14
14
  "node": ">=20"
15
15
  },
16
+ "dependencies": {
17
+ "sql.js": "^1.14.1"
18
+ },
16
19
  "keywords": [
17
20
  "mcp",
18
21
  "agents",
22
+ "chat",
19
23
  "collaboration",
20
- "workspace",
21
24
  "supercollab"
22
25
  ],
23
26
  "license": "UNLICENSED",