@chatpanel/gateway 0.6.13 → 0.6.14

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@chatpanel/gateway",
3
- "version": "0.6.13",
3
+ "version": "0.6.14",
4
4
  "description": "Local privacy gateway — redacts PII out of OpenAI/Anthropic API traffic before it reaches a model, then restores it in the reply. Point opencode, codex, aider, Claude Code, etc. at it.",
5
5
  "type": "module",
6
6
  "bin": {
package/src/server.js CHANGED
@@ -28,7 +28,8 @@ import { createRelaySession, getRelaySession, endRelaySession, pumpBridgeStream,
28
28
  import { shaperFor } from './shape.js';
29
29
  import { startNer } from './ner.js';
30
30
  import { installTimestampedConsole } from './log.js';
31
- import { HistoryStore, saveBackupSecret, loadBackupSecret, hasBackupSecret } from './history-store.js';
31
+ import { saveBackupSecret, loadBackupSecret, hasBackupSecret } from './history-store.js';
32
+ import { createHistoryStore } from './sqlite-store.js';
32
33
  import { ingestBackup } from './backup-ingest.js';
33
34
  import * as nerEngine from './ner-engine.js';
34
35
  import { MODEL_CATALOG, isKnownModel } from './models.js';
@@ -39,12 +40,13 @@ import * as openai from './openai.js';
39
40
  import * as responses from './responses.js';
40
41
  import * as anthropic from './anthropic.js';
41
42
 
42
- export const VERSION = '0.6.13';
43
+ export const VERSION = '0.6.14';
43
44
 
44
- // WARM search tier — one record store + BM25 index per gateway process, fed by the
45
- // extension's ingest sync. Encrypted at rest under ~/.chatpanel, loaded on start so a
46
- // restart doesn't need a full re-ingest (no cold start). See docs/architecture-data-tiers.
47
- const historyStore = new HistoryStore().load();
45
+ // WARM search tier — SQLite + FTS5 record store (falls back to an encrypted-JSON
46
+ // store if SQLite can't load), fed by the extension's ingest sync + backup-ingest.
47
+ // Persistent + memory-mapped, so a restart needs no re-ingest (no cold start).
48
+ // See docs/architecture-data-tiers.
49
+ const historyStore = await createHistoryStore();
48
50
 
49
51
  const KNOWN_AGENTS = new Set(['codex', 'claude', 'opencode', 'pi', 'kiro', 'antigravity']);
50
52
 
@@ -0,0 +1,143 @@
1
+ // WARM store — SQLite + FTS5 backend (the scale engine behind the same interface
2
+ // as history-store.js's HistoryStore). SQLite is memory-mapped, so a year of
3
+ // chats/meetings is searchable without loading the whole corpus into RAM, with
4
+ // battle-tested BM25 full-text search and O(1) record lookups.
5
+ //
6
+ // Dual runtime: the npm gateway runs on Node (node:sqlite, built in since 22); the
7
+ // standalone binary is compiled with Bun (bun:sqlite). Both ship FTS5. The
8
+ // specifier is computed so neither bundler tries to resolve the other runtime's
9
+ // module. createHistoryStore() falls back to the encrypted-JSON HistoryStore if
10
+ // SQLite can't load at all, so this can never break a gateway.
11
+ //
12
+ // AT REST: a local .db file (0600) under ~/.chatpanel, protected by OS disk
13
+ // encryption — the on-device warm tier. Zero-knowledge encryption is the COLD/cloud
14
+ // tier's job, not this one. The backup passphrase (a credential) stays encrypted
15
+ // via history-store.js's saveBackupSecret.
16
+
17
+ import { join } from 'node:path';
18
+ import { mkdirSync } from 'node:fs';
19
+ import os from 'node:os';
20
+ import { HistoryStore } from './history-store.js';
21
+
22
+ const DIR = join(os.homedir(), '.chatpanel');
23
+ const DB_PATH = process.env.CHATPANEL_HISTORY_DB || join(DIR, 'history.db');
24
+
25
+ // Silence node:sqlite's one-time "experimental" warning for a clean CLI; pass
26
+ // every other warning through untouched.
27
+ if (typeof Bun === 'undefined') {
28
+ const emit = process.emitWarning.bind(process);
29
+ process.emitWarning = (w, ...a) => (typeof w === 'string' && w.includes('SQLite is an experimental') ? undefined : emit(w, ...a));
30
+ }
31
+
32
+ // Normalize node:sqlite (DatabaseSync) and bun:sqlite (Database) to one tiny API.
33
+ async function openDb(path) {
34
+ if (typeof Bun !== 'undefined') {
35
+ const { Database } = await import('bun:sqlite');
36
+ const db = new Database(path, { create: true });
37
+ return {
38
+ exec: (sql) => db.run(sql),
39
+ run: (sql, p = []) => db.prepare(sql).run(...p),
40
+ all: (sql, p = []) => db.query(sql).all(...p),
41
+ get: (sql, p = []) => db.query(sql).get(...p),
42
+ };
43
+ }
44
+ const spec = 'node' + ':sqlite'; // computed so the Bun bundler won't touch it
45
+ const { DatabaseSync } = await import(spec);
46
+ const db = new DatabaseSync(path);
47
+ return {
48
+ exec: (sql) => db.exec(sql),
49
+ run: (sql, p = []) => db.prepare(sql).run(...p),
50
+ all: (sql, p = []) => db.prepare(sql).all(...p),
51
+ get: (sql, p = []) => db.prepare(sql).get(...p),
52
+ };
53
+ }
54
+
55
+ // query text → a safe FTS5 MATCH string. Each term is quoted (so FTS5 operators in
56
+ // user text can't inject), joined with OR for recall — bm25 handles the ranking.
57
+ function ftsMatch(query) {
58
+ const terms = String(query || '').toLowerCase().match(/[a-z0-9][a-z0-9'_+-]*/g);
59
+ if (!terms || !terms.length) return null;
60
+ return terms.map((t) => `"${t.replace(/"/g, '""')}"`).join(' OR ');
61
+ }
62
+
63
+ export class SqliteHistoryStore {
64
+ constructor({ path = DB_PATH } = {}) {
65
+ this.path = path;
66
+ this.db = null;
67
+ }
68
+
69
+ async init() {
70
+ if (this.path !== ':memory:') mkdirSync(DIR, { recursive: true });
71
+ this.db = await openDb(this.path);
72
+ this.db.exec('PRAGMA journal_mode=WAL');
73
+ this.db.exec('CREATE TABLE IF NOT EXISTS records(id TEXT PRIMARY KEY, title TEXT, type TEXT, date INTEGER, chars INTEGER)');
74
+ this.db.exec("CREATE VIRTUAL TABLE IF NOT EXISTS fts USING fts5(id UNINDEXED, title, text, tokenize='unicode61')");
75
+ return this;
76
+ }
77
+
78
+ // Kept for interface-compatibility with HistoryStore (SQLite is already loaded).
79
+ load() {
80
+ return this;
81
+ }
82
+
83
+ get size() {
84
+ return this.db.get('SELECT COUNT(*) c FROM records')?.c || 0;
85
+ }
86
+
87
+ bulk({ upserts = [], removes = [] } = {}) {
88
+ this.db.exec('BEGIN');
89
+ try {
90
+ for (const id of removes) {
91
+ this.db.run('DELETE FROM records WHERE id = ?', [id]);
92
+ this.db.run('DELETE FROM fts WHERE id = ?', [id]);
93
+ }
94
+ for (const d of upserts) {
95
+ if (!d || !d.id) continue;
96
+ const text = String(d.text || '');
97
+ this.db.run('DELETE FROM fts WHERE id = ?', [d.id]); // FTS5 has no UPSERT on UNINDEXED id
98
+ this.db.run('INSERT INTO fts(id, title, text) VALUES(?, ?, ?)', [d.id, d.title || '', text]);
99
+ this.db.run('INSERT OR REPLACE INTO records(id, title, type, date, chars) VALUES(?, ?, ?, ?, ?)', [d.id, d.title || '', d.type || '', d.date || 0, text.length]);
100
+ }
101
+ this.db.exec('COMMIT');
102
+ } catch (e) {
103
+ this.db.exec('ROLLBACK');
104
+ throw e;
105
+ }
106
+ return this.size;
107
+ }
108
+
109
+ // [{ id, score, title, type, date }] — score higher = better (bm25 is negated).
110
+ search(query, { limit = 10 } = {}) {
111
+ const match = ftsMatch(query);
112
+ if (!match) return [];
113
+ const rows = this.db.all(
114
+ 'SELECT r.id id, r.title title, r.type type, r.date date, bm25(fts) b FROM fts JOIN records r ON r.id = fts.id WHERE fts MATCH ? ORDER BY b LIMIT ?',
115
+ [match, limit],
116
+ );
117
+ return rows.map((r) => ({ id: r.id, score: -r.b, title: r.title, type: r.type, date: r.date }));
118
+ }
119
+
120
+ list({ limit = 50, offset = 0 } = {}) {
121
+ const total = this.db.get('SELECT COUNT(*) c FROM records')?.c || 0;
122
+ const items = this.db.all('SELECT id, title, type, date, chars FROM records ORDER BY date DESC LIMIT ? OFFSET ?', [limit, offset]);
123
+ return { total, items };
124
+ }
125
+
126
+ get(id) {
127
+ const meta = this.db.get('SELECT id, title, type, date FROM records WHERE id = ?', [id]);
128
+ if (!meta) return null;
129
+ const body = this.db.get('SELECT text FROM fts WHERE id = ?', [id]);
130
+ return { ...meta, text: body?.text || '' };
131
+ }
132
+ }
133
+
134
+ // Pick the best available warm engine. SQLite when it loads; otherwise the
135
+ // encrypted-JSON HistoryStore — so a gateway is never left without a warm store.
136
+ export async function createHistoryStore(opts = {}) {
137
+ try {
138
+ return await new SqliteHistoryStore(opts).init();
139
+ } catch (e) {
140
+ console.log(`[warm] SQLite unavailable (${e.message}); using the encrypted file store`);
141
+ return new HistoryStore(opts).load();
142
+ }
143
+ }