@chatpanel/gateway 0.6.13 → 0.6.15

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.
@@ -2,40 +2,49 @@
2
2
  // CLI entry for the ChatPanel Privacy Gateway.
3
3
  //
4
4
  // chatpanel-gateway start the gateway (foreground)
5
+ // chatpanel-gateway mcp stdio MCP server exposing warm history as tools
5
6
  // chatpanel-gateway --install register login auto-start + start now
6
7
  // chatpanel-gateway --uninstall remove login auto-start
7
8
  // chatpanel-gateway --status is auto-start registered?
8
9
  // chatpanel-gateway --version print version
9
10
  //
10
11
  // Config comes from gateway.config.json / env (see src/config.js).
11
- import { start, VERSION } from '../src/server.js';
12
- import { installService, uninstallService, serviceStatus } from '../src/service.js';
12
+ export {}; // mark as an ES module (all imports below are dynamic)
13
13
 
14
14
  const arg = process.argv[2];
15
15
 
16
16
  try {
17
- switch (arg) {
18
- case '--version':
19
- case '-v':
20
- console.log(VERSION);
21
- break;
22
- case '--install':
23
- installService();
24
- console.log('ChatPanel Privacy Gateway: installed login auto-start and started it.');
25
- break;
26
- case '--uninstall':
27
- uninstallService();
28
- console.log('ChatPanel Privacy Gateway: removed login auto-start.');
29
- break;
30
- case '--status':
31
- console.log(serviceStatus() ? 'installed (auto-start registered)' : 'not installed');
32
- break;
33
- case undefined:
34
- start();
35
- break;
36
- default:
37
- console.error(`unknown option: ${arg}\nUsage: chatpanel-gateway [--install|--uninstall|--status|--version]`);
38
- process.exit(2);
17
+ if (arg === 'mcp') {
18
+ // Its own path: proxies to the running gateway over HTTP and must NOT import
19
+ // server.js (which would open a second handle on the warm SQLite store).
20
+ const { runMcpServer } = await import('../src/mcp.js');
21
+ await runMcpServer();
22
+ } else {
23
+ const { start, VERSION } = await import('../src/server.js');
24
+ const { installService, uninstallService, serviceStatus } = await import('../src/service.js');
25
+ switch (arg) {
26
+ case '--version':
27
+ case '-v':
28
+ console.log(VERSION);
29
+ break;
30
+ case '--install':
31
+ installService();
32
+ console.log('ChatPanel Privacy Gateway: installed login auto-start and started it.');
33
+ break;
34
+ case '--uninstall':
35
+ uninstallService();
36
+ console.log('ChatPanel Privacy Gateway: removed login auto-start.');
37
+ break;
38
+ case '--status':
39
+ console.log(serviceStatus() ? 'installed (auto-start registered)' : 'not installed');
40
+ break;
41
+ case undefined:
42
+ start();
43
+ break;
44
+ default:
45
+ console.error(`unknown option: ${arg}\nUsage: chatpanel-gateway [mcp|--install|--uninstall|--status|--version]`);
46
+ process.exit(2);
47
+ }
39
48
  }
40
49
  } catch (e) {
41
50
  console.error(`error: ${e.message}`);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@chatpanel/gateway",
3
- "version": "0.6.13",
3
+ "version": "0.6.15",
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/mcp.js ADDED
@@ -0,0 +1,148 @@
1
+ // `chatpanel-gateway mcp` — a stdio MCP server that exposes the WARM history store
2
+ // (chats · meetings · notes) as agent tools. Point any MCP client (Codex, OpenCode,
3
+ // Claude Desktop, …) at `chatpanel-gateway mcp` and it gets search/get/list over the
4
+ // full local corpus — the reliable fallback when an agent's own context holds only
5
+ // hot/recent data.
6
+ //
7
+ // It PROXIES to the already-running gateway's HTTP API (127.0.0.1:<port>), so there
8
+ // is exactly one warm store (the service's) and this process never opens the DB.
9
+ // JSON-RPC 2.0 over stdio, newline-delimited — implemented directly (zero deps).
10
+
11
+ import { loadConfig } from './config.js';
12
+
13
+ const PROTOCOL_VERSION = '2024-11-05';
14
+ const SERVER = { name: 'chatpanel-history', version: '1.0.0' };
15
+
16
+ function baseUrl() {
17
+ const env = process.env.CHATPANEL_GATEWAY_URL;
18
+ if (env) return env.replace(/\/+$/, '');
19
+ let port = 4320;
20
+ try {
21
+ port = loadConfig().port || 4320;
22
+ } catch {
23
+ /* default */
24
+ }
25
+ return `http://127.0.0.1:${port}`;
26
+ }
27
+
28
+ const TOOLS = [
29
+ {
30
+ name: 'search_history',
31
+ description: 'Full-text search the user\'s ChatPanel history (past chats and meeting transcripts) by keyword relevance. Use this to recall what was discussed when the current context does not already contain it.',
32
+ inputSchema: {
33
+ type: 'object',
34
+ properties: {
35
+ query: { type: 'string', description: 'Natural-language / keyword query.' },
36
+ limit: { type: 'number', description: 'Max results (default 10).' },
37
+ },
38
+ required: ['query'],
39
+ },
40
+ },
41
+ {
42
+ name: 'get_record',
43
+ description: 'Fetch one full history record (its complete text) by id, e.g. chat:<id> or meeting:<id> returned by search_history.',
44
+ inputSchema: {
45
+ type: 'object',
46
+ properties: { id: { type: 'string', description: 'Record id such as chat:abc or meeting:imp_123.' } },
47
+ required: ['id'],
48
+ },
49
+ },
50
+ {
51
+ name: 'list_history',
52
+ description: 'List history records (newest first) with their id, title, type and date — no bodies. Use to browse or page the corpus.',
53
+ inputSchema: {
54
+ type: 'object',
55
+ properties: {
56
+ limit: { type: 'number', description: 'Max items (default 50).' },
57
+ offset: { type: 'number', description: 'Skip N items for paging (default 0).' },
58
+ },
59
+ },
60
+ },
61
+ ];
62
+
63
+ async function gatewayJson(path, init) {
64
+ const res = await fetch(baseUrl() + path, init);
65
+ const data = await res.json().catch(() => ({}));
66
+ if (!res.ok) throw new Error(data?.error?.message || `gateway ${res.status}`);
67
+ return data;
68
+ }
69
+
70
+ // Run a tool → a plain-text result an agent can read.
71
+ async function callTool(name, args = {}) {
72
+ if (name === 'search_history') {
73
+ const data = await gatewayJson('/v1/history/search', {
74
+ method: 'POST',
75
+ headers: { 'content-type': 'application/json' },
76
+ body: JSON.stringify({ query: String(args.query || ''), limit: Number(args.limit) || 10 }),
77
+ });
78
+ const rows = data.results || [];
79
+ if (!rows.length) return `No matching history for: ${args.query}`;
80
+ return [`${rows.length} result(s) for "${args.query}" (of ${data.size} indexed):`, ...rows.map((r, i) => `${i + 1}. [${r.id}] ${r.title || '(untitled)'} · ${r.type}${r.date ? ' · ' + new Date(r.date).toISOString().slice(0, 10) : ''} · score ${r.score?.toFixed?.(3) ?? r.score}`)].join('\n') + '\n\nUse get_record with an id for the full text.';
81
+ }
82
+ if (name === 'get_record') {
83
+ const data = await gatewayJson(`/v1/history/get?id=${encodeURIComponent(String(args.id || ''))}`);
84
+ const r = data.record;
85
+ return `[${r.id}] ${r.title || '(untitled)'} · ${r.type}${r.date ? ' · ' + new Date(r.date).toISOString().slice(0, 10) : ''}\n\n${r.text || '(empty)'}`;
86
+ }
87
+ if (name === 'list_history') {
88
+ const q = new URLSearchParams({ limit: String(Number(args.limit) || 50), offset: String(Number(args.offset) || 0) });
89
+ const data = await gatewayJson(`/v1/history/list?${q}`);
90
+ const items = data.items || [];
91
+ if (!items.length) return 'History is empty (or the gateway has not been seeded yet).';
92
+ return [`${items.length} of ${data.total} records:`, ...items.map((it) => `[${it.id}] ${it.title || '(untitled)'} · ${it.type}${it.date ? ' · ' + new Date(it.date).toISOString().slice(0, 10) : ''} · ${it.chars} chars`)].join('\n');
93
+ }
94
+ throw new Error(`unknown tool: ${name}`);
95
+ }
96
+
97
+ // Dispatch a JSON-RPC request → a response object (or null for a notification).
98
+ export async function handleRpc(msg) {
99
+ const { id, method, params } = msg || {};
100
+ const ok = (result) => ({ jsonrpc: '2.0', id, result });
101
+ const err = (code, message) => ({ jsonrpc: '2.0', id, error: { code, message } });
102
+ try {
103
+ switch (method) {
104
+ case 'initialize':
105
+ return ok({ protocolVersion: PROTOCOL_VERSION, capabilities: { tools: {} }, serverInfo: SERVER });
106
+ case 'tools/list':
107
+ return ok({ tools: TOOLS });
108
+ case 'tools/call': {
109
+ const text = await callTool(params?.name, params?.arguments || {});
110
+ return ok({ content: [{ type: 'text', text }] });
111
+ }
112
+ case 'ping':
113
+ return ok({});
114
+ default:
115
+ if (typeof method === 'string' && method.startsWith('notifications/')) return null; // notification: no reply
116
+ if (id === undefined) return null; // other notification
117
+ return err(-32601, `method not found: ${method}`);
118
+ }
119
+ } catch (e) {
120
+ // Tool failures come back as a tool result with isError so the agent can react.
121
+ if (method === 'tools/call') return ok({ content: [{ type: 'text', text: `Error: ${e.message}` }], isError: true });
122
+ return err(-32603, e.message);
123
+ }
124
+ }
125
+
126
+ // Read newline-delimited JSON-RPC from stdin, write responses to stdout.
127
+ export async function runMcpServer() {
128
+ let buf = '';
129
+ process.stdin.setEncoding('utf8');
130
+ const write = (obj) => process.stdout.write(JSON.stringify(obj) + '\n');
131
+ for await (const chunk of process.stdin) {
132
+ buf += chunk;
133
+ let nl;
134
+ while ((nl = buf.indexOf('\n')) >= 0) {
135
+ const line = buf.slice(0, nl).trim();
136
+ buf = buf.slice(nl + 1);
137
+ if (!line) continue;
138
+ let msg;
139
+ try {
140
+ msg = JSON.parse(line);
141
+ } catch {
142
+ continue; // ignore malformed lines
143
+ }
144
+ const reply = await handleRpc(msg);
145
+ if (reply) write(reply);
146
+ }
147
+ }
148
+ }
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.15';
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
+ }