@chatpanel/gateway 0.6.14 → 0.6.16
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/bin/chatpanel-gateway.js +33 -24
- package/package.json +1 -1
- package/src/backup-ingest.js +15 -8
- package/src/mcp.js +148 -0
- package/src/server.js +1 -1
package/bin/chatpanel-gateway.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
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.
|
|
3
|
+
"version": "0.6.16",
|
|
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/backup-ingest.js
CHANGED
|
@@ -42,17 +42,24 @@ export function backupToRecords(data) {
|
|
|
42
42
|
const date = c.updatedAt || c.createdAt || 0;
|
|
43
43
|
const body = (c.messages || [])
|
|
44
44
|
.filter((m) => m && m.content)
|
|
45
|
-
.map((m) => `${m.role
|
|
46
|
-
.join('\n');
|
|
47
|
-
out.push({ id: `chat:${c.id}`, type: 'chat', title, date, text: `CHAT: ${title}\n${body}`.trim() });
|
|
45
|
+
.map((m) => `${m.role === 'assistant' ? 'Assistant' : m.role === 'system' ? 'System' : 'You'}: ${m.content}`)
|
|
46
|
+
.join('\n\n');
|
|
47
|
+
out.push({ id: `chat:${c.id}`, type: 'chat', title, date, text: `CHAT: ${title}\n\n${body}`.trim() });
|
|
48
48
|
}
|
|
49
49
|
for (const m of data?.meetings || []) {
|
|
50
|
-
|
|
50
|
+
// Meetings are exported wrapped: { record: {...meeting}, notes: <summary>, topics }.
|
|
51
|
+
const rec = m?.record || m;
|
|
52
|
+
const id = rec?.id;
|
|
51
53
|
if (!id) continue;
|
|
52
|
-
const title =
|
|
53
|
-
const date =
|
|
54
|
-
const
|
|
55
|
-
|
|
54
|
+
const title = rec.title || 'Meeting';
|
|
55
|
+
const date = rec.startedAt || rec.date || 0;
|
|
56
|
+
const notes = typeof m?.notes === 'string' ? m.notes : '';
|
|
57
|
+
const segs = (rec.segments || []).map((s) => `${s.speaker || '?'}: ${s.text || ''}`).join('\n');
|
|
58
|
+
const parts = [`MEETING: ${title}`];
|
|
59
|
+
if (rec.platform) parts.push(`Platform: ${rec.platform}`);
|
|
60
|
+
if (notes) parts.push('', 'SUMMARY:', notes);
|
|
61
|
+
if (segs) parts.push('', 'TRANSCRIPT:', segs);
|
|
62
|
+
out.push({ id: `meeting:${id}`, type: 'meeting', title, date, text: parts.join('\n').trim() });
|
|
56
63
|
}
|
|
57
64
|
return out;
|
|
58
65
|
}
|
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
|
@@ -40,7 +40,7 @@ import * as openai from './openai.js';
|
|
|
40
40
|
import * as responses from './responses.js';
|
|
41
41
|
import * as anthropic from './anthropic.js';
|
|
42
42
|
|
|
43
|
-
export const VERSION = '0.6.
|
|
43
|
+
export const VERSION = '0.6.16';
|
|
44
44
|
|
|
45
45
|
// WARM search tier — SQLite + FTS5 record store (falls back to an encrypted-JSON
|
|
46
46
|
// store if SQLite can't load), fed by the extension's ingest sync + backup-ingest.
|