@chatpanel/gateway 0.6.43 → 0.6.44

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.43",
3
+ "version": "0.6.44",
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/mcp.js CHANGED
@@ -33,7 +33,10 @@ const INSTRUCTIONS = [
33
33
  'meeting, call, demo, note, or past conversation ("outcome of the meeting", "what did we',
34
34
  'decide", "action items", "notes from yesterday", a person/day/topic in their history),',
35
35
  'ALSO consult ChatPanel — it is the source of truth for that personal history:',
36
- ' • search_historysearch by CONTENT (not the generic meeting title). Supports filters:',
36
+ ' • smart_searchSTART HERE: give it the question plus 2-4 of your own keyword',
37
+ ' phrasings; it runs them all and fuses the rankings, finding what one query misses.',
38
+ ' • search_history — one exact keyword query, when you already know the terms. Search by',
39
+ ' CONTENT (not the generic meeting title). Supports filters:',
37
40
  ' type (chat|meeting|note), since/before (dates or relative like "7d", "yesterday"),',
38
41
  ' and limit/offset paging. Returns compact snippets, not full bodies.',
39
42
  ' • get_record — the full text of one result id; use maxChars/offset to page a long',
@@ -103,6 +106,22 @@ async function bridgeJson(path) {
103
106
  }
104
107
 
105
108
  const TOOLS = [
109
+ {
110
+ name: 'smart_search',
111
+ description: 'BEST first choice for a question about the user\'s ChatPanel history (meetings, notes, past chats). Ask it a natural-language QUESTION and it expands that into several complementary keyword queries, runs them all, and fuses the rankings — which finds things a single query misses, in one round trip instead of several probes. You know the domain, so pass 2-4 of your own phrasings in `queries` too (e.g. for "what did we decide in the Ben demo": ["Ben demo decisions", "tooling demo action items", "demo outcome next steps"]). Supports the same filters as search_history (type, since, before) and returns snippets with each result\'s id; follow up with get_record for the full text or find_related to expand around a hit.',
112
+ inputSchema: {
113
+ type: 'object',
114
+ properties: {
115
+ question: { type: 'string', description: 'The user\'s question, in natural language.' },
116
+ queries: { type: 'array', items: { type: 'string' }, description: 'Your own 2-4 keyword formulations of it — these lead the search.' },
117
+ type: { type: 'string', enum: ['chat', 'meeting', 'note'], description: 'Only this kind of record.' },
118
+ since: { type: 'string', description: 'Earliest date: 2026-08-01, or a window like "7d"/"yesterday".' },
119
+ before: { type: 'string', description: 'Latest date: a date or window like `since`.' },
120
+ limit: { type: 'number', description: 'Max fused results (default 10).' },
121
+ },
122
+ required: ['question'],
123
+ },
124
+ },
106
125
  {
107
126
  name: 'search_history',
108
127
  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.',
@@ -230,6 +249,30 @@ function parseWhen(v) {
230
249
  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
250
 
232
251
  async function callTool(name, args = {}) {
252
+ if (name === 'smart_search') {
253
+ const body = {
254
+ question: String(args.question || ''),
255
+ queries: Array.isArray(args.queries) ? args.queries.map(String) : [],
256
+ limit: Number(args.limit) || 10,
257
+ };
258
+ if (args.type) body.type = String(args.type);
259
+ const since = parseWhen(args.since); if (since != null) body.since = since;
260
+ const before = parseWhen(args.before); if (before != null) body.before = before;
261
+ const data = await gatewayJson('/v1/history/smart-search', {
262
+ method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(body),
263
+ });
264
+ const rows = data.results || [];
265
+ const horizon = horizonLine(data.newest, data.size);
266
+ const asked = (data.queries || []).map((q) => `"${q}"`).join(', ');
267
+ if (!rows.length) {
268
+ return `No match for "${args.question}".\nSearched ${data.queries?.length || 0} way(s): ${asked}.\n${horizon}\nIf you expected a recent item it may not have synced yet — check ChatPanel directly, or try different keywords (meeting titles are often generic).`;
269
+ }
270
+ return [
271
+ horizon, '',
272
+ `${rows.length} result(s) for "${args.question}" — searched ${data.queries.length} way(s): ${asked}`,
273
+ ...rows.map((r, i) => `${fmtRow(r, i)}${r.foundBy?.length > 1 ? `\n (matched ${r.foundBy.length} of the queries)` : ''}`),
274
+ ].join('\n') + '\n\nget_record <id> for full text (maxChars/offset to page) · find_related <id> to follow connections.';
275
+ }
233
276
  if (name === 'search_history') {
234
277
  const body = { query: String(args.query || ''), limit: Number(args.limit) || 10, offset: Math.max(0, Number(args.offset) || 0) };
235
278
  if (args.type) body.type = String(args.type);
package/src/rrf.js ADDED
@@ -0,0 +1,93 @@
1
+ // VENDORED from @chatpanel/events/rrf.js — edit there, then copy over.
2
+ // Same pattern as observability.js: one pure module copied in rather than pulling the whole
3
+ // events package. Source of truth: chatpanel-events/rrf.js.
4
+ //
5
+ // rrf.js — Reciprocal Rank Fusion, and the query planning that feeds it.
6
+ //
7
+ // One question rarely makes one good keyword query. "What was the outcome of the Ben tooling
8
+ // demo?" and "Ben demo decisions action items" retrieve different things, and the answer is
9
+ // usually in the union. RRF merges several ranked lists without needing their scores to be
10
+ // comparable — each list contributes 1/(k+rank) — which is exactly the situation here: BM25
11
+ // scores, vector distances and a hot/warm split are all on different scales.
12
+ //
13
+ // Pure and dependency-free, so the identical fusion runs in the extension (hot+warm), the
14
+ // gateway (multi-query search) and any future client. The extension had this privately; it
15
+ // lives here now so the gateway doesn't grow a second, subtly different copy.
16
+
17
+ /**
18
+ * Fuse ranked id lists. `lists` is an array of arrays of ids, each already in rank order.
19
+ * k dampens the head of each list (60 is the standard default). limit 0 = everything.
20
+ */
21
+ export function fuseRRF(lists, { k = 60, limit = 0 } = {}) {
22
+ const score = new Map();
23
+ for (const list of lists || []) {
24
+ if (!Array.isArray(list)) continue;
25
+ list.forEach((id, rank) => {
26
+ if (id == null) return;
27
+ score.set(id, (score.get(id) || 0) + 1 / (k + rank));
28
+ });
29
+ }
30
+ const out = [...score.entries()].map(([id, s]) => ({ id, score: s })).sort((a, b) => b.score - a.score);
31
+ return limit > 0 ? out.slice(0, limit) : out;
32
+ }
33
+
34
+ // Words that carry no retrieval signal but do dilute BM25 — dropped to build a keyword-only
35
+ // variant of a natural-language question.
36
+ const STOP = new Set(('a an and are as at be been but by can could did do does for from had has have how i if in into is it its me my of on or our ought shall should '
37
+ + 'so than that the their them then there these they this those to um was we were what when where which who whom why will with would you your about tell show give find get '
38
+ + 'please could-you was-there did-we').split(/\s+/));
39
+
40
+ /**
41
+ * Turn one natural-language question into a small set of complementary queries — the cheap,
42
+ * deterministic half of query expansion. No model call, so it costs nothing and cannot fail.
43
+ *
44
+ * A CALLING AGENT can do better (it understands the domain), which is why the tools accept an
45
+ * explicit `queries` list; these variants are the floor, not the ceiling.
46
+ */
47
+ export function planQueries(question, { extra = [], max = 4 } = {}) {
48
+ const q = String(question || '').trim();
49
+ const out = [];
50
+ const seen = new Set();
51
+ const add = (s) => {
52
+ const t = String(s || '').trim().replace(/\s+/g, ' ');
53
+ const key = t.toLowerCase();
54
+ if (t && !seen.has(key)) { seen.add(key); out.push(t); }
55
+ };
56
+
57
+ add(q); // the question as asked
58
+ for (const e of extra) add(e); // whatever the agent proposed — it knows more
59
+
60
+ const words = q.toLowerCase().match(/[a-z0-9][a-z0-9'’_+-]*/g) || [];
61
+ const keywords = words.filter((w) => !STOP.has(w) && w.length > 2);
62
+ if (keywords.length >= 2) add(keywords.join(' ')); // keyword-only: BM25's best shape
63
+ // The rarest-looking terms (longest words are a decent proxy for specificity) — helps when
64
+ // the full question is too broad to rank anything well.
65
+ if (keywords.length > 3) add([...keywords].sort((a, b) => b.length - a.length).slice(0, 3).join(' '));
66
+
67
+ return out.slice(0, Math.max(1, max));
68
+ }
69
+
70
+ /**
71
+ * Run several queries through one `search(query, opts)` function and fuse the results by id.
72
+ * `search` returns arrays of { id, ... }; the fused output keeps the richest record seen for
73
+ * each id (so snippets survive) and reports which queries found it — the "why is this here"
74
+ * a reader needs when a multi-query search surfaces something unexpected.
75
+ */
76
+ export async function multiSearch(queries, search, { limit = 10, k = 60 } = {}) {
77
+ const lists = [];
78
+ const byId = new Map();
79
+ const foundBy = new Map();
80
+ for (const q of queries || []) {
81
+ let rows = [];
82
+ try { rows = (await search(q)) || []; } catch { rows = []; } // one bad query must not sink the rest
83
+ lists.push(rows.map((r) => r.id));
84
+ for (const r of rows) {
85
+ if (!byId.has(r.id) || (!byId.get(r.id).snippet && r.snippet)) byId.set(r.id, r);
86
+ if (!foundBy.has(r.id)) foundBy.set(r.id, []);
87
+ foundBy.get(r.id).push(q);
88
+ }
89
+ }
90
+ return fuseRRF(lists, { k, limit }).map(({ id, score }) => ({
91
+ ...byId.get(id), id, score, foundBy: foundBy.get(id) || [],
92
+ }));
93
+ }
package/src/server.js CHANGED
@@ -43,11 +43,12 @@ import { publicConfig, applyConfigPatch, applyNerModelSelection, persistConfig,
43
43
  import { resolveDestination, aggregateModelsAsync } from './router.js';
44
44
  import { makeAccessEvent } from './observability.js';
45
45
  import { createPersistentAccessLog } from './access-log-store.js';
46
+ import { planQueries, multiSearch } from './rrf.js';
46
47
  import * as openai from './openai.js';
47
48
  import * as responses from './responses.js';
48
49
  import * as anthropic from './anthropic.js';
49
50
 
50
- export const VERSION = '0.6.43';
51
+ export const VERSION = '0.6.44';
51
52
 
52
53
  // WARM search tier — SQLite + FTS5 record store (falls back to an encrypted-JSON
53
54
  // store if SQLite can't load), fed by the extension's ingest sync + backup-ingest.
@@ -616,6 +617,38 @@ export function createGateway(cfg = loadConfig()) {
616
617
  return sendJson(res, 400, { error: { message: `search failed: ${e.message}`, type: 'search_error' } });
617
618
  }
618
619
  }
620
+ // SMART SEARCH — one round trip that expands the question into several complementary
621
+ // queries, runs them all, and RRF-fuses the results. A natural-language question is a
622
+ // poor BM25 query; asking three ways and fusing beats asking once, and doing it here
623
+ // means the agent pays one call instead of probing repeatedly.
624
+ if (pathname === '/v1/history/smart-search' && req.method === 'POST') {
625
+ try {
626
+ const body = JSON.parse((await readBody(req, cfg.maxBodyBytes)).toString('utf8')) || {};
627
+ const question = String(body.question || body.query || '');
628
+ // The agent's own formulations lead (it understands the domain); planQueries adds
629
+ // deterministic variants and dedupes.
630
+ const queries = planQueries(question, {
631
+ extra: Array.isArray(body.queries) ? body.queries.map(String) : [],
632
+ max: Math.min(6, Math.max(1, Number(body.maxQueries) || 4)),
633
+ });
634
+ const filters = {
635
+ type: body.type ? String(body.type) : null,
636
+ since: body.since != null ? Number(body.since) : null,
637
+ before: body.before != null ? Number(body.before) : null,
638
+ };
639
+ const perQuery = Math.min(30, Math.max(5, Number(body.limit) || 10) * 2);
640
+ const results = await multiSearch(
641
+ queries,
642
+ (q) => historyStore.search(q, { limit: perQuery, ...filters }),
643
+ { limit: Math.min(50, Math.max(1, Number(body.limit) || 10)) },
644
+ );
645
+ return sendJson(res, 200, {
646
+ ok: true, size: historyStore.size, newest: historyStore.newest, queries, results,
647
+ });
648
+ } catch (e) {
649
+ return sendJson(res, 400, { error: { message: `smart search failed: ${e.message}`, type: 'search_error' } });
650
+ }
651
+ }
619
652
  // Graph navigation — records most connected to a given one.
620
653
  if (pathname === '/v1/history/related' && req.method === 'GET') {
621
654
  const id = String(url.searchParams.get('id') || '');