@supercollab/cli 0.4.1 → 0.4.2
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 +4 -4
- package/bin/supercollab.js +87 -65
- package/package.json +4 -3
package/README.md
CHANGED
|
@@ -4,9 +4,9 @@ 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
|
|
|
@@ -78,7 +78,7 @@ Search modes:
|
|
|
78
78
|
|
|
79
79
|
```text
|
|
80
80
|
keyword: local SQLite FTS5/BM25 over decrypted local transcript
|
|
81
|
-
vector: local BGE cosine search over decrypted
|
|
81
|
+
vector: local BGE cosine search through sqlite-vec over decrypted transcript chunks
|
|
82
82
|
hybrid: reciprocal-rank fusion over keyword and vector results
|
|
83
83
|
```
|
|
84
84
|
|
package/bin/supercollab.js
CHANGED
|
@@ -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.4.
|
|
9
|
+
const VERSION = '0.4.2';
|
|
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;
|
|
@@ -363,16 +363,19 @@ async function doLogin(config, file, opts) {
|
|
|
363
363
|
return { ok: true, username: config.username, user_id: config.userId, config: file };
|
|
364
364
|
}
|
|
365
365
|
|
|
366
|
-
let
|
|
366
|
+
let nativeSqlitePromise = null;
|
|
367
367
|
|
|
368
|
-
async function
|
|
369
|
-
if (!
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
368
|
+
async function loadNativeSqlite() {
|
|
369
|
+
if (!nativeSqlitePromise) {
|
|
370
|
+
nativeSqlitePromise = Promise.all([
|
|
371
|
+
import('better-sqlite3'),
|
|
372
|
+
import('sqlite-vec'),
|
|
373
|
+
]).then(([sqliteMod, sqliteVec]) => ({
|
|
374
|
+
Database: sqliteMod.default || sqliteMod,
|
|
375
|
+
sqliteVec,
|
|
376
|
+
}));
|
|
374
377
|
}
|
|
375
|
-
return
|
|
378
|
+
return nativeSqlitePromise;
|
|
376
379
|
}
|
|
377
380
|
|
|
378
381
|
function nowIso() {
|
|
@@ -388,29 +391,15 @@ function chatDbPath(config, file, roomId) {
|
|
|
388
391
|
}
|
|
389
392
|
|
|
390
393
|
function dbRun(db, sql, params = []) {
|
|
391
|
-
|
|
392
|
-
try {
|
|
393
|
-
stmt.bind(params);
|
|
394
|
-
stmt.step();
|
|
395
|
-
} finally {
|
|
396
|
-
stmt.free();
|
|
397
|
-
}
|
|
394
|
+
return db.prepare(sql).run(...params);
|
|
398
395
|
}
|
|
399
396
|
|
|
400
397
|
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;
|
|
398
|
+
return db.prepare(sql).all(...params);
|
|
410
399
|
}
|
|
411
400
|
|
|
412
401
|
function dbGet(db, sql, params = []) {
|
|
413
|
-
return
|
|
402
|
+
return db.prepare(sql).get(...params) || null;
|
|
414
403
|
}
|
|
415
404
|
|
|
416
405
|
function setMeta(db, key, value) {
|
|
@@ -430,6 +419,25 @@ function tableColumns(db, table) {
|
|
|
430
419
|
}
|
|
431
420
|
}
|
|
432
421
|
|
|
422
|
+
function verifySqliteVecLoaded(db) {
|
|
423
|
+
const row = db.prepare('SELECT vec_version() AS version').get();
|
|
424
|
+
if (!row?.version) throw new Error('sqlite-vec extension did not load');
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
function ensureMessageVectorTable(db) {
|
|
428
|
+
const tableInfo = db.prepare("SELECT sql FROM sqlite_master WHERE type='table' AND name='message_vectors'").get();
|
|
429
|
+
if (tableInfo?.sql) {
|
|
430
|
+
const match = String(tableInfo.sql).match(/float\[(\d+)\]/);
|
|
431
|
+
const hasCosine = String(tableInfo.sql).includes('distance_metric=cosine');
|
|
432
|
+
const dims = match?.[1] ? Number(match[1]) : null;
|
|
433
|
+
if (dims === EMBEDDING_DIMS && hasCosine) return false;
|
|
434
|
+
db.exec('DROP TABLE IF EXISTS message_vectors');
|
|
435
|
+
db.exec('DELETE FROM message_embeddings WHERE profile = ' + JSON.stringify(EMBEDDING_PROFILE.id));
|
|
436
|
+
}
|
|
437
|
+
db.exec(`CREATE VIRTUAL TABLE IF NOT EXISTS message_vectors USING vec0(message_seq TEXT PRIMARY KEY, embedding float[${EMBEDDING_DIMS}] distance_metric=cosine)`);
|
|
438
|
+
return true;
|
|
439
|
+
}
|
|
440
|
+
|
|
433
441
|
function initChatSchema(db) {
|
|
434
442
|
db.exec(`
|
|
435
443
|
CREATE TABLE IF NOT EXISTS meta (
|
|
@@ -456,7 +464,7 @@ function initChatSchema(db) {
|
|
|
456
464
|
CREATE INDEX IF NOT EXISTS idx_messages_channel_created ON messages(channel, created_at);
|
|
457
465
|
`);
|
|
458
466
|
const embeddingColumns = tableColumns(db, 'message_embeddings');
|
|
459
|
-
if (embeddingColumns.length > 0 && (!embeddingColumns.includes('seq') || !embeddingColumns.includes('profile'))) {
|
|
467
|
+
if (embeddingColumns.length > 0 && (!embeddingColumns.includes('seq') || !embeddingColumns.includes('profile') || embeddingColumns.includes('vector'))) {
|
|
460
468
|
db.exec('DROP TABLE IF EXISTS message_embeddings');
|
|
461
469
|
}
|
|
462
470
|
db.exec(`
|
|
@@ -467,12 +475,12 @@ function initChatSchema(db) {
|
|
|
467
475
|
dims INTEGER NOT NULL,
|
|
468
476
|
model TEXT NOT NULL,
|
|
469
477
|
profile TEXT NOT NULL,
|
|
470
|
-
vector TEXT NOT NULL,
|
|
471
478
|
updated_at TEXT NOT NULL,
|
|
472
479
|
PRIMARY KEY(message_id, seq)
|
|
473
480
|
);
|
|
474
481
|
CREATE INDEX IF NOT EXISTS idx_message_embeddings_profile ON message_embeddings(profile);
|
|
475
482
|
`);
|
|
483
|
+
ensureMessageVectorTable(db);
|
|
476
484
|
try {
|
|
477
485
|
db.exec("CREATE VIRTUAL TABLE IF NOT EXISTS messages_fts USING fts5(message_id UNINDEXED, channel UNINDEXED, sender_label, body, metadata, tokenize='porter')");
|
|
478
486
|
setMeta(db, 'fts5', '1');
|
|
@@ -567,17 +575,20 @@ async function embedText(text, { isQuery = false, title = '' } = {}) {
|
|
|
567
575
|
return vector;
|
|
568
576
|
}
|
|
569
577
|
|
|
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
578
|
async function storeEmbeddings(db, local, metadata) {
|
|
577
579
|
const messageId = local.message_id;
|
|
578
580
|
if (!messageId) return { embedded: false, chunks: 0 };
|
|
581
|
+
const oldRows = dbAll(db, 'SELECT seq FROM message_embeddings WHERE message_id=? AND profile<>?', [messageId, EMBEDDING_PROFILE.id]);
|
|
582
|
+
for (const row of oldRows) dbRun(db, 'DELETE FROM message_vectors WHERE message_seq=?', [`${messageId}:${Number(row.seq || 0)}`]);
|
|
579
583
|
dbRun(db, 'DELETE FROM message_embeddings WHERE message_id=? AND profile<>?', [messageId, EMBEDDING_PROFILE.id]);
|
|
580
|
-
const existing = dbGet(
|
|
584
|
+
const existing = dbGet(
|
|
585
|
+
db,
|
|
586
|
+
`SELECT COUNT(*) AS count
|
|
587
|
+
FROM message_embeddings e
|
|
588
|
+
JOIN message_vectors v ON v.message_seq = e.message_id || ':' || e.seq
|
|
589
|
+
WHERE e.message_id=? AND e.profile=?`,
|
|
590
|
+
[messageId, EMBEDDING_PROFILE.id],
|
|
591
|
+
);
|
|
581
592
|
if (Number(existing?.count || 0) > 0) return { embedded: false, chunks: Number(existing.count) };
|
|
582
593
|
|
|
583
594
|
const body = `${local.sender_label || ''}\n${local.body || ''}\n${metadata || ''}`;
|
|
@@ -587,18 +598,19 @@ async function storeEmbeddings(db, local, metadata) {
|
|
|
587
598
|
for (let seq = 0; seq < chunks.length; seq++) {
|
|
588
599
|
const chunk = chunks[seq];
|
|
589
600
|
const vector = await embedText(chunk.text, { title });
|
|
601
|
+
const messageSeq = `${messageId}:${seq}`;
|
|
602
|
+
dbRun(db, 'INSERT OR REPLACE INTO message_vectors(message_seq, embedding) VALUES(?, ?)', [messageSeq, new Float32Array(vector)]);
|
|
590
603
|
dbRun(
|
|
591
604
|
db,
|
|
592
|
-
`INSERT INTO message_embeddings(message_id,seq,pos,dims,model,profile,
|
|
593
|
-
VALUES(
|
|
605
|
+
`INSERT INTO message_embeddings(message_id,seq,pos,dims,model,profile,updated_at)
|
|
606
|
+
VALUES(?,?,?,?,?,?,?)
|
|
594
607
|
ON CONFLICT(message_id, seq) DO UPDATE SET
|
|
595
608
|
pos=excluded.pos,
|
|
596
609
|
dims=excluded.dims,
|
|
597
610
|
model=excluded.model,
|
|
598
611
|
profile=excluded.profile,
|
|
599
|
-
vector=excluded.vector,
|
|
600
612
|
updated_at=excluded.updated_at`,
|
|
601
|
-
[messageId, seq, chunk.pos, EMBEDDING_DIMS, EMBEDDING_MODEL, EMBEDDING_PROFILE.id,
|
|
613
|
+
[messageId, seq, chunk.pos, EMBEDDING_DIMS, EMBEDDING_MODEL, EMBEDDING_PROFILE.id, updatedAt],
|
|
602
614
|
);
|
|
603
615
|
}
|
|
604
616
|
setMeta(db, 'embedding_last_ok_at', updatedAt);
|
|
@@ -621,7 +633,9 @@ async function embedMissingMessages(db, limit = 500) {
|
|
|
621
633
|
FROM messages m
|
|
622
634
|
LEFT JOIN message_embeddings e
|
|
623
635
|
ON e.message_id=m.message_id AND e.profile=?
|
|
624
|
-
|
|
636
|
+
LEFT JOIN message_vectors v
|
|
637
|
+
ON v.message_seq = e.message_id || ':' || e.seq
|
|
638
|
+
WHERE e.message_id IS NULL OR v.message_seq IS NULL
|
|
625
639
|
ORDER BY m.id ASC
|
|
626
640
|
LIMIT ?`,
|
|
627
641
|
[EMBEDDING_PROFILE.id, Math.max(1, Math.min(Number(limit || 500), 2000))],
|
|
@@ -635,25 +649,22 @@ async function embedMissingMessages(db, limit = 500) {
|
|
|
635
649
|
}
|
|
636
650
|
|
|
637
651
|
async function openChatDb(config, file, roomId) {
|
|
638
|
-
const
|
|
652
|
+
const { Database, sqliteVec } = await loadNativeSqlite();
|
|
639
653
|
const root = chatRoot(config, file, roomId);
|
|
640
654
|
const dbPath = chatDbPath(config, file, roomId);
|
|
641
655
|
fs.mkdirSync(root, { recursive: true, mode: 0o700 });
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
}
|
|
656
|
+
const db = new Database(dbPath);
|
|
657
|
+
db.pragma('journal_mode = WAL');
|
|
658
|
+
db.pragma('foreign_keys = ON');
|
|
659
|
+
sqliteVec.load(db);
|
|
660
|
+
verifySqliteVecLoaded(db);
|
|
648
661
|
initChatSchema(db);
|
|
649
662
|
setMeta(db, 'room_id', roomId);
|
|
650
663
|
return { db, root, dbPath, roomId };
|
|
651
664
|
}
|
|
652
665
|
|
|
653
666
|
function saveChatDb(cap) {
|
|
654
|
-
|
|
655
|
-
fs.writeFileSync(tmp, Buffer.from(cap.db.export()), { mode: 0o600 });
|
|
656
|
-
fs.renameSync(tmp, cap.dbPath);
|
|
667
|
+
cap.db.pragma('wal_checkpoint(PASSIVE)');
|
|
657
668
|
try { fs.chmodSync(cap.dbPath, 0o600); } catch {}
|
|
658
669
|
}
|
|
659
670
|
|
|
@@ -829,22 +840,33 @@ async function doChatSearch(config, file, opts) {
|
|
|
829
840
|
let vectorError = null;
|
|
830
841
|
if (mode !== 'keyword') try {
|
|
831
842
|
const qvec = await embedText(query, { isQuery: true });
|
|
832
|
-
const
|
|
833
|
-
|
|
843
|
+
const k = Math.max(maxResults * 4, 50);
|
|
844
|
+
const vecMatches = dbAll(
|
|
834
845
|
cap.db,
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
846
|
+
'SELECT message_seq, distance FROM message_vectors WHERE embedding MATCH ? AND k = ?',
|
|
847
|
+
[new Float32Array(qvec), k],
|
|
848
|
+
);
|
|
849
|
+
const bestByMessage = new Map();
|
|
850
|
+
if (vecMatches.length) {
|
|
851
|
+
const messageSeqs = vecMatches.map((row) => String(row.message_seq));
|
|
852
|
+
const distanceBySeq = new Map(vecMatches.map((row) => [String(row.message_seq), Number(row.distance)]));
|
|
853
|
+
const placeholders = messageSeqs.map(() => '?').join(',');
|
|
854
|
+
for (const row of dbAll(
|
|
855
|
+
cap.db,
|
|
856
|
+
`SELECT m.*, e.seq, e.pos, e.message_id || ':' || e.seq AS message_seq
|
|
857
|
+
FROM message_embeddings e
|
|
858
|
+
JOIN messages m ON m.message_id=e.message_id
|
|
859
|
+
WHERE e.profile=? AND e.message_id || ':' || e.seq IN (${placeholders})`,
|
|
860
|
+
[EMBEDDING_PROFILE.id, ...messageSeqs],
|
|
861
|
+
)) {
|
|
862
|
+
const distance = distanceBySeq.get(String(row.message_seq)) ?? 1;
|
|
863
|
+
const score = 1 - distance;
|
|
864
|
+
if (score <= 0) continue;
|
|
865
|
+
const { message_seq, ...clean } = row;
|
|
866
|
+
const prior = bestByMessage.get(row.message_id);
|
|
867
|
+
if (!prior || score > prior.vector_score) {
|
|
868
|
+
bestByMessage.set(row.message_id, { ...clean, vector_score: score, chunk_seq: Number(row.seq || 0), chunk_pos: Number(row.pos || 0) });
|
|
869
|
+
}
|
|
848
870
|
}
|
|
849
871
|
}
|
|
850
872
|
vectorRows = Array.from(bestByMessage.values())
|
package/package.json
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@supercollab/cli",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.2",
|
|
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",
|
|
@@ -15,7 +15,8 @@
|
|
|
15
15
|
},
|
|
16
16
|
"dependencies": {
|
|
17
17
|
"@huggingface/transformers": "3.8.1",
|
|
18
|
-
"
|
|
18
|
+
"better-sqlite3": "12.11.1",
|
|
19
|
+
"sqlite-vec": "0.1.9"
|
|
19
20
|
},
|
|
20
21
|
"keywords": [
|
|
21
22
|
"mcp",
|