@chatpanel/gateway 0.6.10 → 0.6.12

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.10",
3
+ "version": "0.6.12",
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": {
@@ -27,7 +27,7 @@
27
27
  "node": ">=18"
28
28
  },
29
29
  "dependencies": {
30
- "@chatpanel/pii": "^0.2.10",
30
+ "@chatpanel/pii": "^0.2.11",
31
31
  "@huggingface/transformers": "^4.2.0",
32
32
  "onnxruntime-web": "1.26.0-dev.20260416-b7804b056c"
33
33
  },
@@ -0,0 +1,155 @@
1
+ // WARM-tier record store + encrypted-at-rest persistence (gateway side).
2
+ //
3
+ // The SearchIndex keeps only term-frequencies + light meta, so it can rank but
4
+ // can't hand back a full record. This store keeps the raw records so the gateway
5
+ // can (a) survive a restart WITHOUT the extension re-ingesting everything — the
6
+ // user's "no cold start" requirement — and (b) serve read endpoints (list/get)
7
+ // that an external UI renders. The BM25 index is derived from the store and
8
+ // rebuilt on load (cheap, in-memory), so only the store is persisted.
9
+ //
10
+ // ENCRYPTED AT REST: records are AES-256-GCM'd with a key generated once and kept
11
+ // at ~/.chatpanel/history-key (0600). This is the LOCAL/on-device tier — a local
12
+ // key is correct here; zero-knowledge (keys never on the box) is the future CLOUD
13
+ // tier, not this one. The file on disk is useless without the local key.
14
+
15
+ import { randomBytes, createCipheriv, createDecipheriv } from 'node:crypto';
16
+ import { readFileSync, writeFileSync, existsSync, mkdirSync, chmodSync } from 'node:fs';
17
+ import { join, dirname } from 'node:path';
18
+ import os from 'node:os';
19
+ import { SearchIndex } from './search-index.js';
20
+
21
+ const DIR = join(os.homedir(), '.chatpanel');
22
+ const STORE_PATH = process.env.CHATPANEL_HISTORY_STORE || join(DIR, 'history-store.enc');
23
+ const KEY_PATH = process.env.CHATPANEL_HISTORY_KEY || join(DIR, 'history-key');
24
+
25
+ function loadOrCreateKey() {
26
+ try {
27
+ if (existsSync(KEY_PATH)) return Buffer.from(readFileSync(KEY_PATH, 'utf8').trim(), 'base64');
28
+ } catch {
29
+ /* regenerate below */
30
+ }
31
+ const key = randomBytes(32);
32
+ mkdirSync(dirname(KEY_PATH), { recursive: true });
33
+ writeFileSync(KEY_PATH, key.toString('base64'), { mode: 0o600 });
34
+ try {
35
+ chmodSync(KEY_PATH, 0o600);
36
+ } catch {
37
+ /* best effort on platforms without POSIX perms */
38
+ }
39
+ return key;
40
+ }
41
+
42
+ function encrypt(key, plaintextBuf) {
43
+ const iv = randomBytes(12);
44
+ const cipher = createCipheriv('aes-256-gcm', key, iv);
45
+ const ct = Buffer.concat([cipher.update(plaintextBuf), cipher.final()]);
46
+ return { v: 1, iv: iv.toString('base64'), tag: cipher.getAuthTag().toString('base64'), ct: ct.toString('base64') };
47
+ }
48
+
49
+ function decrypt(key, env) {
50
+ const decipher = createDecipheriv('aes-256-gcm', key, Buffer.from(env.iv, 'base64'));
51
+ decipher.setAuthTag(Buffer.from(env.tag, 'base64'));
52
+ return Buffer.concat([decipher.update(Buffer.from(env.ct, 'base64')), decipher.final()]);
53
+ }
54
+
55
+ // Records-plus-index with lazy, debounced encrypted persistence.
56
+ export class HistoryStore {
57
+ constructor({ storePath = STORE_PATH, persistMs = 2000 } = {}) {
58
+ this.records = new Map(); // id -> { id, text, title, type, date }
59
+ this.index = new SearchIndex();
60
+ this.storePath = storePath;
61
+ this.persistMs = persistMs;
62
+ this._key = null;
63
+ this._timer = null;
64
+ this._dirty = false;
65
+ }
66
+
67
+ get size() {
68
+ return this.records.size;
69
+ }
70
+
71
+ key() {
72
+ if (!this._key) this._key = loadOrCreateKey();
73
+ return this._key;
74
+ }
75
+
76
+ // Load the encrypted store from disk and rebuild the index. Safe on a missing/
77
+ // corrupt file — starts empty rather than throwing (fail-open for a cache).
78
+ load() {
79
+ try {
80
+ if (!existsSync(this.storePath)) return this;
81
+ const env = JSON.parse(readFileSync(this.storePath, 'utf8'));
82
+ const records = JSON.parse(decrypt(this.key(), env).toString('utf8'));
83
+ this.records = new Map(records.map((r) => [r.id, r]));
84
+ this.index = new SearchIndex();
85
+ for (const r of this.records.values()) this.index.upsert(r);
86
+ } catch {
87
+ this.records = new Map();
88
+ this.index = new SearchIndex();
89
+ }
90
+ return this;
91
+ }
92
+
93
+ // Write the encrypted store now (synchronous). Callers normally use the
94
+ // debounced schedulePersist(); this is the flush.
95
+ persistNow() {
96
+ this._dirty = false;
97
+ if (this._timer) {
98
+ clearTimeout(this._timer);
99
+ this._timer = null;
100
+ }
101
+ const buf = Buffer.from(JSON.stringify([...this.records.values()]), 'utf8');
102
+ const env = encrypt(this.key(), buf);
103
+ mkdirSync(dirname(this.storePath), { recursive: true });
104
+ writeFileSync(this.storePath, JSON.stringify(env), { mode: 0o600 });
105
+ }
106
+
107
+ schedulePersist() {
108
+ this._dirty = true;
109
+ if (this._timer) return;
110
+ this._timer = setTimeout(() => {
111
+ this._timer = null;
112
+ if (this._dirty) {
113
+ try {
114
+ this.persistNow();
115
+ } catch {
116
+ /* keep serving from memory even if disk write fails */
117
+ }
118
+ }
119
+ }, this.persistMs);
120
+ if (this._timer.unref) this._timer.unref(); // don't hold the process open
121
+ }
122
+
123
+ // Apply upserts/removes to BOTH the record store and the index, then schedule a
124
+ // persist. Returns the new size. Mirrors SearchIndex.bulk's shape.
125
+ bulk({ upserts = [], removes = [] } = {}) {
126
+ for (const id of removes) {
127
+ this.records.delete(id);
128
+ this.index.remove(id);
129
+ }
130
+ for (const d of upserts) {
131
+ if (!d || !d.id) continue;
132
+ const rec = { id: d.id, text: String(d.text || ''), title: d.title || '', type: d.type || '', date: d.date || 0 };
133
+ this.records.set(rec.id, rec);
134
+ this.index.upsert(rec);
135
+ }
136
+ this.schedulePersist();
137
+ return this.records.size;
138
+ }
139
+
140
+ search(query, opts) {
141
+ return this.index.search(query, opts);
142
+ }
143
+
144
+ // Metadata list for an external UI, newest first, paginated. No bodies.
145
+ list({ limit = 50, offset = 0 } = {}) {
146
+ const all = [...this.records.values()]
147
+ .map((r) => ({ id: r.id, title: r.title, type: r.type, date: r.date, chars: r.text.length }))
148
+ .sort((a, b) => (b.date || 0) - (a.date || 0));
149
+ return { total: all.length, items: all.slice(offset, offset + limit) };
150
+ }
151
+
152
+ get(id) {
153
+ return this.records.get(id) || null;
154
+ }
155
+ }
@@ -0,0 +1,134 @@
1
+ // WARM tier — full-corpus search on the LOCAL gateway.
2
+ //
3
+ // The gateway is the user's own process, so it may hold decrypted records + the key and
4
+ // build a real index outside the browser's memory/CPU (see docs/architecture-data-tiers).
5
+ // This is the compute core: a BM25 doc store you can incrementally upsert/remove and
6
+ // query. Persistence (encrypted at rest) and the HTTP endpoints layer on top of this.
7
+ //
8
+ // The tokenizer + BM25 are a VENDORED copy of the extension's meeting-index.js so the
9
+ // gateway ranks identically to the browser hot tier — keep them in sync. Pure, no deps.
10
+
11
+ const STOP = new Set((
12
+ 'the a an and or but of to in on at for with is are was were be been being it this that these those as i you he '
13
+ + 'if am because need needs needed its lets let '
14
+ + 'she they we us my your our their me him her them so no yes not do does did have has had will would can could '
15
+ + 'should from by about into over under again further then once here there all any both each few more most other '
16
+ + 'some such only own same than too very just dont cant couldnt didnt doesnt hadnt hasnt havent im ive id isnt '
17
+ + 'youre were werent theyre thats theres whats wheres whos wont wouldnt shouldnt okay yeah uh um like really '
18
+ + 'going get got know think mean right well say said also one two how what when where which who whom why'
19
+ ).split(/\s+/));
20
+
21
+ function normalizeToken(raw) {
22
+ let word = String(raw || '')
23
+ .toLowerCase()
24
+ .replace(/[’‘`]/g, "'")
25
+ .replace(/[‐‑‒–—]/g, '-')
26
+ .replace(/^['+_-]+|['+_-]+$/g, '');
27
+ if (word.endsWith("'s")) word = word.slice(0, -2);
28
+ const compact = word.replace(/['_-]+/g, '');
29
+ if (/^[a-z]\+\+$/.test(word)) return word;
30
+ if (compact.length < 2 || /^\d+$/.test(compact)) return '';
31
+ if (STOP.has(word) || STOP.has(compact)) return '';
32
+ return word.replace(/'/g, '');
33
+ }
34
+
35
+ export function tokenize(text) {
36
+ const m = String(text || '').toLowerCase().match(/[a-z0-9][a-z0-9'_+-]{1,}/g);
37
+ if (!m) return [];
38
+ return m.map(normalizeToken).filter(Boolean);
39
+ }
40
+
41
+ // A mutable BM25 index over documents. Each doc: { id, text, title?, type?, date? }.
42
+ // upsert/remove are incremental (no full rebuild); search() ranks lazily off the current
43
+ // term stats. Deletes are honest removals (the whole point of tombstoned sync upstream).
44
+ export class SearchIndex {
45
+ constructor() {
46
+ this.docs = new Map(); // id -> { tf: Map<term,count>, len, meta }
47
+ this.df = new Map(); // term -> #docs containing it
48
+ this.totalLen = 0;
49
+ }
50
+
51
+ get size() { return this.docs.size; }
52
+
53
+ _removeStats(prev) {
54
+ this.totalLen -= prev.len;
55
+ for (const term of prev.tf.keys()) {
56
+ const n = (this.df.get(term) || 0) - 1;
57
+ if (n <= 0) this.df.delete(term); else this.df.set(term, n);
58
+ }
59
+ }
60
+
61
+ upsert(doc) {
62
+ if (!doc || !doc.id) return;
63
+ const prev = this.docs.get(doc.id);
64
+ if (prev) this._removeStats(prev);
65
+ const terms = tokenize(`${doc.title || ''}\n${doc.text || ''}`);
66
+ const tf = new Map();
67
+ for (const t of terms) tf.set(t, (tf.get(t) || 0) + 1);
68
+ for (const t of tf.keys()) this.df.set(t, (this.df.get(t) || 0) + 1);
69
+ this.totalLen += terms.length;
70
+ this.docs.set(doc.id, {
71
+ tf,
72
+ len: terms.length,
73
+ meta: { id: doc.id, title: doc.title || '', type: doc.type || '', date: doc.date || 0 },
74
+ });
75
+ }
76
+
77
+ remove(id) {
78
+ const prev = this.docs.get(id);
79
+ if (!prev) return false;
80
+ this._removeStats(prev);
81
+ this.docs.delete(id);
82
+ return true;
83
+ }
84
+
85
+ bulk({ upserts = [], removes = [] } = {}) {
86
+ for (const id of removes) this.remove(id);
87
+ for (const d of upserts) this.upsert(d);
88
+ return this.size;
89
+ }
90
+
91
+ // Okapi BM25. Returns [{ id, score, title, type, date }] sorted desc, top `limit`.
92
+ search(query, { limit = 10, k1 = 1.5, b = 0.75 } = {}) {
93
+ const qterms = [...new Set(tokenize(query))];
94
+ if (!qterms.length || !this.docs.size) return [];
95
+ const N = this.docs.size;
96
+ const avgdl = this.totalLen / N || 1;
97
+ const idf = new Map();
98
+ for (const t of qterms) {
99
+ const n = this.df.get(t) || 0;
100
+ idf.set(t, n ? Math.log(1 + (N - n + 0.5) / (n + 0.5)) : 0);
101
+ }
102
+ const out = [];
103
+ for (const [id, d] of this.docs) {
104
+ let s = 0;
105
+ for (const t of qterms) {
106
+ const f = d.tf.get(t);
107
+ if (!f) continue;
108
+ s += (idf.get(t) || 0) * (f * (k1 + 1)) / (f + k1 * (1 - b + b * (d.len / avgdl)));
109
+ }
110
+ if (s > 0) out.push({ id, score: s, ...d.meta });
111
+ }
112
+ out.sort((a, c) => c.score - a.score);
113
+ return out.slice(0, Math.max(1, limit));
114
+ }
115
+
116
+ // Serialize/restore for encrypted-at-rest persistence (next slice). Maps → arrays.
117
+ toJSON() {
118
+ return {
119
+ v: 1,
120
+ docs: [...this.docs].map(([id, d]) => ({ id, len: d.len, meta: d.meta, tf: [...d.tf] })),
121
+ };
122
+ }
123
+
124
+ static fromJSON(blob) {
125
+ const idx = new SearchIndex();
126
+ for (const d of (blob?.docs || [])) {
127
+ const tf = new Map(d.tf || []);
128
+ idx.docs.set(d.id, { tf, len: d.len || 0, meta: d.meta || { id: d.id } });
129
+ idx.totalLen += d.len || 0;
130
+ for (const t of tf.keys()) idx.df.set(t, (idx.df.get(t) || 0) + 1);
131
+ }
132
+ return idx;
133
+ }
134
+ }
package/src/server.js CHANGED
@@ -28,6 +28,7 @@ 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 } from './history-store.js';
31
32
  import * as nerEngine from './ner-engine.js';
32
33
  import { MODEL_CATALOG, isKnownModel } from './models.js';
33
34
  import { resolvePro, checkQuota, consume, usage } from './freegate.js';
@@ -37,7 +38,12 @@ import * as openai from './openai.js';
37
38
  import * as responses from './responses.js';
38
39
  import * as anthropic from './anthropic.js';
39
40
 
40
- export const VERSION = '0.6.10';
41
+ export const VERSION = '0.6.12';
42
+
43
+ // WARM search tier — one record store + BM25 index per gateway process, fed by the
44
+ // extension's ingest sync. Encrypted at rest under ~/.chatpanel, loaded on start so a
45
+ // restart doesn't need a full re-ingest (no cold start). See docs/architecture-data-tiers.
46
+ const historyStore = new HistoryStore().load();
41
47
 
42
48
  const KNOWN_AGENTS = new Set(['codex', 'claude', 'opencode', 'pi', 'kiro', 'antigravity']);
43
49
 
@@ -502,6 +508,50 @@ export function createGateway(cfg = loadConfig()) {
502
508
  uptimeSeconds: Math.floor((Date.now() - STARTED_AT) / 1000),
503
509
  });
504
510
  }
511
+ // --- WARM search tier. The extension pushes its DECRYPTED records to this LOCAL
512
+ // process (on-device, loopback- + origin-gated above) which holds a BM25 index off
513
+ // the browser thread and answers full-corpus search — for the panel AND other local
514
+ // tools. Ranks identically to the browser hot tier (shared tokenizer/BM25).
515
+ // POST /v1/history/ingest { upserts:[{id,text,title,type,date}], removes:[id] } → { size }
516
+ // POST /v1/history/search { query, limit } → { results }
517
+ // GET /v1/history/status → { size }
518
+ // GET /v1/history/list?limit&offset → { total, items:[{id,title,type,date,chars}] }
519
+ // GET /v1/history/get?id=… → { record:{id,title,type,date,text} } (for external UIs)
520
+ if (pathname === '/v1/history/ingest' && req.method === 'POST') {
521
+ try {
522
+ const body = JSON.parse((await readBody(req, cfg.maxBodyBytes)).toString('utf8')) || {};
523
+ const size = historyStore.bulk({
524
+ upserts: Array.isArray(body.upserts) ? body.upserts : [],
525
+ removes: Array.isArray(body.removes) ? body.removes : [],
526
+ });
527
+ return sendJson(res, 200, { ok: true, size });
528
+ } catch (e) {
529
+ return sendJson(res, 400, { error: { message: `ingest failed: ${e.message}`, type: 'ingest_error' } });
530
+ }
531
+ }
532
+ if (pathname === '/v1/history/search' && req.method === 'POST') {
533
+ try {
534
+ const body = JSON.parse((await readBody(req, cfg.maxBodyBytes)).toString('utf8')) || {};
535
+ const results = historyStore.search(String(body.query || ''), { limit: Number(body.limit) || 10 });
536
+ return sendJson(res, 200, { ok: true, size: historyStore.size, results });
537
+ } catch (e) {
538
+ return sendJson(res, 400, { error: { message: `search failed: ${e.message}`, type: 'search_error' } });
539
+ }
540
+ }
541
+ if (pathname === '/v1/history/status' && req.method === 'GET') {
542
+ return sendJson(res, 200, { ok: true, size: historyStore.size });
543
+ }
544
+ if (pathname === '/v1/history/list' && req.method === 'GET') {
545
+ const limit = Math.min(500, Math.max(1, Number(url.searchParams.get('limit')) || 50));
546
+ const offset = Math.max(0, Number(url.searchParams.get('offset')) || 0);
547
+ return sendJson(res, 200, { ok: true, ...historyStore.list({ limit, offset }) });
548
+ }
549
+ if (pathname === '/v1/history/get' && req.method === 'GET') {
550
+ const record = historyStore.get(String(url.searchParams.get('id') || ''));
551
+ if (!record) return sendJson(res, 404, { error: { message: 'no such record', type: 'not_found' } });
552
+ return sendJson(res, 200, { ok: true, record });
553
+ }
554
+
505
555
  // The detector, on the gateway's own port (no second port). GET → health;
506
556
  // POST {text} → {entities}. The bundled engine runs IN-PROCESS; a user's own
507
557
  // external detector (if configured) is proxied for back-compat.