@chatpanel/events 0.73.0 → 0.74.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/index.js CHANGED
@@ -188,6 +188,8 @@ export { FIND_TOOL_NAME, FIND_DESCRIPTION, FIND_RESIDENT, findDispatchProvider }
188
188
  export { WEB_SEARCH_TOOL_NAME, WEB_SEARCH_TOOL_SYSTEM, WEB_SEARCH_SPEC, searchResultsToText, webSearchToolProvider } from './web-search-tool.js';
189
189
  export { compressToolSpec, compressToolSpecs, compressionStats, trimDescription, COMPRESSION_MODES, DEFAULT_COMPRESSION } from './tool-schema.js';
190
190
  export { validateRecipe, expandRecipe, recipeParams, mapInput, dryRunRecipe, runPlan, runRecipe, RecipeError, RECIPE_MODES } from './recipe.js';
191
+ export { recipeToolProvider, recipeToolSpec, describeRecipeForApproval, RECIPE_TOOL_NAME } from './recipe-tool.js';
192
+ export { mcpDispatchProvider, MCP_TOOL_NAME } from './mcp-dispatch.js';
191
193
  export { createManifest, ManifestError, SOURCES } from './manifest.js';
192
194
  export { createKernel, meetDecisions, KernelError, REQUIRED_PLUGINS, ALLOW_ALL } from './kernel.js';
193
195
  export { replay, formatReport, parseJsonl, toJsonl } from './harness.js';
@@ -0,0 +1,52 @@
1
+ // PROGRESSIVE DISCLOSURE for MCP servers — one registered tool instead of dozens.
2
+ //
3
+ // MCP is the largest resident cost by far: every connected server contributes a full JSON
4
+ // schema per tool plus an inventory block, and the shared MCP rulebook (~600 tokens of
5
+ // citation policy, argument-forming rules and fallback etiquette) is added once on top.
6
+ // On a setup with a few servers that is thousands of tokens on every turn — including
7
+ // turns that never touch a server.
8
+ //
9
+ // The existing defence was a relevance cap that DROPS tools beyond it. That is a real
10
+ // loss of capability, silently: a tool the model needed but that ranked low simply was
11
+ // not there. A dispatcher keeps every tool reachable and pays only for the menu, so the
12
+ // cap stops being a capability decision and becomes a menu-length decision.
13
+ //
14
+ // PRIVACY: these tools call third parties, so the provider stays flagged `remote`. The
15
+ // harness uses that flag to keep PII off remote tools under "redact remote" — a
16
+ // dispatcher that dropped it would quietly convert redacted tools into unredacted ones.
17
+ // That is the one property here worth a test of its own.
18
+
19
+ import { makeDispatchProvider } from './tool-dispatch.js';
20
+
21
+ // Deliberately NOT `mcp_*`. buildToolset adds the ~600-token shared MCP rulebook whenever
22
+ // a spec name matches /^mcp[_-]/, so a dispatcher called `mcp_call` would collapse the
23
+ // per-server schemas and then re-admit the rulebook it was meant to defer. The rulebook
24
+ // travels with `describe` instead, and remoteness is carried by the provider's `remote`
25
+ // flag rather than inferred from the name — which is where it should have come from
26
+ // anyway.
27
+ export const MCP_TOOL_NAME = 'mcp';
28
+
29
+ const DESCRIPTION =
30
+ 'Call a tool on a connected MCP server (the user\'s own integrations). Pass an `action` '
31
+ + 'and put that action\'s own arguments inside `args`, e.g. '
32
+ + '{"action":"mcp_jira__search","args":{"query":"ATLAS-1"}}. Unsure of an action\'s '
33
+ + 'arguments? {"action":"describe","args":{"tool":"<action>"}} returns its full schema '
34
+ + 'and how to use that server. Match the request\'s domain to the server\'s domain, and '
35
+ + 'do not call these when the page or provided context already answers the question.';
36
+
37
+ export function mcpDispatchProvider(inner, { all = null, rank = undefined } = {}) {
38
+ return makeDispatchProvider({
39
+ all,
40
+ rank,
41
+ name: MCP_TOOL_NAME,
42
+ description: DESCRIPTION,
43
+ // Same lesson as the data and page groups: name the capability, not just the tool.
44
+ resident:
45
+ "You HAVE access to the user's connected MCP servers — their own integrations — "
46
+ + 'through the `mcp` tool. When a request matches a connected server\'s domain, call '
47
+ + 'it rather than saying the integration is unavailable.',
48
+ inner,
49
+ // Load-bearing for redaction, not bookkeeping. See the note above.
50
+ remote: true,
51
+ });
52
+ }
package/package.json CHANGED
@@ -1,25 +1,29 @@
1
1
  {
2
2
  "name": "@chatpanel/events",
3
- "version": "0.73.0",
3
+ "version": "0.74.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",
7
7
  "exports": {
8
8
  ".": "./index.js",
9
9
  "./adapters.js": "./adapters.js",
10
+ "./adaptive-tool-policy.js": "./adaptive-tool-policy.js",
10
11
  "./attribution.js": "./attribution.js",
11
12
  "./backup-envelope.js": "./backup-envelope.js",
12
13
  "./capability.js": "./capability.js",
13
14
  "./citations.js": "./citations.js",
15
+ "./client-prefs.js": "./client-prefs.js",
16
+ "./context-attachments.js": "./context-attachments.js",
14
17
  "./cowriter-router.js": "./cowriter-router.js",
15
- "./cowriter.js": "./cowriter.js",
16
18
  "./cowriter-writer.js": "./cowriter-writer.js",
19
+ "./cowriter.js": "./cowriter.js",
17
20
  "./curate.js": "./curate.js",
18
21
  "./distance.js": "./distance.js",
19
22
  "./entitlement.js": "./entitlement.js",
20
23
  "./entity.js": "./entity.js",
21
24
  "./event.js": "./event.js",
22
25
  "./extraction.js": "./extraction.js",
26
+ "./find-tool.js": "./find-tool.js",
23
27
  "./flowchart.js": "./flowchart.js",
24
28
  "./harness.js": "./harness.js",
25
29
  "./invariants.js": "./invariants.js",
@@ -31,9 +35,13 @@
31
35
  "./manifest.js": "./manifest.js",
32
36
  "./markdown-authoring.js": "./markdown-authoring.js",
33
37
  "./markdown-render.js": "./markdown-render.js",
38
+ "./mcp-client.js": "./mcp-client.js",
39
+ "./mcp-dispatch.js": "./mcp-dispatch.js",
34
40
  "./mcp-errors.js": "./mcp-errors.js",
41
+ "./mcp-manager.js": "./mcp-manager.js",
35
42
  "./media-transcript.js": "./media-transcript.js",
36
43
  "./meeting-analyzers.js": "./meeting-analyzers.js",
44
+ "./meeting-insights.js": "./meeting-insights.js",
37
45
  "./meeting-shape.js": "./meeting-shape.js",
38
46
  "./meeting-text.js": "./meeting-text.js",
39
47
  "./memory.js": "./memory.js",
@@ -52,7 +60,9 @@
52
60
  "./promotion.js": "./promotion.js",
53
61
  "./queue.js": "./queue.js",
54
62
  "./reach.js": "./reach.js",
63
+ "./recipe-tool.js": "./recipe-tool.js",
55
64
  "./recipe.js": "./recipe.js",
65
+ "./record-list.js": "./record-list.js",
56
66
  "./redaction-tokens.js": "./redaction-tokens.js",
57
67
  "./ref.js": "./ref.js",
58
68
  "./registry.js": "./registry.js",
@@ -82,34 +92,26 @@
82
92
  "./theme.js": "./theme.js",
83
93
  "./titles.js": "./titles.js",
84
94
  "./tool-discovery.js": "./tool-discovery.js",
95
+ "./tool-dispatch.js": "./tool-dispatch.js",
85
96
  "./tool-groups.js": "./tool-groups.js",
97
+ "./tool-hints.js": "./tool-hints.js",
86
98
  "./tool-need.js": "./tool-need.js",
87
99
  "./tool-result.js": "./tool-result.js",
88
100
  "./tool-round.js": "./tool-round.js",
89
101
  "./tool-schema.js": "./tool-schema.js",
90
102
  "./tool-traits.js": "./tool-traits.js",
103
+ "./toolset.js": "./toolset.js",
91
104
  "./trajectory.js": "./trajectory.js",
92
105
  "./upcast.js": "./upcast.js",
93
106
  "./vault.js": "./vault.js",
94
107
  "./view.js": "./view.js",
95
108
  "./voice-intents.js": "./voice-intents.js",
96
109
  "./voice-speaker.js": "./voice-speaker.js",
110
+ "./weather-tool.js": "./weather-tool.js",
97
111
  "./weather.js": "./weather.js",
98
- "./web-search.js": "./web-search.js",
99
- "./widget.js": "./widget.js",
100
- "./toolset.js": "./toolset.js",
101
- "./tool-dispatch.js": "./tool-dispatch.js",
102
- "./find-tool.js": "./find-tool.js",
103
112
  "./web-search-tool.js": "./web-search-tool.js",
104
- "./record-list.js": "./record-list.js",
105
- "./meeting-insights.js": "./meeting-insights.js",
106
- "./client-prefs.js": "./client-prefs.js",
107
- "./tool-hints.js": "./tool-hints.js",
108
- "./adaptive-tool-policy.js": "./adaptive-tool-policy.js",
109
- "./mcp-client.js": "./mcp-client.js",
110
- "./mcp-manager.js": "./mcp-manager.js",
111
- "./weather-tool.js": "./weather-tool.js",
112
- "./context-attachments.js": "./context-attachments.js"
113
+ "./web-search.js": "./web-search.js",
114
+ "./widget.js": "./widget.js"
113
115
  },
114
116
  "files": [
115
117
  "LICENSE",
@@ -122,8 +124,8 @@
122
124
  "client-prefs.js",
123
125
  "context-attachments.js",
124
126
  "cowriter-router.js",
125
- "cowriter.js",
126
127
  "cowriter-writer.js",
128
+ "cowriter.js",
127
129
  "curate.js",
128
130
  "distance.js",
129
131
  "entitlement.js",
@@ -144,6 +146,7 @@
144
146
  "markdown-authoring.js",
145
147
  "markdown-render.js",
146
148
  "mcp-client.js",
149
+ "mcp-dispatch.js",
147
150
  "mcp-errors.js",
148
151
  "mcp-manager.js",
149
152
  "media-transcript.js",
@@ -166,6 +169,7 @@
166
169
  "promotion.js",
167
170
  "queue.js",
168
171
  "reach.js",
172
+ "recipe-tool.js",
169
173
  "recipe.js",
170
174
  "record-list.js",
171
175
  "redaction-tokens.js",
package/recipe-tool.js ADDED
@@ -0,0 +1,161 @@
1
+ // The `recipe` tool — a workflow the model did once, kept as data, and run by name.
2
+ //
3
+ // The engine is @chatpanel/events recipe.js: a recipe is `call` / `parallel` / `batch` /
4
+ // `pipeline` with `{ "$param": "x" }` slots, expanded, dry-run and run with no model in
5
+ // the loop. What is here is the CONVERSATIONAL half the spine's amendment A2 asked for —
6
+ // authored in chat, approved before it exists, declarative, never agent-written code:
7
+ //
8
+ // save the model proposes a recipe from what it just did ("you fetched two issues
9
+ // and compared them — keep that as `triage_pair`?"). The dry run is what the
10
+ // PERSON sees on the card: every step, every slot, every warning. Approval
11
+ // stores it; nothing runs.
12
+ // dry_run the same report, on demand, for a saved recipe with real parameters.
13
+ // run expand with the given parameters and execute through the turn's OWN toolset —
14
+ // so the destructive gate, redaction, the loop guard and the shield all apply
15
+ // to every step exactly as if the model had called it. A recipe composes calls;
16
+ // it is never a way around them.
17
+ //
18
+ // One registered tool, not three: a spec per verb is per-turn token cost on every armed
19
+ // turn, and the description already lists the saved recipes by name and parameters, which
20
+ // is the whole catalogue. Bound LATE to the toolset it lives in (`bind`), because the
21
+ // toolset that runs its steps is the one it is a member of.
22
+ //
23
+ // Shared: the extension and the desktop arm the same tool over the same `recipes` prefs
24
+ // section, so a recipe approved in one client runs in the other. The approval card and
25
+ // the store are the host's (injected); everything the model sees is here.
26
+
27
+ import { validateRecipe, expandRecipe, dryRunRecipe, runPlan, recipeParams, RECIPE_MODES } from './recipe.js';
28
+
29
+ export const RECIPE_TOOL_NAME = 'recipe';
30
+
31
+ const RECIPE_NAME_RE = /^[a-z][a-z0-9_-]{0,63}$/i;
32
+
33
+ function catalogue(recipes) {
34
+ const list = (recipes || []).filter((r) => r && r.enabled !== false && r.name);
35
+ if (!list.length) return 'No recipes saved yet.';
36
+ return `Saved recipes: ${list.map((r) => {
37
+ const ps = recipeParams(r).map((p) => (p.required ? p.name : `${p.name}?`));
38
+ return `${r.name}(${ps.join(', ')})${r.description ? ` — ${r.description}` : ''}`;
39
+ }).join('; ')}.`;
40
+ }
41
+
42
+ /**
43
+ * One spec. The shape a model needs to `save` is spelled out once, compactly; the
44
+ * engine's validator names anything it got wrong.
45
+ */
46
+ export function recipeToolSpec(recipes) {
47
+ return {
48
+ name: RECIPE_TOOL_NAME,
49
+ annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false },
50
+ description:
51
+ `Saved, repeatable tool workflows — run by name with no re-planning. ${catalogue(recipes)} `
52
+ + 'Actions: {"action":"run","name":"<recipe>","params":{…}} runs one; '
53
+ + '{"action":"dry_run","name":"<recipe>","params":{…}} shows what it would do without running; '
54
+ + '{"action":"save","recipe":{…}} proposes a NEW one after you have done a multi-step task the '
55
+ + 'user may repeat — the user approves it on a card. A recipe: {"name":"open_bug","description":"…",'
56
+ + '"mode":"call","tool":"<tool name, e.g. mcp_github__create_issue>","arguments":{"title":{"$param":"title"},"labels":["bug"]}}. '
57
+ + `Modes: ${RECIPE_MODES.join(' | ')} — parallel takes "calls":[{tool,arguments}], batch takes "tool"+"items":[{arguments}], `
58
+ + 'pipeline takes "steps":[{tool,arguments,inputMapping:{arg:"$json.path"|"$text"}}]. '
59
+ + 'Use {"$param":"x"} for anything the user will supply each time; a "default" makes it optional.',
60
+ parameters: {
61
+ type: 'object',
62
+ properties: {
63
+ action: { type: 'string', enum: ['run', 'dry_run', 'save'] },
64
+ name: { type: 'string', description: 'Recipe name, for run / dry_run.' },
65
+ params: { type: 'object', description: 'Parameter values, for run / dry_run.', additionalProperties: true },
66
+ recipe: { type: 'object', description: 'The recipe to save, for save.', additionalProperties: true },
67
+ },
68
+ required: ['action'],
69
+ },
70
+ };
71
+ }
72
+
73
+ /** The card a person approves: what it does, step by step, and what the dry run flagged. */
74
+ export function describeRecipeForApproval(recipe, report) {
75
+ const lines = [`${recipe.name}${recipe.description ? ` — ${recipe.description}` : ''}`, `Mode: ${recipe.mode}`];
76
+ const ps = recipeParams(recipe);
77
+ if (ps.length) lines.push(`Parameters: ${ps.map((p) => (p.required ? p.name : `${p.name} (optional)`)).join(', ')}`);
78
+ const calls = report?.calls || [];
79
+ calls.forEach((c, i) => {
80
+ const args = JSON.stringify(c.arguments);
81
+ lines.push(`${calls.length > 1 ? `${i + 1}. ` : ''}${c.tool}${args && args !== '{}' ? ` ${args.length > 140 ? `${args.slice(0, 137)}…` : args}` : ''}${c.mappedLater?.length ? ` (+ ${c.mappedLater.join(', ')} from the previous step)` : ''}`);
82
+ });
83
+ for (const w of report?.warnings || []) lines.push(`⚠ ${w.message}`);
84
+ lines.push('Saved recipes run with no further planning; destructive steps still ask each time.');
85
+ return lines.join('\n');
86
+ }
87
+
88
+ const json = (v) => JSON.stringify(v);
89
+
90
+ /**
91
+ * @param recipes the saved list (settings.recipes)
92
+ * @param confirmSave async (detail, recipe) => 'allow' | 'deny' — the surface's card. Absent on a
93
+ * surface with no window: `save` is then refused, `run` still works.
94
+ * @param saveRecipe async (recipe) => void — persist an approved one
95
+ */
96
+ export function recipeToolProvider({ recipes = [], confirmSave = null, saveRecipe = null } = {}) {
97
+ let bound = null; // { execute, specs, traits, hiddenVia }
98
+ const byName = new Map((recipes || []).filter((r) => r?.name && r.enabled !== false).map((r) => [r.name, r]));
99
+
100
+ // Steps name REAL tools; a tool that lives behind a dispatcher (`mcp_gh__get_issue`
101
+ // behind `mcp`) is reached through it, so every guard that keys on the action fires.
102
+ const execute = (tool, args, meta) => {
103
+ if (!bound) return json({ error: 'The recipe tool is not bound to a toolset yet.' });
104
+ const via = bound.hiddenVia?.get(tool);
105
+ return via ? bound.execute(via, { action: tool, args }, meta) : bound.execute(tool, args, meta);
106
+ };
107
+ const specsForDryRun = () => (bound ? [...(bound.specs || []), ...(bound.reach || [])].filter((s) => s?.name !== RECIPE_TOOL_NAME) : null);
108
+ const traitsOf = (tool) => bound?.traits?.get(tool) || null;
109
+
110
+ return {
111
+ id: 'recipe',
112
+ specs: [recipeToolSpec(recipes)],
113
+ system: byName.size ? 'Saved recipes exist (see the `recipe` tool). When the user asks for one by name or describes what one does, run it rather than re-planning the steps.' : '',
114
+ bind(toolset) {
115
+ bound = { execute: toolset.execute.bind(toolset), specs: toolset.specs, reach: toolset.reach, traits: toolset.traits, hiddenVia: toolset.hiddenVia };
116
+ },
117
+ async execute(name, input) {
118
+ if (name !== RECIPE_TOOL_NAME) return json({ error: `Unknown tool: ${name}` });
119
+ const action = String(input?.action || '');
120
+
121
+ if (action === 'save') {
122
+ const recipe = input?.recipe;
123
+ const v = validateRecipe(recipe);
124
+ if (!v.ok) return json({ error: 'The recipe is not valid.', problems: v.errors });
125
+ if (!RECIPE_NAME_RE.test(recipe.name)) return json({ error: 'name must be a short identifier: letters, digits, _ or -.' });
126
+ if (byName.has(recipe.name)) return json({ error: `A recipe named "${recipe.name}" already exists. Pick another name.` });
127
+ if (!confirmSave || !saveRecipe) return json({ error: 'Saving a recipe needs the user\'s approval, which this surface cannot ask for. Describe the recipe to the user and suggest saving it from the side panel.' });
128
+ const report = dryRunRecipe(recipe, {}, { specs: specsForDryRun(), traitsOf: bound ? (t) => traitsOf(t) || undefined : null });
129
+ // Missing parameters are the POINT of a template; only structural problems block.
130
+ const blocking = (report.warnings || []).filter((w) => w.code === 'unknown_tool');
131
+ if (blocking.length) return json({ error: 'The recipe names tools that are not available in this conversation.', problems: blocking.map((w) => w.message) });
132
+ const detail = describeRecipeForApproval(recipe, report);
133
+ const decision = await confirmSave(detail, recipe);
134
+ if (decision !== 'allow') return json({ error: `The user did not save "${recipe.name}". Do not propose it again this turn.`, declined: true });
135
+ const stored = { ...recipe, enabled: true, createdAt: Date.now() };
136
+ await saveRecipe(stored);
137
+ byName.set(stored.name, stored);
138
+ return json({ saved: stored.name, params: recipeParams(stored).map((p) => p.name), hint: `Run it later with {"action":"run","name":"${stored.name}","params":{…}} or by typing /${stored.name}.` });
139
+ }
140
+
141
+ const recipe = byName.get(String(input?.name || ''));
142
+ if (!recipe) return json({ error: `No recipe named "${input?.name}".`, available: [...byName.keys()] });
143
+ const params = input?.params && typeof input.params === 'object' ? input.params : {};
144
+
145
+ if (action === 'dry_run') {
146
+ const report = dryRunRecipe(recipe, params, { specs: specsForDryRun(), traitsOf: bound ? (t) => traitsOf(t) || undefined : null });
147
+ return json({ name: recipe.name, ok: report.ok, missing: report.missing, calls: report.calls.map((c) => ({ tool: c.tool, arguments: c.arguments, known: c.known, destructive: c.traits?.destructive === true, mappedLater: c.mappedLater })), warnings: report.warnings });
148
+ }
149
+
150
+ if (action === 'run') {
151
+ const ex = expandRecipe(recipe, params);
152
+ if (!ex.plan) return json({ error: 'The saved recipe is not valid.', problems: ex.errors });
153
+ if (!ex.ok) return json({ error: `Missing parameter(s): ${ex.missing.join(', ')}.`, params: recipeParams(recipe) });
154
+ const result = await runPlan(ex.plan, { execute, traitsOf: (t) => traitsOf(t) || undefined });
155
+ return json({ name: recipe.name, ...result });
156
+ }
157
+
158
+ return json({ error: `Unknown action "${action}". Use run, dry_run or save.` });
159
+ },
160
+ };
161
+ }