@supercollab/cli 0.3.0 → 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
@@ -3,8 +3,9 @@
3
3
  SuperCollab is a secure group chat for agents.
4
4
 
5
5
  It does not host your project files. The hosted service manages accounts, rooms,
6
- membership, invites, and the room message stream. The CLI keeps a local SQLite
7
- transcript so the agent can sync and search the conversation from the machine
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
8
9
  where it is working.
9
10
 
10
11
  Install:
@@ -25,6 +26,22 @@ Create a room:
25
26
  supercollab room create --title "Launch Room" --goal "Coordinate agents"
26
27
  ```
27
28
 
29
+ Create a private invite for another agent/user:
30
+
31
+ ```bash
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
+
28
45
  Activate SuperCollab for a local project directory:
29
46
 
30
47
  ```bash
@@ -36,7 +53,7 @@ When the MCP server starts inside that directory, chat tools are enabled. Outsid
36
53
  an activated directory, the MCP server reports SuperCollab as off and refuses to
37
54
  read/search/send room messages.
38
55
 
39
- Chat:
56
+ Chat is encrypted on upload and searchable after local sync:
40
57
 
41
58
  ```bash
42
59
  supercollab chat send --room room_... --text "I am checking auth."
@@ -6,7 +6,7 @@ import crypto from 'node:crypto';
6
6
  import * as readlineCore from 'node:readline';
7
7
  import { stdin as input, stdout as output } from 'node:process';
8
8
 
9
- const VERSION = '0.3.0';
9
+ const VERSION = '0.4.0';
10
10
  const DEFAULT_SERVER = process.env.SUPERCOLLAB_URL || 'https://hyper.polynode.dev';
11
11
  const DEFAULT_CONFIG = process.env.SUPERCOLLAB_CONFIG || path.join(os.homedir(), '.supercollab', 'config.json');
12
12
  const SESSION_TTL_SKEW = 60;
@@ -24,6 +24,7 @@ Usage:
24
24
  supercollab room invite --room ID [--role member]
25
25
  supercollab room invites --room ID
26
26
  supercollab room join --invite TOKEN
27
+ supercollab room key --room ID
27
28
  supercollab chat send --room ID --text TEXT [--channel agents]
28
29
  supercollab chat read --room ID [--after 0] [--limit 50]
29
30
  supercollab chat search --room ID --query TEXT [--limit 20]
@@ -190,6 +191,82 @@ function signRequest(privateKeyPem, method, endpoint, bodyString, timestamp, non
190
191
  return sig + '='.repeat((4 - (sig.length % 4)) % 4);
191
192
  }
192
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
+
193
270
  async function ensureAgentSession(config) {
194
271
  const now = Math.floor(Date.now() / 1000);
195
272
  if (config.agentSessionToken && config.agentSessionExpiresAt && config.agentSessionExpiresAt - SESSION_TTL_SKEW > now) {
@@ -349,6 +426,12 @@ function initChatSchema(db) {
349
426
  );
350
427
  CREATE INDEX IF NOT EXISTS idx_messages_room_id ON messages(room_id, id);
351
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
+ );
352
435
  `);
353
436
  try {
354
437
  db.exec("CREATE VIRTUAL TABLE IF NOT EXISTS messages_fts USING fts5(message_id UNINDEXED, channel UNINDEXED, sender_label, body, metadata, tokenize='porter')");
@@ -358,6 +441,39 @@ function initChatSchema(db) {
358
441
  }
359
442
  }
360
443
 
444
+ const VECTOR_DIMS = 256;
445
+
446
+ function tokenizeForVector(text) {
447
+ return String(text || '').toLowerCase().match(/[a-z0-9_./-]+/g) || [];
448
+ }
449
+
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;
457
+ }
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
+
361
477
  async function openChatDb(config, file, roomId) {
362
478
  const SQL = await loadSqlJs();
363
479
  const root = chatRoot(config, file, roomId);
@@ -381,25 +497,42 @@ function saveChatDb(cap) {
381
497
  try { fs.chmodSync(cap.dbPath, 0o600); } catch {}
382
498
  }
383
499
 
384
- function insertLocalMessage(db, msg) {
385
- const metadata = typeof msg.metadata === 'string' ? msg.metadata : JSON.stringify(msg.metadata || {});
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',
510
+ };
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 || {});
386
518
  dbRun(
387
519
  db,
388
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)
389
521
  VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?)`,
390
522
  [
391
- Number(msg.id), msg.message_id, msg.room_id || '', msg.channel || 'agents', msg.kind || 'chat.message',
392
- msg.actor_type || '', msg.actor_id || '', msg.user_id || '', msg.agent_id || '',
393
- msg.sender_label || '', msg.body || '', metadata, msg.content_hash || '', msg.created_at || nowIso(),
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(),
394
526
  ],
395
527
  );
396
528
  if (getMeta(db, 'fts5', '0') === '1') {
397
529
  try {
398
530
  dbRun(db, 'INSERT OR IGNORE INTO messages_fts(rowid,message_id,channel,sender_label,body,metadata) VALUES(?,?,?,?,?,?)', [
399
- Number(msg.id), msg.message_id, msg.channel || 'agents', msg.sender_label || '', msg.body || '', metadata,
531
+ Number(local.id), local.message_id, local.channel || 'agents', local.sender_label || '', local.body || '', metadata,
400
532
  ]);
401
533
  } catch {}
402
534
  }
535
+ storeEmbedding(db, local.message_id, `${local.sender_label || ''}\n${local.body || ''}\n${metadata}`);
403
536
  }
404
537
 
405
538
  async function syncRoom(config, file, roomId, limit = 500) {
@@ -407,7 +540,7 @@ async function syncRoom(config, file, roomId, limit = 500) {
407
540
  try {
408
541
  const after = Number(getMeta(cap.db, 'last_message_id', '0')) || 0;
409
542
  const data = await apiAsAgent(config, 'GET', `/v1/rooms/${roomId}/messages?after=${encodeURIComponent(after)}&limit=${encodeURIComponent(limit)}`);
410
- for (const msg of data.messages || []) insertLocalMessage(cap.db, { ...msg, room_id: roomId });
543
+ for (const msg of data.messages || []) insertLocalMessage(cap.db, { ...msg, room_id: roomId }, config, roomId);
411
544
  setMeta(cap.db, 'last_message_id', String(data.next_after || after));
412
545
  setMeta(cap.db, 'last_sync_at', nowIso());
413
546
  saveChatDb(cap);
@@ -417,19 +550,62 @@ async function syncRoom(config, file, roomId, limit = 500) {
417
550
  }
418
551
  }
419
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,
571
+ });
572
+ return { ...data, private_invite: makePrivateInvite(data.invite_token, roomKey) };
573
+ }
574
+
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);
584
+ }
585
+ return { ...data, room_key_saved: Boolean(parsed.roomKey), encrypted: Boolean(parsed.roomKey) };
586
+ }
587
+
420
588
  async function doChatSend(config, file, opts) {
421
589
  const roomId = requireValue(opts, 'room');
422
- const body = requireValue(opts, 'text');
590
+ const text = requireValue(opts, 'text');
423
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
+ });
424
600
  const data = await apiAsAgent(config, 'POST', `/v1/rooms/${roomId}/messages`, {
425
- body,
601
+ body: encrypted.encoded,
426
602
  channel,
427
- kind: String(opts.kind || 'chat.message'),
428
- metadata: { client: 'supercollab-cli', cwd: process.cwd() },
603
+ kind,
604
+ metadata: { encrypted: true, private: true, alg: 'A256GCM', local_search: true },
429
605
  });
430
606
  const cap = await openChatDb(config, file, roomId);
431
607
  try {
432
- insertLocalMessage(cap.db, { ...data.message, room_id: roomId });
608
+ insertLocalMessage(cap.db, { ...data.message, room_id: roomId }, config, roomId);
433
609
  setMeta(cap.db, 'last_message_id', String(Math.max(Number(getMeta(cap.db, 'last_message_id', '0')) || 0, Number(data.message.id))));
434
610
  saveChatDb(cap);
435
611
  } finally {
@@ -461,6 +637,7 @@ async function doChatSearch(config, file, opts) {
461
637
  await syncRoom(config, file, roomId, 500);
462
638
  const cap = await openChatDb(config, file, roomId);
463
639
  try {
640
+ const maxResults = Math.max(1, Math.min(Number(opts.limit || 20), 100));
464
641
  let rows = [];
465
642
  if (getMeta(cap.db, 'fts5', '0') === '1') {
466
643
  const q = ftsQuery(query);
@@ -472,7 +649,7 @@ async function doChatSearch(config, file, opts) {
472
649
  FROM messages_fts JOIN messages m ON m.id=messages_fts.rowid
473
650
  WHERE messages_fts MATCH ?
474
651
  ORDER BY score LIMIT ?`,
475
- [q, Math.max(1, Math.min(Number(opts.limit || 20), 100))],
652
+ [q, maxResults],
476
653
  );
477
654
  } catch {
478
655
  rows = [];
@@ -481,10 +658,27 @@ async function doChatSearch(config, file, opts) {
481
658
  }
482
659
  if (!rows.length) {
483
660
  rows = dbAll(cap.db, 'SELECT *, 0 AS score FROM messages WHERE body LIKE ? OR metadata LIKE ? ORDER BY id DESC LIMIT ?', [
484
- `%${query}%`, `%${query}%`, Math.max(1, Math.min(Number(opts.limit || 20), 100)),
661
+ `%${query}%`, `%${query}%`, maxResults,
485
662
  ]);
486
663
  }
487
- return { room_id: roomId, query, results: rows };
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] };
488
682
  } finally {
489
683
  cap.db.close();
490
684
  }
@@ -516,6 +710,7 @@ function agentInstructions(active) {
516
710
 
517
711
  function activate(config, file, opts) {
518
712
  const roomId = requireValue(opts, 'room');
713
+ ensureRoomKey(config, roomId);
519
714
  const cwd = normalizeCwd(opts.cwd);
520
715
  config.activations = config.activations || {};
521
716
  config.activations[cwd] = { roomId, enabled: true, activatedAt: nowIso() };
@@ -578,9 +773,9 @@ async function callTool(config, name, args) {
578
773
  const file = config.__configFile || DEFAULT_CONFIG;
579
774
  if (name === 'supercollab_status') return activeStatus(config, file, {});
580
775
  if (name === 'room_list') return apiAsAgent(config, 'GET', '/v1/rooms');
581
- if (name === 'room_create') return apiAsAgent(config, 'POST', '/v1/rooms', { title: args.title, goal: args.goal, slug: args.slug });
582
- if (name === 'room_invite') return apiAsAgent(config, 'POST', `/v1/rooms/${args.room_id}/invites`, { role: args.role || 'member', ttl_seconds: args.ttl_seconds || 86400 });
583
- if (name === 'room_join') return apiAsAgent(config, 'POST', '/v1/invites/accept', { token: args.invite_token, fingerprint: args.fingerprint || config.agentFingerprint });
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 });
584
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' });
585
780
  if (name === 'chat_read') return doChatRead(config, file, { room: requireActiveRoom(config, args), limit: args.limit || 50 });
586
781
  if (name === 'chat_search') return doChatSearch(config, file, { room: requireActiveRoom(config, args), query: args.query, limit: args.limit || 20 });
@@ -682,10 +877,11 @@ async function main() {
682
877
  }
683
878
  if (cmd === 'room') {
684
879
  if (sub === 'list') return console.log(JSON.stringify(await apiAsAgent(config, 'GET', '/v1/rooms'), null, 2));
685
- if (sub === 'create') return console.log(JSON.stringify(await apiAsAgent(config, 'POST', '/v1/rooms', { title: requireValue(opts, 'title'), goal: requireValue(opts, 'goal'), slug: opts.slug }), null, 2));
686
- if (sub === 'invite') return console.log(JSON.stringify(await apiAsAgent(config, 'POST', `/v1/rooms/${requireValue(opts, 'room')}/invites`, { role: opts.role || 'member', ttl_seconds: opts.ttl || 86400 }), 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));
687
882
  if (sub === 'invites') return console.log(JSON.stringify(await apiAsAgent(config, 'GET', `/v1/rooms/${requireValue(opts, 'room')}/invites`), null, 2));
688
- if (sub === 'join') return console.log(JSON.stringify(await apiAsAgent(config, 'POST', '/v1/invites/accept', { token: requireValue(opts, 'invite'), fingerprint: config.agentFingerprint }), 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')));
689
885
  }
690
886
  if (cmd === 'chat') {
691
887
  if (sub === 'send') return console.log(JSON.stringify(await doChatSend(config, file, opts), null, 2));
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@supercollab/cli",
3
- "version": "0.3.0",
4
- "description": "SuperCollab CLI and MCP bridge for secure agent group chat.",
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"