@chatpanel/gateway 0.6.11 → 0.6.13
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 +1 -1
- package/src/backup-decrypt.js +44 -0
- package/src/backup-ingest.js +76 -0
- package/src/history-store.js +178 -0
- package/src/search-index.js +134 -0
- package/src/server.js +88 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@chatpanel/gateway",
|
|
3
|
-
"version": "0.6.
|
|
3
|
+
"version": "0.6.13",
|
|
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": {
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
// Decrypt a ChatPanel backup envelope — the gateway counterpart to the extension's
|
|
2
|
+
// crypto-backup.js. Same wire format so the gateway can read the user's own daily
|
|
3
|
+
// encrypted backups (with the passphrase they hand off) even when the extension
|
|
4
|
+
// isn't running. WebCrypto (PBKDF2 + AES-GCM) + zlib gunzip; no dependencies.
|
|
5
|
+
//
|
|
6
|
+
// Envelope (v2): { type, version, kdf:{iterations,salt}, cipher:'AES-GCM',
|
|
7
|
+
// compression:'gzip'|'none'|absent, iv, ct }. v1 (no `compression`) → plaintext.
|
|
8
|
+
|
|
9
|
+
import { gunzipSync } from 'node:zlib';
|
|
10
|
+
|
|
11
|
+
const ENCRYPTED_TYPE = 'chatpanel-backup-encrypted';
|
|
12
|
+
const b64 = (s) => Buffer.from(String(s || ''), 'base64');
|
|
13
|
+
|
|
14
|
+
async function deriveKey(passphrase, salt, iterations) {
|
|
15
|
+
const base = await crypto.subtle.importKey('raw', new TextEncoder().encode(passphrase), 'PBKDF2', false, ['deriveKey']);
|
|
16
|
+
return crypto.subtle.deriveKey(
|
|
17
|
+
{ name: 'PBKDF2', salt, iterations, hash: 'SHA-256' },
|
|
18
|
+
base,
|
|
19
|
+
{ name: 'AES-GCM', length: 256 },
|
|
20
|
+
false,
|
|
21
|
+
['decrypt'],
|
|
22
|
+
);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function isEncryptedBackup(obj) {
|
|
26
|
+
return !!obj && typeof obj === 'object' && obj.type === ENCRYPTED_TYPE;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// Envelope + passphrase → the original backup data object. Throws a friendly error
|
|
30
|
+
// on a wrong passphrase or a tampered file (AES-GCM auth-tag mismatch catches both).
|
|
31
|
+
export async function decryptBackupEnvelope(envelope, passphrase) {
|
|
32
|
+
if (!isEncryptedBackup(envelope)) throw new Error('not an encrypted ChatPanel backup');
|
|
33
|
+
if (!passphrase) throw new Error('a passphrase is required to decrypt this backup');
|
|
34
|
+
const key = await deriveKey(passphrase, b64(envelope.kdf?.salt), envelope.kdf?.iterations || 250000);
|
|
35
|
+
let payload;
|
|
36
|
+
try {
|
|
37
|
+
payload = new Uint8Array(await crypto.subtle.decrypt({ name: 'AES-GCM', iv: b64(envelope.iv) }, key, b64(envelope.ct)));
|
|
38
|
+
} catch {
|
|
39
|
+
throw new Error('wrong passphrase, or the backup file is corrupted');
|
|
40
|
+
}
|
|
41
|
+
// v2 gzips before encrypting; v1 (no `compression` key) is plaintext JSON.
|
|
42
|
+
if (envelope.compression === 'gzip') payload = new Uint8Array(gunzipSync(Buffer.from(payload)));
|
|
43
|
+
return JSON.parse(Buffer.from(payload).toString('utf8'));
|
|
44
|
+
}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
// Backup-ingest: seed the warm store from the user's own daily encrypted backup so
|
|
2
|
+
// the gateway holds the full corpus even when the extension isn't running. Finds the
|
|
3
|
+
// newest backup file, decrypts it with the handed-off passphrase, and turns it into
|
|
4
|
+
// warm records.
|
|
5
|
+
//
|
|
6
|
+
// The record extraction here is a SIMPLIFIED mirror of the extension's rich
|
|
7
|
+
// conversationSource/meetingSource — same ids (chat:<id> / meeting:<id>), so when the
|
|
8
|
+
// extension IS running its live-sync upserts its exact records right over these. This
|
|
9
|
+
// path only needs "searchable text + title + date + id"; it is the fallback, not the
|
|
10
|
+
// source of truth.
|
|
11
|
+
|
|
12
|
+
import { readFileSync, existsSync, readdirSync, statSync } from 'node:fs';
|
|
13
|
+
import { join } from 'node:path';
|
|
14
|
+
import os from 'node:os';
|
|
15
|
+
import { decryptBackupEnvelope } from './backup-decrypt.js';
|
|
16
|
+
|
|
17
|
+
const BACKUP_DIR = process.env.CHATPANEL_BACKUP_DIR || join(os.homedir(), 'Downloads', 'ChatPanel Backups');
|
|
18
|
+
const BACKUP_RE = /^chatpanel-backup-[A-Za-z]+\.encrypted\.json$/;
|
|
19
|
+
|
|
20
|
+
// Newest chatpanel-backup-*.encrypted.json in the backups dir, or null.
|
|
21
|
+
export function findLatestBackup(dir = BACKUP_DIR) {
|
|
22
|
+
try {
|
|
23
|
+
let best = null;
|
|
24
|
+
for (const f of readdirSync(dir)) {
|
|
25
|
+
if (!BACKUP_RE.test(f)) continue;
|
|
26
|
+
const path = join(dir, f);
|
|
27
|
+
const mtime = statSync(path).mtimeMs;
|
|
28
|
+
if (!best || mtime > best.mtime) best = { path, mtime };
|
|
29
|
+
}
|
|
30
|
+
return best?.path || null;
|
|
31
|
+
} catch {
|
|
32
|
+
return null;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// Decrypted backup data → warm records [{ id, text, title, type, date }].
|
|
37
|
+
export function backupToRecords(data) {
|
|
38
|
+
const out = [];
|
|
39
|
+
for (const c of data?.conversations || []) {
|
|
40
|
+
if (!c?.id) continue;
|
|
41
|
+
const title = c.title || 'Chat';
|
|
42
|
+
const date = c.updatedAt || c.createdAt || 0;
|
|
43
|
+
const body = (c.messages || [])
|
|
44
|
+
.filter((m) => m && m.content)
|
|
45
|
+
.map((m) => `${m.role || 'user'}: ${m.content}`)
|
|
46
|
+
.join('\n');
|
|
47
|
+
out.push({ id: `chat:${c.id}`, type: 'chat', title, date, text: `CHAT: ${title}\n${body}`.trim() });
|
|
48
|
+
}
|
|
49
|
+
for (const m of data?.meetings || []) {
|
|
50
|
+
const id = m?.id;
|
|
51
|
+
if (!id) continue;
|
|
52
|
+
const title = m.title || 'Meeting';
|
|
53
|
+
const date = m.startedAt || 0;
|
|
54
|
+
const segs = (m.segments || []).map((s) => `${s.speaker || ''}: ${s.text || ''}`).join('\n');
|
|
55
|
+
out.push({ id: `meeting:${id}`, type: 'meeting', title, date, text: `MEETING: ${title}\n${segs}`.trim() });
|
|
56
|
+
}
|
|
57
|
+
return out;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// Decrypt the latest (or given) backup and upsert its records into the store.
|
|
61
|
+
// Returns { ok, ingested, size } or { ok:false, reason }. Never throws on a missing
|
|
62
|
+
// file / passphrase; surfaces a decrypt failure as reason:'decrypt'.
|
|
63
|
+
export async function ingestBackup(store, passphrase, { path } = {}) {
|
|
64
|
+
const file = path || findLatestBackup();
|
|
65
|
+
if (!file || !existsSync(file)) return { ok: false, reason: 'no-backup' };
|
|
66
|
+
if (!passphrase) return { ok: false, reason: 'no-passphrase' };
|
|
67
|
+
let data;
|
|
68
|
+
try {
|
|
69
|
+
data = await decryptBackupEnvelope(JSON.parse(readFileSync(file, 'utf8')), passphrase);
|
|
70
|
+
} catch (e) {
|
|
71
|
+
return { ok: false, reason: 'decrypt', error: String(e?.message || e) };
|
|
72
|
+
}
|
|
73
|
+
const records = backupToRecords(data);
|
|
74
|
+
store.bulk({ upserts: records });
|
|
75
|
+
return { ok: true, file, ingested: records.length, size: store.size };
|
|
76
|
+
}
|
|
@@ -0,0 +1,178 @@
|
|
|
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
|
+
const SECRET_PATH = process.env.CHATPANEL_HISTORY_SECRET || join(DIR, 'history-secret.enc');
|
|
25
|
+
|
|
26
|
+
function loadOrCreateKey() {
|
|
27
|
+
try {
|
|
28
|
+
if (existsSync(KEY_PATH)) return Buffer.from(readFileSync(KEY_PATH, 'utf8').trim(), 'base64');
|
|
29
|
+
} catch {
|
|
30
|
+
/* regenerate below */
|
|
31
|
+
}
|
|
32
|
+
const key = randomBytes(32);
|
|
33
|
+
mkdirSync(dirname(KEY_PATH), { recursive: true });
|
|
34
|
+
writeFileSync(KEY_PATH, key.toString('base64'), { mode: 0o600 });
|
|
35
|
+
try {
|
|
36
|
+
chmodSync(KEY_PATH, 0o600);
|
|
37
|
+
} catch {
|
|
38
|
+
/* best effort on platforms without POSIX perms */
|
|
39
|
+
}
|
|
40
|
+
return key;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function encrypt(key, plaintextBuf) {
|
|
44
|
+
const iv = randomBytes(12);
|
|
45
|
+
const cipher = createCipheriv('aes-256-gcm', key, iv);
|
|
46
|
+
const ct = Buffer.concat([cipher.update(plaintextBuf), cipher.final()]);
|
|
47
|
+
return { v: 1, iv: iv.toString('base64'), tag: cipher.getAuthTag().toString('base64'), ct: ct.toString('base64') };
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function decrypt(key, env) {
|
|
51
|
+
const decipher = createDecipheriv('aes-256-gcm', key, Buffer.from(env.iv, 'base64'));
|
|
52
|
+
decipher.setAuthTag(Buffer.from(env.tag, 'base64'));
|
|
53
|
+
return Buffer.concat([decipher.update(Buffer.from(env.ct, 'base64')), decipher.final()]);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// The handed-off backup passphrase, encrypted at rest with the same local key.
|
|
57
|
+
// Kept separate from the records file: it's a credential, not corpus data, and the
|
|
58
|
+
// gateway needs it before the store is even loaded (startup backup-ingest).
|
|
59
|
+
export function saveBackupSecret(passphrase) {
|
|
60
|
+
const env = encrypt(loadOrCreateKey(), Buffer.from(String(passphrase || ''), 'utf8'));
|
|
61
|
+
mkdirSync(dirname(SECRET_PATH), { recursive: true });
|
|
62
|
+
writeFileSync(SECRET_PATH, JSON.stringify(env), { mode: 0o600 });
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export function loadBackupSecret() {
|
|
66
|
+
try {
|
|
67
|
+
if (!existsSync(SECRET_PATH)) return '';
|
|
68
|
+
return decrypt(loadOrCreateKey(), JSON.parse(readFileSync(SECRET_PATH, 'utf8'))).toString('utf8');
|
|
69
|
+
} catch {
|
|
70
|
+
return '';
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export function hasBackupSecret() {
|
|
75
|
+
return existsSync(SECRET_PATH);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// Records-plus-index with lazy, debounced encrypted persistence.
|
|
79
|
+
export class HistoryStore {
|
|
80
|
+
constructor({ storePath = STORE_PATH, persistMs = 2000 } = {}) {
|
|
81
|
+
this.records = new Map(); // id -> { id, text, title, type, date }
|
|
82
|
+
this.index = new SearchIndex();
|
|
83
|
+
this.storePath = storePath;
|
|
84
|
+
this.persistMs = persistMs;
|
|
85
|
+
this._key = null;
|
|
86
|
+
this._timer = null;
|
|
87
|
+
this._dirty = false;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
get size() {
|
|
91
|
+
return this.records.size;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
key() {
|
|
95
|
+
if (!this._key) this._key = loadOrCreateKey();
|
|
96
|
+
return this._key;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// Load the encrypted store from disk and rebuild the index. Safe on a missing/
|
|
100
|
+
// corrupt file — starts empty rather than throwing (fail-open for a cache).
|
|
101
|
+
load() {
|
|
102
|
+
try {
|
|
103
|
+
if (!existsSync(this.storePath)) return this;
|
|
104
|
+
const env = JSON.parse(readFileSync(this.storePath, 'utf8'));
|
|
105
|
+
const records = JSON.parse(decrypt(this.key(), env).toString('utf8'));
|
|
106
|
+
this.records = new Map(records.map((r) => [r.id, r]));
|
|
107
|
+
this.index = new SearchIndex();
|
|
108
|
+
for (const r of this.records.values()) this.index.upsert(r);
|
|
109
|
+
} catch {
|
|
110
|
+
this.records = new Map();
|
|
111
|
+
this.index = new SearchIndex();
|
|
112
|
+
}
|
|
113
|
+
return this;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// Write the encrypted store now (synchronous). Callers normally use the
|
|
117
|
+
// debounced schedulePersist(); this is the flush.
|
|
118
|
+
persistNow() {
|
|
119
|
+
this._dirty = false;
|
|
120
|
+
if (this._timer) {
|
|
121
|
+
clearTimeout(this._timer);
|
|
122
|
+
this._timer = null;
|
|
123
|
+
}
|
|
124
|
+
const buf = Buffer.from(JSON.stringify([...this.records.values()]), 'utf8');
|
|
125
|
+
const env = encrypt(this.key(), buf);
|
|
126
|
+
mkdirSync(dirname(this.storePath), { recursive: true });
|
|
127
|
+
writeFileSync(this.storePath, JSON.stringify(env), { mode: 0o600 });
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
schedulePersist() {
|
|
131
|
+
this._dirty = true;
|
|
132
|
+
if (this._timer) return;
|
|
133
|
+
this._timer = setTimeout(() => {
|
|
134
|
+
this._timer = null;
|
|
135
|
+
if (this._dirty) {
|
|
136
|
+
try {
|
|
137
|
+
this.persistNow();
|
|
138
|
+
} catch {
|
|
139
|
+
/* keep serving from memory even if disk write fails */
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
}, this.persistMs);
|
|
143
|
+
if (this._timer.unref) this._timer.unref(); // don't hold the process open
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// Apply upserts/removes to BOTH the record store and the index, then schedule a
|
|
147
|
+
// persist. Returns the new size. Mirrors SearchIndex.bulk's shape.
|
|
148
|
+
bulk({ upserts = [], removes = [] } = {}) {
|
|
149
|
+
for (const id of removes) {
|
|
150
|
+
this.records.delete(id);
|
|
151
|
+
this.index.remove(id);
|
|
152
|
+
}
|
|
153
|
+
for (const d of upserts) {
|
|
154
|
+
if (!d || !d.id) continue;
|
|
155
|
+
const rec = { id: d.id, text: String(d.text || ''), title: d.title || '', type: d.type || '', date: d.date || 0 };
|
|
156
|
+
this.records.set(rec.id, rec);
|
|
157
|
+
this.index.upsert(rec);
|
|
158
|
+
}
|
|
159
|
+
this.schedulePersist();
|
|
160
|
+
return this.records.size;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
search(query, opts) {
|
|
164
|
+
return this.index.search(query, opts);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// Metadata list for an external UI, newest first, paginated. No bodies.
|
|
168
|
+
list({ limit = 50, offset = 0 } = {}) {
|
|
169
|
+
const all = [...this.records.values()]
|
|
170
|
+
.map((r) => ({ id: r.id, title: r.title, type: r.type, date: r.date, chars: r.text.length }))
|
|
171
|
+
.sort((a, b) => (b.date || 0) - (a.date || 0));
|
|
172
|
+
return { total: all.length, items: all.slice(offset, offset + limit) };
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
get(id) {
|
|
176
|
+
return this.records.get(id) || null;
|
|
177
|
+
}
|
|
178
|
+
}
|
|
@@ -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,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';
|
|
32
|
+
import { ingestBackup } from './backup-ingest.js';
|
|
31
33
|
import * as nerEngine from './ner-engine.js';
|
|
32
34
|
import { MODEL_CATALOG, isKnownModel } from './models.js';
|
|
33
35
|
import { resolvePro, checkQuota, consume, usage } from './freegate.js';
|
|
@@ -37,7 +39,12 @@ import * as openai from './openai.js';
|
|
|
37
39
|
import * as responses from './responses.js';
|
|
38
40
|
import * as anthropic from './anthropic.js';
|
|
39
41
|
|
|
40
|
-
export const VERSION = '0.6.
|
|
42
|
+
export const VERSION = '0.6.13';
|
|
43
|
+
|
|
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();
|
|
41
48
|
|
|
42
49
|
const KNOWN_AGENTS = new Set(['codex', 'claude', 'opencode', 'pi', 'kiro', 'antigravity']);
|
|
43
50
|
|
|
@@ -502,6 +509,78 @@ export function createGateway(cfg = loadConfig()) {
|
|
|
502
509
|
uptimeSeconds: Math.floor((Date.now() - STARTED_AT) / 1000),
|
|
503
510
|
});
|
|
504
511
|
}
|
|
512
|
+
// --- WARM search tier. The extension pushes its DECRYPTED records to this LOCAL
|
|
513
|
+
// process (on-device, loopback- + origin-gated above) which holds a BM25 index off
|
|
514
|
+
// the browser thread and answers full-corpus search — for the panel AND other local
|
|
515
|
+
// tools. Ranks identically to the browser hot tier (shared tokenizer/BM25).
|
|
516
|
+
// POST /v1/history/ingest { upserts:[{id,text,title,type,date}], removes:[id] } → { size }
|
|
517
|
+
// POST /v1/history/search { query, limit } → { results }
|
|
518
|
+
// GET /v1/history/status → { size }
|
|
519
|
+
// GET /v1/history/list?limit&offset → { total, items:[{id,title,type,date,chars}] }
|
|
520
|
+
// GET /v1/history/get?id=… → { record:{id,title,type,date,text} } (for external UIs)
|
|
521
|
+
if (pathname === '/v1/history/ingest' && req.method === 'POST') {
|
|
522
|
+
try {
|
|
523
|
+
const body = JSON.parse((await readBody(req, cfg.maxBodyBytes)).toString('utf8')) || {};
|
|
524
|
+
const size = historyStore.bulk({
|
|
525
|
+
upserts: Array.isArray(body.upserts) ? body.upserts : [],
|
|
526
|
+
removes: Array.isArray(body.removes) ? body.removes : [],
|
|
527
|
+
});
|
|
528
|
+
return sendJson(res, 200, { ok: true, size });
|
|
529
|
+
} catch (e) {
|
|
530
|
+
return sendJson(res, 400, { error: { message: `ingest failed: ${e.message}`, type: 'ingest_error' } });
|
|
531
|
+
}
|
|
532
|
+
}
|
|
533
|
+
if (pathname === '/v1/history/search' && req.method === 'POST') {
|
|
534
|
+
try {
|
|
535
|
+
const body = JSON.parse((await readBody(req, cfg.maxBodyBytes)).toString('utf8')) || {};
|
|
536
|
+
const results = historyStore.search(String(body.query || ''), { limit: Number(body.limit) || 10 });
|
|
537
|
+
return sendJson(res, 200, { ok: true, size: historyStore.size, results });
|
|
538
|
+
} catch (e) {
|
|
539
|
+
return sendJson(res, 400, { error: { message: `search failed: ${e.message}`, type: 'search_error' } });
|
|
540
|
+
}
|
|
541
|
+
}
|
|
542
|
+
if (pathname === '/v1/history/status' && req.method === 'GET') {
|
|
543
|
+
return sendJson(res, 200, { ok: true, size: historyStore.size });
|
|
544
|
+
}
|
|
545
|
+
if (pathname === '/v1/history/list' && req.method === 'GET') {
|
|
546
|
+
const limit = Math.min(500, Math.max(1, Number(url.searchParams.get('limit')) || 50));
|
|
547
|
+
const offset = Math.max(0, Number(url.searchParams.get('offset')) || 0);
|
|
548
|
+
return sendJson(res, 200, { ok: true, ...historyStore.list({ limit, offset }) });
|
|
549
|
+
}
|
|
550
|
+
if (pathname === '/v1/history/get' && req.method === 'GET') {
|
|
551
|
+
const record = historyStore.get(String(url.searchParams.get('id') || ''));
|
|
552
|
+
if (!record) return sendJson(res, 404, { error: { message: 'no such record', type: 'not_found' } });
|
|
553
|
+
return sendJson(res, 200, { ok: true, record });
|
|
554
|
+
}
|
|
555
|
+
// Key-handoff (loopback only): store the user's backup passphrase so the gateway
|
|
556
|
+
// can decrypt their daily backups unattended. Encrypted at rest with the local
|
|
557
|
+
// key. { passphrase } → stores it and does one immediate ingest. { passphrase:'' }
|
|
558
|
+
// forgets it. GET → whether a key is held (never returns the key itself).
|
|
559
|
+
if (pathname === '/v1/history/key' && req.method === 'GET') {
|
|
560
|
+
return sendJson(res, 200, { ok: true, hasKey: hasBackupSecret() });
|
|
561
|
+
}
|
|
562
|
+
if (pathname === '/v1/history/key' && req.method === 'POST') {
|
|
563
|
+
try {
|
|
564
|
+
const body = JSON.parse((await readBody(req, cfg.maxBodyBytes)).toString('utf8')) || {};
|
|
565
|
+
saveBackupSecret(String(body.passphrase || ''));
|
|
566
|
+
const result = body.passphrase ? await ingestBackup(historyStore, String(body.passphrase)) : { ok: true, ingested: 0 };
|
|
567
|
+
return sendJson(res, 200, { ok: true, hasKey: !!body.passphrase, ...result });
|
|
568
|
+
} catch (e) {
|
|
569
|
+
return sendJson(res, 400, { error: { message: `key handoff failed: ${e.message}`, type: 'key_error' } });
|
|
570
|
+
}
|
|
571
|
+
}
|
|
572
|
+
// Trigger a backup-ingest now (uses the stored key). Optional { path } overrides
|
|
573
|
+
// which backup file to read. Returns how many records were seeded.
|
|
574
|
+
if (pathname === '/v1/history/ingest-backup' && req.method === 'POST') {
|
|
575
|
+
try {
|
|
576
|
+
const body = JSON.parse((await readBody(req, cfg.maxBodyBytes) || '{}').toString('utf8') || '{}') || {};
|
|
577
|
+
const result = await ingestBackup(historyStore, loadBackupSecret(), { path: body.path });
|
|
578
|
+
return sendJson(res, result.ok ? 200 : 409, result);
|
|
579
|
+
} catch (e) {
|
|
580
|
+
return sendJson(res, 400, { error: { message: `backup ingest failed: ${e.message}`, type: 'ingest_error' } });
|
|
581
|
+
}
|
|
582
|
+
}
|
|
583
|
+
|
|
505
584
|
// The detector, on the gateway's own port (no second port). GET → health;
|
|
506
585
|
// POST {text} → {entities}. The bundled engine runs IN-PROCESS; a user's own
|
|
507
586
|
// external detector (if configured) is proxied for back-compat.
|
|
@@ -732,6 +811,14 @@ export function start(cfg = loadConfig()) {
|
|
|
732
811
|
console.log(` redaction: ${cfg.redaction.tier}` + (cfg.redaction.detection?.backend && cfg.redaction.detection.backend !== 'off'
|
|
733
812
|
? ` + ${cfg.redaction.detection.backend} detector` : (cfg.ner?.autostart ? ' (+ NER starting…)' : '')));
|
|
734
813
|
});
|
|
814
|
+
// If the user handed off a backup key, refresh the warm store from the latest
|
|
815
|
+
// daily backup in the background — so the gateway stays current even when the
|
|
816
|
+
// extension never runs. Best-effort; never blocks startup or crashes it.
|
|
817
|
+
if (hasBackupSecret()) {
|
|
818
|
+
ingestBackup(historyStore, loadBackupSecret())
|
|
819
|
+
.then((r) => { if (r?.ok) console.log(` warm : seeded ${r.ingested} records from ${r.file}`); })
|
|
820
|
+
.catch(() => {});
|
|
821
|
+
}
|
|
735
822
|
const shutdown = () => { ner?.stop(); entitlement.stop(); server.close(() => process.exit(0)); };
|
|
736
823
|
process.on('SIGINT', shutdown);
|
|
737
824
|
process.on('SIGTERM', shutdown);
|