@chatpanel/events 0.66.0 → 0.68.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.
package/find-tool.js ADDED
@@ -0,0 +1,59 @@
1
+ // `find` — the user's own data and the web, behind ONE registered tool.
2
+ //
3
+ // The history and web-search schemas cost ~1,760 tokens on EVERY turn (1,081 of schema plus
4
+ // a 678-token system block explaining them), paid whether or not the turn touched the user's
5
+ // data. It was noticed on "hi": the model, handed 678 tokens of instructions about history
6
+ // tools, opened the conversation by reciting them. It was doing what we asked.
7
+ //
8
+ // Relevance-narrowing could not help: local tools are exempt from culling on purpose, and
9
+ // culling them by relevance would trade a constant cost for a guessing game in which a turn
10
+ // that needed history silently lost it. A dispatcher has no such trade: everything stays
11
+ // reachable, nothing is guessed, and the saving is identical on every turn.
12
+ //
13
+ // The NAME and the WORDING are the contract every client shares. The extension's `find`
14
+ // and the desktop's `find` must be the same tool — a model that learned to call one on the
15
+ // panel should find the identical tool in the app, and a recipe recorded on one must run on
16
+ // the other. What goes BEHIND it (which search engine, which history store) is the host's.
17
+
18
+ import { makeDispatchProvider } from './tool-dispatch.js';
19
+
20
+ export const FIND_TOOL_NAME = 'find';
21
+
22
+ export const FIND_DESCRIPTION =
23
+ 'Search and read the user\'s own saved data (past chats, notes, meetings) and the web. '
24
+ + 'Pass an `action` and put that action\'s own arguments inside `args`, e.g. '
25
+ + '{"action":"history_search","args":{"query":"pricing"}}. Unsure of an action\'s '
26
+ + 'arguments? {"action":"describe","args":{"tool":"<action>"}} returns its full schema. '
27
+ + 'Use this when the answer plausibly depends on something the user already has; do not '
28
+ + 'call it for greetings or general knowledge.';
29
+
30
+ // One line resident, not 678. The rest travels with `describe`.
31
+ //
32
+ // SAY THAT IT HAS THE DATA, not just that a tool exists. Asked "check my meetings with
33
+ // <name>", a model answered "I do not have access to your personal calendar, emails, or
34
+ // meeting history" — while `find` was sitting in its toolset. The old line named the tool
35
+ // and left the capability to be inferred, and inference is what small models are worst at.
36
+ export const FIND_RESIDENT =
37
+ "You HAVE access to the user's own ChatPanel data — their past chats, notes, and "
38
+ + 'meeting transcripts and summaries — through the `find` tool, plus the web. When the '
39
+ + 'question is about past meetings, notes, people, decisions, or anything the user '
40
+ + 'discussed or wrote, call `find` FIRST and answer from what it returns. Never tell '
41
+ + 'the user you cannot access their meetings, notes or history: you can.';
42
+
43
+ /**
44
+ * Wrap the real search/read tools (history, web search, weather…) as the one `find` tool.
45
+ *
46
+ * `remote` is false: history is on-device and web search is proxied by the host under its
47
+ * own settings, so the harness hands these tools real values under "redact remote".
48
+ */
49
+ export function findDispatchProvider(inner, { all = null, rank = undefined } = {}) {
50
+ return makeDispatchProvider({
51
+ name: FIND_TOOL_NAME,
52
+ description: FIND_DESCRIPTION,
53
+ resident: FIND_RESIDENT,
54
+ inner,
55
+ remote: false,
56
+ all,
57
+ rank,
58
+ });
59
+ }
package/index.js CHANGED
@@ -177,6 +177,15 @@ export {
177
177
  resultToolSpec, RESULT_TOOL_NAME, DEFAULT_SHIELD, DEFAULT_STORE,
178
178
  } from './tool-result.js';
179
179
  export { findTools, findToolsResult, findActionArgs, oneLiner, overlapRank, FIND_ACTION } from './tool-discovery.js';
180
+ // One registered tool per group, the registry that merges providers, and the two tools every
181
+ // client with a loop offers — the SAME `find` and `web_search` on the panel and in the app.
182
+ export { buildToolset } from './toolset.js';
183
+ export {
184
+ DESCRIBE_ACTION, actionMenu, buildGroupDispatchSpec, validateAction, makeGroupDispatchExecutor,
185
+ withGuidance, makeDispatchProvider, estimateTokens,
186
+ } from './tool-dispatch.js';
187
+ export { FIND_TOOL_NAME, FIND_DESCRIPTION, FIND_RESIDENT, findDispatchProvider } from './find-tool.js';
188
+ export { WEB_SEARCH_TOOL_NAME, WEB_SEARCH_TOOL_SYSTEM, WEB_SEARCH_SPEC, searchResultsToText, webSearchToolProvider } from './web-search-tool.js';
180
189
  export { compressToolSpec, compressToolSpecs, compressionStats, trimDescription, COMPRESSION_MODES, DEFAULT_COMPRESSION } from './tool-schema.js';
181
190
  export { validateRecipe, expandRecipe, recipeParams, mapInput, dryRunRecipe, runPlan, runRecipe, RecipeError, RECIPE_MODES } from './recipe.js';
182
191
  export { createManifest, ManifestError, SOURCES } from './manifest.js';
@@ -264,3 +273,6 @@ export { renderMarkdown, defaultLinkPolicy } from './markdown-render.js';
264
273
  // extension writes and MCP reads, so every client shows one transcript, not three.
265
274
  export { parseMeetingText, speakerStats, densityRibbon } from './meeting-text.js';
266
275
  export { speakerBreakdown, speakerTimeline, formatTalkTime, SPEAKER_SLOTS } from './meeting-shape.js';
276
+ // A list of records as a person reads it, and what a meeting settled — both read, never derived.
277
+ export { SORT_MODES, SORT_LABELS, sortStamp, sortRecords, filterRecords, dayBucket, rowTime, groupRecords } from './record-list.js';
278
+ export { INSIGHT_KINDS, summarySections, insightKindOf, meetingInsights, hasInsights } from './meeting-insights.js';
@@ -0,0 +1,62 @@
1
+ // What a meeting settled, asked for and left open — read out of the summary it already has.
2
+ //
3
+ // The extension writes a meeting's summary as markdown with headings: "## Decisions",
4
+ // "## Action items", "## Open questions", whatever the model chose to call them. A client that
5
+ // wants an Insights view should not re-derive that with a second model call; the sections
6
+ // are there, and one parser that recognises the headings is the same on every client.
7
+ //
8
+ // Pure, forgiving of wording: a heading is matched by what it MEANS ("Decisions", "Agreed",
9
+ // "Outcomes" are one thing), bullets in any marker, numbered or not.
10
+
11
+ const KINDS = Object.freeze([
12
+ { id: 'decisions', label: 'Decisions', re: /\b(decision|decided|agreed|agreement|outcome|resolution)s?\b/i },
13
+ { id: 'actions', label: 'Action items', re: /\b(action|todo|to-do|next step|follow[- ]?up|task|owner)s?\b/i },
14
+ { id: 'questions', label: 'Open questions', re: /\b(question|open item|unresolved|blocker|risk)s?\b/i },
15
+ ]);
16
+
17
+ export const INSIGHT_KINDS = Object.freeze(KINDS.map((k) => ({ id: k.id, label: k.label })));
18
+
19
+ /** Split markdown into `[{ heading, level, items, text }]` on its headings. */
20
+ export function summarySections(markdown) {
21
+ const lines = String(markdown || '').replace(/\r\n?/g, '\n').split('\n');
22
+ const out = [];
23
+ let cur = { heading: '', level: 0, items: [], text: '' };
24
+ const push = () => { if (cur.heading || cur.items.length || cur.text.trim()) out.push({ ...cur, text: cur.text.trim() }); };
25
+ for (const raw of lines) {
26
+ const h = /^\s{0,3}(#{1,6})\s+(.+?)\s*#*\s*$/.exec(raw);
27
+ if (h) { push(); cur = { heading: h[2].trim(), level: h[1].length, items: [], text: '' }; continue; }
28
+ const b = /^\s*(?:[-*•]|\d+[.)])\s+(.+?)\s*$/.exec(raw);
29
+ if (b) { cur.items.push(b[1]); continue; }
30
+ if (raw.trim()) cur.text += (cur.text ? '\n' : '') + raw.trim();
31
+ }
32
+ push();
33
+ return out;
34
+ }
35
+
36
+ /** Which insight a heading names, or null for a section that is neither. */
37
+ export function insightKindOf(heading) {
38
+ const h = String(heading || '');
39
+ for (const k of KINDS) if (k.re.test(h)) return k.id;
40
+ return null;
41
+ }
42
+
43
+ /**
44
+ * `{ decisions, actions, questions, other }` — each a list of `{ text, section }`; `other`
45
+ * keeps the sections that were none of the three, so a view can still show them.
46
+ */
47
+ export function meetingInsights(markdown) {
48
+ const out = { decisions: [], actions: [], questions: [], other: [] };
49
+ for (const s of summarySections(markdown)) {
50
+ const kind = insightKindOf(s.heading);
51
+ const items = s.items.length ? s.items : (s.text ? s.text.split('\n') : []);
52
+ if (!kind) { if (s.heading || items.length) out.other.push(s); continue; }
53
+ for (const text of items) out[kind].push({ text, section: s.heading });
54
+ }
55
+ return out;
56
+ }
57
+
58
+ /** True when the summary has at least one insight worth a tab. */
59
+ export function hasInsights(markdown) {
60
+ const i = meetingInsights(markdown);
61
+ return i.decisions.length + i.actions.length + i.questions.length > 0;
62
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@chatpanel/events",
3
- "version": "0.66.0",
3
+ "version": "0.68.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",
@@ -94,7 +94,13 @@
94
94
  "./voice-speaker.js": "./voice-speaker.js",
95
95
  "./weather.js": "./weather.js",
96
96
  "./web-search.js": "./web-search.js",
97
- "./widget.js": "./widget.js"
97
+ "./widget.js": "./widget.js",
98
+ "./toolset.js": "./toolset.js",
99
+ "./tool-dispatch.js": "./tool-dispatch.js",
100
+ "./find-tool.js": "./find-tool.js",
101
+ "./web-search-tool.js": "./web-search-tool.js",
102
+ "./record-list.js": "./record-list.js",
103
+ "./meeting-insights.js": "./meeting-insights.js"
98
104
  },
99
105
  "files": [
100
106
  "LICENSE",
@@ -111,6 +117,7 @@
111
117
  "entity.js",
112
118
  "event.js",
113
119
  "extraction.js",
120
+ "find-tool.js",
114
121
  "flowchart.js",
115
122
  "harness.js",
116
123
  "index.js",
@@ -126,6 +133,7 @@
126
133
  "mcp-errors.js",
127
134
  "media-transcript.js",
128
135
  "meeting-analyzers.js",
136
+ "meeting-insights.js",
129
137
  "meeting-shape.js",
130
138
  "meeting-text.js",
131
139
  "memory.js",
@@ -144,6 +152,7 @@
144
152
  "queue.js",
145
153
  "reach.js",
146
154
  "recipe.js",
155
+ "record-list.js",
147
156
  "redaction-tokens.js",
148
157
  "ref.js",
149
158
  "registry.js",
@@ -171,18 +180,21 @@
171
180
  "theme.js",
172
181
  "titles.js",
173
182
  "tool-discovery.js",
183
+ "tool-dispatch.js",
174
184
  "tool-groups.js",
175
185
  "tool-need.js",
176
186
  "tool-result.js",
177
187
  "tool-round.js",
178
188
  "tool-schema.js",
179
189
  "tool-traits.js",
190
+ "toolset.js",
180
191
  "trajectory.js",
181
192
  "upcast.js",
182
193
  "vault.js",
183
194
  "view.js",
184
195
  "voice-intents.js",
185
196
  "weather.js",
197
+ "web-search-tool.js",
186
198
  "web-search.js",
187
199
  "widget.js"
188
200
  ],
package/record-list.js ADDED
@@ -0,0 +1,110 @@
1
+ // A list of records as a person reads it: which order, which ones, and under which heading.
2
+ //
3
+ // The desktop showed 300 rows newest-modified-first with a date and nothing else, and it read
4
+ // as unsorted — twelve rows saying "Sep 12" are indistinguishable, "modified" is not the
5
+ // date a person remembers a chat by, and nothing separated today from last month. The query
6
+ // was right; the reading was impossible. These are the rules that make the same rows
7
+ // legible, kept out of the client because every list of records — the extension's history,
8
+ // a phone's — has to answer the same three questions the same way.
9
+ //
10
+ // Pure: `now` is injected, and a record needs only `{ title, snippet?, updatedAt, createdAt }`.
11
+
12
+ export const SORT_MODES = Object.freeze(['recent', 'started', 'title']);
13
+
14
+ export const SORT_LABELS = Object.freeze({
15
+ recent: 'Recently active',
16
+ started: 'Date started',
17
+ title: 'Title A–Z',
18
+ });
19
+
20
+ const num = (v) => (Number.isFinite(Number(v)) ? Number(v) : 0);
21
+
22
+ /** The timestamp a sort mode reads — modified for `recent`, created for `started`. */
23
+ export function sortStamp(rec, mode = 'recent') {
24
+ if (mode === 'started') return num(rec?.createdAt) || num(rec?.updatedAt);
25
+ return num(rec?.updatedAt) || num(rec?.createdAt);
26
+ }
27
+
28
+ export function sortRecords(items, mode = 'recent') {
29
+ const list = Array.isArray(items) ? [...items] : [];
30
+ if (mode === 'title') {
31
+ return list.sort((a, b) => String(a?.title || '').localeCompare(String(b?.title || ''), undefined, { sensitivity: 'base' }) || sortStamp(b) - sortStamp(a));
32
+ }
33
+ const m = SORT_MODES.includes(mode) ? mode : 'recent';
34
+ return list.sort((a, b) => sortStamp(b, m) - sortStamp(a, m));
35
+ }
36
+
37
+ /**
38
+ * Keep the rows every word of the query appears in — title or snippet, any order, any case.
39
+ * A query of nothing keeps everything, so a filter box can be bound to it directly.
40
+ */
41
+ export function filterRecords(items, query) {
42
+ const words = String(query || '').toLowerCase().split(/\s+/).filter(Boolean);
43
+ const list = Array.isArray(items) ? items : [];
44
+ if (!words.length) return list;
45
+ return list.filter((r) => {
46
+ const hay = `${r?.title || ''}\n${r?.snippet || ''}`.toLowerCase();
47
+ return words.every((w) => hay.includes(w));
48
+ });
49
+ }
50
+
51
+ const DAY = 86_400_000;
52
+
53
+ function startOfDay(ts) {
54
+ const d = new Date(ts);
55
+ d.setHours(0, 0, 0, 0);
56
+ return d.getTime();
57
+ }
58
+
59
+ const MONTHS = ['January', 'February', 'March', 'April', 'May', 'June', 'July', 'August', 'September', 'October', 'November', 'December'];
60
+ const DAYS = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday'];
61
+
62
+ /** Which heading a timestamp files under, relative to `now`. */
63
+ export function dayBucket(ts, now = Date.now()) {
64
+ const t = num(ts);
65
+ if (!t) return { key: 'undated', label: 'Undated', order: 9e15 };
66
+ const today = startOfDay(now);
67
+ const day = startOfDay(t);
68
+ const daysAgo = Math.round((today - day) / DAY);
69
+ if (daysAgo <= 0) return { key: 'today', label: 'Today', order: 0 };
70
+ if (daysAgo === 1) return { key: 'yesterday', label: 'Yesterday', order: 1 };
71
+ if (daysAgo < 7) return { key: 'week', label: 'Earlier this week', order: 2 };
72
+ const d = new Date(t);
73
+ const n = new Date(now);
74
+ if (d.getFullYear() === n.getFullYear() && d.getMonth() === n.getMonth()) return { key: 'month', label: 'Earlier this month', order: 3 };
75
+ const label = d.getFullYear() === n.getFullYear() ? MONTHS[d.getMonth()] : `${MONTHS[d.getMonth()]} ${d.getFullYear()}`;
76
+ return { key: `m-${d.getFullYear()}-${d.getMonth()}`, label, order: 4 + (n.getFullYear() * 12 + n.getMonth()) - (d.getFullYear() * 12 + d.getMonth()) };
77
+ }
78
+
79
+ /**
80
+ * The time a row shows, sized to how far away it is: a clock today and yesterday, a weekday
81
+ * this week, a short date after that. Inside a day group the clock is the only thing that
82
+ * tells two rows apart — which is the whole reason the desktop's list looked unsorted.
83
+ */
84
+ export function rowTime(ts, now = Date.now(), { locale = undefined } = {}) {
85
+ const t = num(ts);
86
+ if (!t) return '';
87
+ const b = dayBucket(t, now);
88
+ const d = new Date(t);
89
+ if (b.key === 'today' || b.key === 'yesterday') return d.toLocaleTimeString(locale, { hour: 'numeric', minute: '2-digit' });
90
+ if (b.key === 'week') return DAYS[d.getDay()];
91
+ const sameYear = d.getFullYear() === new Date(now).getFullYear();
92
+ return d.toLocaleDateString(locale, { month: 'short', day: 'numeric', ...(sameYear ? {} : { year: 'numeric' }) });
93
+ }
94
+
95
+ /**
96
+ * Sort, filter and group in one pass: `[{ key, label, items }]`, groups in reading order.
97
+ * With `mode: 'title'` there is one group and no heading, because an alphabetical list has
98
+ * nothing to do with days.
99
+ */
100
+ export function groupRecords(items, { mode = 'recent', query = '', now = Date.now() } = {}) {
101
+ const sorted = sortRecords(filterRecords(items, query), mode);
102
+ if (mode === 'title') return sorted.length ? [{ key: 'all', label: '', items: sorted }] : [];
103
+ const groups = new Map();
104
+ for (const r of sorted) {
105
+ const b = dayBucket(sortStamp(r, mode), now);
106
+ if (!groups.has(b.key)) groups.set(b.key, { key: b.key, label: b.label, order: b.order, items: [] });
107
+ groups.get(b.key).items.push(r);
108
+ }
109
+ return [...groups.values()].sort((a, b) => a.order - b.order).map(({ order, ...g }) => g);
110
+ }
@@ -0,0 +1,231 @@
1
+ // PROGRESSIVE TOOL DISCLOSURE — a group of tools registered as ONE tool.
2
+ //
3
+ // Twenty page-action schemas cost ~3,300 tokens on EVERY turn; six data tools cost ~1,760.
4
+ // Paid whether or not the turn touched any of them, and on a small local model that can eat
5
+ // half the context before the user has typed anything. So a group is registered as one
6
+ // compact tool carrying an action enum and a one-line gist each; the full schema for any
7
+ // action is REACHABLE via `{action:'describe', tool:'<name>'}`, and arguments are validated
8
+ // at execution with a structured error the model can act on.
9
+ //
10
+ // WHY A DISPATCHER RATHER THAN AN INDEX. Over MCP a model may only call tools that are
11
+ // REGISTERED; returning a schema from an index tool would not make the described tool
12
+ // callable. A dispatcher is one registered tool that can reach all of them, so the same
13
+ // mechanism works for a relayed CLI agent and for an in-client loop.
14
+ //
15
+ // The page dispatcher proved the shape and earned three bugs doing it (the stripped `args`
16
+ // envelope, the blinded loop guard, the unreadable activity rows). Every later group — the
17
+ // user's own data, MCP servers, and now the desktop's turn — goes through this instead of
18
+ // re-earning them. What a group supplies is only what is genuinely its own: a name, a
19
+ // sentence about when to reach for it, and whether its tools are remote.
20
+
21
+ import { FIND_ACTION, findToolsResult, findActionArgs } from './tool-discovery.js';
22
+ import { traitsIndex } from './tool-traits.js';
23
+
24
+ export const DESCRIBE_ACTION = 'describe';
25
+
26
+ /** First sentence of a description — enough to choose an action, not to call it blind. */
27
+ function gistOf(spec) {
28
+ const text = String(spec.description || '').replace(/\s+/g, ' ').trim();
29
+ const stop = text.search(/(?<=[.!?])\s/);
30
+ const first = stop > 0 ? text.slice(0, stop) : text;
31
+ return first.length > 90 ? `${first.slice(0, 87).trimEnd()}...` : first;
32
+ }
33
+
34
+ function requiredOf(spec) {
35
+ const req = spec?.parameters?.required;
36
+ return Array.isArray(req) ? req : [];
37
+ }
38
+
39
+ /** The action menu — one line per action, enough to choose but not to call blind. */
40
+ export function actionMenu(specs) {
41
+ return specs.map((s) => {
42
+ const req = requiredOf(s);
43
+ return `- ${s.name}${req.length ? `(${req.join(', ')})` : '()'}: ${gistOf(s)}`;
44
+ }).join('\n');
45
+ }
46
+
47
+ /**
48
+ * Build a dispatcher spec for ANY group of tools.
49
+ *
50
+ * @param hidden how many more actions the group can reach than the menu lists (a relevance
51
+ * cap trimmed it). When > 0 the spec says so and names the way back: `find`
52
+ * searches every tool the group owns, listed or not. Without that line a tool
53
+ * the cap dropped was, for that turn, gone.
54
+ */
55
+ export function buildGroupDispatchSpec({ name, description, specs, hidden = 0 }) {
56
+ const more = hidden > 0
57
+ ? `\n${hidden} more action${hidden === 1 ? '' : 's'} not listed — {"action":"${FIND_ACTION}","args":{"query":"<task words>"}} finds them by name.`
58
+ : '';
59
+ return {
60
+ name,
61
+ description: `${description}\n\nActions:\n${actionMenu(specs)}${more}`,
62
+ parameters: {
63
+ type: 'object',
64
+ properties: {
65
+ action: {
66
+ type: 'string',
67
+ enum: [DESCRIBE_ACTION, ...(hidden > 0 ? [FIND_ACTION] : []), ...specs.map((s) => s.name)],
68
+ description: 'Which action to run.',
69
+ },
70
+ // A DECLARED envelope, not `additionalProperties`. Providers and MCP validators
71
+ // routinely strip properties that are not in `properties`, so undeclared top-level
72
+ // arguments silently vanish before they reach the executor — which is exactly how
73
+ // `structured_insert` lost its `elements` array. Anything declared survives.
74
+ args: {
75
+ type: 'object',
76
+ description: 'The chosen action\'s own arguments, verbatim. Use {} when it takes none.',
77
+ additionalProperties: true,
78
+ },
79
+ tool: { type: 'string', description: `With action="${DESCRIBE_ACTION}": the action to describe.` },
80
+ ...(hidden > 0 ? findActionArgs() : {}),
81
+ },
82
+ required: ['action'],
83
+ additionalProperties: true, // tolerated, but never relied upon — see `args`
84
+ },
85
+ };
86
+ }
87
+
88
+ /**
89
+ * Validate arguments against the REAL spec. Returns null when fine, else a structured error
90
+ * naming exactly what is missing — a bounded repair path instead of a dead turn.
91
+ */
92
+ export function validateAction(spec, args) {
93
+ const missing = requiredOf(spec).filter((k) => args[k] === undefined || args[k] === null);
94
+ if (!missing.length) return null;
95
+ return {
96
+ error: `Missing required argument(s) for "${spec.name}": ${missing.join(', ')}.`,
97
+ required: requiredOf(spec),
98
+ hint: `Put them inside \`args\`: {"action":"${spec.name}","args":{...}}. `
99
+ + `Call {"action":"${DESCRIBE_ACTION}","args":{"tool":"${spec.name}"}} for the full schema.`,
100
+ };
101
+ }
102
+
103
+ /**
104
+ * Route one dispatch call to the real per-action executor.
105
+ *
106
+ * `runAction(name, args, meta)` is the EXISTING guarded executor, so every confirmation gate,
107
+ * budget and site grant keeps firing on the real action name — the dispatcher must never
108
+ * become a way around them.
109
+ *
110
+ * @param specs the MENU — what the dispatcher lists
111
+ * @param all everything the group can reach; defaults to the menu. When larger, `find`
112
+ * searches it and any action in it runs, listed or not.
113
+ * @param rank `(specs, query) => specs` for `find`; the shared IDF ranker when given
114
+ */
115
+ export function makeGroupDispatchExecutor({ name: dispatchName, specs, all = specs, runAction, rank }) {
116
+ const byName = new Map(all.map((s) => [s.name, s]));
117
+ for (const s of specs) byName.set(s.name, s); // the menu's copy wins a duplicate name
118
+ const menuNames = specs.map((s) => s.name);
119
+ return async (name, input, meta) => {
120
+ if (name !== dispatchName) return runAction(name, input, meta); // direct calls still work
121
+ // Accept BOTH shapes. `args` is the declared envelope and the one the description
122
+ // teaches; top-level arguments are merged too, so a model that ignores the envelope — or
123
+ // a provider that happens to pass extras through — still works rather than failing in a
124
+ // way that looks like the tool is broken.
125
+ const raw = input || {};
126
+ const { action: rawAction, args: envelope, tool: rawTool, ...rest } = raw;
127
+ const args = { ...rest, ...(envelope && typeof envelope === 'object' ? envelope : {}) };
128
+ const action = String(rawAction || '');
129
+
130
+ if (action === FIND_ACTION) {
131
+ return findToolsResult(all, String(args.query ?? rawTool ?? ''), { rank, describeAction: DESCRIBE_ACTION, menu: menuNames });
132
+ }
133
+
134
+ if (action === DESCRIBE_ACTION) {
135
+ const spec = byName.get(String(args.tool || rawTool || ''));
136
+ return JSON.stringify(
137
+ spec
138
+ ? {
139
+ name: spec.name,
140
+ // The full contract when the menu carried a compressed one (tool-schema.js).
141
+ description: spec.full?.description || spec.description,
142
+ parameters: spec.full?.parameters || spec.parameters,
143
+ ...(spec.annotations ? { annotations: spec.annotations } : {}),
144
+ callAs: { action: spec.name, args: '<the properties above, verbatim>' },
145
+ }
146
+ : { error: `Unknown action "${args.tool || rawTool}".`, actions: [...byName.keys()] },
147
+ );
148
+ }
149
+
150
+ const spec = byName.get(action);
151
+ if (!spec) {
152
+ return JSON.stringify({
153
+ error: `Unknown action "${action}".`,
154
+ actions: menuNames,
155
+ ...(all.length > specs.length ? { hint: `${all.length - specs.length} more are reachable: {"action":"${FIND_ACTION}","args":{"query":"…"}} finds them.` } : {}),
156
+ });
157
+ }
158
+ const bad = validateAction(spec, args);
159
+ if (bad) return JSON.stringify(bad);
160
+ return runAction(action, args, meta);
161
+ };
162
+ }
163
+
164
+ /**
165
+ * Attach a group's detailed guidance to `describe` instead of the prompt. The model reads it
166
+ * at the moment it is about to act on it — which is when it is most likely to follow it —
167
+ * and a turn that never reaches for the group never pays for it.
168
+ */
169
+ export function withGuidance(execute, guidance) {
170
+ if (!guidance) return execute;
171
+ return async (name, input, meta) => {
172
+ const out = await execute(name, input, meta);
173
+ if (String(input?.action || '') !== DESCRIBE_ACTION) return out;
174
+ try {
175
+ const parsed = JSON.parse(out);
176
+ if (!parsed || !parsed.name) return out;
177
+ return JSON.stringify({ ...parsed, guidance });
178
+ } catch {
179
+ return out;
180
+ }
181
+ };
182
+ }
183
+
184
+ /**
185
+ * Turn a toolset into ONE provider — the reusable half of progressive disclosure.
186
+ *
187
+ * @param inner a toolset ({ specs, execute, system }) — the real tools, kept whole.
188
+ * @param resident the ONE line that stays in the prompt. Everything else the group wants to
189
+ * say travels with `describe`.
190
+ * @param remote true when these tools call a third party. This is load-bearing for
191
+ * PRIVACY, not bookkeeping: the harness uses it to keep PII off remote tools
192
+ * under "redact remote". A dispatcher that lost the flag would quietly turn
193
+ * redacted tools into unredacted ones.
194
+ * @param all every spec the group can reach when the menu (`inner.specs`) is a
195
+ * relevance-capped subset. `find` searches it; any action in it runs.
196
+ * @param rank the ranker `find` uses — the shared IDF one, so discovery agrees with the
197
+ * narrowing that hid the tool in the first place.
198
+ */
199
+ export function makeDispatchProvider({ name, description, resident, inner, remote = false, all = null, rank = undefined }) {
200
+ if (!inner || !inner.specs?.length) return null;
201
+ const specs = inner.specs;
202
+ const reach = all && all.length > specs.length ? all : specs;
203
+ return {
204
+ specs: [buildGroupDispatchSpec({ name, specs, description, hidden: reach.length - specs.length })],
205
+ system: resident,
206
+ remote,
207
+ // What each REAL tool does to the world (annotations, else its name) — read by the round
208
+ // runner through the dispatcher, which otherwise hides every inner spec.
209
+ traits: traitsIndex(reach),
210
+ // …and WHICH tools are behind this name, so a recipe step can name the real tool and be
211
+ // routed through the dispatcher (buildToolset builds `hiddenVia` from it).
212
+ reach,
213
+ execute: withGuidance(
214
+ makeGroupDispatchExecutor({
215
+ name,
216
+ specs,
217
+ all: reach,
218
+ rank,
219
+ // Routes on the REAL tool name so every guard, budget and gate downstream keeps
220
+ // firing on the name it was written against.
221
+ runAction: (toolName, args, meta) => inner.execute(toolName, args, meta),
222
+ }),
223
+ inner.system,
224
+ ),
225
+ };
226
+ }
227
+
228
+ /** Rough token estimate — used by budget tests, not at runtime. */
229
+ export function estimateTokens(value) {
230
+ return Math.round(JSON.stringify(value).length / 4);
231
+ }
package/toolset.js ADDED
@@ -0,0 +1,89 @@
1
+ // A generic tool registry — ANY number of tool providers merged into the one shape a model
2
+ // loop consumes: `{ specs, execute, system }`.
3
+ //
4
+ // A provider is `{ specs: ToolSpec[], execute(name, input, meta) => string | {text, note,
5
+ // image}, system?: string, remote?: boolean, serial?: boolean, traits?: Map, reach?: [] }`.
6
+ // ToolSpec is `{ name, description, parameters (JSON schema), annotations? }`.
7
+ //
8
+ // This lived in the extension for as long as the extension was the only client with a tool
9
+ // loop. The desktop grew one, and the second copy of "first provider to claim a name wins"
10
+ // would have been the second place that rule could quietly differ. Everything here is
11
+ // input → output; the one platform-flavoured thing — the shared MCP guidance a client
12
+ // prepends when any `mcp_*` tool is present — is INJECTED, so this file carries no prompt
13
+ // text of its own and stays off the extension's first-paint budget by exactly the bytes
14
+ // the old copy cost.
15
+
16
+ const REMOTE_NAME_RE = /^mcp[_-]/i;
17
+
18
+ /**
19
+ * @param providers the tool providers, in the order the model should read them
20
+ * @param mcpSystem the shared MCP rules — a string, or a function returning one, consulted
21
+ * only when an `mcp_*` tool is present so a turn without MCP pays nothing
22
+ * @returns the merged toolset, or `undefined` when no provider brought a tool
23
+ */
24
+ export function buildToolset(providers, { mcpSystem = '' } = {}) {
25
+ const list = (providers || []).filter((p) => p && p.specs?.length);
26
+ if (!list.length) return undefined;
27
+
28
+ const specs = [];
29
+ const route = new Map(); // tool name -> the provider.execute that owns it
30
+ // Tools that call a REMOTE server — from a provider flagged remote, or (fallback) whose
31
+ // name matches the mcp_ convention. The PII harness uses this exact set to keep private
32
+ // data off remote tools under "redact remote".
33
+ const remoteTools = new Set();
34
+ // What each HIDDEN tool does — a dispatcher's own index of the tools behind it
35
+ // (tool-traits.js). Top-level specs carry `annotations` and are classified at run time.
36
+ const traits = new Map();
37
+ // Tools that must run one at a time even when read-only: page tools share ONE tab.
38
+ const serialTools = new Set();
39
+ // The tools a dispatcher hides, and which dispatcher: a recipe step names the real tool.
40
+ const reach = [];
41
+ const hiddenVia = new Map();
42
+ for (const p of list) {
43
+ const providerRemote = p.remote === true;
44
+ if (p.traits instanceof Map) for (const [k, v] of p.traits) if (!traits.has(k)) traits.set(k, v);
45
+ if (Array.isArray(p.reach) && p.specs.length === 1) {
46
+ for (const h of p.reach) if (h?.name && !hiddenVia.has(h.name)) { hiddenVia.set(h.name, p.specs[0].name); reach.push(h); }
47
+ }
48
+ for (const s of p.specs) {
49
+ if (route.has(s.name)) continue; // first provider to claim a name wins
50
+ specs.push(s);
51
+ route.set(s.name, p.execute);
52
+ if (providerRemote || REMOTE_NAME_RE.test(String(s.name || ''))) remoteTools.add(s.name);
53
+ if (p.serial === true) serialTools.add(s.name);
54
+ }
55
+ }
56
+ if (!specs.length) return undefined;
57
+
58
+ // Generic MCP rules ONCE (not repeated per server), then each provider's own inventory.
59
+ const hasMcp = specs.some((s) => REMOTE_NAME_RE.test(String(s?.name || '')));
60
+ const shared = hasMcp ? String((typeof mcpSystem === 'function' ? mcpSystem() : mcpSystem) || '') : '';
61
+ const parts = [shared, ...list.map((p) => p.system)];
62
+ const system = parts.map((x) => String(x || '').trim()).filter(Boolean).join('\n\n') || undefined;
63
+ // WHICH blurb costs what — one total for the whole preamble is visible but unattributable,
64
+ // and a number nobody can attribute is a number nobody can reduce.
65
+ const systemParts = {};
66
+ if (shared.trim()) systemParts.mcp = Math.round(shared.length / 4);
67
+ for (const p of list) {
68
+ const t = Math.round(String(p.system || '').trim().length / 4);
69
+ // Named by the dispatcher tool it owns — 'page', 'find', 'mcp' — which is what the
70
+ // reader sees in the tools list and can act on.
71
+ if (t) systemParts[p.id || p.specs[0]?.name || 'group'] = t;
72
+ }
73
+
74
+ return {
75
+ specs,
76
+ system,
77
+ systemParts,
78
+ remoteTools,
79
+ traits,
80
+ serialTools,
81
+ reach,
82
+ hiddenVia,
83
+ async execute(name, input, meta = {}) {
84
+ const fn = route.get(name);
85
+ if (!fn) return JSON.stringify({ error: `Unknown tool: ${name}` });
86
+ return fn(name, input, meta);
87
+ },
88
+ };
89
+ }
@@ -0,0 +1,107 @@
1
+ // `web_search` as a TOOL — the spec, the guidance, and how results are put in front of a
2
+ // model, without the search itself.
3
+ //
4
+ // Running a search is platform work: the extension fetches SERPs from a service worker
5
+ // with DOMParser, the desktop's main process reads anchors with a tokenizer, a gateway that
6
+ // grew one would use its own fetch. What is identical everywhere is what the model is told
7
+ // the tool does, what it is told about citing, and how a result list becomes text it can
8
+ // cite from — so that is what lives here, and `search` is injected.
9
+ //
10
+ // The citation rules are in the RESULT, not only in the system prompt, on purpose: the
11
+ // model reads them at the moment it has sources in hand, which is when it is most likely to
12
+ // follow them, and a turn that never searches never pays for them.
13
+
14
+ export const WEB_SEARCH_TOOL_NAME = 'web_search';
15
+
16
+ export const WEB_SEARCH_TOOL_SYSTEM =
17
+ 'You can call web_search to look up current information from the web — prices, news, recent '
18
+ + 'events, documentation, or anything time-sensitive or that may have changed since your training. '
19
+ + 'Call it whenever the user asks about such things instead of guessing or saying you are unsure. '
20
+ + 'Cite results inline as markdown links — e.g. ([1](https://…)) — never HTML, <sup>, or bare '
21
+ + 'numbers, and finish with a "Sources" list of the links you used.';
22
+
23
+ export const WEB_SEARCH_SPEC = Object.freeze({
24
+ name: WEB_SEARCH_TOOL_NAME,
25
+ annotations: { readOnlyHint: true, idempotentHint: true, openWorldHint: true },
26
+ description:
27
+ 'Search the web and return ranked result snippets with their source URLs. Use this for '
28
+ + 'current events, live prices/quotes, news, product/library docs, or any fact you are unsure '
29
+ + 'about or that may have changed since training — prefer it over guessing.',
30
+ parameters: {
31
+ type: 'object',
32
+ properties: {
33
+ query: { type: 'string', description: 'The search query — a few keywords work best.' },
34
+ },
35
+ required: ['query'],
36
+ additionalProperties: false,
37
+ },
38
+ });
39
+
40
+ /**
41
+ * Flatten a search result into one readable blob the model can cite from.
42
+ *
43
+ * `res` is `{ query, engines: [ids], results: [{ rank, title, url, text }] }`. A citation
44
+ * index of markdown links sits at the TOP so it survives truncation.
45
+ */
46
+ export function searchResultsToText(res) {
47
+ if (!res?.results?.length) {
48
+ // Name the engines HERE especially. The success path already lists them; the failure
49
+ // path did not, so "no web results" looked like "the web has nothing" when it usually
50
+ // means "the one engine we were allowed to ask returned nothing" — a search engine
51
+ // blocking us and a query with no answer are completely different problems, and the
52
+ // model cannot tell them apart without this.
53
+ const tried = (res?.engines || []).join(', ');
54
+ return `No web results for "${res?.query || ''}"`
55
+ + (tried ? ` (searched: ${tried}).` : '.')
56
+ + ' This may mean the engine blocked the request rather than that nothing exists —'
57
+ + ' do NOT conclude the information is unavailable. Try a shorter, more general query'
58
+ + ' (drop dates and qualifiers), and tell the user they can enable another search'
59
+ + ' engine in ChatPanel settings if it keeps failing.';
60
+ }
61
+ const engines = Array.isArray(res.engines) ? res.engines : [];
62
+ const sources = res.results.map((r) => `[${r.rank}] [${r.title}](${r.url})`).join('\n');
63
+ const example = res.results[0].url;
64
+ const head =
65
+ `Web search results for "${res.query}" (engines: ${engines.join(', ')}).\n\n`
66
+ + 'Citation rules: when a claim draws on a result below, cite it inline as a markdown '
67
+ + `link to that result's URL — e.g. ([1](${example})). Cite multiple sources as separate `
68
+ + 'links: ([1](url)) ([3](url)). Do NOT output HTML, <sup>, or bare bracket numbers like '
69
+ + '[1] — every citation must be a clickable markdown link. Finish with a "Sources" section '
70
+ + 'that repeats, as markdown links, each source you cited.\n\n'
71
+ + `Sources:\n${sources}`;
72
+ const body = res.results
73
+ .map((r) => `### [${r.rank}] ${r.title}\n<${r.url}>\n\n${r.text}`)
74
+ .join('\n\n---\n\n');
75
+ return `${head}\n\n---\nResult details:\n\n${body}`;
76
+ }
77
+
78
+ /**
79
+ * The tool provider — `{ specs, system, execute }` for `buildToolset`.
80
+ *
81
+ * @param search `(query) => Promise<{ query, engines, results }>` — the host's search. It
82
+ * may throw; the model gets the message rather than a dead turn.
83
+ */
84
+ export function webSearchToolProvider({ search } = {}) {
85
+ if (typeof search !== 'function') throw new Error('webSearchToolProvider: search required');
86
+ return {
87
+ specs: [WEB_SEARCH_SPEC],
88
+ system: WEB_SEARCH_TOOL_SYSTEM,
89
+ async execute(name, input) {
90
+ if (name !== WEB_SEARCH_TOOL_NAME) return JSON.stringify({ error: `Unknown tool: ${name}` });
91
+ const q = String(input?.query || '').trim();
92
+ if (!q) return 'No query provided to web_search.';
93
+ try {
94
+ const res = await search(q);
95
+ // Return an OBJECT so the step can name the ENGINE that actually served the results.
96
+ // "web_search" alone doesn't tell the user whether Startpage or DuckDuckGo answered —
97
+ // which matters, because engines differ in coverage, and because a CLI agent may have
98
+ // run its OWN search instead of this one. `note` becomes the step's badge; `text` is
99
+ // what the model reads, unchanged.
100
+ const engines = (res?.engines || []).join(', ');
101
+ return { text: searchResultsToText(res), note: engines ? `ChatPanel · ${engines}` : 'ChatPanel' };
102
+ } catch (e) {
103
+ return `web_search failed: ${e?.message || e}`;
104
+ }
105
+ },
106
+ };
107
+ }