@chatpanel/events 0.102.0 → 0.104.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/adapters.js +1 -83
- package/adaptive-tool-policy.js +1 -45
- package/agent.js +2 -255
- package/attribution.js +1 -132
- package/backup-envelope.js +1 -221
- package/board-tool.js +2 -165
- package/budget.js +1 -116
- package/capability.js +1 -126
- package/citations.js +5 -127
- package/client-prefs.js +1 -148
- package/context-attachments.js +8 -144
- package/cowriter-router.js +1 -83
- package/cowriter-writer.js +19 -193
- package/cowriter.js +1 -190
- package/curate.js +3 -509
- package/distance.js +1 -124
- package/engine.js +1 -131
- package/entitlement.js +1 -332
- package/entity.js +1 -354
- package/event.js +1 -175
- package/extraction.js +4 -293
- package/failover.js +1 -93
- package/find-tool.js +1 -66
- package/flowchart.js +2 -644
- package/gate.js +1 -74
- package/harness.js +4 -101
- package/index.js +1 -345
- package/invariants.js +1 -174
- package/job.js +3 -147
- package/kernel.js +1 -255
- package/knowledge-derive.js +1 -267
- package/knowledge.js +3 -230
- package/library.js +11 -280
- package/loop.js +1 -132
- package/manifest.js +1 -107
- package/markdown-authoring.js +11 -288
- package/markdown-render.js +0 -0
- package/mcp-client.js +8 -358
- package/mcp-dispatch.js +1 -52
- package/mcp-errors.js +1 -106
- package/mcp-manager.js +1 -127
- package/media-transcript.js +8 -531
- package/meeting-analyzers.js +1 -83
- package/meeting-insights.js +7 -164
- package/meeting-shape.js +1 -282
- package/meeting-text.js +5 -94
- package/memory.js +2 -638
- package/model-candidates.js +1 -358
- package/model-health.js +1 -182
- package/model-ledger.js +1 -228
- package/model-picker.js +1 -188
- package/note-actions.js +15 -246
- package/note-graph.js +1 -158
- package/note-links.js +1 -68
- package/note-mentions.js +8 -114
- package/note-plan.js +9 -107
- package/note-research.js +1 -135
- package/observability.js +1 -119
- package/omni.js +1 -125
- package/order.js +1 -78
- package/package.json +4 -5
- package/pdf-layout.js +7 -253
- package/policy.js +1 -0
- package/project-run.js +20 -444
- package/project.js +1 -170
- package/promotion.js +1 -171
- package/queue.js +1 -98
- package/reach.js +1 -23
- package/recipe-tool.js +2 -161
- package/recipe.js +1 -264
- package/record-list.js +2 -110
- package/recruit.js +3 -456
- package/redaction-tokens.js +1 -61
- package/ref.js +1 -55
- package/registry.js +1 -240
- package/route-graph.js +1 -115
- package/route-strategies.js +2 -232
- package/router.js +2 -880
- package/rrf.js +1 -89
- package/rules.js +1 -142
- package/schedule.js +1 -663
- package/scm-connection.js +1 -180
- package/scopes.js +1 -12
- package/scorecard.js +1 -335
- package/search-engines.js +1 -81
- package/skill-manifest.js +1 -330
- package/skill-scan.js +3 -209
- package/skill-sources.js +1 -162
- package/skill-vars.js +2 -252
- package/slash-commands.js +1 -158
- package/source-gate.js +1 -92
- package/sources-retrieval.js +5 -186
- package/sources.js +1 -273
- package/store.js +1 -171
- package/structured.js +4 -901
- package/subject-kinds.js +1 -5
- package/subject-name.js +1 -96
- package/sync-plan.js +1 -213
- package/synthesis.js +3 -123
- package/tags.js +1 -203
- package/team-board.js +6 -337
- package/team-cache.js +2 -63
- package/team-observe.js +1 -118
- package/team-org.js +1 -442
- package/team-plan.js +4 -131
- package/team-record.js +1 -225
- package/team-run.js +9 -652
- package/team-subtask.js +2 -191
- package/team-task.js +2 -124
- package/team-tool.js +2 -196
- package/team-trail.js +2 -73
- package/team-worklog.js +2 -149
- package/team.js +1 -302
- package/text-search.js +1 -155
- package/theme.js +17 -154
- package/titles.js +4 -285
- package/tool-discovery.js +1 -87
- package/tool-dispatch.js +5 -230
- package/tool-groups.js +1 -81
- package/tool-hints.js +10 -185
- package/tool-loop-guard.js +4 -182
- package/tool-need.js +2 -124
- package/tool-result.js +2 -438
- package/tool-round.js +0 -0
- package/tool-schema.js +1 -155
- package/tool-traits.js +1 -182
- package/toolset.js +2 -88
- package/trajectory.js +1 -530
- package/turn-loop.js +4 -440
- package/upcast.js +1 -37
- package/vault.js +2 -250
- package/view.js +1 -88
- package/voice-intents.js +2 -1488
- package/voice-speaker.js +1 -98
- package/weather-tool.js +1 -65
- package/weather.js +2 -211
- package/web-search-tool.js +14 -102
- package/web-search.js +1 -160
- package/widget.js +1 -153
package/recipe-tool.js
CHANGED
|
@@ -1,161 +1,2 @@
|
|
|
1
|
-
|
|
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
|
-
}
|
|
1
|
+
import{validateRecipe as x,expandRecipe as j,dryRunRecipe as k,runPlan as E,recipeParams as v,RECIPE_MODES as P}from"./recipe.js";const y="recipe",R=/^[a-z][a-z0-9_-]{0,63}$/i;function O(s){const m=(s||[]).filter(n=>n&&n.enabled!==!1&&n.name);return m.length?`Saved recipes: ${m.map(n=>{const t=v(n).map(i=>i.required?i.name:`${i.name}?`);return`${n.name}(${t.join(", ")})${n.description?` — ${n.description}`:""}`}).join("; ")}.`:"No recipes saved yet."}function S(s){return{name:y,annotations:{readOnlyHint:!1,destructiveHint:!1,idempotentHint:!1},description:`Saved, repeatable tool workflows — run by name with no re-planning. ${O(s)} Actions: {"action":"run","name":"<recipe>","params":{…}} runs one; {"action":"dry_run","name":"<recipe>","params":{…}} shows what it would do without running; {"action":"save","recipe":{…}} proposes a NEW one after you have done a multi-step task the user may repeat — the user approves it on a card. A recipe: {"name":"open_bug","description":"…","mode":"call","tool":"<tool name, e.g. mcp_github__create_issue>","arguments":{"title":{"$param":"title"},"labels":["bug"]}}. Modes: ${P.join(" | ")} — parallel takes "calls":[{tool,arguments}], batch takes "tool"+"items":[{arguments}], pipeline takes "steps":[{tool,arguments,inputMapping:{arg:"$json.path"|"$text"}}]. Use {"$param":"x"} for anything the user will supply each time; a "default" makes it optional.`,parameters:{type:"object",properties:{action:{type:"string",enum:["run","dry_run","save"]},name:{type:"string",description:"Recipe name, for run / dry_run."},params:{type:"object",description:"Parameter values, for run / dry_run.",additionalProperties:!0},recipe:{type:"object",description:"The recipe to save, for save.",additionalProperties:!0}},required:["action"]}}}function T(s,m){const n=[`${s.name}${s.description?` — ${s.description}`:""}`,`Mode: ${s.mode}`],t=v(s);t.length&&n.push(`Parameters: ${t.map(o=>o.required?o.name:`${o.name} (optional)`).join(", ")}`);const i=m?.calls||[];i.forEach((o,g)=>{const u=JSON.stringify(o.arguments);n.push(`${i.length>1?`${g+1}. `:""}${o.tool}${u&&u!=="{}"?` ${u.length>140?`${u.slice(0,137)}…`:u}`:""}${o.mappedLater?.length?` (+ ${o.mappedLater.join(", ")} from the previous step)`:""}`)});for(const o of m?.warnings||[])n.push(`⚠ ${o.message}`);return n.push("Saved recipes run with no further planning; destructive steps still ask each time."),n.join(`
|
|
2
|
+
`)}const a=s=>JSON.stringify(s);function A({recipes:s=[],confirmSave:m=null,saveRecipe:n=null}={}){let t=null;const i=new Map((s||[]).filter(e=>e?.name&&e.enabled!==!1).map(e=>[e.name,e])),o=(e,c,d)=>{if(!t)return a({error:"The recipe tool is not bound to a toolset yet."});const l=t.hiddenVia?.get(e);return l?t.execute(l,{action:e,args:c},d):t.execute(e,c,d)},g=()=>t?[...t.specs||[],...t.reach||[]].filter(e=>e?.name!==y):null,u=e=>t?.traits?.get(e)||null;return{id:"recipe",specs:[S(s)],system:i.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.":"",bind(e){t={execute:e.execute.bind(e),specs:e.specs,reach:e.reach,traits:e.traits,hiddenVia:e.hiddenVia}},async execute(e,c){if(e!==y)return a({error:`Unknown tool: ${e}`});const d=String(c?.action||"");if(d==="save"){const r=c?.recipe,p=x(r);if(!p.ok)return a({error:"The recipe is not valid.",problems:p.errors});if(!R.test(r.name))return a({error:"name must be a short identifier: letters, digits, _ or -."});if(i.has(r.name))return a({error:`A recipe named "${r.name}" already exists. Pick another name.`});if(!m||!n)return a({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."});const $=k(r,{},{specs:g(),traitsOf:t?f=>u(f)||void 0:null}),w=($.warnings||[]).filter(f=>f.code==="unknown_tool");if(w.length)return a({error:"The recipe names tools that are not available in this conversation.",problems:w.map(f=>f.message)});const _=T(r,$);if(await m(_,r)!=="allow")return a({error:`The user did not save "${r.name}". Do not propose it again this turn.`,declined:!0});const h={...r,enabled:!0,createdAt:Date.now()};return await n(h),i.set(h.name,h),a({saved:h.name,params:v(h).map(f=>f.name),hint:`Run it later with {"action":"run","name":"${h.name}","params":{…}} or by typing /${h.name}.`})}const l=i.get(String(c?.name||""));if(!l)return a({error:`No recipe named "${c?.name}".`,available:[...i.keys()]});const b=c?.params&&typeof c.params=="object"?c.params:{};if(d==="dry_run"){const r=k(l,b,{specs:g(),traitsOf:t?p=>u(p)||void 0:null});return a({name:l.name,ok:r.ok,missing:r.missing,calls:r.calls.map(p=>({tool:p.tool,arguments:p.arguments,known:p.known,destructive:p.traits?.destructive===!0,mappedLater:p.mappedLater})),warnings:r.warnings})}if(d==="run"){const r=j(l,b);if(!r.plan)return a({error:"The saved recipe is not valid.",problems:r.errors});if(!r.ok)return a({error:`Missing parameter(s): ${r.missing.join(", ")}.`,params:v(l)});const p=await E(r.plan,{execute:o,traitsOf:$=>u($)||void 0});return a({name:l.name,...p})}return a({error:`Unknown action "${d}". Use run, dry_run or save.`})}}}export{y as RECIPE_TOOL_NAME,T as describeRecipeForApproval,A as recipeToolProvider,S as recipeToolSpec};
|
package/recipe.js
CHANGED
|
@@ -1,264 +1 @@
|
|
|
1
|
-
|
|
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
|
-
}
|
|
1
|
+
import{runToolRound as R}from"./tool-round.js";import{toolTraits as O,needsConfirmation as q}from"./tool-traits.js";const j=Object.freeze(["call","parallel","batch","pipeline"]);class M extends Error{constructor(n,l){super(l),this.name="RecipeError",this.code=n}}const c=e=>e!==null&&typeof e=="object"&&!Array.isArray(e),w=e=>c(e)&&typeof e.$param=="string"&&Object.keys(e).every(n=>n==="$param"||n==="default");function J(e){const n=[],l=new Set,s=t=>{if(w(t)){l.has(t.$param)||(l.add(t.$param),n.push({name:t.$param,required:!("default"in t),default:t.default}));return}Array.isArray(t)?t.forEach(s):c(t)&&Object.values(t).forEach(s)};return s(e),n}function S(e){const n=[];if(!c(e))return{ok:!1,errors:["recipe must be an object"]};(!e.name||typeof e.name!="string"||!/^[a-z][a-z0-9_-]{0,63}$/i.test(e.name))&&n.push("name: a short identifier (letters, digits, _ -) is required"),j.includes(e.mode)||n.push(`mode: one of ${j.join(", ")}`);const l=(s,t)=>{if(!c(s)){n.push(`${t}: must be an object`);return}(!s.tool||typeof s.tool!="string")&&n.push(`${t}.tool: required`),s.arguments!==void 0&&!c(s.arguments)&&n.push(`${t}.arguments: must be an object`),s.inputMapping!==void 0&&(!c(s.inputMapping)||Object.values(s.inputMapping).some(p=>typeof p!="string"||!/^\$(text|json)(\.[^.\s]+)*$/.test(p)))&&n.push(`${t}.inputMapping: values must be "$text", "$json" or "$json.<path>"`),s.onMappingMissing!==void 0&&!["continue","fail"].includes(s.onMappingMissing)&&n.push(`${t}.onMappingMissing: "continue" or "fail"`)};switch(e.mode){case"call":l(e,"recipe");break;case"parallel":!Array.isArray(e.calls)||!e.calls.length?n.push("calls: a non-empty array"):e.calls.forEach((s,t)=>l(s,`calls[${t}]`));break;case"batch":(!e.tool||typeof e.tool!="string")&&n.push("tool: required"),!Array.isArray(e.items)||!e.items.length?n.push("items: a non-empty array"):e.items.forEach((s,t)=>{(!c(s)||!c(s.arguments))&&n.push(`items[${t}].arguments: must be an object`)});break;case"pipeline":!Array.isArray(e.steps)||!e.steps.length?n.push("steps: a non-empty array"):(e.steps.forEach((s,t)=>l(s,`steps[${t}]`)),c(e.steps[0])&&e.steps[0].inputMapping&&n.push("steps[0].inputMapping: the first step has no previous result"));break;default:break}return{ok:n.length===0,errors:n}}function x(e,n={}){const l=S(e);if(!l.ok)return{ok:!1,errors:l.errors,missing:[],plan:null};const s=new Set,t=r=>{if(w(r)){if(Object.prototype.hasOwnProperty.call(n,r.$param)&&n[r.$param]!==void 0)return n[r.$param];if("default"in r)return r.default;s.add(r.$param);return}if(Array.isArray(r))return r.map(t);if(c(r)){const m={};for(const[f,o]of Object.entries(r)){const a=t(o);a!==void 0&&(m[f]=a)}return m}return r},p=r=>({tool:r.tool,arguments:t(r.arguments||{}),...r.inputMapping?{inputMapping:{...r.inputMapping}}:{},...r.onMappingMissing?{onMappingMissing:r.onMappingMissing}:{}});let d;switch(e.mode){case"call":d=[p(e)];break;case"parallel":d=e.calls.map(p);break;case"batch":d=e.items.map(r=>({tool:e.tool,arguments:t(r.arguments)}));break;case"pipeline":d=e.steps.map(p);break;default:d=[]}return{ok:s.size===0,errors:[],missing:[...s],plan:{name:e.name,mode:e.mode,calls:d}}}const _=e=>typeof e=="string"?e:e&&typeof e=="object"&&typeof e.text=="string"?e.text:e==null?"":JSON.stringify(e);function E(e){try{return{ok:!0,value:JSON.parse(e)}}catch{return{ok:!1}}}function N(e,n={},l={}){const s={...l},t={},p=[],d=_(e);let r;for(const[m,f]of Object.entries(n||{})){if(f==="$text"){s[m]=d,t[m]=d;continue}if(!f.startsWith("$json")){p.push({arg:m,expr:f,reason:"unknown expression"});continue}if(r===void 0&&(r=E(d)),!r.ok){p.push({arg:m,expr:f,reason:"previous result is not JSON"});continue}let o=r.value;const a=f.slice(5).split(".").filter(Boolean);let i=!0;for(const u of a)if(Array.isArray(o)&&/^\d+$/.test(u))o=o[Number(u)];else if(c(o)&&Object.prototype.hasOwnProperty.call(o,u))o=o[u];else{i=!1;break}if(!i||o===void 0){p.push({arg:m,expr:f,reason:"path not found in previous result"});continue}s[m]=o,t[m]=o}return{arguments:s,mapped:t,skipped:p}}function L(e,n={},{specs:l=null,traitsOf:s=null}={}){const t=x(e,n);if(!t.plan)return{ok:!1,errors:t.errors,missing:t.missing,calls:[],warnings:[],destructive:[]};const p=l?new Map(l.filter(a=>a?.name).map(a=>[a.name,a])):null,d=s||(a=>O(p?.get(a)||a)),r=[],m=[],f=t.plan.calls.map((a,i)=>{const u=p?.get(a.tool),g=p?!!u:null,k=d(a.tool),h=new Set(Object.keys(a.inputMapping||{})),$=(Array.isArray(u?.parameters?.required)?u.parameters.required:Array.isArray(u?.inputSchema?.required)?u.inputSchema.required:[]).filter(A=>a.arguments[A]===void 0&&!h.has(A)),y={index:i,tool:a.tool,arguments:a.arguments,known:g,traits:k,missingRequired:$,mappedLater:[...h]};return g===!1&&r.push({index:i,code:"unknown_tool",message:`No tool named "${a.tool}" is available.`}),$.length&&r.push({index:i,code:"missing_required",message:`"${a.tool}" needs ${$.join(", ")}.`}),q(k)&&(m.push(i),r.push({index:i,code:"destructive",message:`"${a.tool}" is destructive.`})),t.plan.mode==="pipeline"&&i>0&&!h.size&&r.push({index:i,code:"no_mapping",message:`steps[${i}] uses nothing from the previous step; was that intended?`}),y});return{ok:!(t.missing.length>0||r.some(a=>a.code==="unknown_tool"||a.code==="missing_required")),errors:[],missing:t.missing,plan:t.plan,calls:f,warnings:r,destructive:m}}const I=e=>e==null?!1:typeof e=="string"?/^error:/i.test(e)||/^\{\s*"error"/.test(e):typeof e=="object"&&(e.error!=null||e.isError===!0);async function P(e,{execute:n,traitsOf:l,concurrent:s,isError:t=I,onStart:p,onDone:d}={}){if(typeof n!="function")throw new M("BAD_RUN","execute required");if(!e||!Array.isArray(e.calls))throw new M("BAD_PLAN","plan.calls required");const r={recipe:e.name,mode:e.mode};if(e.mode==="pipeline"){const o=[];let a;for(let i=0;i<e.calls.length;i+=1){const u=e.calls[i];let g=u.arguments,k={},h=[];if(i>0&&u.inputMapping){const y=N(a,u.inputMapping,u.arguments);if(g=y.arguments,k=y.mapped,h=y.skipped,h.length&&u.onMappingMissing==="fail")return o.push({index:i,tool:u.tool,arguments:g,mappedArguments:k,skippedMappings:h,result:null,status:"skipped"}),{status:"failed",failedStep:i,reason:"mapping_missing",steps:o,finalResult:null}}p?.({name:u.tool,input:g},i);let b;try{b=await n(u.tool,g,{...r,step:i})}catch(y){b={error:`Tool ${u.tool} failed: ${y?.message||y}`}}d?.({name:u.tool,input:g},i,b);const $=t(b);if(o.push({index:i,tool:u.tool,arguments:g,mappedArguments:k,skippedMappings:h,result:b,status:$?"failed":"ok"}),$)return{status:"failed",failedStep:i,reason:"tool_error",steps:o,finalResult:null};a=b}return{status:"completed",steps:o,finalResult:a}}const m=e.calls.map(o=>({name:o.tool,input:o.arguments})),f=await R(m,{execute:(o,a)=>n(o.name,o.input,{...r,index:a}),traitsOf:l?o=>l(o.name):void 0,concurrent:s,isError:t,onStart:p,onDone:d});return{status:f.status,succeeded:f.succeeded,failed:f.failed,failedIndexes:f.failedIndexes,results:f.results,coalesced:f.coalesced}}async function B(e,n,l){const s=x(e,n);if(!s.plan)throw new M("INVALID",s.errors.join("; "));if(!s.ok)throw new M("MISSING_PARAMS",`missing: ${s.missing.join(", ")}`);return P(s.plan,l)}export{j as RECIPE_MODES,M as RecipeError,L as dryRunRecipe,x as expandRecipe,N as mapInput,J as recipeParams,P as runPlan,B as runRecipe,S as validateRecipe};
|
package/record-list.js
CHANGED
|
@@ -1,110 +1,2 @@
|
|
|
1
|
-
|
|
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
|
-
}
|
|
1
|
+
const f=Object.freeze(["recent","started","title"]),k=Object.freeze({recent:"Recently active",started:"Date started",title:"Title A–Z"}),i=e=>Number.isFinite(Number(e))?Number(e):0;function l(e,t="recent"){return t==="started"?i(e?.createdAt)||i(e?.updatedAt):i(e?.updatedAt)||i(e?.createdAt)}function h(e,t="recent"){const s=Array.isArray(e)?[...e]:[];if(t==="title")return s.sort((o,r)=>String(o?.title||"").localeCompare(String(r?.title||""),void 0,{sensitivity:"base"})||l(r)-l(o));const u=f.includes(t)?t:"recent";return s.sort((o,r)=>l(r,u)-l(o,u))}function p(e,t){const s=String(t||"").toLowerCase().split(/\s+/).filter(Boolean),u=Array.isArray(e)?e:[];return s.length?u.filter(o=>{const r=`${o?.title||""}
|
|
2
|
+
${o?.snippet||""}`.toLowerCase();return s.every(n=>r.includes(n))}):u}const b=864e5;function c(e){const t=new Date(e);return t.setHours(0,0,0,0),t.getTime()}const d=["January","February","March","April","May","June","July","August","September","October","November","December"],m=["Sunday","Monday","Tuesday","Wednesday","Thursday","Friday","Saturday"];function y(e,t=Date.now()){const s=i(e);if(!s)return{key:"undated",label:"Undated",order:9e15};const u=c(t),o=c(s),r=Math.round((u-o)/b);if(r<=0)return{key:"today",label:"Today",order:0};if(r===1)return{key:"yesterday",label:"Yesterday",order:1};if(r<7)return{key:"week",label:"Earlier this week",order:2};const n=new Date(s),a=new Date(t);if(n.getFullYear()===a.getFullYear()&&n.getMonth()===a.getMonth())return{key:"month",label:"Earlier this month",order:3};const g=n.getFullYear()===a.getFullYear()?d[n.getMonth()]:`${d[n.getMonth()]} ${n.getFullYear()}`;return{key:`m-${n.getFullYear()}-${n.getMonth()}`,label:g,order:4+(a.getFullYear()*12+a.getMonth())-(n.getFullYear()*12+n.getMonth())}}function D(e,t=Date.now(),{locale:s=void 0}={}){const u=i(e);if(!u)return"";const o=y(u,t),r=new Date(u);if(o.key==="today"||o.key==="yesterday")return r.toLocaleTimeString(s,{hour:"numeric",minute:"2-digit"});if(o.key==="week")return m[r.getDay()];const n=r.getFullYear()===new Date(t).getFullYear();return r.toLocaleDateString(s,{month:"short",day:"numeric",...n?{}:{year:"numeric"}})}function A(e,{mode:t="recent",query:s="",now:u=Date.now()}={}){const o=h(p(e,s),t);if(t==="title")return o.length?[{key:"all",label:"",items:o}]:[];const r=new Map;for(const n of o){const a=y(l(n,t),u);r.has(a.key)||r.set(a.key,{key:a.key,label:a.label,order:a.order,items:[]}),r.get(a.key).items.push(n)}return[...r.values()].sort((n,a)=>n.order-a.order).map(({order:n,...a})=>a)}export{k as SORT_LABELS,f as SORT_MODES,y as dayBucket,p as filterRecords,A as groupRecords,D as rowTime,h as sortRecords,l as sortStamp};
|