@chatpanel/gateway 0.6.40 → 0.6.42
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/connect-agents.js +1 -1
- package/src/history-store.js +49 -4
- package/src/mcp.js +95 -12
- package/src/observability.js +6 -4
- package/src/server.js +17 -3
- package/src/sqlite-store.js +43 -6
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@chatpanel/gateway",
|
|
3
|
-
"version": "0.6.
|
|
3
|
+
"version": "0.6.42",
|
|
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/connect-agents.js
CHANGED
|
@@ -20,7 +20,7 @@ import os from 'node:os';
|
|
|
20
20
|
|
|
21
21
|
// The six read-only tools the ChatPanel MCP server exposes (history + skills). Named here so
|
|
22
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'];
|
|
23
|
+
const TOOLS = ['search_history', 'get_record', 'find_related', 'list_history', 'list_skills', 'open_skill', 'read_skill_file'];
|
|
24
24
|
|
|
25
25
|
// Resolve the command a config should launch. A bare name works when the client inherits a
|
|
26
26
|
// normal PATH; an absolute path is the safe fallback when it does not.
|
package/src/history-store.js
CHANGED
|
@@ -187,8 +187,32 @@ export class HistoryStore {
|
|
|
187
187
|
return n;
|
|
188
188
|
}
|
|
189
189
|
|
|
190
|
-
|
|
191
|
-
|
|
190
|
+
// Mirror the SQLite engine's richer contract so both backends behave the same: type/date
|
|
191
|
+
// filters, offset paging, and a matching snippet (over-fetch from the ranker, then filter
|
|
192
|
+
// and page, since SearchIndex has no WHERE clause).
|
|
193
|
+
search(query, { limit = 10, offset = 0, type = null, since = null, before = null } = {}) {
|
|
194
|
+
const raw = this.index.search(query, { limit: (limit + offset) * 3 + 20 });
|
|
195
|
+
const out = [];
|
|
196
|
+
for (const r of raw) {
|
|
197
|
+
const rec = this.records.get(r.id);
|
|
198
|
+
if (!rec) continue;
|
|
199
|
+
if (type && rec.type !== type) continue;
|
|
200
|
+
if (since != null && (rec.date || 0) < since) continue;
|
|
201
|
+
if (before != null && (rec.date || 0) > before) continue;
|
|
202
|
+
out.push({ id: r.id, score: r.score, title: rec.title, type: rec.type, date: rec.date, snippet: snippetOf(rec.text, query) });
|
|
203
|
+
}
|
|
204
|
+
return out.slice(offset, offset + limit);
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
// Graph navigation — records most connected to this one, by shared content.
|
|
208
|
+
related(id, { limit = 5 } = {}) {
|
|
209
|
+
const rec = this.records.get(id);
|
|
210
|
+
if (!rec) return [];
|
|
211
|
+
const raw = this.index.search(`${rec.title || ''} ${String(rec.text || '').slice(0, 2000)}`, { limit: limit + 5 });
|
|
212
|
+
return raw.filter((r) => r.id !== id).slice(0, limit).map((r) => {
|
|
213
|
+
const m = this.records.get(r.id) || {};
|
|
214
|
+
return { id: r.id, score: r.score, title: m.title, type: m.type, date: m.date };
|
|
215
|
+
});
|
|
192
216
|
}
|
|
193
217
|
|
|
194
218
|
// Metadata list for an external UI, newest first, paginated. No bodies.
|
|
@@ -199,7 +223,28 @@ export class HistoryStore {
|
|
|
199
223
|
return { total: all.length, items: all.slice(offset, offset + limit) };
|
|
200
224
|
}
|
|
201
225
|
|
|
202
|
-
|
|
203
|
-
|
|
226
|
+
// Paged fetch for token management (maxChars/offset), matching SqliteHistoryStore.get.
|
|
227
|
+
get(id, { maxChars = null, offset = 0 } = {}) {
|
|
228
|
+
const rec = this.records.get(id);
|
|
229
|
+
if (!rec) return null;
|
|
230
|
+
const full = String(rec.text || '');
|
|
231
|
+
let text = offset ? full.slice(offset) : full;
|
|
232
|
+
let truncated = false;
|
|
233
|
+
if (maxChars && text.length > maxChars) { text = text.slice(0, maxChars); truncated = true; }
|
|
234
|
+
return { ...rec, text, totalChars: full.length, offset: Number(offset) || 0, truncated };
|
|
204
235
|
}
|
|
205
236
|
}
|
|
237
|
+
|
|
238
|
+
// A short excerpt of `text` around the first query-term hit — the token-friendly preview a
|
|
239
|
+
// search returns instead of the whole body. Falls back to the head when no term matches.
|
|
240
|
+
function snippetOf(text, query, radius = 90) {
|
|
241
|
+
const s = String(text || '');
|
|
242
|
+
if (!s) return '';
|
|
243
|
+
const terms = String(query || '').toLowerCase().match(/[a-z0-9][a-z0-9'_+-]*/g) || [];
|
|
244
|
+
const lower = s.toLowerCase();
|
|
245
|
+
let idx = -1;
|
|
246
|
+
for (const t of terms) { const i = lower.indexOf(t); if (i >= 0 && (idx < 0 || i < idx)) idx = i; }
|
|
247
|
+
if (idx < 0) return s.slice(0, radius * 2).replace(/\s+/g, ' ').trim();
|
|
248
|
+
const start = Math.max(0, idx - radius);
|
|
249
|
+
return `${start > 0 ? '… ' : ''}${s.slice(start, idx + radius).replace(/\s+/g, ' ').trim()}${idx + radius < s.length ? ' …' : ''}`;
|
|
250
|
+
}
|
package/src/mcp.js
CHANGED
|
@@ -21,6 +21,29 @@ import { ensureGatewayToken } from './gateway-token.js';
|
|
|
21
21
|
const PROTOCOL_VERSION = '2024-11-05';
|
|
22
22
|
const SERVER = { name: 'chatpanel-history', version: '1.0.0' };
|
|
23
23
|
|
|
24
|
+
// MCP servers may return `instructions` from initialize; hosts (Codex, Claude Code, …) fold
|
|
25
|
+
// them into the model's context. This is where we STEER tool selection so a person doesn't
|
|
26
|
+
// have to say "use ChatPanel": it tells the model that the user's personal meetings/notes/
|
|
27
|
+
// chats live here and must be reached through these tools, not by grepping the working dir.
|
|
28
|
+
const INSTRUCTIONS = [
|
|
29
|
+
'ChatPanel holds the USER\'S OWN meetings, notes and past chats — transcripts, decisions,',
|
|
30
|
+
'action items, summaries — which usually are NOT in the working directory.',
|
|
31
|
+
'',
|
|
32
|
+
'Keep using all your normal tools. ADDITIONALLY, whenever the user\'s question touches a',
|
|
33
|
+
'meeting, call, demo, note, or past conversation ("outcome of the meeting", "what did we',
|
|
34
|
+
'decide", "action items", "notes from yesterday", a person/day/topic in their history),',
|
|
35
|
+
'ALSO consult ChatPanel — it is the source of truth for that personal history:',
|
|
36
|
+
' • search_history — search by CONTENT (not the generic meeting title). Supports filters:',
|
|
37
|
+
' type (chat|meeting|note), since/before (dates or relative like "7d", "yesterday"),',
|
|
38
|
+
' and limit/offset paging. Returns compact snippets, not full bodies.',
|
|
39
|
+
' • get_record — the full text of one result id; use maxChars/offset to page a long',
|
|
40
|
+
' transcript instead of pulling it all into context.',
|
|
41
|
+
' • find_related — follow the graph: given a record id, the records most connected to it.',
|
|
42
|
+
'Prefer these for the user\'s history and combine them with your other tools as you see fit.',
|
|
43
|
+
'Every result states how fresh the local copy is; if something recent is missing it may not',
|
|
44
|
+
'have synced yet — say so rather than concluding it does not exist.',
|
|
45
|
+
].join('\n');
|
|
46
|
+
|
|
24
47
|
// The calling agent's self-reported name (from MCP `initialize` clientInfo), so the
|
|
25
48
|
// observability dashboard can say WHICH agent read what. Untrusted; the gateway coerces it.
|
|
26
49
|
let clientName = 'unknown';
|
|
@@ -82,22 +105,42 @@ async function bridgeJson(path) {
|
|
|
82
105
|
const TOOLS = [
|
|
83
106
|
{
|
|
84
107
|
name: 'search_history',
|
|
85
|
-
description: '
|
|
108
|
+
description: 'Search the user\'s ChatPanel history — their past chats, meeting/call transcripts, and notes — by keyword relevance. Consult this (in ADDITION to your other tools) whenever the question touches a meeting, call, demo, note, or past conversation: "outcome of the meeting", "what did we decide", "action items", "what did <person> say", "notes from yesterday". Filters: `type` (chat|meeting|note), `since`/`before` (a date like 2026-08-01 or a relative window like "7d"/"yesterday"), and `limit`/`offset` for paging. Returns compact SNIPPETS (the matching excerpt) with each record\'s id/title/type/date — token-friendly; call get_record for the full text and find_related to follow connections. This is a LOCAL WARM COPY that syncs from ChatPanel; very recent items may not be here yet — results report how current the index is, so if something is missing it likely has not synced. Meeting titles are often generic ("Zoom Meeting"), so search by CONTENT, not the title.',
|
|
86
109
|
inputSchema: {
|
|
87
110
|
type: 'object',
|
|
88
111
|
properties: {
|
|
89
|
-
query: { type: 'string', description: '
|
|
112
|
+
query: { type: 'string', description: 'Content keywords (topics, names, decisions) — not the meeting title.' },
|
|
113
|
+
type: { type: 'string', enum: ['chat', 'meeting', 'note'], description: 'Only this kind of record.' },
|
|
114
|
+
since: { type: 'string', description: 'Earliest date: 2026-08-01, or a relative window like "7d", "2 weeks", "yesterday".' },
|
|
115
|
+
before: { type: 'string', description: 'Latest date: a date or relative window like `since`.' },
|
|
90
116
|
limit: { type: 'number', description: 'Max results (default 10).' },
|
|
117
|
+
offset: { type: 'number', description: 'Skip N results for paging (default 0).' },
|
|
91
118
|
},
|
|
92
119
|
required: ['query'],
|
|
93
120
|
},
|
|
94
121
|
},
|
|
95
122
|
{
|
|
96
123
|
name: 'get_record',
|
|
97
|
-
description: 'Fetch one
|
|
124
|
+
description: 'Fetch one history record\'s full text by id (chat:<id>, meeting:<id>, note:<id> from search_history). For a long transcript, page it with maxChars + offset instead of pulling it all into context — the result says how many chars remain.',
|
|
125
|
+
inputSchema: {
|
|
126
|
+
type: 'object',
|
|
127
|
+
properties: {
|
|
128
|
+
id: { type: 'string', description: 'Record id such as chat:abc, meeting:imp_123, or note:xyz.' },
|
|
129
|
+
maxChars: { type: 'number', description: 'Return at most this many characters (token management for long transcripts).' },
|
|
130
|
+
offset: { type: 'number', description: 'Start at this character offset — page through with the offset the previous call reports.' },
|
|
131
|
+
},
|
|
132
|
+
required: ['id'],
|
|
133
|
+
},
|
|
134
|
+
},
|
|
135
|
+
{
|
|
136
|
+
name: 'find_related',
|
|
137
|
+
description: 'Graph navigation: given a record id (from search_history), return the records most connected to it by shared content — the meetings/notes/chats about the same topic, people or thread. Use it to expand from one hit to the surrounding context instead of re-searching.',
|
|
98
138
|
inputSchema: {
|
|
99
139
|
type: 'object',
|
|
100
|
-
properties: {
|
|
140
|
+
properties: {
|
|
141
|
+
id: { type: 'string', description: 'The record id to find neighbours of.' },
|
|
142
|
+
limit: { type: 'number', description: 'Max related records (default 5).' },
|
|
143
|
+
},
|
|
101
144
|
required: ['id'],
|
|
102
145
|
},
|
|
103
146
|
},
|
|
@@ -164,22 +207,62 @@ async function gatewayJson(path, init) {
|
|
|
164
207
|
}
|
|
165
208
|
|
|
166
209
|
// Run a tool → a plain-text result an agent can read.
|
|
210
|
+
// Parse a since/before value into epoch ms: an ISO-ish date (2026-08-01) or a relative window
|
|
211
|
+
// meaning "within the last N" — "7d", "2 weeks", "3 months", "yesterday", "today".
|
|
212
|
+
function parseWhen(v) {
|
|
213
|
+
if (v == null || v === '') return null;
|
|
214
|
+
if (typeof v === 'number') return v;
|
|
215
|
+
const s = String(v).trim().toLowerCase();
|
|
216
|
+
const DAY = 86_400_000;
|
|
217
|
+
if (s === 'today') return Date.now() - DAY;
|
|
218
|
+
if (s === 'yesterday') return Date.now() - 2 * DAY;
|
|
219
|
+
const rel = /^(\d+)\s*(d|day|days|w|week|weeks|m|month|months|y|year|years)$/.exec(s);
|
|
220
|
+
if (rel) {
|
|
221
|
+
const n = Number(rel[1]);
|
|
222
|
+
const u = rel[2][0];
|
|
223
|
+
const mult = u === 'd' ? DAY : u === 'w' ? 7 * DAY : u === 'm' ? 30 * DAY : 365 * DAY;
|
|
224
|
+
return Date.now() - n * mult;
|
|
225
|
+
}
|
|
226
|
+
const t = Date.parse(s);
|
|
227
|
+
return Number.isNaN(t) ? null : t;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
const fmtRow = (r, i) => `${i + 1}. [${r.id}] ${r.title || '(untitled)'} · ${r.type}${r.date ? ' · ' + new Date(r.date).toISOString().slice(0, 10) : ''}${r.snippet ? `\n ${r.snippet}` : ''}`;
|
|
231
|
+
|
|
167
232
|
async function callTool(name, args = {}) {
|
|
168
233
|
if (name === 'search_history') {
|
|
234
|
+
const body = { query: String(args.query || ''), limit: Number(args.limit) || 10, offset: Math.max(0, Number(args.offset) || 0) };
|
|
235
|
+
if (args.type) body.type = String(args.type);
|
|
236
|
+
const since = parseWhen(args.since); if (since != null) body.since = since;
|
|
237
|
+
const before = parseWhen(args.before); if (before != null) body.before = before;
|
|
169
238
|
const data = await gatewayJson('/v1/history/search', {
|
|
170
|
-
method: 'POST',
|
|
171
|
-
headers: { 'content-type': 'application/json' },
|
|
172
|
-
body: JSON.stringify({ query: String(args.query || ''), limit: Number(args.limit) || 10 }),
|
|
239
|
+
method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(body),
|
|
173
240
|
});
|
|
174
241
|
const rows = data.results || [];
|
|
175
242
|
const horizon = horizonLine(data.newest, data.size);
|
|
176
|
-
|
|
177
|
-
|
|
243
|
+
const filt = [args.type && `type=${args.type}`, args.since && `since=${args.since}`, args.before && `before=${args.before}`].filter(Boolean).join(', ');
|
|
244
|
+
const tag = filt ? ` (${filt})` : '';
|
|
245
|
+
if (!rows.length) return `No match for "${args.query}"${tag}.\n${horizon}\nIf you expected a recent item, it may not have synced yet — check ChatPanel directly, or broaden the query (titles are often generic; try content keywords, or drop a filter).`;
|
|
246
|
+
return [horizon, '', `${rows.length} result(s) for "${args.query}"${tag}:`, ...rows.map(fmtRow)].join('\n')
|
|
247
|
+
+ '\n\nget_record <id> for full text (maxChars/offset to page) · find_related <id> to follow connections.';
|
|
178
248
|
}
|
|
179
249
|
if (name === 'get_record') {
|
|
180
|
-
const
|
|
250
|
+
const q = new URLSearchParams({ id: String(args.id || '') });
|
|
251
|
+
if (args.maxChars != null) q.set('maxChars', String(Math.max(1, Number(args.maxChars) || 0)));
|
|
252
|
+
if (args.offset != null) q.set('offset', String(Math.max(0, Number(args.offset) || 0)));
|
|
253
|
+
const data = await gatewayJson(`/v1/history/get?${q}`);
|
|
181
254
|
const r = data.record;
|
|
182
|
-
|
|
255
|
+
const head = `[${r.id}] ${r.title || '(untitled)'} · ${r.type}${r.date ? ' · ' + new Date(r.date).toISOString().slice(0, 10) : ''}`;
|
|
256
|
+
const more = r.truncated
|
|
257
|
+
? `\n\n[showing ${r.text.length} of ${r.totalChars} chars (from offset ${r.offset}). For the next part call get_record again with offset=${r.offset + r.text.length}.]`
|
|
258
|
+
: '';
|
|
259
|
+
return `${head}\n\n${r.text || '(empty)'}${more}`;
|
|
260
|
+
}
|
|
261
|
+
if (name === 'find_related') {
|
|
262
|
+
const data = await gatewayJson(`/v1/history/related?id=${encodeURIComponent(String(args.id || ''))}&limit=${Number(args.limit) || 5}`);
|
|
263
|
+
const rows = data.results || [];
|
|
264
|
+
if (!rows.length) return `Nothing related to ${args.id} found (or that id isn't in the warm index — run search_history first to get a valid id).`;
|
|
265
|
+
return [`Records related to ${args.id}:`, ...rows.map(fmtRow)].join('\n') + '\n\nget_record <id> for full text.';
|
|
183
266
|
}
|
|
184
267
|
if (name === 'list_history') {
|
|
185
268
|
const q = new URLSearchParams({ limit: String(Number(args.limit) || 50), offset: String(Number(args.offset) || 0) });
|
|
@@ -223,7 +306,7 @@ export async function handleRpc(msg) {
|
|
|
223
306
|
switch (method) {
|
|
224
307
|
case 'initialize':
|
|
225
308
|
clientName = params?.clientInfo?.name || clientName;
|
|
226
|
-
return ok({ protocolVersion: PROTOCOL_VERSION, capabilities: { tools: {} }, serverInfo: SERVER });
|
|
309
|
+
return ok({ protocolVersion: PROTOCOL_VERSION, capabilities: { tools: {} }, serverInfo: SERVER, instructions: INSTRUCTIONS });
|
|
227
310
|
case 'tools/list':
|
|
228
311
|
return ok({ tools: TOOLS });
|
|
229
312
|
case 'tools/call': {
|
package/src/observability.js
CHANGED
|
@@ -2,7 +2,6 @@
|
|
|
2
2
|
// The gateway keeps its dependency surface small; this one pure module is copied in
|
|
3
3
|
// rather than pulling the whole events package, the same way the bridge vendors its
|
|
4
4
|
// events files. Source of truth: chatpanel-events/observability.js.
|
|
5
|
-
//
|
|
6
5
|
// observability.js — the contract for "who consumed what, when, and how much is stored".
|
|
7
6
|
//
|
|
8
7
|
// ChatPanel's data is reachable by more than one agent now: the side panel, and any CLI
|
|
@@ -30,10 +29,13 @@ export const ACCESS_LOG_MAX = 500;
|
|
|
30
29
|
// listed here is dropped. Content-bearing fields (a search `query`) are deliberately ABSENT —
|
|
31
30
|
// the tool name already says "a search happened"; the words searched are not logged.
|
|
32
31
|
const SAFE_ARGS = {
|
|
33
|
-
|
|
32
|
+
// Metadata filters are safe to keep (they are not content) and useful to see in the log:
|
|
33
|
+
// "type=meeting since=7d". The search QUERY is deliberately absent — never recorded.
|
|
34
|
+
search_history: ['type', 'since', 'before', 'limit', 'offset'],
|
|
34
35
|
list_history: ['limit', 'offset'],
|
|
35
|
-
get_record: ['id'],
|
|
36
|
-
|
|
36
|
+
get_record: ['id', 'maxChars', 'offset'], // opaque record id + paging, not content
|
|
37
|
+
find_related: ['id', 'limit'], // graph navigation from an opaque id
|
|
38
|
+
open_skill: ['skill'], // skill names are catalog identifiers, not PII
|
|
37
39
|
read_skill_file: ['skill', 'path'],
|
|
38
40
|
list_skills: ['limit'],
|
|
39
41
|
};
|
package/src/server.js
CHANGED
|
@@ -46,7 +46,7 @@ import * as openai from './openai.js';
|
|
|
46
46
|
import * as responses from './responses.js';
|
|
47
47
|
import * as anthropic from './anthropic.js';
|
|
48
48
|
|
|
49
|
-
export const VERSION = '0.6.
|
|
49
|
+
export const VERSION = '0.6.42';
|
|
50
50
|
|
|
51
51
|
// WARM search tier — SQLite + FTS5 record store (falls back to an encrypted-JSON
|
|
52
52
|
// store if SQLite can't load), fed by the extension's ingest sync + backup-ingest.
|
|
@@ -603,12 +603,24 @@ export function createGateway(cfg = loadConfig()) {
|
|
|
603
603
|
if (pathname === '/v1/history/search' && req.method === 'POST') {
|
|
604
604
|
try {
|
|
605
605
|
const body = JSON.parse((await readBody(req, cfg.maxBodyBytes)).toString('utf8')) || {};
|
|
606
|
-
const results = historyStore.search(String(body.query || ''), {
|
|
606
|
+
const results = historyStore.search(String(body.query || ''), {
|
|
607
|
+
limit: Number(body.limit) || 10,
|
|
608
|
+
offset: Math.max(0, Number(body.offset) || 0),
|
|
609
|
+
type: body.type ? String(body.type) : null,
|
|
610
|
+
since: body.since != null ? Number(body.since) : null,
|
|
611
|
+
before: body.before != null ? Number(body.before) : null,
|
|
612
|
+
});
|
|
607
613
|
return sendJson(res, 200, { ok: true, size: historyStore.size, newest: historyStore.newest, results });
|
|
608
614
|
} catch (e) {
|
|
609
615
|
return sendJson(res, 400, { error: { message: `search failed: ${e.message}`, type: 'search_error' } });
|
|
610
616
|
}
|
|
611
617
|
}
|
|
618
|
+
// Graph navigation — records most connected to a given one.
|
|
619
|
+
if (pathname === '/v1/history/related' && req.method === 'GET') {
|
|
620
|
+
const id = String(url.searchParams.get('id') || '');
|
|
621
|
+
const limit = Math.min(30, Math.max(1, Number(url.searchParams.get('limit')) || 5));
|
|
622
|
+
return sendJson(res, 200, { ok: true, results: historyStore.related(id, { limit }) });
|
|
623
|
+
}
|
|
612
624
|
if (pathname === '/v1/history/status' && req.method === 'GET') {
|
|
613
625
|
return sendJson(res, 200, { ok: true, size: historyStore.size, newest: historyStore.newest, bytes: historyStore.bytes });
|
|
614
626
|
}
|
|
@@ -639,7 +651,9 @@ export function createGateway(cfg = loadConfig()) {
|
|
|
639
651
|
return sendJson(res, 200, { ok: true, ...historyStore.list({ limit, offset }) });
|
|
640
652
|
}
|
|
641
653
|
if (pathname === '/v1/history/get' && req.method === 'GET') {
|
|
642
|
-
const
|
|
654
|
+
const maxChars = url.searchParams.get('maxChars') != null ? Math.max(1, Number(url.searchParams.get('maxChars')) || 0) : null;
|
|
655
|
+
const offset = Math.max(0, Number(url.searchParams.get('offset')) || 0);
|
|
656
|
+
const record = historyStore.get(String(url.searchParams.get('id') || ''), { maxChars, offset });
|
|
643
657
|
if (!record) return sendJson(res, 404, { error: { message: 'no such record', type: 'not_found' } });
|
|
644
658
|
return sendJson(res, 200, { ok: true, record });
|
|
645
659
|
}
|
package/src/sqlite-store.js
CHANGED
|
@@ -130,13 +130,44 @@ export class SqliteHistoryStore {
|
|
|
130
130
|
return this.size;
|
|
131
131
|
}
|
|
132
132
|
|
|
133
|
-
// [{ id, score, title, type, date }] — score higher = better (bm25 is negated).
|
|
134
|
-
|
|
133
|
+
// [{ id, score, title, type, date, snippet }] — score higher = better (bm25 is negated).
|
|
134
|
+
// Filters: type (chat|meeting|note), since/before (epoch ms bounds), offset (paging).
|
|
135
|
+
// `snippet` returns the matching excerpt from the transcript/body so a caller can rank and
|
|
136
|
+
// decide what to fetch WITHOUT pulling full bodies — the token-management path.
|
|
137
|
+
search(query, { limit = 10, offset = 0, type = null, since = null, before = null } = {}) {
|
|
135
138
|
const match = ftsMatch(query);
|
|
136
139
|
if (!match) return [];
|
|
140
|
+
const where = ['fts MATCH ?'];
|
|
141
|
+
const params = [match];
|
|
142
|
+
if (type) { where.push('r.type = ?'); params.push(String(type)); }
|
|
143
|
+
if (since != null) { where.push('r.date >= ?'); params.push(Number(since)); }
|
|
144
|
+
if (before != null) { where.push('r.date <= ?'); params.push(Number(before)); }
|
|
145
|
+
params.push(limit, offset);
|
|
137
146
|
const rows = this.db.all(
|
|
138
|
-
|
|
139
|
-
|
|
147
|
+
`SELECT r.id id, r.title title, r.type type, r.date date, bm25(fts) b,
|
|
148
|
+
snippet(fts, 2, '«', '»', ' … ', 12) snip
|
|
149
|
+
FROM fts JOIN records r ON r.id = fts.id
|
|
150
|
+
WHERE ${where.join(' AND ')} ORDER BY b LIMIT ? OFFSET ?`,
|
|
151
|
+
params,
|
|
152
|
+
);
|
|
153
|
+
return rows.map((r) => ({ id: r.id, score: -r.b, title: r.title, type: r.type, date: r.date, snippet: r.snip || '' }));
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// Graph navigation — records most connected to a given one, by shared content. "More like
|
|
157
|
+
// this": build a MATCH from the record's title + a sample of its body, rank by bm25, drop
|
|
158
|
+
// self. Cheap and index-only; no embeddings needed for a first-class "related" primitive.
|
|
159
|
+
related(id, { limit = 5 } = {}) {
|
|
160
|
+
const rec = this.get(id);
|
|
161
|
+
if (!rec) return [];
|
|
162
|
+
const terms = ftsMatch(`${rec.title || ''} ${String(rec.text || '').slice(0, 2000)}`);
|
|
163
|
+
if (!terms) return [];
|
|
164
|
+
// Cap the OR-set so a long transcript doesn't build a giant MATCH.
|
|
165
|
+
const capped = terms.split(' OR ').slice(0, 40).join(' OR ');
|
|
166
|
+
const rows = this.db.all(
|
|
167
|
+
`SELECT r.id id, r.title title, r.type type, r.date date, bm25(fts) b
|
|
168
|
+
FROM fts JOIN records r ON r.id = fts.id
|
|
169
|
+
WHERE fts MATCH ? AND r.id != ? ORDER BY b LIMIT ?`,
|
|
170
|
+
[capped, id, limit],
|
|
140
171
|
);
|
|
141
172
|
return rows.map((r) => ({ id: r.id, score: -r.b, title: r.title, type: r.type, date: r.date }));
|
|
142
173
|
}
|
|
@@ -147,11 +178,17 @@ export class SqliteHistoryStore {
|
|
|
147
178
|
return { total, items };
|
|
148
179
|
}
|
|
149
180
|
|
|
150
|
-
|
|
181
|
+
// Paged fetch for token management: maxChars caps the returned slice, offset pages a long
|
|
182
|
+
// transcript. Reports totalChars + truncated so a caller knows there is more to fetch.
|
|
183
|
+
get(id, { maxChars = null, offset = 0 } = {}) {
|
|
151
184
|
const meta = this.db.get('SELECT id, title, type, date FROM records WHERE id = ?', [id]);
|
|
152
185
|
if (!meta) return null;
|
|
153
186
|
const body = this.db.get('SELECT text FROM fts WHERE id = ?', [id]);
|
|
154
|
-
|
|
187
|
+
const full = body?.text || '';
|
|
188
|
+
let text = offset ? full.slice(offset) : full;
|
|
189
|
+
let truncated = false;
|
|
190
|
+
if (maxChars && text.length > maxChars) { text = text.slice(0, maxChars); truncated = true; }
|
|
191
|
+
return { ...meta, text, totalChars: full.length, offset: Number(offset) || 0, truncated };
|
|
155
192
|
}
|
|
156
193
|
|
|
157
194
|
// Wipe every record — the user purging the on-disk warm copy. Returns how many were dropped.
|