@matatbread/matbot-triggers 0.4.21 → 0.4.22

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.4.21",
7
+ "version": "0.4.22",
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.4.21"
36
+ "@matatbread/matbot-plugin-api": "^0.4.22"
37
37
  },
38
38
  "devDependencies": {
39
39
  "typescript": "^6.0.3",
40
- "@matatbread/matbot-plugin-api": "^0.4.21"
40
+ "@matatbread/matbot-plugin-api": "^0.4.22"
41
41
  },
42
42
  "scripts": {
43
43
  "typecheck": "tsc --noEmit"
package/src/dispatch.ts CHANGED
@@ -1,12 +1,14 @@
1
1
  import type { MatbotMachine, Session, PromptFn, MessageContent } from '@matatbread/matbot-plugin-api';
2
2
  import { invokeTool } from '@matatbread/matbot-plugin-api';
3
- import type { Trigger, TriggerInvoke } from './types.js';
3
+ import type { Trigger, TriggerInvoke, FiredCondition } from './types.js';
4
4
 
5
5
  export interface DispatchOutcome {
6
6
  /** Whether the tool yielded a result — i.e. whether the model should be woken with it (inject). */
7
7
  hadResult: boolean;
8
8
  /** The yielded result (meaningful only when `hadResult`). */
9
9
  result: unknown;
10
+ /** The tool did not complete — absent, errored, threw, or was cut off — and an error marker says so. */
11
+ failed: boolean;
10
12
  /** Durable markers to persist: any the tool emitted, plus a synthesised error marker if it failed.
11
13
  * These outlive the silent firing, so a post-mortem can see what each trigger actually did. */
12
14
  markers: MessageContent[];
@@ -48,14 +50,17 @@ export async function checkInvocation(services: MatbotMachine, invoke: TriggerIn
48
50
  * caller injects it); a tool that yields none is a silent side-effect (the model never wakes). Any
49
51
  * `marker` events the tool emits are collected for the caller to persist, and a tool that errors or
50
52
  * throws — or names an absent tool — is recorded as an error marker rather than vanishing into a log;
51
- * one cut off by an abort is recorded as interrupted, not as a failure.
53
+ * one cut off by an abort is recorded as interrupted, not as a failure. `matched` rides on that marker,
54
+ * since a failed fire leaves no fire marker of its own to say why it fired.
52
55
  */
53
56
  export async function dispatchTrigger(
54
57
  services: MatbotMachine,
55
58
  trigger: Trigger,
56
59
  ctx: { session: Session; signal: AbortSignal; provider: string; prompt?: PromptFn },
60
+ matched?: FiredCondition[],
57
61
  ): Promise<DispatchOutcome> {
58
62
  const markers: MessageContent[] = [];
63
+ let failed = false;
59
64
  // An aborted signal (a mid-turn steer, a user cancel) means the tool was cut off, not that it faulted:
60
65
  // the raw abort reason is an internal token ("steer") that reads as a real failure in a durable marker.
61
66
  // The runner reframes this for the tools it runs itself (INTERRUPTED_TOOL_RESULT); a trigger's tool runs
@@ -63,9 +68,11 @@ export async function dispatchTrigger(
63
68
  // that was interrupted did nothing, and a post-mortem wants to know that — just not as an error.
64
69
  // `code` rides along so the next turn can tell a malformed invocation from an ordinary tool failure.
65
70
  const fail = (error: string, code?: number): void => {
71
+ failed = true;
72
+ const why = matched !== undefined ? { matched } : {};
66
73
  const data = ctx.signal.aborted
67
- ? { triggerId: trigger.id, tool: trigger.invoke.tool, interrupted: true }
68
- : { triggerId: trigger.id, tool: trigger.invoke.tool, error, ...(code !== undefined ? { code } : {}) };
74
+ ? { triggerId: trigger.id, tool: trigger.invoke.tool, interrupted: true, ...why }
75
+ : { triggerId: trigger.id, tool: trigger.invoke.tool, error, ...(code !== undefined ? { code } : {}), ...why };
69
76
  markers.push({ type: 'marker', creator: 'triggers', data });
70
77
  };
71
78
 
@@ -79,7 +86,7 @@ export async function dispatchTrigger(
79
86
  if (services.tools.resolve(trigger.invoke.tool) === null) {
80
87
  console.warn(`[triggers] trigger ${trigger.id} invokes unknown tool "${trigger.invoke.tool}"; skipped.`);
81
88
  fail(`tool "${trigger.invoke.tool}" not registered`);
82
- return { hadResult: false, result: undefined, markers };
89
+ return { hadResult: false, result: undefined, failed, markers };
83
90
  }
84
91
 
85
92
  // The prompt (when the firing hook carries one — a live interactive session behind this turn) is
@@ -105,7 +112,7 @@ export async function dispatchTrigger(
105
112
  fail(e instanceof Error ? e.message : String(e));
106
113
  }
107
114
 
108
- return { hadResult, result, markers };
115
+ return { hadResult, result, failed, markers };
109
116
  }
110
117
 
111
118
  /**
package/src/plugin.ts CHANGED
@@ -3,7 +3,7 @@ import type { MatbotPluginSpec, MatbotMachine, Store, Message, MessageContent, S
3
3
  import { TriggerManager } from './manager.js';
4
4
  import { dispatchTrigger, renderResult, TOOL_INPUT_INVALID } from './dispatch.js';
5
5
  import { createTriggerActionTool, createTriggersConfigTool } from './tools.js';
6
- import type { Trigger, Triggers, FiredCondition, TriggerCooldown } from './types.js';
6
+ import type { Trigger, Triggers, FiredCondition, TriggerCooldown, TriggerSurface } from './types.js';
7
7
 
8
8
  /** A firing trigger's id plus the specific condition(s) that matched — the unit markers trace, so a
9
9
  * post-mortem can see not just that a trigger fired but which rubric the classifier judged true and
@@ -16,15 +16,19 @@ interface FiredSource {
16
16
  /** The outcome of the concurrent user-phase evaluation (see the racing note in {@link setupTriggers}):
17
17
  * the rendered result bodies split by delivery — `ephemeral` (a `ephemeral`-kind fire, informs one
18
18
  * answer) vs `durable` (a `contextual`-kind fire, folds onto the user turn and persists) — plus the
19
- * firing sources and any markers (a silent side-effect's tool markers, or a trace). All empty ⇒
20
- * nothing fired / nothing to do. */
19
+ * firing sources, the silent fires (already traced in `markers`, kept apart only so the cool-down can
20
+ * count them before those markers are persisted) and any markers. All empty ⇒ nothing fired / nothing
21
+ * to do. */
21
22
  interface UserPhaseOutcome {
22
23
  ephemeralBodies: string[];
23
24
  durableBodies: string[];
24
25
  sources: FiredSource[];
26
+ silent: FiredSource[];
25
27
  markers: MessageContent[];
26
28
  }
27
29
 
30
+ const NO_USER_PHASE = (): UserPhaseOutcome => ({ ephemeralBodies: [], durableBodies: [], sources: [], silent: [], markers: [] });
31
+
28
32
  /** A user-phase evaluation in flight for one turn, shared between the three places that may deliver its
29
33
  * correction: the runner's in-situ restart (via the `DeferredScreen.claim()` the screen hook returns),
30
34
  * an optional pre-first-token grace inject (screen, when `classifierGraceMs > 0`), and the post-commit
@@ -161,6 +165,14 @@ function followupFiredMarker(triggers: FiredSource[]): MessageContent {
161
165
  return { type: 'marker', creator: 'triggers', data: { event: 'followup-fired', surface: 'agent', triggers } };
162
166
  }
163
167
 
168
+ // A fire whose tool completed without a result (remember_fact, say) delivers nothing, so no other
169
+ // marker carries its `matched` — and a silent side-effect is exactly where a false positive goes
170
+ // unnoticed, writing a fact or a file nobody asked for. The tool's own markers say what it did; this
171
+ // says which rule fired it and why. A failed fire is traced by its error marker instead.
172
+ function silentFiredMarker(surface: TriggerSurface, triggers: FiredSource[]): MessageContent {
173
+ return { type: 'marker', creator: 'triggers', data: { event: 'silent-fired', surface, triggers } };
174
+ }
175
+
164
176
  // True when the latest turn is a retract-redo: a `matbot-retraction` marker sits after the last genuine
165
177
  // (non-robo) user message — i.e. this user message was already processed on the original attempt, so
166
178
  // user-phase (ephemeral/contextual) triggers and their side effects already fired. Re-firing them on the
@@ -201,9 +213,10 @@ function retractActiveLastTurn(messages: Message[]): Set<string> {
201
213
  }
202
214
 
203
215
  // Every marker event that records a trigger ACTUALLY firing (as opposed to being held off), across both
204
- // surfaces and all three delivery paths. This is the durable fire log the cool-down is counted from —
205
- // there is no in-memory counter, so a cool-down holds across restart, reload and a backend swap.
206
- const FIRE_EVENTS = new Set(['retract-fired', 'followup-fired', 'user-insitu-fired', 'user-retract-fired']);
216
+ // surfaces, all three delivery paths and the silent side-effect. This is the durable fire log the
217
+ // cool-down is counted from — there is no in-memory counter, so a cool-down holds across restart, reload
218
+ // and a backend swap.
219
+ const FIRE_EVENTS = new Set(['retract-fired', 'followup-fired', 'user-insitu-fired', 'user-retract-fired', 'silent-fired']);
207
220
 
208
221
  /** Per-trigger firing history, in turns. `thisTurn` counts fires inside the current turn region (since
209
222
  * the last genuine user message; a redo or robo resubmit belongs to that same turn). `since` is how
@@ -364,13 +377,15 @@ export async function setupTriggers(services: MatbotMachine): Promise<TriggerMan
364
377
  const ephemeralBodies: string[] = [];
365
378
  const durableBodies: string[] = [];
366
379
  const sources: FiredSource[] = [];
380
+ const silent: FiredSource[] = [];
367
381
  const markers: MessageContent[] = [];
368
382
  // A per-trigger cool-down applies to both surfaces: matching says the trigger is relevant, the
369
383
  // cool-down says whether it may act again this soon. Held-off triggers are traced, never silent.
370
384
  const cooled = applyCooldown(fired, fireHistory(ctx.session.messages));
371
385
  markers.push(...cooled.markers);
372
386
  for (const { trigger, kinds, matched } of cooled.kept) {
373
- const out = await dispatchTrigger(services, trigger, { session: ctx.session, signal: ctx.signal, provider: ctx.provider, ...(ctx.prompt !== undefined ? { prompt: ctx.prompt } : {}) });
387
+ const out = await dispatchTrigger(services, trigger, { session: ctx.session, signal: ctx.signal, provider: ctx.provider, ...(ctx.prompt !== undefined ? { prompt: ctx.prompt } : {}) }, matched);
388
+ if (!out.hadResult && !out.failed) silent.push({ id: trigger.id, matched });
374
389
  if (out.hadResult) {
375
390
  // Contextual dominates: a trigger carrying both kinds folds durable (a superset of ephemeral —
376
391
  // it persists AND informs this answer), mirroring the agent surface's retract-over-followup.
@@ -380,7 +395,8 @@ export async function setupTriggers(services: MatbotMachine): Promise<TriggerMan
380
395
  }
381
396
  markers.push(...out.markers);
382
397
  }
383
- return { ephemeralBodies, durableBodies, sources, markers };
398
+ if (silent.length > 0) markers.push(silentFiredMarker('user', silent));
399
+ return { ephemeralBodies, durableBodies, sources, silent, markers };
384
400
  };
385
401
 
386
402
  // user phase — kicked off pre-response, NOT awaited. Judging the user message concurrently with the
@@ -421,7 +437,7 @@ export async function setupTriggers(services: MatbotMachine): Promise<TriggerMan
421
437
  const rec: UserPhaseInFlight = { verdict: undefined as unknown as Promise<UserPhaseOutcome>, ready: false, correction: {}, claimed: false };
422
438
  rec.verdict = runUserPhase({ session: ctx.session, signal: ctx.signal, provider: ctx.config.provider, ...(ctx.prompt !== undefined ? { prompt: ctx.prompt } : {}) })
423
439
  .then(out => { rec.ready = true; rec.correction = fenceCorrection(out); return out; })
424
- .catch((): UserPhaseOutcome => { rec.ready = true; return { ephemeralBodies: [], durableBodies: [], sources: [], markers: [] }; });
440
+ .catch((): UserPhaseOutcome => { rec.ready = true; return NO_USER_PHASE(); });
425
441
  pendingUserPhase.set(traceId, rec);
426
442
 
427
443
  // Optional grace: wait up to `classifierGraceMs` for the verdict before racing. If it lands with a
@@ -473,7 +489,7 @@ export async function setupTriggers(services: MatbotMachine): Promise<TriggerMan
473
489
 
474
490
  if (ctx.resubmitDepth > 0) return;
475
491
 
476
- const userOut: UserPhaseOutcome = rec ? await rec.verdict : { ephemeralBodies: [], durableBodies: [], sources: [], markers: [] };
492
+ const userOut: UserPhaseOutcome = rec ? await rec.verdict : NO_USER_PHASE();
477
493
  // `claimed` ⇒ the correction was already delivered on the clean path (the runner's in-situ restart
478
494
  // or the screen grace inject), so it must NOT also enter the retract context below.
479
495
  const insitu = rec?.claimed ?? false;
@@ -503,13 +519,14 @@ export async function setupTriggers(services: MatbotMachine): Promise<TriggerMan
503
519
  const retractSources: FiredSource[] = [];
504
520
  const followupBodies: string[] = [];
505
521
  const followupSources: FiredSource[] = [];
522
+ const silentSources: FiredSource[] = [];
506
523
  const suppressed: FiredSource[] = [];
507
524
  const markers: MessageContent[] = [...userOut.markers];
508
525
  // The raced user-phase fires belong to THIS turn but aren't in the committed messages yet (their
509
526
  // markers ride out on this hook's return), so fold them in before counting — otherwise a trigger
510
527
  // that fired on the user surface would look untouched to its own per-turn cap.
511
528
  const hist = fireHistory(ctx.session.messages);
512
- for (const s of userOut.sources) {
529
+ for (const s of [...userOut.sources, ...userOut.silent]) {
513
530
  hist.thisTurn.set(s.id, (hist.thisTurn.get(s.id) ?? 0) + 1);
514
531
  hist.since.set(s.id, 0);
515
532
  }
@@ -520,7 +537,8 @@ export async function setupTriggers(services: MatbotMachine): Promise<TriggerMan
520
537
  suppressed.push({ id: trigger.id, matched: matched.filter(m => m.kind === 'retract') });
521
538
  continue;
522
539
  }
523
- const out = await dispatchTrigger(services, trigger, { session: ctx.session, signal: ctx.signal, provider: ctx.config.provider, ...(ctx.prompt !== undefined ? { prompt: ctx.prompt } : {}) });
540
+ const out = await dispatchTrigger(services, trigger, { session: ctx.session, signal: ctx.signal, provider: ctx.config.provider, ...(ctx.prompt !== undefined ? { prompt: ctx.prompt } : {}) }, matched);
541
+ if (!out.hadResult && !out.failed) silentSources.push({ id: trigger.id, matched });
524
542
  if (out.hadResult) {
525
543
  if (kinds.includes('retract')) {
526
544
  retractBodies.push(renderResult(out.result));
@@ -532,6 +550,7 @@ export async function setupTriggers(services: MatbotMachine): Promise<TriggerMan
532
550
  }
533
551
  markers.push(...out.markers);
534
552
  }
553
+ if (silentSources.length > 0) markers.push(silentFiredMarker('agent', silentSources));
535
554
  // Suppression is never silent — record which rules were held off and why.
536
555
  if (suppressed.length > 0) {
537
556
  markers.push(suppressedMarker('retract-convergence', 'retract held off: this rule was active on the previous turn and is still matching (non-converging)', suppressed));