@matatbread/matbot-triggers 0.2.4 → 0.2.6

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/package.json CHANGED
@@ -4,7 +4,7 @@
4
4
  "node",
5
5
  "browser"
6
6
  ],
7
- "version": "0.2.4",
7
+ "version": "0.2.6",
8
8
  "engines": {
9
9
  "node": ">=24.0.0"
10
10
  },
@@ -33,11 +33,11 @@
33
33
  "access": "public"
34
34
  },
35
35
  "peerDependencies": {
36
- "@matatbread/matbot-plugin-api": "^0.2.4"
36
+ "@matatbread/matbot-plugin-api": "^0.2.6"
37
37
  },
38
38
  "devDependencies": {
39
39
  "typescript": "^5.8.0",
40
- "@matatbread/matbot-plugin-api": "^0.2.4"
40
+ "@matatbread/matbot-plugin-api": "^0.2.6"
41
41
  },
42
42
  "scripts": {
43
43
  "typecheck": "tsc --noEmit"
package/src/dispatch.ts CHANGED
@@ -1,15 +1,7 @@
1
- import type { MatbotMachine, Session, ToolContext, PromptFn, FormField, MessageContent } from '@matatbread/matbot-plugin-api';
1
+ import type { MatbotMachine, Session, PromptFn, MessageContent } from '@matatbread/matbot-plugin-api';
2
+ import { invokeTool } from '@matatbread/matbot-plugin-api';
2
3
  import type { Trigger } from './types.js';
3
4
 
4
- // Fallback when the firing hook carries no interactive prompt (cron/background run, or a frontend
5
- // that supplied none): a tool that tries to prompt resolves to a rejection it surfaces as a normal
6
- // tool error. When the hook DOES carry a prompt (a live interactive session behind this turn), it is
7
- // forwarded instead, so a trigger can invoke an interactive tool (e.g. `ask_user`) for real.
8
- const rejectingPrompt: PromptFn = (((p: string | FormField): Promise<string> => {
9
- const label = typeof p === 'string' ? p : p.label;
10
- return Promise.reject(new Error(`Non-interactive context: cannot prompt for "${label}"`));
11
- }) as PromptFn);
12
-
13
5
  export interface DispatchOutcome {
14
6
  /** Whether the tool yielded a result — i.e. whether the model should be woken with it (inject). */
15
7
  hadResult: boolean;
@@ -44,32 +36,23 @@ export async function dispatchTrigger(
44
36
  // and the worst case is "this trigger did nothing and left a trace"; sibling triggers, the hook,
45
37
  // and the session are unaffected. (The hook dispatcher's own try/catch is a second layer behind this.)
46
38
  try {
47
- const tool = services.tools.resolve(trigger.invoke.tool);
48
- if (tool === null) {
39
+ if (services.tools.resolve(trigger.invoke.tool) === null) {
49
40
  console.warn(`[triggers] trigger ${trigger.id} invokes unknown tool "${trigger.invoke.tool}"; skipped.`);
50
41
  fail(`tool "${trigger.invoke.tool}" not registered`);
51
42
  return { hadResult: false, result: undefined, markers };
52
43
  }
53
44
 
54
- const toolCtx: ToolContext = {
55
- callId: crypto.randomUUID(),
56
- session: ctx.session,
57
- signal: ctx.signal,
58
- vault: services.Vault,
59
- provider: ctx.provider,
60
- prompt: ctx.prompt ?? rejectingPrompt,
61
- // Forward the prompt into plugin loading too (mirrors the normal loop): collision resolution
62
- // during a loaded plugin's setup() prompts the user, so a trigger that loads a plugin (e.g. the
63
- // DRTX case — an agent reply triggers `plugin(load …)`) is interactive end-to-end, not just at
64
- // the tool's own ctx.prompt calls.
65
- loadPlugin: (specifier: string) => services.loadPlugin(specifier, ctx.prompt ?? rejectingPrompt),
66
- unloadPlugin: (specifier: string) => services.unloadPlugin(specifier),
67
- ...(services.workdir !== undefined ? { workdir: services.workdir } : {}),
68
- ...(services.configPath !== undefined ? { configPath: services.configPath } : {}),
69
- ...(services.files !== undefined ? { files: services.files } : {}),
70
- };
45
+ // The prompt (when the firing hook carries one — a live interactive session behind this turn) is
46
+ // forwarded so a trigger can invoke an interactive tool (e.g. `ask_user`) for real; absent, the
47
+ // tool runs non-interactively and a prompt attempt surfaces as a normal tool error.
48
+ const events = invokeTool(services, trigger.invoke.tool, trigger.invoke.params, {
49
+ session: ctx.session,
50
+ signal: ctx.signal,
51
+ provider: ctx.provider,
52
+ ...(ctx.prompt !== undefined ? { prompt: ctx.prompt } : {}),
53
+ });
71
54
 
72
- for await (const ev of tool.executor.execute(trigger.invoke.params, toolCtx)) {
55
+ for await (const ev of events) {
73
56
  if (ev.type === 'result') { result = ev.value; hadResult = true; }
74
57
  else if (ev.type === 'marker') { markers.push({ type: 'marker', creator: ev.creator, data: ev.data }); }
75
58
  else if (ev.type === 'error') {
package/src/tools.ts CHANGED
@@ -1,6 +1,26 @@
1
- import type { Tool, ToolExecutor, ToolContext, ToolEvent, MatbotMachine } from '@matatbread/matbot-plugin-api';
1
+ import type { Tool, ToolExecutor, ToolResult, ToolResultOf, ToolContext, MatbotMachine } from '@matatbread/matbot-plugin-api';
2
2
  import type { TriggerManager } from './manager.js';
3
- import type { TriggerCondition, TriggerKind } from './types.js';
3
+ import type { TriggerCondition, TriggerKind, Trigger } from './types.js';
4
+
5
+ declare module '@matatbread/matbot-plugin-api' {
6
+ interface ToolResults {
7
+ // One arm per action: a caller of `invokeTool(machine, 'trigger_action', { action: '…' })` gets the
8
+ // matching result narrowed by the `action` it passed (see ToolResult / the multi-action note on ToolResults).
9
+ trigger_action:
10
+ | ToolResult<{ triggers: Trigger[] }, { action: 'list' }>
11
+ | ToolResult<{ triggers: Trigger[] }, { action: 'query' }>
12
+ | ToolResult<Trigger, { action: 'get' }>
13
+ | ToolResult<{ id: string }, { action: 'add' }>
14
+ | ToolResult<Trigger, { action: 'update' }>
15
+ | ToolResult<{ id: string }, { action: 'remove' }>
16
+ | ToolResult<{ triggers: Trigger[] }, { action: 'move' }>
17
+ | ToolResult<{ triggers: Trigger[] }, { action: 'copy' }>;
18
+ triggers_config:
19
+ | ToolResult<{ classifierProvider: string | null; available: string[] }, { action: 'get' }>
20
+ | ToolResult<{ classifierProvider: string }, { action: 'set' }>
21
+ | ToolResult<{ classifierProvider: null }, { action: 'clear' }>;
22
+ }
23
+ }
4
24
 
5
25
  const KINDS: readonly TriggerKind[] = ['ephemeral', 'contextual', 'retract', 'followup'];
6
26
  const isKind = (x: unknown): x is TriggerKind => typeof x === 'string' && (KINDS as readonly string[]).includes(x);
@@ -39,7 +59,17 @@ type TriggerActionInput =
39
59
  | { action: 'get'; id: string }
40
60
  | { action: 'add'; conditions: TriggerCondition[]; tool: string; params?: unknown; enabled?: boolean }
41
61
  | { action: 'update'; id: string; conditions?: TriggerCondition[]; tool?: string; params?: unknown; enabled?: boolean }
42
- | { action: 'remove'; id: string };
62
+ | { action: 'remove'; id: string }
63
+ | { action: 'move'; tool?: string; params?: unknown; toTool: string; toParams?: unknown }
64
+ | { action: 'copy'; tool?: string; params?: unknown; toTool: string; toParams?: unknown };
65
+
66
+ // The query selector shared by query/move/copy: `tool`/`params` narrow the set by what a trigger invokes.
67
+ function queryFilter(a: { tool?: unknown; params?: unknown }): { tool?: string; params?: unknown } {
68
+ return {
69
+ ...(typeof a.tool === 'string' ? { tool: a.tool } : {}),
70
+ ...(a.params !== undefined ? { params: a.params } : {}),
71
+ };
72
+ }
43
73
 
44
74
  // A condition is valid if it has a recognised `kind` and a string `rule`.
45
75
  function validConditions(x: unknown): x is TriggerCondition[] {
@@ -49,9 +79,9 @@ function validConditions(x: unknown): x is TriggerCondition[] {
49
79
  typeof (c as { rule?: unknown }).rule === 'string');
50
80
  }
51
81
 
52
- export function createTriggerActionTool(manager: TriggerManager): Tool {
53
- const executor: ToolExecutor = {
54
- async *execute(input: unknown, _ctx: ToolContext): AsyncIterable<ToolEvent> {
82
+ export function createTriggerActionTool(manager: TriggerManager): Tool<ToolResultOf<'trigger_action'>> {
83
+ const executor: ToolExecutor<ToolResultOf<'trigger_action'>> = {
84
+ async *execute(input: unknown, _ctx: ToolContext) {
55
85
  const args = input as Partial<TriggerActionInput> & { action?: string };
56
86
 
57
87
  switch (args.action) {
@@ -62,10 +92,7 @@ export function createTriggerActionTool(manager: TriggerManager): Tool {
62
92
 
63
93
  case 'query': {
64
94
  const a = args as Extract<TriggerActionInput, { action: 'query' }>;
65
- yield { type: 'result', value: { triggers: manager.query({
66
- ...(typeof a.tool === 'string' ? { tool: a.tool } : {}),
67
- ...(a.params !== undefined ? { params: a.params } : {}),
68
- }) } };
95
+ yield { type: 'result', value: { triggers: manager.query(queryFilter(a)) } };
69
96
  return;
70
97
  }
71
98
 
@@ -118,8 +145,39 @@ export function createTriggerActionTool(manager: TriggerManager): Tool {
118
145
  return;
119
146
  }
120
147
 
148
+ case 'move': {
149
+ const a = args as Extract<TriggerActionInput, { action: 'move' }>;
150
+ if (typeof a.toTool !== 'string') { yield { type: 'error', message: 'action "move" requires a string "toTool" to re-target the selected triggers onto.' }; return; }
151
+ if (a.tool === undefined && a.params === undefined) { yield { type: 'error', message: 'action "move" requires a filter ("tool" and/or "params") selecting which triggers to re-target — refusing to move every trigger.' }; return; }
152
+ const invoke = { tool: a.toTool, ...(a.toParams !== undefined ? { params: a.toParams } : {}) };
153
+ const triggers: Trigger[] = [];
154
+ for (const t of manager.query(queryFilter(a))) {
155
+ const updated = await manager.update(t.id, { invoke });
156
+ if (updated !== undefined) triggers.push(updated);
157
+ }
158
+ yield { type: 'result', value: { triggers } };
159
+ return;
160
+ }
161
+
162
+ case 'copy': {
163
+ const a = args as Extract<TriggerActionInput, { action: 'copy' }>;
164
+ if (typeof a.toTool !== 'string') { yield { type: 'error', message: 'action "copy" requires a string "toTool" to point the duplicates at.' }; return; }
165
+ if (a.tool === undefined && a.params === undefined) { yield { type: 'error', message: 'action "copy" requires a filter ("tool" and/or "params") selecting which triggers to duplicate — refusing to duplicate every trigger.' }; return; }
166
+ const invoke = { tool: a.toTool, ...(a.toParams !== undefined ? { params: a.toParams } : {}) };
167
+ const triggers: Trigger[] = [];
168
+ for (const t of manager.query(queryFilter(a))) {
169
+ triggers.push(await manager.add({
170
+ conditions: t.conditions,
171
+ invoke,
172
+ ...(t.enabled !== undefined ? { enabled: t.enabled } : {}),
173
+ }));
174
+ }
175
+ yield { type: 'result', value: { triggers } };
176
+ return;
177
+ }
178
+
121
179
  default:
122
- yield { type: 'error', message: `Unknown action "${String(args.action)}". Expected one of: list, query, get, add, update, remove.` };
180
+ yield { type: 'error', message: `Unknown action "${String(args.action)}". Expected one of: list, query, get, add, update, remove, move, copy.` };
123
181
  }
124
182
  },
125
183
  };
@@ -129,7 +187,8 @@ export function createTriggerActionTool(manager: TriggerManager): Tool {
129
187
  description:
130
188
  'Manage triggers — data-driven hooks that invoke a tool when an LLM classifier judges one of ' +
131
189
  'their conditions matched against the current turn. Use this to list triggers, find the ones ' +
132
- 'that invoke a given tool (query), read one, create one, edit one by id, or delete one.\n\n' +
190
+ 'that invoke a given tool (query), read one, create one, edit one by id, delete one, or bulk ' +
191
+ 're-target a selected set onto a new tool — in place (move) or as duplicates (copy).\n\n' +
133
192
  GUIDANCE + '\n\n' +
134
193
  'A trigger has a stable `id` — address it by that, never by its conditions. To change one, ' +
135
194
  '"get" or "list" first to read the id, then "update" by id.\n\n' +
@@ -143,18 +202,25 @@ export function createTriggerActionTool(manager: TriggerManager): Tool {
143
202
  " | { action: 'get'; id: string } // -> the trigger\n" +
144
203
  " | { action: 'add'; conditions: TriggerCondition[]; tool: string; params?: object; enabled?: boolean } // -> { id }\n" +
145
204
  " | { action: 'update'; id: string; conditions?: TriggerCondition[]; tool?: string; params?: object; enabled?: boolean } // edit by id\n" +
146
- " | { action: 'remove'; id: string }; // -> { id }\n" +
147
- '```',
205
+ " | { action: 'remove'; id: string } // -> { id }\n" +
206
+ " | { action: 'move'; tool?: string; params?: object; toTool: string; toParams?: object } // re-target the queried set's invoke to toTool/toParams in place -> { triggers: [...] }\n" +
207
+ " | { action: 'copy'; tool?: string; params?: object; toTool: string; toParams?: object }; // duplicate the queried set, the copies invoking toTool/toParams -> { triggers: [...] }\n" +
208
+ '```\n\n' +
209
+ 'For `move`/`copy`, `tool`/`params` are the SAME selector as `query` (they pick which triggers ' +
210
+ 'to act on); `toTool`/`toParams` are the new invocation. A filter is required — both refuse to ' +
211
+ 'act on the entire trigger set.',
148
212
  inputSchema: {
149
213
  type: 'object',
150
214
  required: ['action'],
151
215
  properties: {
152
- action: { type: 'string', enum: ['list', 'query', 'get', 'add', 'update', 'remove'], description: 'The operation to perform.' },
216
+ action: { type: 'string', enum: ['list', 'query', 'get', 'add', 'update', 'remove', 'move', 'copy'], description: 'The operation to perform.' },
153
217
  id: { type: 'string', description: 'Trigger id. Required for get/update/remove.' },
154
218
  conditions: { type: 'array', description: 'Conditions [{ kind: "ephemeral"|"contextual"|"retract"|"followup", rule: string }]. Required for add.',
155
219
  items: { type: 'object', properties: { kind: { type: 'string', enum: ['ephemeral', 'contextual', 'retract', 'followup'] }, rule: { type: 'string' } } } },
156
- tool: { type: 'string', description: 'Name of the tool to invoke when a condition matches. Required for add.' },
157
- params: { type: 'object', description: 'Params passed verbatim as the invoked tool\'s input.' },
220
+ tool: { type: 'string', description: 'For add: the tool to invoke. For query/move/copy: the selector matching invoke.tool.' },
221
+ params: { type: 'object', description: 'For add: params passed verbatim as the invoked tool\'s input. For query/move/copy: the selector matching invoke.params.' },
222
+ toTool: { type: 'string', description: 'New tool the selected triggers should invoke. Required for move/copy.' },
223
+ toParams: { type: 'object', description: 'New params for the selected triggers\' invocation (move/copy).' },
158
224
  enabled: { type: 'boolean', description: 'Set false to keep but disable the trigger.' },
159
225
  },
160
226
  },
@@ -168,10 +234,10 @@ export function createTriggerActionTool(manager: TriggerManager): Tool {
168
234
  * own provider (so triggers work with zero config); set it to pin a small/fast model. Resolved per
169
235
  * evaluation, so a change takes effect on the next turn without a restart.
170
236
  */
171
- export function createTriggersConfigTool(services: MatbotMachine): Tool {
237
+ export function createTriggersConfigTool(services: MatbotMachine): Tool<ToolResultOf<'triggers_config'>> {
172
238
  const KEY = 'classifierProvider';
173
- const executor: ToolExecutor = {
174
- async *execute(input: unknown, _ctx: ToolContext): AsyncIterable<ToolEvent> {
239
+ const executor: ToolExecutor<ToolResultOf<'triggers_config'>> = {
240
+ async *execute(input: unknown, _ctx: ToolContext) {
175
241
  const args = input as { action?: string; provider?: string };
176
242
  const settings = services.settings();
177
243
  const available = [...services.providers.keys()];