@chatpanel/events 0.63.0 → 0.65.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/recipe.js ADDED
@@ -0,0 +1,264 @@
1
+ // Recipes — a multi-step tool workflow written down once and run by name, with no model
2
+ // in the loop.
3
+ //
4
+ // Skills are prompts: they tell a model what to do and the model decides which tools to
5
+ // call. A recipe is the other thing — the DECISION already made, as data: "open_bug" is
6
+ // `create_issue` with `labels: ["bug"]` baked in and `title`/`body` filled from the
7
+ // caller; "triage_pair" fetches two issues at once; "search_then_read" searches, then
8
+ // reads the first hit. It runs identically every time, from any client, with no tokens
9
+ // spent deciding, and it is the shape the harness spine calls class R: declarative,
10
+ // permissioned, never agent-written code. Amendment A2 asked for automations to be
11
+ // authored conversationally and approved before they run; this is what gets approved.
12
+ //
13
+ // Four modes, matching the primitives a round already has: `call` (one tool),
14
+ // `parallel` (independent calls, run through tool-round.js so reads overlap), `batch` (one
15
+ // tool, many argument sets) and `pipeline` (each step may map values out of the previous
16
+ // step's result with `inputMapping`: "$text" for its text, "$json" for it parsed,
17
+ // "$json.items.0.key" for a path inside).
18
+ //
19
+ // Two properties are load-bearing:
20
+ // • DRY RUN before side effects — `dryRunRecipe` resolves every parameter and names
21
+ // every unknown tool, missing required field and destructive call without executing
22
+ // anything. It is the propose step of propose → approve → activate.
23
+ // • PARTIAL FAILURE IS RECOVERABLE — a parallel/batch run returns every sibling's result
24
+ // plus `failedIndexes`; a pipeline stops at the failing step and returns the outputs
25
+ // of the steps before it, plus which mappings resolved and which did not. Nothing is
26
+ // retried blindly, and nothing that succeeded is lost.
27
+ //
28
+ // Pure. Execution, tool specs and traits are injected; nothing here knows a model, a
29
+ // window, or where recipes are stored.
30
+
31
+ import { runToolRound } from './tool-round.js';
32
+ import { toolTraits, needsConfirmation } from './tool-traits.js';
33
+
34
+ export const RECIPE_MODES = Object.freeze(['call', 'parallel', 'batch', 'pipeline']);
35
+
36
+ export class RecipeError extends Error {
37
+ constructor(code, message) { super(message); this.name = 'RecipeError'; this.code = code; }
38
+ }
39
+
40
+ const isRecord = (v) => v !== null && typeof v === 'object' && !Array.isArray(v);
41
+ const isParamRef = (v) => isRecord(v) && typeof v.$param === 'string' && Object.keys(v).every((k) => k === '$param' || k === 'default');
42
+
43
+ /** Every `{ "$param": name }` a recipe reads, in first-seen order. */
44
+ export function recipeParams(recipe) {
45
+ const names = [];
46
+ const seen = new Set();
47
+ const walk = (v) => {
48
+ if (isParamRef(v)) { if (!seen.has(v.$param)) { seen.add(v.$param); names.push({ name: v.$param, required: !('default' in v), default: v.default }); } return; }
49
+ if (Array.isArray(v)) v.forEach(walk);
50
+ else if (isRecord(v)) Object.values(v).forEach(walk);
51
+ };
52
+ walk(recipe);
53
+ return names;
54
+ }
55
+
56
+ /** Structural validity — the errors a person authoring one needs to see. */
57
+ export function validateRecipe(recipe) {
58
+ const errors = [];
59
+ if (!isRecord(recipe)) return { ok: false, errors: ['recipe must be an object'] };
60
+ if (!recipe.name || typeof recipe.name !== 'string' || !/^[a-z][a-z0-9_-]{0,63}$/i.test(recipe.name)) errors.push('name: a short identifier (letters, digits, _ -) is required');
61
+ if (!RECIPE_MODES.includes(recipe.mode)) errors.push(`mode: one of ${RECIPE_MODES.join(', ')}`);
62
+ const call = (c, where) => {
63
+ if (!isRecord(c)) { errors.push(`${where}: must be an object`); return; }
64
+ if (!c.tool || typeof c.tool !== 'string') errors.push(`${where}.tool: required`);
65
+ if (c.arguments !== undefined && !isRecord(c.arguments)) errors.push(`${where}.arguments: must be an object`);
66
+ if (c.inputMapping !== undefined && (!isRecord(c.inputMapping) || Object.values(c.inputMapping).some((m) => typeof m !== 'string' || !/^\$(text|json)(\.[^.\s]+)*$/.test(m)))) {
67
+ errors.push(`${where}.inputMapping: values must be "$text", "$json" or "$json.<path>"`);
68
+ }
69
+ if (c.onMappingMissing !== undefined && !['continue', 'fail'].includes(c.onMappingMissing)) errors.push(`${where}.onMappingMissing: "continue" or "fail"`);
70
+ };
71
+ switch (recipe.mode) {
72
+ case 'call': call(recipe, 'recipe'); break;
73
+ case 'parallel':
74
+ if (!Array.isArray(recipe.calls) || !recipe.calls.length) errors.push('calls: a non-empty array');
75
+ else recipe.calls.forEach((c, i) => call(c, `calls[${i}]`));
76
+ break;
77
+ case 'batch':
78
+ if (!recipe.tool || typeof recipe.tool !== 'string') errors.push('tool: required');
79
+ if (!Array.isArray(recipe.items) || !recipe.items.length) errors.push('items: a non-empty array');
80
+ else recipe.items.forEach((it, i) => { if (!isRecord(it) || !isRecord(it.arguments)) errors.push(`items[${i}].arguments: must be an object`); });
81
+ break;
82
+ case 'pipeline':
83
+ if (!Array.isArray(recipe.steps) || !recipe.steps.length) errors.push('steps: a non-empty array');
84
+ else {
85
+ recipe.steps.forEach((s, i) => call(s, `steps[${i}]`));
86
+ if (isRecord(recipe.steps[0]) && recipe.steps[0].inputMapping) errors.push('steps[0].inputMapping: the first step has no previous result');
87
+ }
88
+ break;
89
+ default: break;
90
+ }
91
+ return { ok: errors.length === 0, errors };
92
+ }
93
+
94
+ /**
95
+ * Substitute parameters. Returns the plan — a list of concrete calls in the recipe's mode
96
+ * — and the names of any parameters missing without a default. A missing parameter is
97
+ * reported, not thrown: the caller decides whether to ask for it or fail.
98
+ */
99
+ export function expandRecipe(recipe, params = {}) {
100
+ const v = validateRecipe(recipe);
101
+ if (!v.ok) return { ok: false, errors: v.errors, missing: [], plan: null };
102
+ const missing = new Set();
103
+ const fill = (x) => {
104
+ if (isParamRef(x)) {
105
+ if (Object.prototype.hasOwnProperty.call(params, x.$param) && params[x.$param] !== undefined) return params[x.$param];
106
+ if ('default' in x) return x.default;
107
+ missing.add(x.$param);
108
+ return undefined;
109
+ }
110
+ if (Array.isArray(x)) return x.map(fill);
111
+ if (isRecord(x)) {
112
+ const out = {};
113
+ for (const [k, val] of Object.entries(x)) { const f = fill(val); if (f !== undefined) out[k] = f; }
114
+ return out;
115
+ }
116
+ return x;
117
+ };
118
+ const one = (c) => ({ tool: c.tool, arguments: fill(c.arguments || {}), ...(c.inputMapping ? { inputMapping: { ...c.inputMapping } } : {}), ...(c.onMappingMissing ? { onMappingMissing: c.onMappingMissing } : {}) });
119
+ let calls;
120
+ switch (recipe.mode) {
121
+ case 'call': calls = [one(recipe)]; break;
122
+ case 'parallel': calls = recipe.calls.map(one); break;
123
+ case 'batch': calls = recipe.items.map((it) => ({ tool: recipe.tool, arguments: fill(it.arguments) })); break;
124
+ case 'pipeline': calls = recipe.steps.map(one); break;
125
+ default: calls = [];
126
+ }
127
+ return { ok: missing.size === 0, errors: [], missing: [...missing], plan: { name: recipe.name, mode: recipe.mode, calls } };
128
+ }
129
+
130
+ // ── mapping ──────────────────────────────────────────────────────────────────────────
131
+
132
+ const textOf = (r) => (typeof r === 'string' ? r : r && typeof r === 'object' && typeof r.text === 'string' ? r.text : r == null ? '' : JSON.stringify(r));
133
+
134
+ function parseJson(text) {
135
+ try { return { ok: true, value: JSON.parse(text) }; } catch { return { ok: false }; }
136
+ }
137
+
138
+ /**
139
+ * Resolve one step's `inputMapping` against the previous result.
140
+ * @returns `{ arguments, mapped: { [arg]: value }, skipped: [{ arg, expr, reason }] }`
141
+ */
142
+ export function mapInput(previous, mapping = {}, base = {}) {
143
+ const args = { ...base };
144
+ const mapped = {};
145
+ const skipped = [];
146
+ const text = textOf(previous);
147
+ let json;
148
+ for (const [arg, expr] of Object.entries(mapping || {})) {
149
+ if (expr === '$text') { args[arg] = text; mapped[arg] = text; continue; }
150
+ if (!expr.startsWith('$json')) { skipped.push({ arg, expr, reason: 'unknown expression' }); continue; }
151
+ if (json === undefined) json = parseJson(text);
152
+ if (!json.ok) { skipped.push({ arg, expr, reason: 'previous result is not JSON' }); continue; }
153
+ let cur = json.value;
154
+ const path = expr.slice(5).split('.').filter(Boolean);
155
+ let found = true;
156
+ for (const seg of path) {
157
+ if (Array.isArray(cur) && /^\d+$/.test(seg)) cur = cur[Number(seg)];
158
+ else if (isRecord(cur) && Object.prototype.hasOwnProperty.call(cur, seg)) cur = cur[seg];
159
+ else { found = false; break; }
160
+ }
161
+ if (!found || cur === undefined) { skipped.push({ arg, expr, reason: 'path not found in previous result' }); continue; }
162
+ args[arg] = cur;
163
+ mapped[arg] = cur;
164
+ }
165
+ return { arguments: args, mapped, skipped };
166
+ }
167
+
168
+ // ── dry run ──────────────────────────────────────────────────────────────────────────
169
+
170
+ /**
171
+ * Everything a person should know before approving: unknown tools, missing required
172
+ * fields, destructive calls, and which arguments a pipeline will only learn at run time.
173
+ *
174
+ * @param specs the tool specs the runtime will execute against (for existence and
175
+ * required fields); omit to skip those checks
176
+ * @param traitsOf `(tool) => traits`; defaults to toolTraits on the spec or name
177
+ */
178
+ export function dryRunRecipe(recipe, params = {}, { specs = null, traitsOf = null } = {}) {
179
+ const ex = expandRecipe(recipe, params);
180
+ if (!ex.plan) return { ok: false, errors: ex.errors, missing: ex.missing, calls: [], warnings: [], destructive: [] };
181
+ const byName = specs ? new Map(specs.filter((s) => s?.name).map((s) => [s.name, s])) : null;
182
+ const traits = traitsOf || ((tool) => toolTraits(byName?.get(tool) || tool));
183
+ const warnings = [];
184
+ const destructive = [];
185
+ const calls = ex.plan.calls.map((c, i) => {
186
+ const spec = byName?.get(c.tool);
187
+ const known = byName ? !!spec : null;
188
+ const t = traits(c.tool);
189
+ const mappedLater = new Set(Object.keys(c.inputMapping || {}));
190
+ const required = Array.isArray(spec?.parameters?.required) ? spec.parameters.required : Array.isArray(spec?.inputSchema?.required) ? spec.inputSchema.required : [];
191
+ const missingRequired = required.filter((k) => c.arguments[k] === undefined && !mappedLater.has(k));
192
+ const row = { index: i, tool: c.tool, arguments: c.arguments, known, traits: t, missingRequired, mappedLater: [...mappedLater] };
193
+ if (known === false) warnings.push({ index: i, code: 'unknown_tool', message: `No tool named "${c.tool}" is available.` });
194
+ if (missingRequired.length) warnings.push({ index: i, code: 'missing_required', message: `"${c.tool}" needs ${missingRequired.join(', ')}.` });
195
+ if (needsConfirmation(t)) { destructive.push(i); warnings.push({ index: i, code: 'destructive', message: `"${c.tool}" is destructive.` }); }
196
+ if (ex.plan.mode === 'pipeline' && i > 0 && !mappedLater.size) warnings.push({ index: i, code: 'no_mapping', message: `steps[${i}] uses nothing from the previous step; was that intended?` });
197
+ return row;
198
+ });
199
+ const blocking = ex.missing.length > 0 || warnings.some((w) => w.code === 'unknown_tool' || w.code === 'missing_required');
200
+ return { ok: !blocking, errors: [], missing: ex.missing, plan: ex.plan, calls, warnings, destructive };
201
+ }
202
+
203
+ // ── run ──────────────────────────────────────────────────────────────────────────────
204
+
205
+ const defaultIsError = (r) => {
206
+ if (r == null) return false;
207
+ if (typeof r === 'string') return /^error:/i.test(r) || /^\{\s*"error"/.test(r);
208
+ return typeof r === 'object' && (r.error != null || r.isError === true);
209
+ };
210
+
211
+ /**
212
+ * Run an expanded plan. `execute(tool, arguments, meta)` is the host's toolset executor —
213
+ * every guard, redaction and confirmation it wraps applies unchanged, because a recipe is
214
+ * a way of composing calls, never a way around them.
215
+ */
216
+ export async function runPlan(plan, { execute, traitsOf, concurrent, isError = defaultIsError, onStart, onDone } = {}) {
217
+ if (typeof execute !== 'function') throw new RecipeError('BAD_RUN', 'execute required');
218
+ if (!plan || !Array.isArray(plan.calls)) throw new RecipeError('BAD_PLAN', 'plan.calls required');
219
+ const meta = { recipe: plan.name, mode: plan.mode };
220
+
221
+ if (plan.mode === 'pipeline') {
222
+ const steps = [];
223
+ let previous;
224
+ for (let i = 0; i < plan.calls.length; i += 1) {
225
+ const c = plan.calls[i];
226
+ let args = c.arguments;
227
+ let mapped = {};
228
+ let skipped = [];
229
+ if (i > 0 && c.inputMapping) {
230
+ const m = mapInput(previous, c.inputMapping, c.arguments);
231
+ args = m.arguments; mapped = m.mapped; skipped = m.skipped;
232
+ if (skipped.length && c.onMappingMissing === 'fail') {
233
+ steps.push({ index: i, tool: c.tool, arguments: args, mappedArguments: mapped, skippedMappings: skipped, result: null, status: 'skipped' });
234
+ return { status: 'failed', failedStep: i, reason: 'mapping_missing', steps, finalResult: null };
235
+ }
236
+ }
237
+ onStart?.({ name: c.tool, input: args }, i);
238
+ let result;
239
+ try { result = await execute(c.tool, args, { ...meta, step: i }); } catch (e) { result = { error: `Tool ${c.tool} failed: ${e?.message || e}` }; }
240
+ onDone?.({ name: c.tool, input: args }, i, result);
241
+ const failed = isError(result);
242
+ steps.push({ index: i, tool: c.tool, arguments: args, mappedArguments: mapped, skippedMappings: skipped, result, status: failed ? 'failed' : 'ok' });
243
+ if (failed) return { status: 'failed', failedStep: i, reason: 'tool_error', steps, finalResult: null };
244
+ previous = result;
245
+ }
246
+ return { status: 'completed', steps, finalResult: previous };
247
+ }
248
+
249
+ const calls = plan.calls.map((c) => ({ name: c.tool, input: c.arguments }));
250
+ const round = await runToolRound(calls, {
251
+ execute: (call, i) => execute(call.name, call.input, { ...meta, index: i }),
252
+ traitsOf: traitsOf ? (c) => traitsOf(c.name) : undefined,
253
+ concurrent, isError, onStart, onDone,
254
+ });
255
+ return { status: round.status, succeeded: round.succeeded, failed: round.failed, failedIndexes: round.failedIndexes, results: round.results, coalesced: round.coalesced };
256
+ }
257
+
258
+ /** Expand, then run. Missing parameters fail before anything executes. */
259
+ export async function runRecipe(recipe, params, options) {
260
+ const ex = expandRecipe(recipe, params);
261
+ if (!ex.plan) throw new RecipeError('INVALID', ex.errors.join('; '));
262
+ if (!ex.ok) throw new RecipeError('MISSING_PARAMS', `missing: ${ex.missing.join(', ')}`);
263
+ return runPlan(ex.plan, options);
264
+ }
@@ -0,0 +1,87 @@
1
+ // `find` — the way back to a tool the menu left out.
2
+ //
3
+ // Narrowing keeps a turn's tool list short by ranking the connected tools against the
4
+ // message and showing the top few. It keeps the EXECUTE map whole on purpose — a tool the
5
+ // model knows the name of still runs — but nothing told the model the names it was not
6
+ // shown. So a tool that ranked low was, for that turn, gone: the model could not ask for
7
+ // it because it did not know to.
8
+ //
9
+ // This is the missing half. A `find` action searches the FULL set — every tool the group
10
+ // owns, narrowed or not — and answers with names and one-liners, cheap enough to sit on
11
+ // every dispatcher and cheap enough to call on a hunch. The model discovers, then calls;
12
+ // the menu stays short; the capability decision the cap used to make silently is now a
13
+ // call away. `describe` already gives the full schema for one action, so the two together
14
+ // are the pay-as-you-go ladder: names → one line → full schema, each on demand.
15
+ //
16
+ // Ranking is injected. The lexical, IDF-weighted ranker the extension and gateway share
17
+ // lives in @chatpanel/pii; this package must stay dependency-free, so it takes `rank` and
18
+ // falls back to a plain token overlap when a host has none.
19
+
20
+ export const FIND_ACTION = 'find';
21
+
22
+ const STOP = new Set(['the', 'and', 'for', 'with', 'that', 'this', 'use', 'can', 'you', 'your', 'from', 'what', 'how', 'are', 'get', 'find', 'tool', 'tools']);
23
+
24
+ const tokens = (s) => String(s || '').toLowerCase().split(/[^a-z0-9]+/).filter((w) => w.length > 2 && !STOP.has(w));
25
+
26
+ /** Token overlap — a fallback, not a ranker anyone should prefer. */
27
+ export function overlapRank(specs, query) {
28
+ const q = new Set(tokens(query));
29
+ if (!q.size) return [...specs];
30
+ return specs
31
+ .map((s, i) => {
32
+ const hay = `${s.name} ${s.description || ''}`.toLowerCase();
33
+ let n = 0;
34
+ for (const w of q) if (hay.includes(w)) n += 1;
35
+ return { s, i, n };
36
+ })
37
+ .sort((a, b) => (b.n - a.n) || (a.i - b.i))
38
+ .map((x) => x.s);
39
+ }
40
+
41
+ /** First sentence, whitespace collapsed, capped — the "one line" of the ladder. */
42
+ export function oneLiner(description, max = 110) {
43
+ const s = String(description || '').replace(/\s+/g, ' ').trim();
44
+ const cut = s.search(/[.!?]\s|\n/);
45
+ const first = cut > 20 ? s.slice(0, cut + 1) : s;
46
+ return first.length > max ? `${first.slice(0, max - 1).trimEnd()}…` : first;
47
+ }
48
+
49
+ const requiredOf = (spec) => {
50
+ const p = spec?.parameters || spec?.inputSchema;
51
+ return Array.isArray(p?.required) ? p.required.map(String) : [];
52
+ };
53
+
54
+ /**
55
+ * @param specs the FULL set — not the narrowed menu
56
+ * @param rank `(specs, query) => specs` most-relevant first
57
+ * @returns `[{ name, summary, required }]`
58
+ */
59
+ export function findTools(specs, query, { limit = 8, rank = overlapRank } = {}) {
60
+ const list = (specs || []).filter((s) => s && s.name);
61
+ const q = String(query || '').trim();
62
+ const ranked = q ? rank(list, q) : list;
63
+ const cap = Math.max(1, Math.min(50, Number(limit) || 8));
64
+ return ranked.slice(0, cap).map((s) => ({ name: s.name, summary: oneLiner(s.description), required: requiredOf(s) }));
65
+ }
66
+
67
+ /** The JSON text a `find` action returns, with the next step spelled out. */
68
+ export function findToolsResult(specs, query, { limit, rank, describeAction = 'describe', menu = [] } = {}) {
69
+ const found = findTools(specs, query, { limit, rank });
70
+ const total = (specs || []).length;
71
+ const hidden = new Set(menu.map((m) => (typeof m === 'string' ? m : m?.name)));
72
+ return JSON.stringify({
73
+ query: String(query || ''),
74
+ matches: found.map((f) => ({ ...f, listed: hidden.size ? hidden.has(f.name) : undefined })),
75
+ total,
76
+ hint: found.length
77
+ ? `Call one as {"action":"<name>","args":{…}}. Unsure of its arguments? {"action":"${describeAction}","args":{"tool":"<name>"}}.`
78
+ : `No tool matched "${query}" among ${total}. Try other words, or describe the task differently.`,
79
+ });
80
+ }
81
+
82
+ /** The parameter schema fragment a dispatcher advertises for `find`. */
83
+ export function findActionArgs() {
84
+ return {
85
+ query: { type: 'string', description: `With action="${FIND_ACTION}": words describing the task; returns matching tool names.` },
86
+ };
87
+ }