@chatpanel/gateway 0.6.37 → 0.6.39
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/history-store.js +14 -1
- package/src/mcp.js +48 -8
- package/src/observability.js +121 -0
- package/src/server.js +45 -3
- package/src/sqlite-store.js +17 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@chatpanel/gateway",
|
|
3
|
-
"version": "0.6.
|
|
3
|
+
"version": "0.6.39",
|
|
4
4
|
"description": "Local privacy gateway \u2014 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/history-store.js
CHANGED
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
// tier, not this one. The file on disk is useless without the local key.
|
|
14
14
|
|
|
15
15
|
import { randomBytes, createCipheriv, createDecipheriv } from 'node:crypto';
|
|
16
|
-
import { readFileSync, writeFileSync, existsSync, mkdirSync, chmodSync, unlinkSync } from 'node:fs';
|
|
16
|
+
import { readFileSync, writeFileSync, existsSync, mkdirSync, chmodSync, unlinkSync, statSync } from 'node:fs';
|
|
17
17
|
import { join, dirname } from 'node:path';
|
|
18
18
|
import os from 'node:os';
|
|
19
19
|
import { SearchIndex } from './search-index.js';
|
|
@@ -96,6 +96,19 @@ export class HistoryStore {
|
|
|
96
96
|
return this.records.size;
|
|
97
97
|
}
|
|
98
98
|
|
|
99
|
+
// The timestamp of the freshest record, so a client can tell how current this warm copy
|
|
100
|
+
// is — the difference between "no such meeting" and "not synced yet".
|
|
101
|
+
get newest() {
|
|
102
|
+
let max = 0;
|
|
103
|
+
for (const r of this.records.values()) if ((r.date || 0) > max) max = r.date || 0;
|
|
104
|
+
return max || null;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// On-disk footprint for the storage dashboard: the encrypted store file.
|
|
108
|
+
get bytes() {
|
|
109
|
+
try { return existsSync(this.storePath) ? statSync(this.storePath).size : 0; } catch { return 0; }
|
|
110
|
+
}
|
|
111
|
+
|
|
99
112
|
key() {
|
|
100
113
|
if (!this._key) this._key = loadOrCreateKey();
|
|
101
114
|
return this._key;
|
package/src/mcp.js
CHANGED
|
@@ -16,10 +16,31 @@
|
|
|
16
16
|
|
|
17
17
|
import { loadConfig } from './config.js';
|
|
18
18
|
import { readBridgeToken } from './bridge.js';
|
|
19
|
+
import { ensureGatewayToken } from './gateway-token.js';
|
|
19
20
|
|
|
20
21
|
const PROTOCOL_VERSION = '2024-11-05';
|
|
21
22
|
const SERVER = { name: 'chatpanel-history', version: '1.0.0' };
|
|
22
23
|
|
|
24
|
+
// The calling agent's self-reported name (from MCP `initialize` clientInfo), so the
|
|
25
|
+
// observability dashboard can say WHICH agent read what. Untrusted; the gateway coerces it.
|
|
26
|
+
let clientName = 'unknown';
|
|
27
|
+
|
|
28
|
+
// Report one tool call to the long-lived gateway service's access log. Fire-and-forget:
|
|
29
|
+
// telemetry must never slow, block or fail a tool call. Authorized with the gateway token
|
|
30
|
+
// (readable only by this same-user process), so a drive-by localhost page can't forge entries.
|
|
31
|
+
// Raw args are sent, but the server REDACTS them before storing (a search query is never
|
|
32
|
+
// kept) — and the query already crossed this same loopback on the search call itself.
|
|
33
|
+
function reportAccess(evt) {
|
|
34
|
+
try {
|
|
35
|
+
const token = ensureGatewayToken();
|
|
36
|
+
fetch(`${baseUrl()}/v1/observability/access`, {
|
|
37
|
+
method: 'POST',
|
|
38
|
+
headers: { 'content-type': 'application/json', ...(token ? { Authorization: `Bearer ${token}` } : {}) },
|
|
39
|
+
body: JSON.stringify(evt),
|
|
40
|
+
}).catch(() => {});
|
|
41
|
+
} catch { /* no token or gateway down — drop the telemetry, never the call */ }
|
|
42
|
+
}
|
|
43
|
+
|
|
23
44
|
function baseUrl() {
|
|
24
45
|
const env = process.env.CHATPANEL_GATEWAY_URL;
|
|
25
46
|
if (env) return env.replace(/\/+$/, '');
|
|
@@ -61,7 +82,7 @@ async function bridgeJson(path) {
|
|
|
61
82
|
const TOOLS = [
|
|
62
83
|
{
|
|
63
84
|
name: 'search_history',
|
|
64
|
-
description: 'Full-text search the user\'s ChatPanel history — past chats, meeting transcripts, and notes — by keyword relevance.
|
|
85
|
+
description: 'Full-text search the user\'s ChatPanel history — past chats, meeting transcripts, and notes — by keyword relevance. This is a LOCAL WARM COPY that syncs from ChatPanel; very recent items (a meeting from the last few hours) may not be here yet — every result reports how current the index is. If the user is sure something exists and it is not found, it likely has not synced; say so rather than concluding it does not exist. Meeting titles are often generic ("Zoom Meeting"), so search by CONTENT (topics, names, decisions), not the meeting title.',
|
|
65
86
|
inputSchema: {
|
|
66
87
|
type: 'object',
|
|
67
88
|
properties: {
|
|
@@ -82,7 +103,7 @@ const TOOLS = [
|
|
|
82
103
|
},
|
|
83
104
|
{
|
|
84
105
|
name: 'list_history',
|
|
85
|
-
description: 'List history records (newest first) with their id, title, type and date — no bodies. Use to
|
|
106
|
+
description: 'List history records (newest first) with their id, title, type and date — no bodies. Reports how current this warm copy is (its newest record). Use to see the index horizon and browse/page the corpus.',
|
|
86
107
|
inputSchema: {
|
|
87
108
|
type: 'object',
|
|
88
109
|
properties: {
|
|
@@ -119,6 +140,15 @@ const TOOLS = [
|
|
|
119
140
|
},
|
|
120
141
|
];
|
|
121
142
|
|
|
143
|
+
// A one-line freshness banner from the store's newest record, so every answer states the
|
|
144
|
+
// index horizon — the model can then say "not synced yet" instead of "does not exist".
|
|
145
|
+
function horizonLine(newest, size) {
|
|
146
|
+
if (!newest) return `Index: ${size} records (warm copy synced from ChatPanel).`;
|
|
147
|
+
const d = new Date(newest);
|
|
148
|
+
const iso = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')} ${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`;
|
|
149
|
+
return `Index: ${size} records, current through ${iso} (local warm copy — items newer than this may not have synced from ChatPanel yet).`;
|
|
150
|
+
}
|
|
151
|
+
|
|
122
152
|
async function gatewayJson(path, init) {
|
|
123
153
|
let res;
|
|
124
154
|
try {
|
|
@@ -142,8 +172,9 @@ async function callTool(name, args = {}) {
|
|
|
142
172
|
body: JSON.stringify({ query: String(args.query || ''), limit: Number(args.limit) || 10 }),
|
|
143
173
|
});
|
|
144
174
|
const rows = data.results || [];
|
|
145
|
-
|
|
146
|
-
|
|
175
|
+
const horizon = horizonLine(data.newest, data.size);
|
|
176
|
+
if (!rows.length) return `No match for "${args.query}".\n${horizon}\nIf you expected a recent item, it may not have synced yet — check ChatPanel directly, or try broader content keywords (titles are often generic).`;
|
|
177
|
+
return [horizon, '', `${rows.length} result(s) for "${args.query}":`, ...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.';
|
|
147
178
|
}
|
|
148
179
|
if (name === 'get_record') {
|
|
149
180
|
const data = await gatewayJson(`/v1/history/get?id=${encodeURIComponent(String(args.id || ''))}`);
|
|
@@ -154,8 +185,9 @@ async function callTool(name, args = {}) {
|
|
|
154
185
|
const q = new URLSearchParams({ limit: String(Number(args.limit) || 50), offset: String(Number(args.offset) || 0) });
|
|
155
186
|
const data = await gatewayJson(`/v1/history/list?${q}`);
|
|
156
187
|
const items = data.items || [];
|
|
157
|
-
if (!items.length) return 'History is empty (or the gateway has not been seeded yet).';
|
|
158
|
-
|
|
188
|
+
if (!items.length) return 'History is empty (or the gateway has not been seeded yet — open ChatPanel with warm sync enabled).';
|
|
189
|
+
const newest = items[0]?.date || null;
|
|
190
|
+
return [horizonLine(newest, data.total), '', `${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');
|
|
159
191
|
}
|
|
160
192
|
if (name === 'list_skills') {
|
|
161
193
|
let data;
|
|
@@ -190,12 +222,20 @@ export async function handleRpc(msg) {
|
|
|
190
222
|
try {
|
|
191
223
|
switch (method) {
|
|
192
224
|
case 'initialize':
|
|
225
|
+
clientName = params?.clientInfo?.name || clientName;
|
|
193
226
|
return ok({ protocolVersion: PROTOCOL_VERSION, capabilities: { tools: {} }, serverInfo: SERVER });
|
|
194
227
|
case 'tools/list':
|
|
195
228
|
return ok({ tools: TOOLS });
|
|
196
229
|
case 'tools/call': {
|
|
197
|
-
const
|
|
198
|
-
|
|
230
|
+
const started = Date.now();
|
|
231
|
+
try {
|
|
232
|
+
const text = await callTool(params?.name, params?.arguments || {});
|
|
233
|
+
reportAccess({ client: clientName, tool: params?.name, ok: true, ms: Date.now() - started, args: params?.arguments || {} });
|
|
234
|
+
return ok({ content: [{ type: 'text', text }] });
|
|
235
|
+
} catch (e) {
|
|
236
|
+
reportAccess({ client: clientName, tool: params?.name, ok: false, ms: Date.now() - started, args: params?.arguments || {}, error: e.message });
|
|
237
|
+
throw e;
|
|
238
|
+
}
|
|
199
239
|
}
|
|
200
240
|
case 'ping':
|
|
201
241
|
return ok({});
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
// VENDORED from @chatpanel/events/observability.js — edit there, then copy over.
|
|
2
|
+
// The gateway keeps its dependency surface small; this one pure module is copied in
|
|
3
|
+
// rather than pulling the whole events package, the same way the bridge vendors its
|
|
4
|
+
// events files. Source of truth: chatpanel-events/observability.js.
|
|
5
|
+
//
|
|
6
|
+
// observability.js — the contract for "who consumed what, when, and how much is stored".
|
|
7
|
+
//
|
|
8
|
+
// ChatPanel's data is reachable by more than one agent now: the side panel, and any CLI
|
|
9
|
+
// (Codex, Claude Code, OpenCode…) wired to the gateway's MCP server. Once several agents
|
|
10
|
+
// read your history and skills, you need to SEE that — which agent touched what, and how
|
|
11
|
+
// much sits in each storage tier. That is one question with one answer shape, so it lives
|
|
12
|
+
// here, not re-derived in every client. The extension renders it; the gateway records it;
|
|
13
|
+
// a desktop/mobile app will do both against this same contract.
|
|
14
|
+
//
|
|
15
|
+
// Pure and dependency-free (the @chatpanel/events rule): identical code in browser ESM,
|
|
16
|
+
// the gateway (Node) and a mobile JS runtime. No clock, no storage, no platform APIs —
|
|
17
|
+
// the caller passes `ts`; the caller owns persistence.
|
|
18
|
+
//
|
|
19
|
+
// PRIVACY IS THE POINT of the redactor below. An access log that stored raw tool arguments
|
|
20
|
+
// would quietly become a second copy of every search query — the exact PII we redact
|
|
21
|
+
// everywhere else. So the note attached to each event is built from a per-tool WHITELIST of
|
|
22
|
+
// non-sensitive fields; a search query's TEXT is never recorded, only that a search ran.
|
|
23
|
+
|
|
24
|
+
export const ACCESS_LOG_VERSION = 1;
|
|
25
|
+
|
|
26
|
+
// Default ring size — enough to see a working session's activity without unbounded growth.
|
|
27
|
+
export const ACCESS_LOG_MAX = 500;
|
|
28
|
+
|
|
29
|
+
// Per-tool whitelist: which argument fields are safe to keep in the human note. Anything not
|
|
30
|
+
// listed here is dropped. Content-bearing fields (a search `query`) are deliberately ABSENT —
|
|
31
|
+
// the tool name already says "a search happened"; the words searched are not logged.
|
|
32
|
+
const SAFE_ARGS = {
|
|
33
|
+
search_history: ['limit'],
|
|
34
|
+
list_history: ['limit', 'offset'],
|
|
35
|
+
get_record: ['id'], // opaque record id, not content
|
|
36
|
+
open_skill: ['skill'], // skill names are catalog identifiers, not PII
|
|
37
|
+
read_skill_file: ['skill', 'path'],
|
|
38
|
+
list_skills: ['limit'],
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* A short, SAFE descriptor of a call's arguments for display. Never returns content that
|
|
43
|
+
* could carry PII. Unknown tools get an empty note (the tool name is the only signal).
|
|
44
|
+
*/
|
|
45
|
+
export function redactAccessArgs(tool, args) {
|
|
46
|
+
const allow = SAFE_ARGS[tool];
|
|
47
|
+
if (!allow || !args || typeof args !== 'object') return '';
|
|
48
|
+
const parts = [];
|
|
49
|
+
for (const key of allow) {
|
|
50
|
+
const v = args[key];
|
|
51
|
+
if (v === undefined || v === null || v === '') continue;
|
|
52
|
+
// Cap any string field so a long id/path can't smuggle content or blow up the row.
|
|
53
|
+
const s = typeof v === 'string' ? (v.length > 80 ? `${v.slice(0, 77)}…` : v) : String(v);
|
|
54
|
+
parts.push(`${key}=${s}`);
|
|
55
|
+
}
|
|
56
|
+
return parts.join(' ');
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Normalize one access into the record everything stores and renders. `client` is the calling
|
|
61
|
+
* agent's self-reported name (MCP clientInfo) — untrusted, so it's coerced to a short string.
|
|
62
|
+
*/
|
|
63
|
+
export function makeAccessEvent({ ts, client, tool, ok = true, ms, args, error } = {}) {
|
|
64
|
+
return {
|
|
65
|
+
v: ACCESS_LOG_VERSION,
|
|
66
|
+
ts: Number(ts) || 0,
|
|
67
|
+
client: shortStr(client, 'unknown', 60),
|
|
68
|
+
tool: shortStr(tool, 'unknown', 60),
|
|
69
|
+
ok: !!ok,
|
|
70
|
+
ms: Number.isFinite(ms) ? Math.max(0, Math.round(ms)) : null,
|
|
71
|
+
note: redactAccessArgs(tool, args),
|
|
72
|
+
error: error ? shortStr(error, '', 200) : '',
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function shortStr(v, fallback, max) {
|
|
77
|
+
const s = (v == null ? '' : String(v)).trim() || fallback;
|
|
78
|
+
return s.length > max ? `${s.slice(0, max - 1)}…` : s;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* A tiny fixed-capacity ring for access events. Pure and synchronous — the gateway keeps one
|
|
83
|
+
* in memory and snapshots it for the dashboard; the caller decides whether/how to persist.
|
|
84
|
+
*/
|
|
85
|
+
export function createAccessLog(max = ACCESS_LOG_MAX) {
|
|
86
|
+
const cap = Math.max(1, max | 0);
|
|
87
|
+
let buf = [];
|
|
88
|
+
return {
|
|
89
|
+
push(evt) { buf.push(evt); if (buf.length > cap) buf = buf.slice(buf.length - cap); return evt; },
|
|
90
|
+
// Newest first, optionally limited — the order a dashboard wants.
|
|
91
|
+
snapshot(limit) { const out = buf.slice().reverse(); return limit ? out.slice(0, limit) : out; },
|
|
92
|
+
get size() { return buf.length; },
|
|
93
|
+
clear() { buf = []; },
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// ── Storage tiers ────────────────────────────────────────────────────────────────────────
|
|
98
|
+
// One descriptor per place data lives: hot (browser), warm (local gateway), cold (cloud,
|
|
99
|
+
// future). The dashboard renders a row per tier; a tier that isn't configured says so.
|
|
100
|
+
|
|
101
|
+
export function makeStorageTier({ tier, label, present = true, records = null, bytes = null, newest = null, note = '' } = {}) {
|
|
102
|
+
return {
|
|
103
|
+
tier: String(tier || ''),
|
|
104
|
+
label: String(label || tier || ''),
|
|
105
|
+
present: !!present,
|
|
106
|
+
records: records == null ? null : Math.max(0, records | 0),
|
|
107
|
+
bytes: bytes == null ? null : Math.max(0, Number(bytes) || 0),
|
|
108
|
+
newest: newest == null ? null : Number(newest) || 0,
|
|
109
|
+
note: String(note || ''),
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** Human-friendly byte size. Binary units, one decimal above KB. */
|
|
114
|
+
export function formatBytes(n) {
|
|
115
|
+
const b = Number(n);
|
|
116
|
+
if (!Number.isFinite(b) || b <= 0) return '0 B';
|
|
117
|
+
const units = ['B', 'KB', 'MB', 'GB', 'TB'];
|
|
118
|
+
let i = 0, v = b;
|
|
119
|
+
while (v >= 1024 && i < units.length - 1) { v /= 1024; i++; }
|
|
120
|
+
return `${i === 0 ? Math.round(v) : v.toFixed(1)} ${units[i]}`;
|
|
121
|
+
}
|
package/src/server.js
CHANGED
|
@@ -41,11 +41,12 @@ import { STT_MODEL_CATALOG, isKnownSttModel, isValidCustomSttId, DEFAULT_STT_MOD
|
|
|
41
41
|
import { resolvePro, checkQuota, consume, usage } from './freegate.js';
|
|
42
42
|
import { publicConfig, applyConfigPatch, applyNerModelSelection, persistConfig, configPath } from './configstore.js';
|
|
43
43
|
import { resolveDestination, aggregateModelsAsync } from './router.js';
|
|
44
|
+
import { createAccessLog, makeAccessEvent } from './observability.js';
|
|
44
45
|
import * as openai from './openai.js';
|
|
45
46
|
import * as responses from './responses.js';
|
|
46
47
|
import * as anthropic from './anthropic.js';
|
|
47
48
|
|
|
48
|
-
export const VERSION = '0.6.
|
|
49
|
+
export const VERSION = '0.6.39';
|
|
49
50
|
|
|
50
51
|
// WARM search tier — SQLite + FTS5 record store (falls back to an encrypted-JSON
|
|
51
52
|
// store if SQLite can't load), fed by the extension's ingest sync + backup-ingest.
|
|
@@ -53,6 +54,13 @@ export const VERSION = '0.6.37';
|
|
|
53
54
|
// See docs/architecture-data-tiers.
|
|
54
55
|
const historyStore = await createHistoryStore();
|
|
55
56
|
|
|
57
|
+
// OBSERVABILITY — an in-memory ring of "which agent read what, when". Populated by the
|
|
58
|
+
// MCP server process (chatpanel-gateway mcp) reporting each tool call here, so a person
|
|
59
|
+
// can SEE cross-agent access in ChatPanel's dashboard. In-memory by design: a working
|
|
60
|
+
// session's activity, not an audit trail that itself becomes data to protect. The note on
|
|
61
|
+
// each event is redacted (never a search query) by makeAccessEvent.
|
|
62
|
+
const accessLog = createAccessLog();
|
|
63
|
+
|
|
56
64
|
const KNOWN_AGENTS = new Set(['codex', 'claude', 'opencode', 'pi', 'kiro', 'antigravity']);
|
|
57
65
|
|
|
58
66
|
// Auto-narrow: arm only the top-K most-relevant MCP tools per turn (speed). Mirrors
|
|
@@ -522,6 +530,19 @@ export function createGateway(cfg = loadConfig()) {
|
|
|
522
530
|
&& req.method === 'POST' && !isAdminAuthorized(req)) {
|
|
523
531
|
return sendJson(res, 403, { error: { message: 'history key route: extension origin or gateway token required', type: 'forbidden' } });
|
|
524
532
|
}
|
|
533
|
+
// Reads of the warm index are open (that's the product — Codex/OpenCode query it).
|
|
534
|
+
// WRITES are not: a drive-by localhost page or a random local process must not be
|
|
535
|
+
// able to inject records the model then trusts as the user's real history. Ingest
|
|
536
|
+
// requires the extension Origin (which its POST always carries) or the gateway token.
|
|
537
|
+
if (pathname === '/v1/history/ingest' && req.method === 'POST' && !isAdminAuthorized(req)) {
|
|
538
|
+
return sendJson(res, 403, { error: { message: 'history ingest is a write — extension origin or gateway token required', type: 'forbidden' } });
|
|
539
|
+
}
|
|
540
|
+
// The access log is who-read-what — sensitive, and writable only by the local MCP
|
|
541
|
+
// process (which sends the gateway token). Extension Origin or token for both the
|
|
542
|
+
// read (dashboard) and the report (MCP child); a drive-by page has neither.
|
|
543
|
+
if (pathname.startsWith('/v1/observability') && !isAdminAuthorized(req)) {
|
|
544
|
+
return sendJson(res, 403, { error: { message: 'observability: extension origin or gateway token required', type: 'forbidden' } });
|
|
545
|
+
}
|
|
525
546
|
|
|
526
547
|
if (req.method === 'GET' && pathname === '/health') {
|
|
527
548
|
// `stt` is ADDITIVE (Tesla rule): old clients ignore it, new clients use it
|
|
@@ -579,13 +600,34 @@ export function createGateway(cfg = loadConfig()) {
|
|
|
579
600
|
try {
|
|
580
601
|
const body = JSON.parse((await readBody(req, cfg.maxBodyBytes)).toString('utf8')) || {};
|
|
581
602
|
const results = historyStore.search(String(body.query || ''), { limit: Number(body.limit) || 10 });
|
|
582
|
-
return sendJson(res, 200, { ok: true, size: historyStore.size, results });
|
|
603
|
+
return sendJson(res, 200, { ok: true, size: historyStore.size, newest: historyStore.newest, results });
|
|
583
604
|
} catch (e) {
|
|
584
605
|
return sendJson(res, 400, { error: { message: `search failed: ${e.message}`, type: 'search_error' } });
|
|
585
606
|
}
|
|
586
607
|
}
|
|
587
608
|
if (pathname === '/v1/history/status' && req.method === 'GET') {
|
|
588
|
-
return sendJson(res, 200, { ok: true, size: historyStore.size });
|
|
609
|
+
return sendJson(res, 200, { ok: true, size: historyStore.size, newest: historyStore.newest, bytes: historyStore.bytes });
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
// OBSERVABILITY (admin-gated above).
|
|
613
|
+
// GET /v1/observability → { storage:{warm:{records,bytes,newest}}, access:[…] }
|
|
614
|
+
// POST /v1/observability/access ← the MCP process reports one tool call
|
|
615
|
+
if (pathname === '/v1/observability' && req.method === 'GET') {
|
|
616
|
+
const limit = Math.min(500, Math.max(1, Number(url.searchParams.get('limit')) || 200));
|
|
617
|
+
return sendJson(res, 200, {
|
|
618
|
+
ok: true,
|
|
619
|
+
storage: { warm: { records: historyStore.size, bytes: historyStore.bytes, newest: historyStore.newest } },
|
|
620
|
+
access: accessLog.snapshot(limit),
|
|
621
|
+
});
|
|
622
|
+
}
|
|
623
|
+
if (pathname === '/v1/observability/access' && req.method === 'POST') {
|
|
624
|
+
try {
|
|
625
|
+
const body = JSON.parse((await readBody(req, cfg.maxBodyBytes)).toString('utf8')) || {};
|
|
626
|
+
const evt = accessLog.push(makeAccessEvent({ ts: Date.now(), ...body }));
|
|
627
|
+
return sendJson(res, 200, { ok: true, event: evt });
|
|
628
|
+
} catch (e) {
|
|
629
|
+
return sendJson(res, 400, { error: { message: `access log failed: ${e.message}`, type: 'observability_error' } });
|
|
630
|
+
}
|
|
589
631
|
}
|
|
590
632
|
if (pathname === '/v1/history/list' && req.method === 'GET') {
|
|
591
633
|
const limit = Math.min(500, Math.max(1, Number(url.searchParams.get('limit')) || 50));
|
package/src/sqlite-store.js
CHANGED
|
@@ -15,7 +15,7 @@
|
|
|
15
15
|
// via history-store.js's saveBackupSecret.
|
|
16
16
|
|
|
17
17
|
import { join } from 'node:path';
|
|
18
|
-
import { mkdirSync, chmodSync, existsSync } from 'node:fs';
|
|
18
|
+
import { mkdirSync, chmodSync, existsSync, statSync } from 'node:fs';
|
|
19
19
|
import os from 'node:os';
|
|
20
20
|
import { HistoryStore } from './history-store.js';
|
|
21
21
|
|
|
@@ -92,6 +92,22 @@ export class SqliteHistoryStore {
|
|
|
92
92
|
return this.db.get('SELECT COUNT(*) c FROM records')?.c || 0;
|
|
93
93
|
}
|
|
94
94
|
|
|
95
|
+
// Freshness horizon — the newest record's timestamp. The MCP tools surface this so
|
|
96
|
+
// an agent knows how current the warm copy is (and stops denying un-synced items).
|
|
97
|
+
get newest() {
|
|
98
|
+
return this.db.get('SELECT MAX(date) m FROM records')?.m || 0;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// On-disk footprint for the storage dashboard: the .db plus its WAL/shm sidecars.
|
|
102
|
+
get bytes() {
|
|
103
|
+
if (this.path === ':memory:') return 0;
|
|
104
|
+
let total = 0;
|
|
105
|
+
for (const p of [this.path, `${this.path}-wal`, `${this.path}-shm`]) {
|
|
106
|
+
try { if (existsSync(p)) total += statSync(p).size; } catch { /* ignore */ }
|
|
107
|
+
}
|
|
108
|
+
return total;
|
|
109
|
+
}
|
|
110
|
+
|
|
95
111
|
bulk({ upserts = [], removes = [] } = {}) {
|
|
96
112
|
this.db.exec('BEGIN');
|
|
97
113
|
try {
|