@chatpanel/gateway 0.6.36 → 0.6.38
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/README.md +23 -0
- package/bin/chatpanel-gateway.js +5 -0
- package/package.json +1 -1
- package/src/connect-agents.js +173 -0
- package/src/history-store.js +8 -0
- package/src/mcp.js +17 -6
- package/src/server.js +3 -3
package/README.md
CHANGED
|
@@ -191,6 +191,29 @@ command = "chatpanel-gateway"
|
|
|
191
191
|
args = ["mcp"]
|
|
192
192
|
```
|
|
193
193
|
|
|
194
|
+
> **Corporate Codex with an approval guardian?** If a tool call is rejected with
|
|
195
|
+
> *"Automatic approval review failed"* (an `approvals_reviewer = "auto_review"` policy
|
|
196
|
+
> can't assess a tool it doesn't know), pre-approve ChatPanel's read-only tools the same
|
|
197
|
+
> way you would any other server — one block per tool:
|
|
198
|
+
>
|
|
199
|
+
> ```toml
|
|
200
|
+
> [mcp_servers.chatpanel.tools.search_history]
|
|
201
|
+
> approval_mode = "approve"
|
|
202
|
+
> [mcp_servers.chatpanel.tools.get_record]
|
|
203
|
+
> approval_mode = "approve"
|
|
204
|
+
> [mcp_servers.chatpanel.tools.list_history]
|
|
205
|
+
> approval_mode = "approve"
|
|
206
|
+
> [mcp_servers.chatpanel.tools.list_skills]
|
|
207
|
+
> approval_mode = "approve"
|
|
208
|
+
> [mcp_servers.chatpanel.tools.open_skill]
|
|
209
|
+
> approval_mode = "approve"
|
|
210
|
+
> [mcp_servers.chatpanel.tools.read_skill_file]
|
|
211
|
+
> approval_mode = "approve"
|
|
212
|
+
> ```
|
|
213
|
+
>
|
|
214
|
+
> All six only read local data (your redacted history and your installed skills), so
|
|
215
|
+
> approving them carries no write or network risk.
|
|
216
|
+
|
|
194
217
|
**Claude Code** — one command (`--scope user` makes it available in every project):
|
|
195
218
|
|
|
196
219
|
```bash
|
package/bin/chatpanel-gateway.js
CHANGED
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
// chatpanel-gateway start the gateway (foreground)
|
|
5
5
|
// chatpanel-gateway mcp stdio MCP server exposing warm history as tools
|
|
6
6
|
// chatpanel-gateway local show the local runtime — bridge + gateway, one view
|
|
7
|
+
// chatpanel-gateway connect point your CLI agents (Codex, Claude Code, …) at this server
|
|
7
8
|
// chatpanel-gateway --install register login auto-start + start now
|
|
8
9
|
// chatpanel-gateway --uninstall remove login auto-start
|
|
9
10
|
// chatpanel-gateway --status is auto-start registered?
|
|
@@ -24,6 +25,10 @@ try {
|
|
|
24
25
|
// Read-only unified view of both services. No server.js import — just HTTP probes.
|
|
25
26
|
const { localStatus, formatLocalStatus } = await import('../src/local-status.js');
|
|
26
27
|
process.stdout.write(formatLocalStatus(await localStatus()));
|
|
28
|
+
} else if (arg === 'connect') {
|
|
29
|
+
const { connectAgents, formatConnect } = await import('../src/connect-agents.js');
|
|
30
|
+
const dryRun = process.argv.includes('--dry-run') || process.argv.includes('-n');
|
|
31
|
+
process.stdout.write(formatConnect(connectAgents({ dryRun }), { dryRun }));
|
|
27
32
|
} else {
|
|
28
33
|
const { start, VERSION } = await import('../src/server.js');
|
|
29
34
|
const { installService, uninstallService, serviceStatus } = await import('../src/service.js');
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@chatpanel/gateway",
|
|
3
|
-
"version": "0.6.
|
|
3
|
+
"version": "0.6.38",
|
|
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": {
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
// connect-agents.js — wire the local CLI agents to the ChatPanel MCP server in one step.
|
|
2
|
+
//
|
|
3
|
+
// `chatpanel-gateway connect` detects the agent CLIs installed on this machine and points
|
|
4
|
+
// each at `chatpanel-gateway mcp`, so a person gets their history + skills in every agent
|
|
5
|
+
// without hand-editing four different config files in four different formats.
|
|
6
|
+
//
|
|
7
|
+
// Two rules keep this safe to run unattended:
|
|
8
|
+
// • BACK UP then EDIT, and be IDEMPOTENT — running it twice changes nothing the second
|
|
9
|
+
// time. A user's existing MCP servers are never touched.
|
|
10
|
+
// • Only auto-write a format we can write CORRECTLY. Codex (TOML append), Claude Code
|
|
11
|
+
// (its own CLI) and Gemini (plain JSON) are safe. For agents whose config is JSONC with
|
|
12
|
+
// comments, or whose format we cannot verify, we PRINT the exact snippet instead of
|
|
13
|
+
// risking a corrupted config. Guessing a format and clobbering a config is worse than
|
|
14
|
+
// asking the user to paste four lines.
|
|
15
|
+
|
|
16
|
+
import { readFileSync, writeFileSync, existsSync, copyFileSync, mkdirSync } from 'node:fs';
|
|
17
|
+
import { execFileSync } from 'node:child_process';
|
|
18
|
+
import { join } from 'node:path';
|
|
19
|
+
import os from 'node:os';
|
|
20
|
+
|
|
21
|
+
// The six read-only tools the ChatPanel MCP server exposes (history + skills). Named here so
|
|
22
|
+
// the Codex approval blocks stay in step with what the server actually advertises.
|
|
23
|
+
const TOOLS = ['search_history', 'get_record', 'list_history', 'list_skills', 'open_skill', 'read_skill_file'];
|
|
24
|
+
|
|
25
|
+
// Resolve the command a config should launch. A bare name works when the client inherits a
|
|
26
|
+
// normal PATH; an absolute path is the safe fallback when it does not.
|
|
27
|
+
export function resolveServerCommand(which = whichSync) {
|
|
28
|
+
const abs = which('chatpanel-gateway');
|
|
29
|
+
return abs || 'chatpanel-gateway';
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function whichSync(bin) {
|
|
33
|
+
try {
|
|
34
|
+
return execFileSync('which', [bin], { encoding: 'utf8' }).trim() || null;
|
|
35
|
+
} catch {
|
|
36
|
+
return null;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function backup(path) {
|
|
41
|
+
const b = `${path}.bak.chatpanel`;
|
|
42
|
+
try { copyFileSync(path, b); return b; } catch { return null; }
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// ── Codex ──────────────────────────────────────────────────────────────────────────────
|
|
46
|
+
// ~/.codex/config.toml. TOML, and Node has no TOML writer — but the change is a pure
|
|
47
|
+
// APPEND of a server block plus per-tool approval blocks, so a text append (guarded by a
|
|
48
|
+
// presence check) is correct and non-destructive. The approval blocks matter: a corporate
|
|
49
|
+
// Codex with `approvals_reviewer = "auto_review"` rejects a tool it cannot assess, and this
|
|
50
|
+
// is how every other server on such a setup is pre-approved.
|
|
51
|
+
function connectCodex({ cmd, dryRun, home }) {
|
|
52
|
+
const dir = join(home, '.codex');
|
|
53
|
+
const path = join(dir, 'config.toml');
|
|
54
|
+
if (!existsSync(dir)) return { id: 'codex', status: 'absent' };
|
|
55
|
+
const cur = existsSync(path) ? readFileSync(path, 'utf8') : '';
|
|
56
|
+
if (/\[mcp_servers\.chatpanel\]/.test(cur)) return { id: 'codex', status: 'already', path };
|
|
57
|
+
|
|
58
|
+
let block = '\n# ChatPanel — local history (redacted) + your installed skills, via one MCP server.\n';
|
|
59
|
+
block += `[mcp_servers.chatpanel]\ncommand = ${JSON.stringify(cmd)}\nargs = ["mcp"]\n\n`;
|
|
60
|
+
block += '# Read-only tools — pre-approved so a corporate auto_review guardian does not reject them.\n';
|
|
61
|
+
for (const t of TOOLS) block += `[mcp_servers.chatpanel.tools.${t}]\napproval_mode = "approve"\n\n`;
|
|
62
|
+
|
|
63
|
+
if (dryRun) return { id: 'codex', status: 'would', path, preview: block.trim() };
|
|
64
|
+
const bak = backup(path);
|
|
65
|
+
writeFileSync(path, `${cur.replace(/\s*$/, '')}\n${block}`);
|
|
66
|
+
return { id: 'codex', status: 'configured', path, backup: bak };
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// ── Claude Code ────────────────────────────────────────────────────────────────────────
|
|
70
|
+
// Its own CLI owns ~/.claude.json, so use `claude mcp add` rather than editing it. --scope
|
|
71
|
+
// user makes it available in every project.
|
|
72
|
+
function connectClaude({ cmd, dryRun, which }) {
|
|
73
|
+
const claude = which('claude');
|
|
74
|
+
if (!claude) return { id: 'claude-code', status: 'absent' };
|
|
75
|
+
// Already added? `claude mcp get` exits non-zero when it is not there.
|
|
76
|
+
try { execFileSync(claude, ['mcp', 'get', 'chatpanel'], { stdio: 'ignore' }); return { id: 'claude-code', status: 'already' }; } catch { /* not present */ }
|
|
77
|
+
if (dryRun) return { id: 'claude-code', status: 'would', preview: `claude mcp add --scope user chatpanel ${cmd} mcp` };
|
|
78
|
+
try {
|
|
79
|
+
execFileSync(claude, ['mcp', 'add', '--scope', 'user', 'chatpanel', cmd, 'mcp'], { stdio: 'ignore' });
|
|
80
|
+
return { id: 'claude-code', status: 'configured' };
|
|
81
|
+
} catch (e) {
|
|
82
|
+
return { id: 'claude-code', status: 'error', message: e.message };
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// ── Gemini CLI / Antigravity ─────────────────────────────────────────────────────────────
|
|
87
|
+
// ~/.gemini/settings.json — plain JSON, safe to parse and merge.
|
|
88
|
+
function connectGemini({ cmd, dryRun, home }) {
|
|
89
|
+
const dir = join(home, '.gemini');
|
|
90
|
+
const path = join(dir, 'settings.json');
|
|
91
|
+
if (!existsSync(dir)) return { id: 'gemini', status: 'absent' };
|
|
92
|
+
let cfg = {};
|
|
93
|
+
if (existsSync(path)) {
|
|
94
|
+
try { cfg = JSON.parse(readFileSync(path, 'utf8')); } catch { return { id: 'gemini', status: 'error', message: 'settings.json is not valid JSON — left untouched', path }; }
|
|
95
|
+
}
|
|
96
|
+
cfg.mcpServers = cfg.mcpServers || {};
|
|
97
|
+
if (cfg.mcpServers.chatpanel) return { id: 'gemini', status: 'already', path };
|
|
98
|
+
const entry = { command: cmd, args: ['mcp'] };
|
|
99
|
+
if (dryRun) return { id: 'gemini', status: 'would', path, preview: JSON.stringify({ mcpServers: { chatpanel: entry } }, null, 2) };
|
|
100
|
+
const bak = existsSync(path) ? backup(path) : null;
|
|
101
|
+
cfg.mcpServers.chatpanel = entry;
|
|
102
|
+
mkdirSync(dir, { recursive: true });
|
|
103
|
+
writeFileSync(path, `${JSON.stringify(cfg, null, 2)}\n`);
|
|
104
|
+
return { id: 'gemini', status: 'configured', path, backup: bak };
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// ── Agents we do NOT auto-write (JSONC with comments, or a format we can't verify) ───────
|
|
108
|
+
// Detected and given the exact snippet, rather than risking a clobbered config.
|
|
109
|
+
function manualAgent(id, present, snippet) {
|
|
110
|
+
return present ? { id, status: 'manual', snippet } : { id, status: 'absent' };
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function otherAgents({ cmd, home, which }) {
|
|
114
|
+
const out = [];
|
|
115
|
+
// OpenCode — opencode.json / .jsonc. JSONC keeps comments; we won't rewrite and drop them.
|
|
116
|
+
out.push(manualAgent('opencode', existsSync(join(home, '.opencode')) || existsSync(join(home, '.config', 'opencode')),
|
|
117
|
+
`Add to your opencode config (opencode.json):\n "mcp": { "chatpanel": { "type": "local", "command": [${JSON.stringify(cmd)}, "mcp"], "enabled": true } }`));
|
|
118
|
+
// GitHub Copilot CLI.
|
|
119
|
+
out.push(manualAgent('copilot', existsSync(join(home, '.copilot')),
|
|
120
|
+
`Copilot CLI: add an MCP server named "chatpanel" running: ${cmd} mcp`));
|
|
121
|
+
// Hermes.
|
|
122
|
+
out.push(manualAgent('hermes', existsSync(join(home, '.hermes')) || !!which('hermes'),
|
|
123
|
+
`Hermes: register an MCP server "chatpanel" with command ${cmd} mcp (see Hermes' MCP docs).`));
|
|
124
|
+
// Pi.
|
|
125
|
+
out.push(manualAgent('pi', existsSync(join(home, '.pi')) || !!which('pi'),
|
|
126
|
+
`Pi: add an MCP server "chatpanel" running: ${cmd} mcp`));
|
|
127
|
+
return out;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Detect and (unless dryRun) configure every agent found. Returns one result per agent so a
|
|
132
|
+
* caller can render a summary. Pure-ish: the only side effects are the config writes, and
|
|
133
|
+
* dryRun suppresses them.
|
|
134
|
+
*/
|
|
135
|
+
export function connectAgents({ dryRun = false, cmd, home = os.homedir(), which = whichSync } = {}) {
|
|
136
|
+
const serverCmd = cmd || resolveServerCommand(which);
|
|
137
|
+
const results = [
|
|
138
|
+
connectCodex({ cmd: serverCmd, dryRun, home }),
|
|
139
|
+
connectClaude({ cmd: serverCmd, dryRun, which }),
|
|
140
|
+
connectGemini({ cmd: serverCmd, dryRun, home }),
|
|
141
|
+
...otherAgents({ cmd: serverCmd, home, which }),
|
|
142
|
+
];
|
|
143
|
+
return { cmd: serverCmd, results };
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
const LABEL = {
|
|
147
|
+
codex: 'Codex', 'claude-code': 'Claude Code', gemini: 'Gemini / Antigravity',
|
|
148
|
+
opencode: 'OpenCode', copilot: 'GitHub Copilot', hermes: 'Hermes', pi: 'Pi',
|
|
149
|
+
};
|
|
150
|
+
|
|
151
|
+
/** Human summary for the CLI. */
|
|
152
|
+
export function formatConnect({ cmd, results }, { dryRun = false } = {}) {
|
|
153
|
+
const lines = [`ChatPanel MCP — connecting your agents to: ${cmd} mcp`, ''];
|
|
154
|
+
const icon = { configured: '✓', already: '•', would: '→', manual: '✎', absent: '·', error: '✕' };
|
|
155
|
+
for (const r of results) {
|
|
156
|
+
const name = LABEL[r.id] || r.id;
|
|
157
|
+
if (r.status === 'absent') continue; // don't list agents that aren't installed
|
|
158
|
+
const head = ` ${icon[r.status] || '?'} ${name}`;
|
|
159
|
+
if (r.status === 'configured') lines.push(`${head} — configured${r.backup ? ` (backup: ${r.backup})` : ''}`);
|
|
160
|
+
else if (r.status === 'already') lines.push(`${head} — already connected`);
|
|
161
|
+
else if (r.status === 'would') lines.push(`${head} — would configure:\n ${(r.preview || '').split('\n').join('\n ')}`);
|
|
162
|
+
else if (r.status === 'manual') lines.push(`${head} — add it yourself:\n ${r.snippet.split('\n').join('\n ')}`);
|
|
163
|
+
else if (r.status === 'error') lines.push(`${head} — ${r.message}`);
|
|
164
|
+
}
|
|
165
|
+
const configured = results.filter((r) => r.status === 'configured').length;
|
|
166
|
+
lines.push('');
|
|
167
|
+
if (dryRun) lines.push(' (dry run — nothing was written. Re-run without --dry-run to apply.)');
|
|
168
|
+
else if (configured) lines.push(` Done. Restart the configured agent(s) to pick up the new server.`);
|
|
169
|
+
else lines.push(' Nothing to change.');
|
|
170
|
+
const anyManual = results.some((r) => r.status === 'manual');
|
|
171
|
+
if (anyManual) lines.push(' Agents marked ✎ use a config format best edited by hand — the snippet above is exact.');
|
|
172
|
+
return `${lines.join('\n')}\n`;
|
|
173
|
+
}
|
package/src/history-store.js
CHANGED
|
@@ -96,6 +96,14 @@ 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
|
+
|
|
99
107
|
key() {
|
|
100
108
|
if (!this._key) this._key = loadOrCreateKey();
|
|
101
109
|
return this._key;
|
package/src/mcp.js
CHANGED
|
@@ -61,7 +61,7 @@ async function bridgeJson(path) {
|
|
|
61
61
|
const TOOLS = [
|
|
62
62
|
{
|
|
63
63
|
name: 'search_history',
|
|
64
|
-
description: 'Full-text search the user\'s ChatPanel history — past chats, meeting transcripts, and notes — by keyword relevance.
|
|
64
|
+
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
65
|
inputSchema: {
|
|
66
66
|
type: 'object',
|
|
67
67
|
properties: {
|
|
@@ -82,7 +82,7 @@ const TOOLS = [
|
|
|
82
82
|
},
|
|
83
83
|
{
|
|
84
84
|
name: 'list_history',
|
|
85
|
-
description: 'List history records (newest first) with their id, title, type and date — no bodies. Use to
|
|
85
|
+
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
86
|
inputSchema: {
|
|
87
87
|
type: 'object',
|
|
88
88
|
properties: {
|
|
@@ -119,6 +119,15 @@ const TOOLS = [
|
|
|
119
119
|
},
|
|
120
120
|
];
|
|
121
121
|
|
|
122
|
+
// A one-line freshness banner from the store's newest record, so every answer states the
|
|
123
|
+
// index horizon — the model can then say "not synced yet" instead of "does not exist".
|
|
124
|
+
function horizonLine(newest, size) {
|
|
125
|
+
if (!newest) return `Index: ${size} records (warm copy synced from ChatPanel).`;
|
|
126
|
+
const d = new Date(newest);
|
|
127
|
+
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')}`;
|
|
128
|
+
return `Index: ${size} records, current through ${iso} (local warm copy — items newer than this may not have synced from ChatPanel yet).`;
|
|
129
|
+
}
|
|
130
|
+
|
|
122
131
|
async function gatewayJson(path, init) {
|
|
123
132
|
let res;
|
|
124
133
|
try {
|
|
@@ -142,8 +151,9 @@ async function callTool(name, args = {}) {
|
|
|
142
151
|
body: JSON.stringify({ query: String(args.query || ''), limit: Number(args.limit) || 10 }),
|
|
143
152
|
});
|
|
144
153
|
const rows = data.results || [];
|
|
145
|
-
|
|
146
|
-
|
|
154
|
+
const horizon = horizonLine(data.newest, data.size);
|
|
155
|
+
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).`;
|
|
156
|
+
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
157
|
}
|
|
148
158
|
if (name === 'get_record') {
|
|
149
159
|
const data = await gatewayJson(`/v1/history/get?id=${encodeURIComponent(String(args.id || ''))}`);
|
|
@@ -154,8 +164,9 @@ async function callTool(name, args = {}) {
|
|
|
154
164
|
const q = new URLSearchParams({ limit: String(Number(args.limit) || 50), offset: String(Number(args.offset) || 0) });
|
|
155
165
|
const data = await gatewayJson(`/v1/history/list?${q}`);
|
|
156
166
|
const items = data.items || [];
|
|
157
|
-
if (!items.length) return 'History is empty (or the gateway has not been seeded yet).';
|
|
158
|
-
|
|
167
|
+
if (!items.length) return 'History is empty (or the gateway has not been seeded yet — open ChatPanel with warm sync enabled).';
|
|
168
|
+
const newest = items[0]?.date || null;
|
|
169
|
+
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
170
|
}
|
|
160
171
|
if (name === 'list_skills') {
|
|
161
172
|
let data;
|
package/src/server.js
CHANGED
|
@@ -45,7 +45,7 @@ import * as openai from './openai.js';
|
|
|
45
45
|
import * as responses from './responses.js';
|
|
46
46
|
import * as anthropic from './anthropic.js';
|
|
47
47
|
|
|
48
|
-
export const VERSION = '0.6.
|
|
48
|
+
export const VERSION = '0.6.38';
|
|
49
49
|
|
|
50
50
|
// WARM search tier — SQLite + FTS5 record store (falls back to an encrypted-JSON
|
|
51
51
|
// store if SQLite can't load), fed by the extension's ingest sync + backup-ingest.
|
|
@@ -579,13 +579,13 @@ export function createGateway(cfg = loadConfig()) {
|
|
|
579
579
|
try {
|
|
580
580
|
const body = JSON.parse((await readBody(req, cfg.maxBodyBytes)).toString('utf8')) || {};
|
|
581
581
|
const results = historyStore.search(String(body.query || ''), { limit: Number(body.limit) || 10 });
|
|
582
|
-
return sendJson(res, 200, { ok: true, size: historyStore.size, results });
|
|
582
|
+
return sendJson(res, 200, { ok: true, size: historyStore.size, newest: historyStore.newest, results });
|
|
583
583
|
} catch (e) {
|
|
584
584
|
return sendJson(res, 400, { error: { message: `search failed: ${e.message}`, type: 'search_error' } });
|
|
585
585
|
}
|
|
586
586
|
}
|
|
587
587
|
if (pathname === '/v1/history/status' && req.method === 'GET') {
|
|
588
|
-
return sendJson(res, 200, { ok: true, size: historyStore.size });
|
|
588
|
+
return sendJson(res, 200, { ok: true, size: historyStore.size, newest: historyStore.newest });
|
|
589
589
|
}
|
|
590
590
|
if (pathname === '/v1/history/list' && req.method === 'GET') {
|
|
591
591
|
const limit = Math.min(500, Math.max(1, Number(url.searchParams.get('limit')) || 50));
|