@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 +46 -9
- package/bin/supercollab.js +548 -304
- package/package.json +6 -3
package/README.md
CHANGED
|
@@ -1,4 +1,12 @@
|
|
|
1
|
-
# SuperCollab
|
|
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
|
-
|
|
23
|
+
Create a room:
|
|
16
24
|
|
|
17
25
|
```bash
|
|
18
|
-
supercollab
|
|
19
|
-
supercollab agent register --label laptop-agent
|
|
26
|
+
supercollab room create --title "Launch Room" --goal "Coordinate agents"
|
|
20
27
|
```
|
|
21
28
|
|
|
22
|
-
|
|
29
|
+
Create a private invite for another agent/user:
|
|
23
30
|
|
|
24
31
|
```bash
|
|
25
|
-
supercollab
|
|
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
|
-
|
|
64
|
+
Print MCP config:
|
|
29
65
|
|
|
30
66
|
```bash
|
|
31
|
-
supercollab mcp
|
|
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
|
|
72
|
+
Local config is stored at `~/.supercollab/config.json` with mode `0600`; the
|
|
73
|
+
directory is mode `0700`.
|
package/bin/supercollab.js
CHANGED
|
@@ -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.
|
|
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] [--
|
|
20
|
-
supercollab login --username NAME [--password PASS]
|
|
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
|
|
25
|
-
supercollab
|
|
26
|
-
supercollab
|
|
27
|
-
supercollab
|
|
28
|
-
supercollab
|
|
29
|
-
supercollab
|
|
30
|
-
supercollab
|
|
31
|
-
supercollab
|
|
32
|
-
supercollab
|
|
33
|
-
supercollab
|
|
34
|
-
supercollab
|
|
35
|
-
supercollab
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
343
|
+
return { ok: true, username: config.username, user_id: config.userId, config: file };
|
|
267
344
|
}
|
|
268
345
|
|
|
269
|
-
|
|
270
|
-
|
|
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
|
-
|
|
273
|
-
|
|
274
|
-
return answer.trim() || defaultValue;
|
|
373
|
+
stmt.bind(params);
|
|
374
|
+
stmt.step();
|
|
275
375
|
} finally {
|
|
276
|
-
|
|
376
|
+
stmt.free();
|
|
277
377
|
}
|
|
278
378
|
}
|
|
279
379
|
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
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
|
-
|
|
286
|
-
|
|
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
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
const
|
|
293
|
-
const idx =
|
|
294
|
-
|
|
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
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
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
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
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
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
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
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
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
|
|
355
|
-
const
|
|
356
|
-
const
|
|
357
|
-
const
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
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
|
-
|
|
379
|
-
const
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
if (
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
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
|
-
|
|
418
|
-
|
|
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
|
-
|
|
430
|
-
const
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
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
|
-
|
|
466
|
-
|
|
467
|
-
|
|
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
|
|
522
|
-
toolSchema('
|
|
523
|
-
toolSchema('
|
|
524
|
-
toolSchema('
|
|
525
|
-
toolSchema('
|
|
526
|
-
toolSchema('
|
|
527
|
-
toolSchema('
|
|
528
|
-
toolSchema('
|
|
529
|
-
toolSchema('
|
|
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
|
-
|
|
544
|
-
if (name === '
|
|
545
|
-
if (name === '
|
|
546
|
-
if (name === '
|
|
547
|
-
if (name === '
|
|
548
|
-
if (name === '
|
|
549
|
-
if (name === '
|
|
550
|
-
if (name === '
|
|
551
|
-
if (name === '
|
|
552
|
-
if (name === '
|
|
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
|
|
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
|
-
|
|
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 === '
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
if (sub === '
|
|
637
|
-
if (sub === '
|
|
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 === '
|
|
640
|
-
if (sub === '
|
|
641
|
-
if (sub === '
|
|
642
|
-
if (sub === '
|
|
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 === '
|
|
654
|
-
|
|
655
|
-
|
|
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.
|
|
4
|
-
"description": "SuperCollab CLI and MCP bridge for
|
|
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",
|