@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 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 so the
8
- agent can decrypt, sync, index, and search the conversation from the machine
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 local transcript chunks
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
 
@@ -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.1';
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 sqlJsPromise = null;
366
+ let nativeSqlitePromise = null;
367
367
 
368
- async function loadSqlJs() {
369
- if (!sqlJsPromise) {
370
- sqlJsPromise = import('sql.js').then((mod) => {
371
- const init = mod.default || mod;
372
- return init();
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 sqlJsPromise;
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
- const stmt = db.prepare(sql);
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
- const stmt = db.prepare(sql);
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 dbAll(db, sql, params)[0] || null;
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(db, 'SELECT COUNT(*) AS count FROM message_embeddings WHERE message_id=? AND profile=?', [messageId, EMBEDDING_PROFILE.id]);
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,vector,updated_at)
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, JSON.stringify(vector), updatedAt],
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
- WHERE e.message_id IS NULL
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 SQL = await loadSqlJs();
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
- let db;
643
- if (fs.existsSync(dbPath)) {
644
- db = new SQL.Database(fs.readFileSync(dbPath));
645
- } else {
646
- db = new SQL.Database();
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
- const tmp = `${cap.dbPath}.${process.pid}.tmp`;
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 bestByMessage = new Map();
833
- for (const row of dbAll(
843
+ const k = Math.max(maxResults * 4, 50);
844
+ const vecMatches = dbAll(
834
845
  cap.db,
835
- `SELECT m.*, e.vector, e.seq, e.pos
836
- FROM message_embeddings e JOIN messages m ON m.message_id=e.message_id
837
- WHERE e.profile=?
838
- ORDER BY m.id DESC LIMIT 3000`,
839
- [EMBEDDING_PROFILE.id],
840
- )) {
841
- let score = 0;
842
- try { score = cosine(qvec, JSON.parse(row.vector)); } catch {}
843
- if (score <= 0) continue;
844
- const { vector, ...clean } = row;
845
- const prior = bestByMessage.get(row.message_id);
846
- if (!prior || score > prior.vector_score) {
847
- bestByMessage.set(row.message_id, { ...clean, vector_score: score, chunk_seq: Number(row.seq || 0), chunk_pos: Number(row.pos || 0) });
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.1",
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": "./bin/supercollab.js"
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
- "sql.js": "^1.14.1"
18
+ "better-sqlite3": "12.11.1",
19
+ "sqlite-vec": "0.1.9"
19
20
  },
20
21
  "keywords": [
21
22
  "mcp",