@chatpanel/gateway 0.6.41 → 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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@chatpanel/gateway",
3
- "version": "0.6.41",
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": {
@@ -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.
@@ -187,8 +187,32 @@ export class HistoryStore {
187
187
  return n;
188
188
  }
189
189
 
190
- search(query, opts) {
191
- return this.index.search(query, opts);
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
- get(id) {
203
- return this.records.get(id) || null;
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
@@ -26,18 +26,22 @@ const SERVER = { name: 'chatpanel-history', version: '1.0.0' };
26
26
  // have to say "use ChatPanel": it tells the model that the user's personal meetings/notes/
27
27
  // chats live here and must be reached through these tools, not by grepping the working dir.
28
28
  const INSTRUCTIONS = [
29
- 'ChatPanel holds the USER\'S OWN meetings, notes and past chats (transcripts, decisions,',
30
- 'action items, summaries). It is the source of truth for anything personal/organizational —',
31
- 'NOT the code in the working directory.',
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.',
32
31
  '',
33
- 'When the user asks about a meeting, a call, a demo, notes, a past conversation, "what did',
34
- 'we decide", "the outcome of…", "action items", "what did X say", or anything scoped to a',
35
- 'day/person/topic in THEIR history call search_history FIRST (search by content, not the',
36
- 'meeting title), then get_record for the full text. Do NOT grep the working directory, git',
37
- 'history, README or MEMORY files for the user\'s personal meeting/chat content it is not',
38
- 'there; it is in ChatPanel. list_skills/open_skill expose the user\'s installed skills the',
39
- 'same way. Every result states how fresh the local copy is; if something recent is missing,',
40
- 'it may not have synced yet say so rather than concluding it does not exist.',
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.',
41
45
  ].join('\n');
42
46
 
43
47
  // The calling agent's self-reported name (from MCP `initialize` clientInfo), so the
@@ -101,22 +105,42 @@ async function bridgeJson(path) {
101
105
  const TOOLS = [
102
106
  {
103
107
  name: 'search_history',
104
- description: 'Search the user\'s ChatPanel history — their past chats, meeting/call transcripts, and notes — by keyword relevance. USE THIS FIRST, without being asked, whenever the user\'s question is about a meeting, call, demo, standup, note, or past conversation: "outcome of the meeting", "what did we decide/agree", "action items", "what did <person> say", "notes from yesterday/last week", a topic or person scoped to their history. That content lives ONLY in ChatPanel do NOT grep the working directory, git history, README, or MEMORY files for it; it is not there. 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, so if something is missing 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 title. Then call get_record with a returned id for the full text.',
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.',
105
109
  inputSchema: {
106
110
  type: 'object',
107
111
  properties: {
108
- query: { type: 'string', description: 'Natural-language / keyword query.' },
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`.' },
109
116
  limit: { type: 'number', description: 'Max results (default 10).' },
117
+ offset: { type: 'number', description: 'Skip N results for paging (default 0).' },
110
118
  },
111
119
  required: ['query'],
112
120
  },
113
121
  },
114
122
  {
115
123
  name: 'get_record',
116
- description: 'Fetch one full history record (its complete text) by id, e.g. chat:<id>, meeting:<id>, or note:<id> returned by search_history.',
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.',
117
125
  inputSchema: {
118
126
  type: 'object',
119
- properties: { id: { type: 'string', description: 'Record id such as chat:abc, meeting:imp_123, or note:xyz.' } },
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.',
138
+ inputSchema: {
139
+ type: 'object',
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
+ },
120
144
  required: ['id'],
121
145
  },
122
146
  },
@@ -183,22 +207,62 @@ async function gatewayJson(path, init) {
183
207
  }
184
208
 
185
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
+
186
232
  async function callTool(name, args = {}) {
187
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;
188
238
  const data = await gatewayJson('/v1/history/search', {
189
- method: 'POST',
190
- headers: { 'content-type': 'application/json' },
191
- body: JSON.stringify({ query: String(args.query || ''), limit: Number(args.limit) || 10 }),
239
+ method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(body),
192
240
  });
193
241
  const rows = data.results || [];
194
242
  const horizon = horizonLine(data.newest, data.size);
195
- 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).`;
196
- 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.';
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.';
197
248
  }
198
249
  if (name === 'get_record') {
199
- const data = await gatewayJson(`/v1/history/get?id=${encodeURIComponent(String(args.id || ''))}`);
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}`);
200
254
  const r = data.record;
201
- return `[${r.id}] ${r.title || '(untitled)'} · ${r.type}${r.date ? ' · ' + new Date(r.date).toISOString().slice(0, 10) : ''}\n\n${r.text || '(empty)'}`;
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.';
202
266
  }
203
267
  if (name === 'list_history') {
204
268
  const q = new URLSearchParams({ limit: String(Number(args.limit) || 50), offset: String(Number(args.offset) || 0) });
@@ -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
- search_history: ['limit'],
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'], // opaque record id, not content
36
- open_skill: ['skill'], // skill names are catalog identifiers, not PII
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.41';
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 || ''), { limit: Number(body.limit) || 10 });
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 record = historyStore.get(String(url.searchParams.get('id') || ''));
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
  }
@@ -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
- search(query, { limit = 10 } = {}) {
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
- '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 ?',
139
- [match, limit],
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
- get(id) {
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
- return { ...meta, text: body?.text || '' };
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.