@supercollab/cli 0.4.1 → 0.4.3
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 +21 -7
- package/bin/supercollab.js +496 -68
- package/package.json +5 -3
package/README.md
CHANGED
|
@@ -4,17 +4,31 @@ 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
6
|
membership, invites, and an encrypted room message stream. Message bodies are
|
|
7
|
-
encrypted locally before upload. The CLI keeps a local SQLite transcript
|
|
8
|
-
agent can decrypt, sync, index, and search the
|
|
9
|
-
where it is working.
|
|
7
|
+
encrypted locally before upload. The CLI keeps a local native SQLite transcript
|
|
8
|
+
with FTS5 and sqlite-vec so the agent can decrypt, sync, index, and search the
|
|
9
|
+
conversation from the machine where it is working.
|
|
10
10
|
|
|
11
11
|
Install:
|
|
12
12
|
|
|
13
13
|
```bash
|
|
14
14
|
npm install -g @supercollab/cli
|
|
15
|
+
supercollab
|
|
15
16
|
```
|
|
16
17
|
|
|
17
|
-
|
|
18
|
+
Running `supercollab` opens the guided setup flow. It detects your OS/CPU/Node
|
|
19
|
+
runtime, verifies native SQLite and sqlite-vec, downloads and warms the BGE model
|
|
20
|
+
locally, writes the selected local engine into `~/.supercollab/config.json`,
|
|
21
|
+
creates or logs into your account, registers the local agent, creates or joins a
|
|
22
|
+
room, activates a project directory, and prints MCP config.
|
|
23
|
+
|
|
24
|
+
You can also run the checks directly:
|
|
25
|
+
|
|
26
|
+
```bash
|
|
27
|
+
supercollab doctor
|
|
28
|
+
supercollab doctor --json
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
Manual account setup:
|
|
18
32
|
|
|
19
33
|
```bash
|
|
20
34
|
supercollab register --username your_name
|
|
@@ -78,13 +92,13 @@ Search modes:
|
|
|
78
92
|
|
|
79
93
|
```text
|
|
80
94
|
keyword: local SQLite FTS5/BM25 over decrypted local transcript
|
|
81
|
-
vector: local BGE cosine search over decrypted
|
|
95
|
+
vector: local BGE cosine search through sqlite-vec over decrypted transcript chunks
|
|
82
96
|
hybrid: reciprocal-rank fusion over keyword and vector results
|
|
83
97
|
```
|
|
84
98
|
|
|
85
99
|
The hosted SuperCollab service never computes embeddings and never receives the
|
|
86
|
-
room key.
|
|
87
|
-
|
|
100
|
+
room key. Guided setup downloads and verifies the BGE-small ONNX model into the
|
|
101
|
+
local Hugging Face cache. To verify or prewarm the local embedding system:
|
|
88
102
|
|
|
89
103
|
```bash
|
|
90
104
|
supercollab embeddings status
|
package/bin/supercollab.js
CHANGED
|
@@ -3,10 +3,11 @@ import fs from 'node:fs';
|
|
|
3
3
|
import os from 'node:os';
|
|
4
4
|
import path from 'node:path';
|
|
5
5
|
import crypto from 'node:crypto';
|
|
6
|
+
import { spawnSync } from 'node:child_process';
|
|
6
7
|
import * as readlineCore from 'node:readline';
|
|
7
8
|
import { stdin as input, stdout as output } from 'node:process';
|
|
8
9
|
|
|
9
|
-
const VERSION = '0.4.
|
|
10
|
+
const VERSION = '0.4.3';
|
|
10
11
|
const DEFAULT_SERVER = process.env.SUPERCOLLAB_URL || 'https://hyper.polynode.dev';
|
|
11
12
|
const DEFAULT_CONFIG = process.env.SUPERCOLLAB_CONFIG || path.join(os.homedir(), '.supercollab', 'config.json');
|
|
12
13
|
const SESSION_TTL_SKEW = 60;
|
|
@@ -33,6 +34,8 @@ function printHelp() {
|
|
|
33
34
|
console.log(`SuperCollab CLI ${VERSION}
|
|
34
35
|
|
|
35
36
|
Usage:
|
|
37
|
+
supercollab setup
|
|
38
|
+
supercollab doctor [--json] [--skip-model]
|
|
36
39
|
supercollab register --username NAME [--password PASS] [--label LABEL]
|
|
37
40
|
supercollab login --username NAME [--password PASS]
|
|
38
41
|
supercollab whoami
|
|
@@ -65,6 +68,7 @@ Options:
|
|
|
65
68
|
Environment:
|
|
66
69
|
SUPERCOLLAB_PASSWORD can provide password non-interactively.
|
|
67
70
|
SUPERCOLLAB_CONFIG can override config path.
|
|
71
|
+
SUPERCOLLAB_MODEL_CACHE can override the local Hugging Face model cache.
|
|
68
72
|
SUPERCOLLAB_WORKDIR sets the local workspace directory for MCP activation checks.
|
|
69
73
|
`);
|
|
70
74
|
}
|
|
@@ -363,16 +367,91 @@ async function doLogin(config, file, opts) {
|
|
|
363
367
|
return { ok: true, username: config.username, user_id: config.userId, config: file };
|
|
364
368
|
}
|
|
365
369
|
|
|
366
|
-
|
|
370
|
+
function commandExists(command, args = ['--version']) {
|
|
371
|
+
const result = spawnSync(command, args, { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] });
|
|
372
|
+
return {
|
|
373
|
+
ok: result.status === 0,
|
|
374
|
+
command,
|
|
375
|
+
status: result.status,
|
|
376
|
+
output: String(result.stdout || result.stderr || '').trim().split('\n')[0] || '',
|
|
377
|
+
};
|
|
378
|
+
}
|
|
367
379
|
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
380
|
+
function installAdvice(profile, checks = {}) {
|
|
381
|
+
const advice = [];
|
|
382
|
+
const platform = profile.platform;
|
|
383
|
+
if (!checks.node_supported?.ok) {
|
|
384
|
+
advice.push('Install Node.js 20 LTS or newer, then reinstall @supercollab/cli.');
|
|
385
|
+
}
|
|
386
|
+
if (!checks.native_sqlite_vec?.ok) {
|
|
387
|
+
if (profile.tools?.npm_ignore_scripts?.output === 'true') {
|
|
388
|
+
advice.push('Your npm config has `ignore-scripts=true`, which prevents native SQLite from installing. Run `npm config set ignore-scripts false`, then reinstall or run `npm rebuild -g better-sqlite3 --ignore-scripts=false`.');
|
|
389
|
+
}
|
|
390
|
+
if (platform === 'darwin') {
|
|
391
|
+
advice.push('Install Apple command line tools with `xcode-select --install`, then run `npm rebuild -g better-sqlite3`.');
|
|
392
|
+
} else if (platform === 'linux') {
|
|
393
|
+
advice.push('Install Python 3, make, and a C/C++ compiler, then run `npm rebuild -g better-sqlite3`.');
|
|
394
|
+
} else if (platform === 'win32') {
|
|
395
|
+
advice.push('Install Microsoft Visual Studio Build Tools with the C++ workload and Python, then run `npm rebuild -g better-sqlite3`.');
|
|
396
|
+
} else {
|
|
397
|
+
advice.push('Install a native build toolchain for your OS, then run `npm rebuild -g better-sqlite3`.');
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
if (!checks.bge_model?.ok && checks.bge_model?.error) {
|
|
401
|
+
advice.push('Check internet access to Hugging Face model downloads, then run `supercollab embeddings warmup`.');
|
|
402
|
+
}
|
|
403
|
+
return advice;
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
function detectSystemProfile() {
|
|
407
|
+
const cpus = os.cpus() || [];
|
|
408
|
+
const nodeMajor = Number(process.versions.node.split('.')[0]);
|
|
409
|
+
const tools = {
|
|
410
|
+
npm: commandExists('npm', ['--version']),
|
|
411
|
+
npm_ignore_scripts: commandExists('npm', ['config', 'get', 'ignore-scripts']),
|
|
412
|
+
};
|
|
413
|
+
if (process.platform === 'darwin') {
|
|
414
|
+
tools.xcode_select = commandExists('xcode-select', ['-p']);
|
|
415
|
+
tools.clang = commandExists('clang', ['--version']);
|
|
416
|
+
} else if (process.platform === 'linux') {
|
|
417
|
+
tools.python3 = commandExists('python3', ['--version']);
|
|
418
|
+
tools.make = commandExists('make', ['--version']);
|
|
419
|
+
tools.cc = commandExists('cc', ['--version']);
|
|
420
|
+
tools.gpp = commandExists('g++', ['--version']);
|
|
421
|
+
} else if (process.platform === 'win32') {
|
|
422
|
+
tools.python = commandExists('python', ['--version']);
|
|
423
|
+
tools.node_gyp = commandExists('node-gyp', ['--version']);
|
|
424
|
+
}
|
|
425
|
+
return {
|
|
426
|
+
detected_at: nowIso(),
|
|
427
|
+
cli_version: VERSION,
|
|
428
|
+
platform: process.platform,
|
|
429
|
+
arch: process.arch,
|
|
430
|
+
os_type: os.type(),
|
|
431
|
+
os_release: os.release(),
|
|
432
|
+
hostname: os.hostname(),
|
|
433
|
+
node: process.versions.node,
|
|
434
|
+
node_modules_abi: process.versions.modules,
|
|
435
|
+
node_supported: nodeMajor >= 20,
|
|
436
|
+
cpu_model: cpus[0]?.model || null,
|
|
437
|
+
cpu_count: cpus.length,
|
|
438
|
+
tools,
|
|
439
|
+
};
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
let nativeSqlitePromise = null;
|
|
443
|
+
|
|
444
|
+
async function loadNativeSqlite() {
|
|
445
|
+
if (!nativeSqlitePromise) {
|
|
446
|
+
nativeSqlitePromise = Promise.all([
|
|
447
|
+
import('better-sqlite3'),
|
|
448
|
+
import('sqlite-vec'),
|
|
449
|
+
]).then(([sqliteMod, sqliteVec]) => ({
|
|
450
|
+
Database: sqliteMod.default || sqliteMod,
|
|
451
|
+
sqliteVec,
|
|
452
|
+
}));
|
|
374
453
|
}
|
|
375
|
-
return
|
|
454
|
+
return nativeSqlitePromise;
|
|
376
455
|
}
|
|
377
456
|
|
|
378
457
|
function nowIso() {
|
|
@@ -388,29 +467,15 @@ function chatDbPath(config, file, roomId) {
|
|
|
388
467
|
}
|
|
389
468
|
|
|
390
469
|
function dbRun(db, sql, params = []) {
|
|
391
|
-
|
|
392
|
-
try {
|
|
393
|
-
stmt.bind(params);
|
|
394
|
-
stmt.step();
|
|
395
|
-
} finally {
|
|
396
|
-
stmt.free();
|
|
397
|
-
}
|
|
470
|
+
return db.prepare(sql).run(...params);
|
|
398
471
|
}
|
|
399
472
|
|
|
400
473
|
function dbAll(db, sql, params = []) {
|
|
401
|
-
|
|
402
|
-
const rows = [];
|
|
403
|
-
try {
|
|
404
|
-
stmt.bind(params);
|
|
405
|
-
while (stmt.step()) rows.push(stmt.getAsObject());
|
|
406
|
-
} finally {
|
|
407
|
-
stmt.free();
|
|
408
|
-
}
|
|
409
|
-
return rows;
|
|
474
|
+
return db.prepare(sql).all(...params);
|
|
410
475
|
}
|
|
411
476
|
|
|
412
477
|
function dbGet(db, sql, params = []) {
|
|
413
|
-
return
|
|
478
|
+
return db.prepare(sql).get(...params) || null;
|
|
414
479
|
}
|
|
415
480
|
|
|
416
481
|
function setMeta(db, key, value) {
|
|
@@ -430,6 +495,25 @@ function tableColumns(db, table) {
|
|
|
430
495
|
}
|
|
431
496
|
}
|
|
432
497
|
|
|
498
|
+
function verifySqliteVecLoaded(db) {
|
|
499
|
+
const row = db.prepare('SELECT vec_version() AS version').get();
|
|
500
|
+
if (!row?.version) throw new Error('sqlite-vec extension did not load');
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
function ensureMessageVectorTable(db) {
|
|
504
|
+
const tableInfo = db.prepare("SELECT sql FROM sqlite_master WHERE type='table' AND name='message_vectors'").get();
|
|
505
|
+
if (tableInfo?.sql) {
|
|
506
|
+
const match = String(tableInfo.sql).match(/float\[(\d+)\]/);
|
|
507
|
+
const hasCosine = String(tableInfo.sql).includes('distance_metric=cosine');
|
|
508
|
+
const dims = match?.[1] ? Number(match[1]) : null;
|
|
509
|
+
if (dims === EMBEDDING_DIMS && hasCosine) return false;
|
|
510
|
+
db.exec('DROP TABLE IF EXISTS message_vectors');
|
|
511
|
+
db.exec('DELETE FROM message_embeddings WHERE profile = ' + JSON.stringify(EMBEDDING_PROFILE.id));
|
|
512
|
+
}
|
|
513
|
+
db.exec(`CREATE VIRTUAL TABLE IF NOT EXISTS message_vectors USING vec0(message_seq TEXT PRIMARY KEY, embedding float[${EMBEDDING_DIMS}] distance_metric=cosine)`);
|
|
514
|
+
return true;
|
|
515
|
+
}
|
|
516
|
+
|
|
433
517
|
function initChatSchema(db) {
|
|
434
518
|
db.exec(`
|
|
435
519
|
CREATE TABLE IF NOT EXISTS meta (
|
|
@@ -456,7 +540,7 @@ function initChatSchema(db) {
|
|
|
456
540
|
CREATE INDEX IF NOT EXISTS idx_messages_channel_created ON messages(channel, created_at);
|
|
457
541
|
`);
|
|
458
542
|
const embeddingColumns = tableColumns(db, 'message_embeddings');
|
|
459
|
-
if (embeddingColumns.length > 0 && (!embeddingColumns.includes('seq') || !embeddingColumns.includes('profile'))) {
|
|
543
|
+
if (embeddingColumns.length > 0 && (!embeddingColumns.includes('seq') || !embeddingColumns.includes('profile') || embeddingColumns.includes('vector'))) {
|
|
460
544
|
db.exec('DROP TABLE IF EXISTS message_embeddings');
|
|
461
545
|
}
|
|
462
546
|
db.exec(`
|
|
@@ -467,12 +551,12 @@ function initChatSchema(db) {
|
|
|
467
551
|
dims INTEGER NOT NULL,
|
|
468
552
|
model TEXT NOT NULL,
|
|
469
553
|
profile TEXT NOT NULL,
|
|
470
|
-
vector TEXT NOT NULL,
|
|
471
554
|
updated_at TEXT NOT NULL,
|
|
472
555
|
PRIMARY KEY(message_id, seq)
|
|
473
556
|
);
|
|
474
557
|
CREATE INDEX IF NOT EXISTS idx_message_embeddings_profile ON message_embeddings(profile);
|
|
475
558
|
`);
|
|
559
|
+
ensureMessageVectorTable(db);
|
|
476
560
|
try {
|
|
477
561
|
db.exec("CREATE VIRTUAL TABLE IF NOT EXISTS messages_fts USING fts5(message_id UNINDEXED, channel UNINDEXED, sender_label, body, metadata, tokenize='porter')");
|
|
478
562
|
setMeta(db, 'fts5', '1');
|
|
@@ -567,17 +651,20 @@ async function embedText(text, { isQuery = false, title = '' } = {}) {
|
|
|
567
651
|
return vector;
|
|
568
652
|
}
|
|
569
653
|
|
|
570
|
-
function cosine(a, b) {
|
|
571
|
-
let score = 0;
|
|
572
|
-
for (let i = 0; i < Math.min(a.length, b.length); i++) score += a[i] * b[i];
|
|
573
|
-
return score;
|
|
574
|
-
}
|
|
575
|
-
|
|
576
654
|
async function storeEmbeddings(db, local, metadata) {
|
|
577
655
|
const messageId = local.message_id;
|
|
578
656
|
if (!messageId) return { embedded: false, chunks: 0 };
|
|
657
|
+
const oldRows = dbAll(db, 'SELECT seq FROM message_embeddings WHERE message_id=? AND profile<>?', [messageId, EMBEDDING_PROFILE.id]);
|
|
658
|
+
for (const row of oldRows) dbRun(db, 'DELETE FROM message_vectors WHERE message_seq=?', [`${messageId}:${Number(row.seq || 0)}`]);
|
|
579
659
|
dbRun(db, 'DELETE FROM message_embeddings WHERE message_id=? AND profile<>?', [messageId, EMBEDDING_PROFILE.id]);
|
|
580
|
-
const existing = dbGet(
|
|
660
|
+
const existing = dbGet(
|
|
661
|
+
db,
|
|
662
|
+
`SELECT COUNT(*) AS count
|
|
663
|
+
FROM message_embeddings e
|
|
664
|
+
JOIN message_vectors v ON v.message_seq = e.message_id || ':' || e.seq
|
|
665
|
+
WHERE e.message_id=? AND e.profile=?`,
|
|
666
|
+
[messageId, EMBEDDING_PROFILE.id],
|
|
667
|
+
);
|
|
581
668
|
if (Number(existing?.count || 0) > 0) return { embedded: false, chunks: Number(existing.count) };
|
|
582
669
|
|
|
583
670
|
const body = `${local.sender_label || ''}\n${local.body || ''}\n${metadata || ''}`;
|
|
@@ -587,18 +674,19 @@ async function storeEmbeddings(db, local, metadata) {
|
|
|
587
674
|
for (let seq = 0; seq < chunks.length; seq++) {
|
|
588
675
|
const chunk = chunks[seq];
|
|
589
676
|
const vector = await embedText(chunk.text, { title });
|
|
677
|
+
const messageSeq = `${messageId}:${seq}`;
|
|
678
|
+
dbRun(db, 'INSERT OR REPLACE INTO message_vectors(message_seq, embedding) VALUES(?, ?)', [messageSeq, new Float32Array(vector)]);
|
|
590
679
|
dbRun(
|
|
591
680
|
db,
|
|
592
|
-
`INSERT INTO message_embeddings(message_id,seq,pos,dims,model,profile,
|
|
593
|
-
VALUES(
|
|
681
|
+
`INSERT INTO message_embeddings(message_id,seq,pos,dims,model,profile,updated_at)
|
|
682
|
+
VALUES(?,?,?,?,?,?,?)
|
|
594
683
|
ON CONFLICT(message_id, seq) DO UPDATE SET
|
|
595
684
|
pos=excluded.pos,
|
|
596
685
|
dims=excluded.dims,
|
|
597
686
|
model=excluded.model,
|
|
598
687
|
profile=excluded.profile,
|
|
599
|
-
vector=excluded.vector,
|
|
600
688
|
updated_at=excluded.updated_at`,
|
|
601
|
-
[messageId, seq, chunk.pos, EMBEDDING_DIMS, EMBEDDING_MODEL, EMBEDDING_PROFILE.id,
|
|
689
|
+
[messageId, seq, chunk.pos, EMBEDDING_DIMS, EMBEDDING_MODEL, EMBEDDING_PROFILE.id, updatedAt],
|
|
602
690
|
);
|
|
603
691
|
}
|
|
604
692
|
setMeta(db, 'embedding_last_ok_at', updatedAt);
|
|
@@ -621,7 +709,9 @@ async function embedMissingMessages(db, limit = 500) {
|
|
|
621
709
|
FROM messages m
|
|
622
710
|
LEFT JOIN message_embeddings e
|
|
623
711
|
ON e.message_id=m.message_id AND e.profile=?
|
|
624
|
-
|
|
712
|
+
LEFT JOIN message_vectors v
|
|
713
|
+
ON v.message_seq = e.message_id || ':' || e.seq
|
|
714
|
+
WHERE e.message_id IS NULL OR v.message_seq IS NULL
|
|
625
715
|
ORDER BY m.id ASC
|
|
626
716
|
LIMIT ?`,
|
|
627
717
|
[EMBEDDING_PROFILE.id, Math.max(1, Math.min(Number(limit || 500), 2000))],
|
|
@@ -635,25 +725,22 @@ async function embedMissingMessages(db, limit = 500) {
|
|
|
635
725
|
}
|
|
636
726
|
|
|
637
727
|
async function openChatDb(config, file, roomId) {
|
|
638
|
-
const
|
|
728
|
+
const { Database, sqliteVec } = await loadNativeSqlite();
|
|
639
729
|
const root = chatRoot(config, file, roomId);
|
|
640
730
|
const dbPath = chatDbPath(config, file, roomId);
|
|
641
731
|
fs.mkdirSync(root, { recursive: true, mode: 0o700 });
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
}
|
|
732
|
+
const db = new Database(dbPath);
|
|
733
|
+
db.pragma('journal_mode = WAL');
|
|
734
|
+
db.pragma('foreign_keys = ON');
|
|
735
|
+
sqliteVec.load(db);
|
|
736
|
+
verifySqliteVecLoaded(db);
|
|
648
737
|
initChatSchema(db);
|
|
649
738
|
setMeta(db, 'room_id', roomId);
|
|
650
739
|
return { db, root, dbPath, roomId };
|
|
651
740
|
}
|
|
652
741
|
|
|
653
742
|
function saveChatDb(cap) {
|
|
654
|
-
|
|
655
|
-
fs.writeFileSync(tmp, Buffer.from(cap.db.export()), { mode: 0o600 });
|
|
656
|
-
fs.renameSync(tmp, cap.dbPath);
|
|
743
|
+
cap.db.pragma('wal_checkpoint(PASSIVE)');
|
|
657
744
|
try { fs.chmodSync(cap.dbPath, 0o600); } catch {}
|
|
658
745
|
}
|
|
659
746
|
|
|
@@ -829,22 +916,33 @@ async function doChatSearch(config, file, opts) {
|
|
|
829
916
|
let vectorError = null;
|
|
830
917
|
if (mode !== 'keyword') try {
|
|
831
918
|
const qvec = await embedText(query, { isQuery: true });
|
|
832
|
-
const
|
|
833
|
-
|
|
919
|
+
const k = Math.max(maxResults * 4, 50);
|
|
920
|
+
const vecMatches = dbAll(
|
|
834
921
|
cap.db,
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
922
|
+
'SELECT message_seq, distance FROM message_vectors WHERE embedding MATCH ? AND k = ?',
|
|
923
|
+
[new Float32Array(qvec), k],
|
|
924
|
+
);
|
|
925
|
+
const bestByMessage = new Map();
|
|
926
|
+
if (vecMatches.length) {
|
|
927
|
+
const messageSeqs = vecMatches.map((row) => String(row.message_seq));
|
|
928
|
+
const distanceBySeq = new Map(vecMatches.map((row) => [String(row.message_seq), Number(row.distance)]));
|
|
929
|
+
const placeholders = messageSeqs.map(() => '?').join(',');
|
|
930
|
+
for (const row of dbAll(
|
|
931
|
+
cap.db,
|
|
932
|
+
`SELECT m.*, e.seq, e.pos, e.message_id || ':' || e.seq AS message_seq
|
|
933
|
+
FROM message_embeddings e
|
|
934
|
+
JOIN messages m ON m.message_id=e.message_id
|
|
935
|
+
WHERE e.profile=? AND e.message_id || ':' || e.seq IN (${placeholders})`,
|
|
936
|
+
[EMBEDDING_PROFILE.id, ...messageSeqs],
|
|
937
|
+
)) {
|
|
938
|
+
const distance = distanceBySeq.get(String(row.message_seq)) ?? 1;
|
|
939
|
+
const score = 1 - distance;
|
|
940
|
+
if (score <= 0) continue;
|
|
941
|
+
const { message_seq, ...clean } = row;
|
|
942
|
+
const prior = bestByMessage.get(row.message_id);
|
|
943
|
+
if (!prior || score > prior.vector_score) {
|
|
944
|
+
bestByMessage.set(row.message_id, { ...clean, vector_score: score, chunk_seq: Number(row.seq || 0), chunk_pos: Number(row.pos || 0) });
|
|
945
|
+
}
|
|
848
946
|
}
|
|
849
947
|
}
|
|
850
948
|
vectorRows = Array.from(bestByMessage.values())
|
|
@@ -1072,14 +1170,14 @@ async function runMcp(opts) {
|
|
|
1072
1170
|
|
|
1073
1171
|
function printCodexConfig(opts) {
|
|
1074
1172
|
const file = configPath(opts);
|
|
1075
|
-
console.log(
|
|
1173
|
+
console.log(mcpConfigText(String(opts.client || 'codex'), file));
|
|
1076
1174
|
}
|
|
1077
1175
|
|
|
1078
1176
|
async function embeddingStatus() {
|
|
1079
1177
|
return {
|
|
1080
1178
|
ok: true,
|
|
1081
1179
|
profile: EMBEDDING_PROFILE,
|
|
1082
|
-
model_download: '
|
|
1180
|
+
model_download: 'installed during setup, or manually via `supercollab embeddings warmup`',
|
|
1083
1181
|
cache_dir: process.env.SUPERCOLLAB_MODEL_CACHE || 'default @huggingface/transformers cache',
|
|
1084
1182
|
};
|
|
1085
1183
|
}
|
|
@@ -1093,14 +1191,344 @@ async function embeddingWarmup() {
|
|
|
1093
1191
|
};
|
|
1094
1192
|
}
|
|
1095
1193
|
|
|
1194
|
+
async function nativeEngineCheck() {
|
|
1195
|
+
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'supercollab-doctor-'));
|
|
1196
|
+
const dbPath = path.join(tempDir, 'native.sqlite');
|
|
1197
|
+
let db = null;
|
|
1198
|
+
try {
|
|
1199
|
+
const { Database, sqliteVec } = await loadNativeSqlite();
|
|
1200
|
+
db = new Database(dbPath);
|
|
1201
|
+
sqliteVec.load(db);
|
|
1202
|
+
const version = db.prepare('SELECT vec_version() AS version').get()?.version;
|
|
1203
|
+
db.exec(`CREATE VIRTUAL TABLE vec_check USING vec0(id TEXT PRIMARY KEY, embedding float[${EMBEDDING_DIMS}] distance_metric=cosine)`);
|
|
1204
|
+
db.prepare('INSERT INTO vec_check(id, embedding) VALUES(?, ?)').run('ok', new Float32Array(new Array(EMBEDDING_DIMS).fill(0).map((_, i) => i === 0 ? 1 : 0)));
|
|
1205
|
+
const rows = db.prepare('SELECT id, distance FROM vec_check WHERE embedding MATCH ? AND k = 1').all(new Float32Array(new Array(EMBEDDING_DIMS).fill(0).map((_, i) => i === 0 ? 1 : 0)));
|
|
1206
|
+
if (rows[0]?.id !== 'ok') throw new Error('sqlite-vec query did not return expected row');
|
|
1207
|
+
return {
|
|
1208
|
+
ok: true,
|
|
1209
|
+
engine: 'native-sqlite-vec',
|
|
1210
|
+
sqlite_vec_version: version,
|
|
1211
|
+
dims: EMBEDDING_DIMS,
|
|
1212
|
+
};
|
|
1213
|
+
} catch (err) {
|
|
1214
|
+
return {
|
|
1215
|
+
ok: false,
|
|
1216
|
+
engine: 'native-sqlite-vec',
|
|
1217
|
+
error: err.message || String(err),
|
|
1218
|
+
};
|
|
1219
|
+
} finally {
|
|
1220
|
+
try { db?.close(); } catch {}
|
|
1221
|
+
try { fs.rmSync(tempDir, { recursive: true, force: true }); } catch {}
|
|
1222
|
+
}
|
|
1223
|
+
}
|
|
1224
|
+
|
|
1225
|
+
async function runDoctor(config, file, opts = {}) {
|
|
1226
|
+
const profile = detectSystemProfile();
|
|
1227
|
+
const checks = {
|
|
1228
|
+
node_supported: {
|
|
1229
|
+
ok: profile.node_supported,
|
|
1230
|
+
version: profile.node,
|
|
1231
|
+
required: '>=20',
|
|
1232
|
+
},
|
|
1233
|
+
native_sqlite_vec: await nativeEngineCheck(),
|
|
1234
|
+
};
|
|
1235
|
+
|
|
1236
|
+
if (!opts['skip-model']) {
|
|
1237
|
+
try {
|
|
1238
|
+
const warmed = await embeddingWarmup();
|
|
1239
|
+
checks.bge_model = {
|
|
1240
|
+
ok: warmed.ok && warmed.dims === EMBEDDING_DIMS,
|
|
1241
|
+
dims: warmed.dims,
|
|
1242
|
+
profile: EMBEDDING_PROFILE,
|
|
1243
|
+
cache_dir: process.env.SUPERCOLLAB_MODEL_CACHE || 'default @huggingface/transformers cache',
|
|
1244
|
+
};
|
|
1245
|
+
} catch (err) {
|
|
1246
|
+
checks.bge_model = {
|
|
1247
|
+
ok: false,
|
|
1248
|
+
profile: EMBEDDING_PROFILE,
|
|
1249
|
+
error: err.message || String(err),
|
|
1250
|
+
};
|
|
1251
|
+
}
|
|
1252
|
+
} else {
|
|
1253
|
+
checks.bge_model = {
|
|
1254
|
+
ok: null,
|
|
1255
|
+
skipped: true,
|
|
1256
|
+
profile: EMBEDDING_PROFILE,
|
|
1257
|
+
};
|
|
1258
|
+
}
|
|
1259
|
+
|
|
1260
|
+
const ok = checks.node_supported.ok && checks.native_sqlite_vec.ok && (checks.bge_model.ok === true || checks.bge_model.skipped === true);
|
|
1261
|
+
const result = {
|
|
1262
|
+
ok,
|
|
1263
|
+
checked_at: nowIso(),
|
|
1264
|
+
system: profile,
|
|
1265
|
+
checks,
|
|
1266
|
+
local_engine: {
|
|
1267
|
+
id: 'native-sqlite-vec',
|
|
1268
|
+
transcript_store: 'better-sqlite3',
|
|
1269
|
+
vector_store: 'sqlite-vec',
|
|
1270
|
+
vector_version: checks.native_sqlite_vec.sqlite_vec_version || null,
|
|
1271
|
+
embedding_profile_id: EMBEDDING_PROFILE.id,
|
|
1272
|
+
},
|
|
1273
|
+
advice: installAdvice(profile, checks),
|
|
1274
|
+
};
|
|
1275
|
+
|
|
1276
|
+
config.systemProfile = profile;
|
|
1277
|
+
config.localEngine = result.local_engine;
|
|
1278
|
+
config.embeddingProfile = EMBEDDING_PROFILE;
|
|
1279
|
+
config.setupChecks = {
|
|
1280
|
+
ok,
|
|
1281
|
+
checked_at: result.checked_at,
|
|
1282
|
+
checks,
|
|
1283
|
+
advice: result.advice,
|
|
1284
|
+
};
|
|
1285
|
+
saveConfig(config, file);
|
|
1286
|
+
return result;
|
|
1287
|
+
}
|
|
1288
|
+
|
|
1289
|
+
function printDoctor(result) {
|
|
1290
|
+
console.log(JSON.stringify(result, null, 2));
|
|
1291
|
+
}
|
|
1292
|
+
|
|
1293
|
+
async function loadPrompts() {
|
|
1294
|
+
return import('@clack/prompts');
|
|
1295
|
+
}
|
|
1296
|
+
|
|
1297
|
+
function isConfigured(config) {
|
|
1298
|
+
return Boolean(config.userToken && config.agentId && config.agentPrivateKeyPem);
|
|
1299
|
+
}
|
|
1300
|
+
|
|
1301
|
+
function promptRequired(value) {
|
|
1302
|
+
return String(value || '').trim() ? undefined : 'Required';
|
|
1303
|
+
}
|
|
1304
|
+
|
|
1305
|
+
async function ensureAgentForSetup(config, file, prompts) {
|
|
1306
|
+
if (config.agentId && config.agentPrivateKeyPem) return null;
|
|
1307
|
+
const label = await prompts.text({
|
|
1308
|
+
message: 'Name this local agent',
|
|
1309
|
+
placeholder: `${os.hostname()}-agent`,
|
|
1310
|
+
defaultValue: `${os.hostname()}-agent`,
|
|
1311
|
+
validate: promptRequired,
|
|
1312
|
+
});
|
|
1313
|
+
if (prompts.isCancel(label)) throw new Error('cancelled');
|
|
1314
|
+
const agent = await registerAgent(config, String(label));
|
|
1315
|
+
saveConfig(config, file);
|
|
1316
|
+
return agent;
|
|
1317
|
+
}
|
|
1318
|
+
|
|
1319
|
+
async function runAuthSetup(config, file, prompts) {
|
|
1320
|
+
if (isConfigured(config)) {
|
|
1321
|
+
const reuse = await prompts.confirm({
|
|
1322
|
+
message: `Use existing login as ${config.username || 'current user'}?`,
|
|
1323
|
+
initialValue: true,
|
|
1324
|
+
});
|
|
1325
|
+
if (prompts.isCancel(reuse)) throw new Error('cancelled');
|
|
1326
|
+
if (reuse) return { reused: true };
|
|
1327
|
+
}
|
|
1328
|
+
|
|
1329
|
+
const mode = await prompts.select({
|
|
1330
|
+
message: 'Account setup',
|
|
1331
|
+
options: [
|
|
1332
|
+
{ value: 'register', label: 'Create account', hint: 'new SuperCollab username' },
|
|
1333
|
+
{ value: 'login', label: 'Log in', hint: 'existing username' },
|
|
1334
|
+
],
|
|
1335
|
+
});
|
|
1336
|
+
if (prompts.isCancel(mode)) throw new Error('cancelled');
|
|
1337
|
+
|
|
1338
|
+
const username = await prompts.text({
|
|
1339
|
+
message: mode === 'register' ? 'Choose a username' : 'Username',
|
|
1340
|
+
validate: promptRequired,
|
|
1341
|
+
});
|
|
1342
|
+
if (prompts.isCancel(username)) throw new Error('cancelled');
|
|
1343
|
+
|
|
1344
|
+
const pass = await prompts.password({
|
|
1345
|
+
message: mode === 'register' ? 'Choose a password' : 'Password',
|
|
1346
|
+
validate: (value) => String(value || '').length >= 8 ? undefined : 'Use at least 8 characters',
|
|
1347
|
+
});
|
|
1348
|
+
if (prompts.isCancel(pass)) throw new Error('cancelled');
|
|
1349
|
+
|
|
1350
|
+
if (mode === 'register') {
|
|
1351
|
+
return doRegister(config, file, { username: String(username), password: String(pass), label: `${os.hostname()}-agent` });
|
|
1352
|
+
}
|
|
1353
|
+
|
|
1354
|
+
const login = await doLogin(config, file, { username: String(username), password: String(pass) });
|
|
1355
|
+
const agent = await ensureAgentForSetup(config, file, prompts);
|
|
1356
|
+
return { ...login, agent_id: agent?.agent_id || config.agentId, fingerprint: agent?.fingerprint || config.agentFingerprint };
|
|
1357
|
+
}
|
|
1358
|
+
|
|
1359
|
+
async function runRoomSetup(config, file, prompts) {
|
|
1360
|
+
const choice = await prompts.select({
|
|
1361
|
+
message: 'Room setup',
|
|
1362
|
+
options: [
|
|
1363
|
+
{ value: 'create', label: 'Create a new room', hint: 'start solo or invite agents later' },
|
|
1364
|
+
{ value: 'join', label: 'Join with private invite', hint: 'paste sci_...sck_...' },
|
|
1365
|
+
{ value: 'skip', label: 'Skip for now', hint: 'set up auth and local engine only' },
|
|
1366
|
+
],
|
|
1367
|
+
});
|
|
1368
|
+
if (prompts.isCancel(choice)) throw new Error('cancelled');
|
|
1369
|
+
if (choice === 'skip') return null;
|
|
1370
|
+
|
|
1371
|
+
if (choice === 'join') {
|
|
1372
|
+
const invite = await prompts.text({
|
|
1373
|
+
message: 'Paste private invite',
|
|
1374
|
+
placeholder: 'sci_....sck_...',
|
|
1375
|
+
validate: promptRequired,
|
|
1376
|
+
});
|
|
1377
|
+
if (prompts.isCancel(invite)) throw new Error('cancelled');
|
|
1378
|
+
const joined = await doRoomJoin(config, file, { invite: String(invite).trim() });
|
|
1379
|
+
return { room_id: joined.room_id || joined.workspace_id, joined };
|
|
1380
|
+
}
|
|
1381
|
+
|
|
1382
|
+
const title = await prompts.text({
|
|
1383
|
+
message: 'Room title',
|
|
1384
|
+
placeholder: 'Launch Room',
|
|
1385
|
+
validate: promptRequired,
|
|
1386
|
+
});
|
|
1387
|
+
if (prompts.isCancel(title)) throw new Error('cancelled');
|
|
1388
|
+
const goal = await prompts.text({
|
|
1389
|
+
message: 'Room goal',
|
|
1390
|
+
placeholder: 'Coordinate agents on this project',
|
|
1391
|
+
validate: promptRequired,
|
|
1392
|
+
});
|
|
1393
|
+
if (prompts.isCancel(goal)) throw new Error('cancelled');
|
|
1394
|
+
const created = await doRoomCreate(config, file, { title: String(title), goal: String(goal) });
|
|
1395
|
+
return { room_id: created.room_id || created.id, created };
|
|
1396
|
+
}
|
|
1397
|
+
|
|
1398
|
+
async function runActivationSetup(config, file, roomId, prompts) {
|
|
1399
|
+
if (!roomId) return null;
|
|
1400
|
+
const shouldActivate = await prompts.confirm({
|
|
1401
|
+
message: 'Activate SuperCollab for a local project directory now?',
|
|
1402
|
+
initialValue: true,
|
|
1403
|
+
});
|
|
1404
|
+
if (prompts.isCancel(shouldActivate)) throw new Error('cancelled');
|
|
1405
|
+
if (!shouldActivate) return null;
|
|
1406
|
+
const cwd = await prompts.text({
|
|
1407
|
+
message: 'Project directory',
|
|
1408
|
+
defaultValue: process.cwd(),
|
|
1409
|
+
placeholder: process.cwd(),
|
|
1410
|
+
validate: (value) => {
|
|
1411
|
+
const resolved = path.resolve(String(value || ''));
|
|
1412
|
+
return fs.existsSync(resolved) ? undefined : 'Directory does not exist';
|
|
1413
|
+
},
|
|
1414
|
+
});
|
|
1415
|
+
if (prompts.isCancel(cwd)) throw new Error('cancelled');
|
|
1416
|
+
return activate(config, file, { room: roomId, cwd: String(cwd) });
|
|
1417
|
+
}
|
|
1418
|
+
|
|
1419
|
+
async function runSetupSmoke(config, file, roomId, prompts) {
|
|
1420
|
+
if (!roomId) return null;
|
|
1421
|
+
const shouldSmoke = await prompts.confirm({
|
|
1422
|
+
message: 'Send a setup note and verify local BGE search?',
|
|
1423
|
+
initialValue: true,
|
|
1424
|
+
});
|
|
1425
|
+
if (prompts.isCancel(shouldSmoke)) throw new Error('cancelled');
|
|
1426
|
+
if (!shouldSmoke) return null;
|
|
1427
|
+
const marker = `setup-${Date.now()}-${crypto.randomBytes(4).toString('hex')}`;
|
|
1428
|
+
const text = `SuperCollab setup complete on ${os.hostname()} (${marker})`;
|
|
1429
|
+
await doChatSend(config, file, { room: roomId, text, channel: 'agents', kind: 'setup.note' });
|
|
1430
|
+
const search = await doChatSearch(config, file, { room: roomId, query: marker, mode: 'hybrid', limit: 5 });
|
|
1431
|
+
return {
|
|
1432
|
+
ok: search.results.some((row) => String(row.body || '').includes(marker)),
|
|
1433
|
+
marker,
|
|
1434
|
+
search_count: search.results.length,
|
|
1435
|
+
};
|
|
1436
|
+
}
|
|
1437
|
+
|
|
1438
|
+
function mcpConfigText(client, file) {
|
|
1439
|
+
const escaped = file.replaceAll('\\', '\\\\').replaceAll('"', '\\"');
|
|
1440
|
+
if (client === 'claude') {
|
|
1441
|
+
return JSON.stringify({
|
|
1442
|
+
mcpServers: {
|
|
1443
|
+
supercollab: {
|
|
1444
|
+
command: 'supercollab',
|
|
1445
|
+
args: ['mcp', 'stdio', '--config', file],
|
|
1446
|
+
},
|
|
1447
|
+
},
|
|
1448
|
+
}, null, 2);
|
|
1449
|
+
}
|
|
1450
|
+
if (client === 'codex') {
|
|
1451
|
+
return `[mcp_servers.supercollab]\ncommand = "supercollab"\nargs = ["mcp", "stdio", "--config", "${escaped}"]`;
|
|
1452
|
+
}
|
|
1453
|
+
return `supercollab mcp stdio --config "${escaped}"`;
|
|
1454
|
+
}
|
|
1455
|
+
|
|
1456
|
+
async function runSetupWizard(config, file, opts = {}) {
|
|
1457
|
+
if (!process.stdin.isTTY || !process.stdout.isTTY) {
|
|
1458
|
+
throw new Error('interactive setup requires a TTY; run `supercollab doctor --json` for non-interactive diagnostics');
|
|
1459
|
+
}
|
|
1460
|
+
const prompts = await loadPrompts();
|
|
1461
|
+
prompts.intro(`SuperCollab ${VERSION}`);
|
|
1462
|
+
try {
|
|
1463
|
+
const spin = prompts.spinner();
|
|
1464
|
+
spin.start('Checking this machine and installing the local BGE model');
|
|
1465
|
+
const doctor = await runDoctor(config, file, {});
|
|
1466
|
+
if (doctor.ok) {
|
|
1467
|
+
spin.stop(`Local engine ready: ${doctor.local_engine.id}, ${doctor.local_engine.vector_version || 'sqlite-vec'}`);
|
|
1468
|
+
} else {
|
|
1469
|
+
spin.stop('Local engine check needs attention');
|
|
1470
|
+
for (const line of doctor.advice || []) prompts.note(line, 'Fix');
|
|
1471
|
+
const cont = await prompts.confirm({ message: 'Continue setup anyway?', initialValue: false });
|
|
1472
|
+
if (prompts.isCancel(cont) || !cont) throw new Error('cancelled');
|
|
1473
|
+
}
|
|
1474
|
+
|
|
1475
|
+
await runAuthSetup(config, file, prompts);
|
|
1476
|
+
const room = await runRoomSetup(config, file, prompts);
|
|
1477
|
+
const roomId = room?.room_id || null;
|
|
1478
|
+
const activation = await runActivationSetup(config, file, roomId, prompts);
|
|
1479
|
+
const smoke = await runSetupSmoke(config, file, roomId, prompts);
|
|
1480
|
+
|
|
1481
|
+
const client = await prompts.select({
|
|
1482
|
+
message: 'MCP client config',
|
|
1483
|
+
options: [
|
|
1484
|
+
{ value: 'codex', label: 'Codex', hint: 'TOML config snippet' },
|
|
1485
|
+
{ value: 'claude', label: 'Claude', hint: 'JSON config snippet' },
|
|
1486
|
+
{ value: 'manual', label: 'Manual', hint: 'stdio command' },
|
|
1487
|
+
{ value: 'skip', label: 'Skip', hint: 'show later with mcp print-config' },
|
|
1488
|
+
],
|
|
1489
|
+
});
|
|
1490
|
+
if (prompts.isCancel(client)) throw new Error('cancelled');
|
|
1491
|
+
|
|
1492
|
+
config.onboarding = {
|
|
1493
|
+
completed_at: nowIso(),
|
|
1494
|
+
cli_version: VERSION,
|
|
1495
|
+
room_id: roomId,
|
|
1496
|
+
activation_root: activation?.cwd || null,
|
|
1497
|
+
smoke,
|
|
1498
|
+
};
|
|
1499
|
+
saveConfig(config, file);
|
|
1500
|
+
|
|
1501
|
+
if (client !== 'skip') {
|
|
1502
|
+
prompts.note(mcpConfigText(client, file), `${client} MCP config`);
|
|
1503
|
+
}
|
|
1504
|
+
prompts.outro(`Ready. Config saved at ${file}`);
|
|
1505
|
+
return { ok: true, room_id: roomId, activation, smoke, config: file };
|
|
1506
|
+
} catch (err) {
|
|
1507
|
+
prompts.cancel(err.message === 'cancelled' ? 'Setup cancelled.' : `Setup stopped: ${err.message}`);
|
|
1508
|
+
throw err;
|
|
1509
|
+
}
|
|
1510
|
+
}
|
|
1511
|
+
|
|
1096
1512
|
async function main() {
|
|
1097
1513
|
const { positionals, opts } = parse(process.argv.slice(2));
|
|
1098
|
-
if (opts.help
|
|
1514
|
+
if (opts.help) { printHelp(); return; }
|
|
1099
1515
|
const [cmd, sub] = positionals;
|
|
1100
1516
|
const file = configPath(opts);
|
|
1101
1517
|
const config = attachRuntimeConfig(loadConfig(file), file);
|
|
1102
1518
|
config.serverUrl = opts.server || config.serverUrl || DEFAULT_SERVER;
|
|
1103
1519
|
|
|
1520
|
+
if (positionals.length === 0) {
|
|
1521
|
+
if (process.stdin.isTTY && process.stdout.isTTY) return runSetupWizard(config, file, opts);
|
|
1522
|
+
printHelp();
|
|
1523
|
+
return;
|
|
1524
|
+
}
|
|
1525
|
+
if (cmd === 'setup') return runSetupWizard(config, file, opts);
|
|
1526
|
+
if (cmd === 'doctor') {
|
|
1527
|
+
const data = await runDoctor(config, file, opts);
|
|
1528
|
+
if (opts.json) return console.log(JSON.stringify(data, null, 2));
|
|
1529
|
+
printDoctor(data);
|
|
1530
|
+
return;
|
|
1531
|
+
}
|
|
1104
1532
|
if (cmd === 'register') return console.log(JSON.stringify(await doRegister(config, file, opts), null, 2));
|
|
1105
1533
|
if (cmd === 'login') return console.log(JSON.stringify(await doLogin(config, file, opts), null, 2));
|
|
1106
1534
|
if (cmd === 'whoami') return console.log(JSON.stringify(await api(config, 'GET', '/v1/me'), null, 2));
|
package/package.json
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@supercollab/cli",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.3",
|
|
4
4
|
"description": "SuperCollab CLI and MCP bridge for encrypted local-search agent group chat.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
7
|
-
"supercollab": "
|
|
7
|
+
"supercollab": "bin/supercollab.js"
|
|
8
8
|
},
|
|
9
9
|
"files": [
|
|
10
10
|
"bin/supercollab.js",
|
|
@@ -14,8 +14,10 @@
|
|
|
14
14
|
"node": ">=20"
|
|
15
15
|
},
|
|
16
16
|
"dependencies": {
|
|
17
|
+
"@clack/prompts": "0.11.0",
|
|
17
18
|
"@huggingface/transformers": "3.8.1",
|
|
18
|
-
"
|
|
19
|
+
"better-sqlite3": "12.11.1",
|
|
20
|
+
"sqlite-vec": "0.1.9"
|
|
19
21
|
},
|
|
20
22
|
"keywords": [
|
|
21
23
|
"mcp",
|