@chatpanel/events 0.12.1 → 0.13.0

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.
Files changed (3) hide show
  1. package/index.js +1 -0
  2. package/package.json +4 -2
  3. package/rrf.js +89 -0
package/index.js CHANGED
@@ -29,6 +29,7 @@ export { defineSearchEngine, reconcileEngines, attemptOrder, ENGINE_KINDS, Searc
29
29
  export { defineToolGroup, createToolGroupRegistry, ToolGroupError } from './tool-groups.js';
30
30
  export { toolNeedFor } from './tool-need.js';
31
31
  export { parseFlowchart, layoutFlowchart, renderFlowchartSvg } from './flowchart.js';
32
+ export { fuseRRF, planQueries, multiSearch } from './rrf.js';
32
33
  export {
33
34
  ACCESS_LOG_VERSION, ACCESS_LOG_MAX, redactAccessArgs, makeAccessEvent,
34
35
  createAccessLog, makeStorageTier, formatBytes,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@chatpanel/events",
3
- "version": "0.12.1",
3
+ "version": "0.13.0",
4
4
  "description": "The canonical ChatPanel event-log and capability contracts \u2014 typed durable facts, clock-free deterministic linearization, schema upcasting, and the invariants the replay harness asserts. Pure, dependency-free ESM shared by the ChatPanel extension, gateway and bridge.",
5
5
  "type": "module",
6
6
  "main": "index.js",
@@ -39,7 +39,8 @@
39
39
  "./trajectory.js": "./trajectory.js",
40
40
  "./upcast.js": "./upcast.js",
41
41
  "./observability.js": "./observability.js",
42
- "./flowchart.js": "./flowchart.js"
42
+ "./flowchart.js": "./flowchart.js",
43
+ "./rrf.js": "./rrf.js"
43
44
  },
44
45
  "files": [
45
46
  "LICENSE",
@@ -64,6 +65,7 @@
64
65
  "registry.js",
65
66
  "route-graph.js",
66
67
  "router.js",
68
+ "rrf.js",
67
69
  "rules.js",
68
70
  "scopes.js",
69
71
  "search-engines.js",
package/rrf.js ADDED
@@ -0,0 +1,89 @@
1
+ // rrf.js — Reciprocal Rank Fusion, and the query planning that feeds it.
2
+ //
3
+ // One question rarely makes one good keyword query. "What was the outcome of the Ben tooling
4
+ // demo?" and "Ben demo decisions action items" retrieve different things, and the answer is
5
+ // usually in the union. RRF merges several ranked lists without needing their scores to be
6
+ // comparable — each list contributes 1/(k+rank) — which is exactly the situation here: BM25
7
+ // scores, vector distances and a hot/warm split are all on different scales.
8
+ //
9
+ // Pure and dependency-free, so the identical fusion runs in the extension (hot+warm), the
10
+ // gateway (multi-query search) and any future client. The extension had this privately; it
11
+ // lives here now so the gateway doesn't grow a second, subtly different copy.
12
+
13
+ /**
14
+ * Fuse ranked id lists. `lists` is an array of arrays of ids, each already in rank order.
15
+ * k dampens the head of each list (60 is the standard default). limit 0 = everything.
16
+ */
17
+ export function fuseRRF(lists, { k = 60, limit = 0 } = {}) {
18
+ const score = new Map();
19
+ for (const list of lists || []) {
20
+ if (!Array.isArray(list)) continue;
21
+ list.forEach((id, rank) => {
22
+ if (id == null) return;
23
+ score.set(id, (score.get(id) || 0) + 1 / (k + rank));
24
+ });
25
+ }
26
+ const out = [...score.entries()].map(([id, s]) => ({ id, score: s })).sort((a, b) => b.score - a.score);
27
+ return limit > 0 ? out.slice(0, limit) : out;
28
+ }
29
+
30
+ // Words that carry no retrieval signal but do dilute BM25 — dropped to build a keyword-only
31
+ // variant of a natural-language question.
32
+ 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 '
33
+ + '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 '
34
+ + 'please could-you was-there did-we').split(/\s+/));
35
+
36
+ /**
37
+ * Turn one natural-language question into a small set of complementary queries — the cheap,
38
+ * deterministic half of query expansion. No model call, so it costs nothing and cannot fail.
39
+ *
40
+ * A CALLING AGENT can do better (it understands the domain), which is why the tools accept an
41
+ * explicit `queries` list; these variants are the floor, not the ceiling.
42
+ */
43
+ export function planQueries(question, { extra = [], max = 4 } = {}) {
44
+ const q = String(question || '').trim();
45
+ const out = [];
46
+ const seen = new Set();
47
+ const add = (s) => {
48
+ const t = String(s || '').trim().replace(/\s+/g, ' ');
49
+ const key = t.toLowerCase();
50
+ if (t && !seen.has(key)) { seen.add(key); out.push(t); }
51
+ };
52
+
53
+ add(q); // the question as asked
54
+ for (const e of extra) add(e); // whatever the agent proposed — it knows more
55
+
56
+ const words = q.toLowerCase().match(/[a-z0-9][a-z0-9'’_+-]*/g) || [];
57
+ const keywords = words.filter((w) => !STOP.has(w) && w.length > 2);
58
+ if (keywords.length >= 2) add(keywords.join(' ')); // keyword-only: BM25's best shape
59
+ // The rarest-looking terms (longest words are a decent proxy for specificity) — helps when
60
+ // the full question is too broad to rank anything well.
61
+ if (keywords.length > 3) add([...keywords].sort((a, b) => b.length - a.length).slice(0, 3).join(' '));
62
+
63
+ return out.slice(0, Math.max(1, max));
64
+ }
65
+
66
+ /**
67
+ * Run several queries through one `search(query, opts)` function and fuse the results by id.
68
+ * `search` returns arrays of { id, ... }; the fused output keeps the richest record seen for
69
+ * each id (so snippets survive) and reports which queries found it — the "why is this here"
70
+ * a reader needs when a multi-query search surfaces something unexpected.
71
+ */
72
+ export async function multiSearch(queries, search, { limit = 10, k = 60 } = {}) {
73
+ const lists = [];
74
+ const byId = new Map();
75
+ const foundBy = new Map();
76
+ for (const q of queries || []) {
77
+ let rows = [];
78
+ try { rows = (await search(q)) || []; } catch { rows = []; } // one bad query must not sink the rest
79
+ lists.push(rows.map((r) => r.id));
80
+ for (const r of rows) {
81
+ if (!byId.has(r.id) || (!byId.get(r.id).snippet && r.snippet)) byId.set(r.id, r);
82
+ if (!foundBy.has(r.id)) foundBy.set(r.id, []);
83
+ foundBy.get(r.id).push(q);
84
+ }
85
+ }
86
+ return fuseRRF(lists, { k, limit }).map(({ id, score }) => ({
87
+ ...byId.get(id), id, score, foundBy: foundBy.get(id) || [],
88
+ }));
89
+ }