@chatpanel/events 0.72.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 +4 -0
- package/mcp-dispatch.js +52 -0
- package/meeting-insights.js +102 -0
- package/meeting-shape.js +20 -0
- package/package.json +21 -17
- package/recipe-tool.js +161 -0
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';
|
|
@@ -275,9 +277,11 @@ export { renderMarkdown, defaultLinkPolicy } from './markdown-render.js';
|
|
|
275
277
|
// extension writes and MCP reads, so every client shows one transcript, not three.
|
|
276
278
|
export { parseMeetingText, speakerStats, densityRibbon } from './meeting-text.js';
|
|
277
279
|
export { speakerBreakdown, speakerTimeline, formatTalkTime, SPEAKER_SLOTS } from './meeting-shape.js';
|
|
280
|
+
export { isSpeakerImageValue, speakerLabeller } from './meeting-shape.js';
|
|
278
281
|
// A list of records as a person reads it, and what a meeting settled — both read, never derived.
|
|
279
282
|
export { SORT_MODES, SORT_LABELS, sortStamp, sortRecords, filterRecords, dayBucket, rowTime, groupRecords } from './record-list.js';
|
|
280
283
|
export { INSIGHT_KINDS, summarySections, insightKindOf, meetingInsights, hasInsights } from './meeting-insights.js';
|
|
284
|
+
export { demd, NOTE_SECTIONS, noteSectionKind, MOMENT_BADGES, momentBadge, parseMeetingNotes, groupActionsByOwner } from './meeting-insights.js';
|
|
281
285
|
// The settings every client shares, and how two edited copies reconcile (per-section LWW).
|
|
282
286
|
export { PREF_SECTIONS, PREF_SECTION_IDS, sectionValue, pickSections, applySections, sectionHash, mergeStamped, changedSections } from './client-prefs.js';
|
|
283
287
|
// The MCP client (one per server, http or stdio-via-bridge) and the prompt text about tools.
|
package/mcp-dispatch.js
ADDED
|
@@ -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/meeting-insights.js
CHANGED
|
@@ -60,3 +60,105 @@ export function hasInsights(markdown) {
|
|
|
60
60
|
const i = meetingInsights(markdown);
|
|
61
61
|
return i.decisions.length + i.actions.length + i.questions.length > 0;
|
|
62
62
|
}
|
|
63
|
+
|
|
64
|
+
// ── The meeting page's own read of the notes ──────────────────────────────────────
|
|
65
|
+
//
|
|
66
|
+
// The Insights tab on a meeting is five tiles — Summary, Topics, Key Moments, Shared Links,
|
|
67
|
+
// Action Items — and the extension's meetings page parsed them out of the notes with its own
|
|
68
|
+
// section matcher, badge reader and owner/due grammar, inline. The desktop's meeting page
|
|
69
|
+
// draws the same five tiles, so the parse is here: one answer to "which line is a risk".
|
|
70
|
+
//
|
|
71
|
+
// Different from `meetingInsights` above on purpose: that groups by what a line MEANS
|
|
72
|
+
// (decisions / actions / questions) for a dashboard count; this keeps the notes' own
|
|
73
|
+
// sections and the marks inside them (a [Risk] badge, an _(owner)_, a due date).
|
|
74
|
+
|
|
75
|
+
const isBullet = (l) => /^\s*([-*+]|\d+\.)\s+/.test(l);
|
|
76
|
+
const stripBullet = (l) => l.replace(/^\s*([-*+]|\d+\.)\s+/, '').trim();
|
|
77
|
+
|
|
78
|
+
/** Plain text of a markdown-ish line: bold, italic, code and underscores unwrapped. */
|
|
79
|
+
export const demd = (s) => String(s ?? '').replace(/\*\*(.+?)\*\*/g, '$1').replace(/(^|[^*])\*(?!\s)(.+?)\*/g, '$1$2').replace(/`(.+?)`/g, '$1').replace(/_(.+?)_/g, '$1').trim();
|
|
80
|
+
|
|
81
|
+
export const NOTE_SECTIONS = Object.freeze(['summary', 'topics', 'moments', 'links', 'actions']);
|
|
82
|
+
|
|
83
|
+
/** Which of the five tiles a heading belongs to, or null. */
|
|
84
|
+
export function noteSectionKind(heading) {
|
|
85
|
+
const s = String(heading || '').toLowerCase();
|
|
86
|
+
if (/tl;?dr|summary|overview|recap/.test(s)) return 'summary';
|
|
87
|
+
if (/topic|agenda/.test(s)) return 'topics';
|
|
88
|
+
if (/key moment|moments|highlight|decision/.test(s)) return 'moments';
|
|
89
|
+
if (/shared link|link|url|resource|reference/.test(s)) return 'links';
|
|
90
|
+
if (/action|task|next step|to-?do|follow-?up/.test(s)) return 'actions';
|
|
91
|
+
return null;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export const MOMENT_BADGES = Object.freeze(['decision', 'risk', 'question', 'highlight']);
|
|
95
|
+
|
|
96
|
+
/** A key moment's badge — `[Risk] the replica…` → risk — defaulting to highlight. */
|
|
97
|
+
export function momentBadge(text) {
|
|
98
|
+
const t = String(text ?? '');
|
|
99
|
+
// `**Risk:**` puts the colon before the closing stars; `**Risk**:` after. Both are a badge.
|
|
100
|
+
// Anchored: a badge is how the line STARTS — "a plain highlight" is not a highlight badge.
|
|
101
|
+
const m = t.match(/^\s*\*{0,2}\[?\s*(decision|risk|question|highlight)\s*\]?\s*:?\*{0,2}\s*:?/i);
|
|
102
|
+
if (m) return { badge: m[1].toLowerCase(), text: t.slice(m.index + m[0].length).trim() };
|
|
103
|
+
return { badge: 'highlight', text: t };
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* The five tiles, read out of a meeting's notes.
|
|
108
|
+
* -> { summary, topics: [], moments: [{ badge, text }], links: [], actions: [{ text, done, owner, due, lineIndex }], hasAny }
|
|
109
|
+
*
|
|
110
|
+
* An action item is `- [ ] text _(owner)_ — due` in any of its looser forms; a plain bullet
|
|
111
|
+
* under the actions heading is an item with nothing known about it.
|
|
112
|
+
*/
|
|
113
|
+
export function parseMeetingNotes(md) {
|
|
114
|
+
const out = { summary: '', topics: [], moments: [], links: [], actions: [], hasAny: false };
|
|
115
|
+
const src = String(md ?? '');
|
|
116
|
+
if (!src.trim()) return out;
|
|
117
|
+
let cur = 'summary';
|
|
118
|
+
const summaryParts = [];
|
|
119
|
+
src.replace(/\r\n?/g, '\n').split('\n').forEach((raw, idx) => {
|
|
120
|
+
const line = raw.replace(/\s+$/, '');
|
|
121
|
+
const h = line.match(/^#{1,6}\s+(.*)$/);
|
|
122
|
+
if (h) { cur = noteSectionKind(h[1]); return; }
|
|
123
|
+
if (!line.trim()) return;
|
|
124
|
+
if (cur === 'summary') summaryParts.push(isBullet(line) ? stripBullet(line) : line.trim());
|
|
125
|
+
else if (cur === 'topics') { if (isBullet(line)) out.topics.push(demd(stripBullet(line))); }
|
|
126
|
+
else if (cur === 'moments') { if (isBullet(line)) { const b = momentBadge(stripBullet(line)); out.moments.push({ badge: b.badge, text: demd(b.text) }); } }
|
|
127
|
+
else if (cur === 'links') {
|
|
128
|
+
if (isBullet(line)) {
|
|
129
|
+
const value = demd(stripBullet(line));
|
|
130
|
+
if (value && !/^no shared links\.?$/i.test(value)) out.links.push(value);
|
|
131
|
+
}
|
|
132
|
+
} else if (cur === 'actions') {
|
|
133
|
+
const m = line.match(/^\s*[-*+]\s*\[([ xX])\]\s*(.*)$/);
|
|
134
|
+
if (m) {
|
|
135
|
+
let text = m[2].trim(); let owner = ''; let due = '';
|
|
136
|
+
const ow = text.match(/_\(([^)]+)\)_|\(([^)]+)\)/);
|
|
137
|
+
if (ow) { owner = (ow[1] || ow[2] || '').trim(); text = text.replace(ow[0], '').trim(); }
|
|
138
|
+
const du = text.match(/[—-]\s*_?([^_]+?)_?\s*$/);
|
|
139
|
+
if (du && /due|\d|mon|tue|wed|thu|fri|sat|sun|jan|feb|mar|apr|may|jun|jul|aug|sep|oct|nov|dec|today|tomorrow|eod|eow|next/i.test(du[1])) {
|
|
140
|
+
due = du[1].replace(/^due\s*/i, '').trim(); text = text.slice(0, du.index).trim();
|
|
141
|
+
}
|
|
142
|
+
out.actions.push({ text: demd(text), done: m[1].toLowerCase() === 'x', owner: demd(owner), due, lineIndex: idx });
|
|
143
|
+
} else if (isBullet(line)) out.actions.push({ text: demd(stripBullet(line)), done: false, owner: '', due: '', lineIndex: idx });
|
|
144
|
+
}
|
|
145
|
+
});
|
|
146
|
+
out.summary = demd(summaryParts.join(' ').trim());
|
|
147
|
+
out.hasAny = !!(out.summary || out.topics.length || out.moments.length || out.links.length || out.actions.length);
|
|
148
|
+
return out;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/** Action items grouped by owner, named owners first, "Unassigned" last. */
|
|
152
|
+
export function groupActionsByOwner(actions) {
|
|
153
|
+
const groups = new Map();
|
|
154
|
+
(actions || []).forEach((action, index) => {
|
|
155
|
+
const owner = (action.owner || '').trim() || 'Unassigned';
|
|
156
|
+
if (!groups.has(owner)) groups.set(owner, { owner, items: [] });
|
|
157
|
+
groups.get(owner).items.push({ action, index });
|
|
158
|
+
});
|
|
159
|
+
return [...groups.values()].sort((a, b) => {
|
|
160
|
+
if (a.owner === 'Unassigned') return 1;
|
|
161
|
+
if (b.owner === 'Unassigned') return -1;
|
|
162
|
+
return a.owner.localeCompare(b.owner);
|
|
163
|
+
});
|
|
164
|
+
}
|
package/meeting-shape.js
CHANGED
|
@@ -260,3 +260,23 @@ export function formatTalkTime(ms) {
|
|
|
260
260
|
if (mins < 60) return `${mins} min`;
|
|
261
261
|
return `${Math.floor(mins / 60)}h ${String(mins % 60).padStart(2, '0')}m`;
|
|
262
262
|
}
|
|
263
|
+
|
|
264
|
+
/**
|
|
265
|
+
* A "speaker" that is really an avatar URL. Some captures (Zoom among them) put the picture
|
|
266
|
+
* where the name goes, for a participant whose name was never captured. The URL is a fine
|
|
267
|
+
* identity — distinct people get distinct URLs, so the arithmetic above is right — but it
|
|
268
|
+
* is not a label, and printing it puts a tracking-shaped link in a legend. A chart calls
|
|
269
|
+
* such a speaker "Participant", numbered when there is more than one.
|
|
270
|
+
*/
|
|
271
|
+
export const isSpeakerImageValue = (value) => typeof value === 'string'
|
|
272
|
+
&& /^https?:\/\/\S+$/i.test(value.trim())
|
|
273
|
+
&& /\.(png|jpe?g|gif|webp|svg)(\?|#|$)|images\.zoom\.us|\/p\/v2\/|gravatar|avatar|googleusercontent|wbxcdn|teams\.(microsoft|live)/i.test(value);
|
|
274
|
+
|
|
275
|
+
/** What to call each speaker on screen — names as they are, avatar URLs as "Participant n". */
|
|
276
|
+
export function speakerLabeller(speakers) {
|
|
277
|
+
const names = (speakers || []).map((s) => (typeof s === 'string' ? s : s?.speaker));
|
|
278
|
+
const anon = names.filter(isSpeakerImageValue);
|
|
279
|
+
const numbered = anon.length > 1;
|
|
280
|
+
const index = new Map(anon.map((n, i) => [n, `Participant ${i + 1}`]));
|
|
281
|
+
return (name) => (isSpeakerImageValue(name) ? (numbered ? index.get(name) : 'Participant') : name);
|
|
282
|
+
}
|
package/package.json
CHANGED
|
@@ -1,25 +1,29 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@chatpanel/events",
|
|
3
|
-
"version": "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
|
-
"./
|
|
105
|
-
"./
|
|
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
|
+
}
|