@matatbread/matbot-triggers 0.1.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/LICENSE ADDED
@@ -0,0 +1,201 @@
1
+ Apache License
2
+ Version 2.0, January 2004
3
+ http://www.apache.org/licenses/
4
+
5
+ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
6
+
7
+ 1. Definitions.
8
+
9
+ "License" shall mean the terms and conditions for use, reproduction,
10
+ and distribution as defined by Sections 1 through 9 of this document.
11
+
12
+ "Licensor" shall mean the copyright owner or entity authorized by
13
+ the copyright owner that is granting the License.
14
+
15
+ "Legal Entity" shall mean the union of the acting entity and all
16
+ other entities that control, are controlled by, or are under common
17
+ control with that entity. For the purposes of this definition,
18
+ "control" means (i) the power, direct or indirect, to cause the
19
+ direction or management of such entity, whether by contract or
20
+ otherwise, or (ii) ownership of fifty percent (50%) or more of the
21
+ outstanding shares, or (iii) beneficial ownership of such entity.
22
+
23
+ "You" (or "Your") shall mean an individual or Legal Entity
24
+ exercising permissions granted by this License.
25
+
26
+ "Source" form shall mean the preferred form for making modifications,
27
+ including but not limited to software source code, documentation
28
+ source, and configuration files.
29
+
30
+ "Object" form shall mean any form resulting from mechanical
31
+ transformation or translation of a Source form, including but
32
+ not limited to compiled object code, generated documentation,
33
+ and conversions to other media types.
34
+
35
+ "Work" shall mean the work of authorship, whether in Source or
36
+ Object form, made available under the License, as indicated by a
37
+ copyright notice that is included in or attached to the work
38
+ (an example is provided in the Appendix below).
39
+
40
+ "Derivative Works" shall mean any work, whether in Source or Object
41
+ form, that is based on (or derived from) the Work and for which the
42
+ editorial revisions, annotations, elaborations, or other modifications
43
+ represent, as a whole, an original work of authorship. For the purposes
44
+ of this License, Derivative Works shall not include works that remain
45
+ separable from, or merely link (or bind by name) to the interfaces of,
46
+ the Work and Derivative Works thereof.
47
+
48
+ "Contribution" shall mean any work of authorship, including
49
+ the original version of the Work and any modifications or additions
50
+ to that Work or Derivative Works thereof, that is intentionally
51
+ submitted to Licensor for inclusion in the Work by the copyright owner
52
+ or by an individual or Legal Entity authorized to submit on behalf of
53
+ the copyright owner. For the purposes of this definition, "submitted"
54
+ means any form of electronic, verbal, or written communication sent
55
+ to the Licensor or its representatives, including but not limited to
56
+ communication on electronic mailing lists, source code control systems,
57
+ and issue tracking systems that are managed by, or on behalf of, the
58
+ Licensor for the purpose of discussing and improving the Work, but
59
+ excluding communication that is conspicuously marked or otherwise
60
+ designated in writing by the copyright owner as "Not a Contribution."
61
+
62
+ "Contributor" shall mean Licensor and any individual or Legal Entity
63
+ on behalf of whom a Contribution has been received by Licensor and
64
+ subsequently incorporated within the Work.
65
+
66
+ 2. Grant of Copyright License. Subject to the terms and conditions of
67
+ this License, each Contributor hereby grants to You a perpetual,
68
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
69
+ copyright license to reproduce, prepare Derivative Works of,
70
+ publicly display, publicly perform, sublicense, and distribute the
71
+ Work and such Derivative Works in Source or Object form.
72
+
73
+ 3. Grant of Patent License. Subject to the terms and conditions of
74
+ this License, each Contributor hereby grants to You a perpetual,
75
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
76
+ (except as stated in this section) patent license to make, have made,
77
+ use, offer to sell, sell, import, and otherwise transfer the Work,
78
+ where such license applies only to those patent claims licensable
79
+ by such Contributor that are necessarily infringed by their
80
+ Contribution(s) alone or by combination of their Contribution(s)
81
+ with the Work to which such Contribution(s) was submitted. If You
82
+ institute patent litigation against any entity (including a
83
+ cross-claim or counterclaim in a lawsuit) alleging that the Work
84
+ or a Contribution incorporated within the Work constitutes direct
85
+ or contributory patent infringement, then any patent licenses
86
+ granted to You under this License for that Work shall terminate
87
+ as of the date such litigation is filed.
88
+
89
+ 4. Redistribution. You may reproduce and distribute copies of the
90
+ Work or Derivative Works thereof in any medium, with or without
91
+ modifications, and in Source or Object form, provided that You
92
+ meet the following conditions:
93
+
94
+ (a) You must give any other recipients of the Work or
95
+ Derivative Works a copy of this License; and
96
+
97
+ (b) You must cause any modified files to carry prominent notices
98
+ stating that You changed the files; and
99
+
100
+ (c) You must retain, in the Source form of any Derivative Works
101
+ that You distribute, all copyright, patent, trademark, and
102
+ attribution notices from the Source form of the Work,
103
+ excluding those notices that do not pertain to any part of
104
+ the Derivative Works; and
105
+
106
+ (d) If the Work includes a "NOTICE" text file as part of its
107
+ distribution, then any Derivative Works that You distribute must
108
+ include a readable copy of the attribution notices contained
109
+ within such NOTICE file, excluding those notices that do not
110
+ pertain to any part of the Derivative Works, in at least one
111
+ of the following places: within a NOTICE text file distributed
112
+ as part of the Derivative Works; within the Source form or
113
+ documentation, if provided along with the Derivative Works; or,
114
+ within a display generated by the Derivative Works, if and
115
+ wherever such third-party notices normally appear. The contents
116
+ of the NOTICE file are for informational purposes only and
117
+ do not modify the License. You may add Your own attribution
118
+ notices within Derivative Works that You distribute, alongside
119
+ or as an addendum to the NOTICE text from the Work, provided
120
+ that such additional attribution notices cannot be construed
121
+ as modifying the License.
122
+
123
+ You may add Your own copyright statement to Your modifications and
124
+ may provide additional or different license terms and conditions
125
+ for use, reproduction, or distribution of Your modifications, or
126
+ for any such Derivative Works as a whole, provided Your use,
127
+ reproduction, and distribution of the Work otherwise complies with
128
+ the conditions stated in this License.
129
+
130
+ 5. Submission of Contributions. Unless You explicitly state otherwise,
131
+ any Contribution intentionally submitted for inclusion in the Work
132
+ by You to the Licensor shall be under the terms and conditions of
133
+ this License, without any additional terms or conditions.
134
+ Notwithstanding the above, nothing herein shall supersede or modify
135
+ the terms of any separate license agreement you may have executed
136
+ with Licensor regarding such Contributions.
137
+
138
+ 6. Trademarks. This License does not grant permission to use the trade
139
+ names, trademarks, service marks, or product names of the Licensor,
140
+ except as required for reasonable and customary use in describing the
141
+ origin of the Work and reproducing the content of the NOTICE file.
142
+
143
+ 7. Disclaimer of Warranty. Unless required by applicable law or
144
+ agreed to in writing, Licensor provides the Work (and each
145
+ Contributor provides its Contributions) on an "AS IS" BASIS,
146
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
147
+ implied, including, without limitation, any warranties or conditions
148
+ of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
149
+ PARTICULAR PURPOSE. You are solely responsible for determining the
150
+ appropriateness of using or redistributing the Work and assume any
151
+ risks associated with Your exercise of permissions under this License.
152
+
153
+ 8. Limitation of Liability. In no event and under no legal theory,
154
+ whether in tort (including negligence), contract, or otherwise,
155
+ unless required by applicable law (such as deliberate and grossly
156
+ negligent acts) or agreed to in writing, shall any Contributor be
157
+ liable to You for damages, including any direct, indirect, special,
158
+ incidental, or consequential damages of any character arising as a
159
+ result of this License or out of the use or inability to use the
160
+ Work (including but not limited to damages for loss of goodwill,
161
+ work stoppage, computer failure or malfunction, or any and all
162
+ other commercial damages or losses), even if such Contributor
163
+ has been advised of the possibility of such damages.
164
+
165
+ 9. Accepting Warranty or Additional Liability. While redistributing
166
+ the Work or Derivative Works thereof, You may choose to offer,
167
+ and charge a fee for, acceptance of support, warranty, indemnity,
168
+ or other liability obligations and/or rights consistent with this
169
+ License. However, in accepting such obligations, You may act only
170
+ on Your own behalf and on Your sole responsibility, not on behalf
171
+ of any other Contributor, and only if You agree to indemnify,
172
+ defend, and hold each Contributor harmless for any liability
173
+ incurred by, or claims asserted against, such Contributor by reason
174
+ of your accepting any such warranty or additional liability.
175
+
176
+ END OF TERMS AND CONDITIONS
177
+
178
+ APPENDIX: How to apply the Apache License to your work.
179
+
180
+ To apply the Apache License to your work, attach the following
181
+ boilerplate notice, with the fields enclosed by brackets "[]"
182
+ replaced with your own identifying information. (Don't include
183
+ the brackets!) The text should be enclosed in the appropriate
184
+ comment syntax for the file format. We also recommend that a
185
+ file or class name and description of purpose be included on the
186
+ same "printed page" as the copyright notice for easier
187
+ identification within third-party archives.
188
+
189
+ Copyright 2026 Mat Woolf
190
+
191
+ Licensed under the Apache License, Version 2.0 (the "License");
192
+ you may not use this file except in compliance with the License.
193
+ You may obtain a copy of the License at
194
+
195
+ http://www.apache.org/licenses/LICENSE-2.0
196
+
197
+ Unless required by applicable law or agreed to in writing, software
198
+ distributed under the License is distributed on an "AS IS" BASIS,
199
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
200
+ See the License for the specific language governing permissions and
201
+ limitations under the License.
package/README.md ADDED
@@ -0,0 +1,5 @@
1
+ # @matatbread/matbot-triggers
2
+
3
+ This is a [matbot](https://github.com/MatAtBread/matbot) plugin.
4
+
5
+ Data-driven hooks: stored conditions that, when an LLM classifier judges them matched, invoke a tool. Cross-runtime (node + browser).
package/package.json ADDED
@@ -0,0 +1,38 @@
1
+ {
2
+ "name": "@matatbread/matbot-triggers",
3
+ "matbotRuntime": [
4
+ "node",
5
+ "browser"
6
+ ],
7
+ "version": "0.1.6",
8
+ "engines": {
9
+ "node": ">=24.0.0"
10
+ },
11
+ "files": [
12
+ "src"
13
+ ],
14
+ "description": "Data-driven hooks: stored conditions that, when an LLM classifier judges them matched, invoke a tool. Cross-runtime (node + browser).",
15
+ "license": "Apache-2.0",
16
+ "type": "module",
17
+ "exports": {
18
+ ".": "./src/index.ts"
19
+ },
20
+ "repository": {
21
+ "type": "git",
22
+ "url": "git+https://github.com/MatAtBread/matbot.git",
23
+ "directory": "plugins/triggers"
24
+ },
25
+ "publishConfig": {
26
+ "access": "public"
27
+ },
28
+ "peerDependencies": {
29
+ "@matatbread/matbot-plugin-api": "0.1.6"
30
+ },
31
+ "devDependencies": {
32
+ "typescript": "^5.8.0",
33
+ "@matatbread/matbot-plugin-api": "0.1.6"
34
+ },
35
+ "scripts": {
36
+ "typecheck": "tsc --noEmit"
37
+ }
38
+ }
@@ -0,0 +1,100 @@
1
+ import type { MatbotMachine, Session, ToolContext, PromptFn, FormField, MessageContent } from '@matatbread/matbot-plugin-api';
2
+ import type { Trigger } from './types.js';
3
+
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
+ export interface DispatchOutcome {
14
+ /** Whether the tool yielded a result — i.e. whether the model should be woken with it (inject). */
15
+ hadResult: boolean;
16
+ /** The yielded result (meaningful only when `hadResult`). */
17
+ result: unknown;
18
+ /** Durable markers to persist: any the tool emitted, plus a synthesised error marker if it failed.
19
+ * These outlive the silent firing, so a post-mortem can see what each trigger actually did. */
20
+ markers: MessageContent[];
21
+ }
22
+
23
+ /**
24
+ * Invoke a fired trigger's tool, observing its output. The observational rule decides whether the
25
+ * model wakes: a tool that yields a `result` is producing model-facing content (`hadResult` → the
26
+ * caller injects it); a tool that yields none is a silent side-effect (the model never wakes). Any
27
+ * `marker` events the tool emits are collected for the caller to persist, and a tool that errors or
28
+ * throws — or names an absent tool — is recorded as an error marker rather than vanishing into a log.
29
+ */
30
+ export async function dispatchTrigger(
31
+ services: MatbotMachine,
32
+ trigger: Trigger,
33
+ ctx: { session: Session; signal: AbortSignal; provider: string; prompt?: PromptFn },
34
+ ): Promise<DispatchOutcome> {
35
+ const markers: MessageContent[] = [];
36
+ const fail = (error: string): void => {
37
+ markers.push({ type: 'marker', creator: 'triggers', data: { triggerId: trigger.id, tool: trigger.invoke.tool, error } });
38
+ };
39
+
40
+ let result: unknown;
41
+ let hadResult = false;
42
+ // Total by construction: every path through here either returns an outcome or records a `fail`
43
+ // marker — nothing escapes. A trigger's tool can throw, hang-then-abort, or emit an error event,
44
+ // and the worst case is "this trigger did nothing and left a trace"; sibling triggers, the hook,
45
+ // and the session are unaffected. (The hook dispatcher's own try/catch is a second layer behind this.)
46
+ try {
47
+ const tool = services.tools.resolve(trigger.invoke.tool);
48
+ if (tool === null) {
49
+ console.warn(`[triggers] trigger ${trigger.id} invokes unknown tool "${trigger.invoke.tool}"; skipped.`);
50
+ fail(`tool "${trigger.invoke.tool}" not registered`);
51
+ return { hadResult: false, result: undefined, markers };
52
+ }
53
+
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
+ };
71
+
72
+ for await (const ev of tool.executor.execute(trigger.invoke.params, toolCtx)) {
73
+ if (ev.type === 'result') { result = ev.value; hadResult = true; }
74
+ else if (ev.type === 'marker') { markers.push({ type: 'marker', creator: ev.creator, data: ev.data }); }
75
+ else if (ev.type === 'error') {
76
+ console.warn(`[triggers] trigger ${trigger.id} tool "${trigger.invoke.tool}" errored: ${ev.message}`);
77
+ fail(ev.message);
78
+ }
79
+ }
80
+ } catch (e) {
81
+ console.warn(`[triggers] trigger ${trigger.id} tool "${trigger.invoke.tool}" threw:`, e);
82
+ fail(e instanceof Error ? e.message : String(e));
83
+ }
84
+
85
+ return { hadResult, result, markers };
86
+ }
87
+
88
+ /**
89
+ * Render a tool result into the text injected for the model. A string is used verbatim; an object
90
+ * with a string `content` field (the shape `skill_action(load)` and other prose-producing tools
91
+ * return) uses that; anything else is shown as JSON. (Rendering fidelity for arbitrary tools is a
92
+ * known rough edge — see the slice notes.)
93
+ */
94
+ export function renderResult(value: unknown): string {
95
+ if (typeof value === 'string') return value;
96
+ if (value !== null && typeof value === 'object' && typeof (value as { content?: unknown }).content === 'string') {
97
+ return (value as { content: string }).content;
98
+ }
99
+ return JSON.stringify(value, null, 2);
100
+ }
package/src/index.ts ADDED
@@ -0,0 +1,6 @@
1
+ export type { Trigger, TriggerCondition, TriggerInvoke, TriggerKind, TriggerSurface, TriggerSpec, Triggers, FiredCondition } from './types.js';
2
+ export { surfaceOfKind } from './types.js';
3
+ export { TriggerManager } from './manager.js';
4
+ export { dispatchTrigger, renderResult } from './dispatch.js';
5
+ export { createTriggerActionTool, createTriggersConfigTool } from './tools.js';
6
+ export { createTriggersPlugin, setupTriggers, plugin } from './plugin.js';
package/src/manager.ts ADDED
@@ -0,0 +1,219 @@
1
+ import type { Store, MatbotMachine } from '@matatbread/matbot-plugin-api';
2
+ import type { Trigger, TriggerSpec, TriggerSurface, TriggerKind, Triggers, FiredCondition } from './types.js';
3
+ import { surfaceOfKind } from './types.js';
4
+
5
+ const MAX_MSG_CHARS = 1500;
6
+
7
+ // Back-compat default: installs that configured a provider literally named "skills-classifier" (the
8
+ // former hard-coded classifier name) keep working with no migration. A `classifierProvider` setting
9
+ // overrides it; absent both, the classifier falls back to the turn's own provider.
10
+ const LEGACY_CLASSIFIER = 'skills-classifier';
11
+
12
+ function clip(text: string): string {
13
+ if (text.length <= MAX_MSG_CHARS) return text;
14
+ const half = Math.floor((MAX_MSG_CHARS - 3) / 2);
15
+ return text.slice(0, half) + '...' + text.slice(-half);
16
+ }
17
+
18
+ /** Stable identity for seed idempotency: a trigger is "the same" if it invokes the same tool with
19
+ * the same params. Triggers carry no name, so the invocation is the natural key. */
20
+ function invokeKey(t: { invoke: Trigger['invoke'] }): string {
21
+ return t.invoke.tool + '\u0000' + JSON.stringify(t.invoke.params ?? null);
22
+ }
23
+
24
+ /**
25
+ * Owns the live trigger set: an in-memory list backed by a {@link Store} for persistence. All CRUD
26
+ * goes through here so the plugin's hooks and the `trigger_action` tool share one source of truth.
27
+ * Constructed only with web-platform primitives, so it runs in the browser too.
28
+ */
29
+ export class TriggerManager implements Triggers {
30
+ private readonly triggers = new Map<string, Trigger>();
31
+ private readonly store: Store<Trigger>;
32
+ private readonly services: MatbotMachine;
33
+ // Aborts on teardown (clear()), ending the mounted-swap subscription set up in setupTriggers.
34
+ private readonly lifecycle = new AbortController();
35
+
36
+ constructor(store: Store<Trigger>, services: MatbotMachine) {
37
+ this.store = store;
38
+ this.services = services;
39
+ }
40
+
41
+ /** Ends with the manager (teardown). Hand to `services.mounted.consume` so a StorageBackend swap
42
+ * re-reads the new backend's triggers, and the loop stops when the plugin unloads. */
43
+ get signal(): AbortSignal { return this.lifecycle.signal; }
44
+
45
+ // The classifier provider, resolved live per evaluation (so a triggers_config change takes effect on
46
+ // the next turn): the `classifierProvider` setting if set and valid, else the legacy "skills-classifier"
47
+ // provider if present, else the current turn's own provider. There is always a turn provider to fall
48
+ // back to, so the classifier always has a model — triggers work with zero config.
49
+ async resolveClassifierProvider(turnProvider: string): Promise<string> {
50
+ const pinned = await this.services.settings().get<string>('classifierProvider');
51
+ if (pinned !== undefined && this.services.providers.has(pinned)) return pinned;
52
+ if (this.services.providers.has(LEGACY_CLASSIFIER)) return LEGACY_CLASSIFIER;
53
+ return turnProvider;
54
+ }
55
+
56
+ /** (Re)load persisted triggers into memory. Re-runnable: the initial boot load and every later
57
+ * StorageBackend swap funnel through here. Reading `this.store` (a swap-following proxy) always hits
58
+ * the live backend, so a swap re-reads the new backend's triggers. Clears first — the old in-memory
59
+ * set belongs to the displaced backend. */
60
+ async load(): Promise<void> {
61
+ this.triggers.clear();
62
+ const { items } = await this.store.query({});
63
+ for (const t of items) {
64
+ this.triggers.set(t.id, t);
65
+ }
66
+ }
67
+
68
+ all(): Trigger[] { return [...this.triggers.values()]; }
69
+ get(id: string): Trigger | undefined { return this.triggers.get(id); }
70
+
71
+ /** Triggers whose invocation matches the filter: `tool` (if given) must equal `invoke.tool`, and
72
+ * `params` (if given) must deep-equal `invoke.params`. The natural "which trigger(s) fire tool X
73
+ * (with these args)" lookup — e.g. the one that loads a given skill. */
74
+ query(filter: { tool?: string; params?: unknown }): Trigger[] {
75
+ return this.all().filter(t => {
76
+ if (filter.tool !== undefined && t.invoke.tool !== filter.tool) return false;
77
+ if (filter.params !== undefined &&
78
+ JSON.stringify(t.invoke.params ?? null) !== JSON.stringify(filter.params)) return false;
79
+ return true;
80
+ });
81
+ }
82
+
83
+ async add(spec: TriggerSpec): Promise<Trigger> {
84
+ const now = new Date().toISOString();
85
+ const doc: Trigger = {
86
+ id: crypto.randomUUID(),
87
+ version: Date.now().toString(),
88
+ conditions: spec.conditions,
89
+ invoke: spec.invoke,
90
+ ...(spec.enabled !== undefined ? { enabled: spec.enabled } : {}),
91
+ createdAt: now,
92
+ updatedAt: now,
93
+ };
94
+ await this.store.set(doc.id, doc);
95
+ this.triggers.set(doc.id, doc);
96
+ return doc;
97
+ }
98
+
99
+ async update(id: string, patch: Partial<TriggerSpec>): Promise<Trigger | undefined> {
100
+ const cur = this.triggers.get(id);
101
+ if (cur === undefined) return undefined;
102
+ return this.casMutate(cur, prev => ({
103
+ ...prev,
104
+ ...(patch.conditions !== undefined ? { conditions: patch.conditions } : {}),
105
+ ...(patch.invoke !== undefined ? { invoke: patch.invoke } : {}),
106
+ ...(patch.enabled !== undefined ? { enabled: patch.enabled } : {}),
107
+ version: Date.now().toString(),
108
+ updatedAt: new Date().toISOString(),
109
+ }));
110
+ }
111
+
112
+ async remove(id: string): Promise<boolean> {
113
+ const cur = this.triggers.get(id);
114
+ if (cur === undefined) return false;
115
+ await this.store.delete(id, cur.version);
116
+ this.triggers.delete(id);
117
+ return true;
118
+ }
119
+
120
+ async importIfAbsent(spec: TriggerSpec): Promise<Trigger> {
121
+ const key = invokeKey(spec);
122
+ const existing = this.all().find(t => invokeKey(t) === key);
123
+ if (existing !== undefined) return existing;
124
+ return this.add(spec);
125
+ }
126
+
127
+ clear(): void { this.lifecycle.abort(); this.triggers.clear(); }
128
+
129
+ /**
130
+ * LLM-judge every enabled condition on `surface` against the current turn and return the distinct
131
+ * triggers that fired, each with the set of `kinds` whose conditions matched (a trigger can carry
132
+ * more than one kind on the same surface — `retract` and `followup` both read the agent response —
133
+ * so the caller resolves the delivery) AND `matched` — the specific condition(s) that fired, with
134
+ * the classifier's one-line reason for each. Without `matched`, a trigger with several conditions on
135
+ * the same kind is indistinguishable in the trace from one with a single condition — "it fired" tells
136
+ * you nothing about *why*, which is the question a false-positive post-mortem actually asks. Both
137
+ * sides of the exchange are passed: `subject` is judged, `context` is what it is paired with — many
138
+ * conditions are relational ("disputes the previous answer") and can only be judged from the pair. No
139
+ * LLM call when there are no candidate conditions or the subject is empty.
140
+ */
141
+ async evaluate(
142
+ surface: TriggerSurface,
143
+ subject: { label: string; text: string },
144
+ context: { label: string; text: string },
145
+ signal: AbortSignal,
146
+ turnProvider: string,
147
+ ): Promise<{ trigger: Trigger; kinds: TriggerKind[]; matched: FiredCondition[] }[]> {
148
+ // Candidate key is `${triggerId}#${conditionIndex}` — addressing conditions by index is fine
149
+ // because evaluation is per-turn and the trigger set is stable for its duration. The surface a
150
+ // condition belongs to is derived from its `kind` (ephemeral/contextual→user, retract/followup→agent).
151
+ const candidates = this.all()
152
+ .filter(t => t.enabled !== false)
153
+ .flatMap(t => t.conditions
154
+ .map((c, i) => ({ triggerId: t.id, key: `${t.id}#${i}`, index: i, kind: c.kind, rule: c.rule }))
155
+ .filter(c => surfaceOfKind(c.kind) === surface));
156
+ if (candidates.length === 0 || subject.text === '') return [];
157
+
158
+ const res = await this.services.singleTurn({
159
+ provider: await this.resolveClassifierProvider(turnProvider),
160
+ signal,
161
+ system:
162
+ 'You are a trigger classifier for a conversational assistant. Below is the current exchange — ' +
163
+ 'the user message and the assistant message, in chronological order and clearly labelled — ' +
164
+ `followed by a list of conditions. Evaluate each condition against the "${subject.label}". The ` +
165
+ `"${context.label}" is the message it is paired with; use it fully whenever a condition is ` +
166
+ 'relational (refers to what was asked, answered, disputed, or repeated). Fire a condition when, ' +
167
+ `reading the "${subject.label}" in light of the "${context.label}", it holds. Return ONLY a JSON ` +
168
+ 'object mapping each condition id (the bracketed value) to an object {"match": true|false, ' +
169
+ '"why": "<one short sentence citing the specific evidence>"}. No other text.',
170
+ prompt:
171
+ `${context.label} (earlier):\n${context.text === '' ? '(none)' : clip(context.text)}\n\n` +
172
+ `${subject.label} (later — evaluate the conditions against THIS):\n${clip(subject.text)}\n\n` +
173
+ `Conditions:\n${candidates.map(c => `[${c.key}] ${c.rule}`).join('\n')}`,
174
+ });
175
+
176
+ let verdicts: Record<string, unknown> = {};
177
+ try {
178
+ const m = res.text.match(/\{[\s\S]*\}/);
179
+ verdicts = m ? JSON.parse(m[0]) : {};
180
+ } catch {
181
+ console.warn(`[triggers] ${surface} classifier returned non-JSON:`, res.text.slice(0, 200));
182
+ return [];
183
+ }
184
+
185
+ // Group fired conditions back to their triggers, keeping each matched condition's index/rule/why
186
+ // alongside the distinct kinds that matched (kinds is a projection of matched, kept for callers
187
+ // that only need delivery routing).
188
+ const firedByTrigger = new Map<string, FiredCondition[]>();
189
+ for (const c of candidates) {
190
+ const v = verdicts[c.key] as { match?: unknown; why?: unknown } | boolean | undefined;
191
+ const isMatch = typeof v === 'object' && v !== null ? v.match === true : v === true;
192
+ if (!isMatch) continue;
193
+ const why = typeof v === 'object' && v !== null && typeof v.why === 'string' ? v.why : undefined;
194
+ const list = firedByTrigger.get(c.triggerId) ?? [];
195
+ list.push({ index: c.index, kind: c.kind, rule: c.rule, ...(why !== undefined ? { why } : {}) });
196
+ firedByTrigger.set(c.triggerId, list);
197
+ }
198
+ return [...firedByTrigger].flatMap(([id, matched]) => {
199
+ const trigger = this.triggers.get(id);
200
+ return trigger ? [{ trigger, kinds: [...new Set(matched.map(m => m.kind))], matched }] : [];
201
+ });
202
+ }
203
+
204
+ private async casMutate(doc: Trigger, mutate: (cur: Trigger) => Trigger): Promise<Trigger> {
205
+ let cur = doc;
206
+ for (;;) {
207
+ const next = mutate(cur);
208
+ const r = await this.store.cas(cur.id, cur.version, next);
209
+ if (r.ok) { this.triggers.set(next.id, next); return next; }
210
+ const fresh = await this.store.get(cur.id);
211
+ if (fresh === null) {
212
+ await this.store.set(next.id, next);
213
+ this.triggers.set(next.id, next);
214
+ return next;
215
+ }
216
+ cur = fresh;
217
+ }
218
+ }
219
+ }
package/src/plugin.ts ADDED
@@ -0,0 +1,373 @@
1
+ import { PLUGIN_API_VERSION } from '@matatbread/matbot-plugin-api';
2
+ import type { MatbotPluginSpec, MatbotMachine, Store, Message, MessageContent } from '@matatbread/matbot-plugin-api';
3
+ import { TriggerManager } from './manager.js';
4
+ import { dispatchTrigger, renderResult } from './dispatch.js';
5
+ import { createTriggerActionTool, createTriggersConfigTool } from './tools.js';
6
+ import type { Trigger, Triggers, FiredCondition } from './types.js';
7
+
8
+ /** A firing trigger's id plus the specific condition(s) that matched — the unit markers trace, so a
9
+ * post-mortem can see not just that a trigger fired but which rubric the classifier judged true and
10
+ * (if it said) why. */
11
+ interface FiredSource {
12
+ id: string;
13
+ matched: FiredCondition[];
14
+ }
15
+
16
+ declare module '@matatbread/matbot-plugin-api' {
17
+ interface MatbotServices {
18
+ /** The live trigger set (data-driven hooks). Registered by setupTriggers; consumed by plugins
19
+ * that seed built-in triggers (e.g. cognition). Its presence is the "triggers wired" signal. */
20
+ Triggers?: Triggers;
21
+ }
22
+ }
23
+
24
+ /** One-shot reachability probe for a pinned provider, used only when forming installationMessage
25
+ * (install/reload) — never on the hot path. Fails soft: a thrown error becomes `{ ok: false }`. */
26
+ async function testProvider(services: MatbotMachine, provider: string): Promise<{ ok: boolean; error?: string }> {
27
+ try {
28
+ await services.singleTurn({ provider, prompt: 'Reply with "ok".', signal: AbortSignal.timeout(15000) });
29
+ return { ok: true };
30
+ } catch (e) {
31
+ return { ok: false, error: e instanceof Error ? e.message : String(e) };
32
+ }
33
+ }
34
+
35
+ // Multiple fired triggers' results are joined with a separator.
36
+ const JOIN = '\n\n---\n\n';
37
+
38
+ // Out-of-band tool output is delivered by folding it onto the user turn (user phase / agent retract)
39
+ // or as a robo turn (agent followup). Without a provenance marker the model reads it as the user
40
+ // speaking — observed in testing: the model's own thinking said "the user wants me to follow the
41
+ // skill…" about a system-injected directive. The dispatcher stays a dumb transport (it injects
42
+ // whatever a tool yields); this fence is the triggers plugin's own framing, marking the payload as
43
+ // system-supplied so the model treats it as context to act on, not a user utterance. A tool whose
44
+ // result is already a directive (e.g. `skill_action(use)`) self-frames *what* to do; the fence adds
45
+ // *who* supplied it — orthogonal, composes cleanly.
46
+ function fence(body: string): string {
47
+ return `[Additional context — supplied by the system, not part of the user's message. ` +
48
+ `Take it into account when responding.]\n\n${body}\n\n[End of additional context.]`;
49
+ }
50
+
51
+ // A durable trace of a user-phase injection. For `ephemeral-inject` the injected `text` is never
52
+ // otherwise persisted, so without this a post-mortem can't see what the system fed the model before it
53
+ // answered; for `durable-inject` the text IS persisted (folded onto the user turn), so `text` is
54
+ // omitted and the marker records only WHICH condition fired and why. LLM-invisible like any marker; a
55
+ // frontend may ignore it (diagnostic, not user-facing). `triggers` are the firing sources (id +
56
+ // matched conditions). (The agent-phase injections are traced elsewhere: a retract redo's context by
57
+ // the core retraction marker, and a `followup` resubmit by its persisted robo turn.)
58
+ function injectionMarker(event: 'ephemeral-inject' | 'durable-inject', triggers: FiredSource[], text?: string): MessageContent {
59
+ return { type: 'marker', creator: 'triggers', data: { event, surface: 'user', triggers, ...(text !== undefined ? { text } : {}) } };
60
+ }
61
+
62
+ // The core retraction marker's creator (core/src/session-runner.ts). Hardcoded as a
63
+ // documented cross-package string contract — markers are keyed by a creator string, and triggers
64
+ // depends only on plugin-api, not core. Keep in sync with RETRACTION_CREATOR there.
65
+ const RETRACTION_CREATOR = 'matbot-retraction';
66
+
67
+ // Suppression is NEVER silent: when a guard holds a trigger back, it leaves this marker naming the
68
+ // `cause` (a machine tag), a human `reason`, and the triggers (id + matched conditions) — so "why
69
+ // didn't it fire?" is answerable from the session months later rather than from a remembered heuristic.
70
+ // `retractFiredMarker` records which triggers/conditions caused a retract. Both are LLM-invisible
71
+ // diagnostics. The convergence guard reads back BOTH retract-fired AND its own `retract-convergence`
72
+ // suppressions (see retractActiveLastTurn).
73
+ type SuppressCause = 'user-redo' | 'retract-convergence' | 'followup-shadowed';
74
+ function suppressedMarker(cause: SuppressCause, reason: string, triggers: FiredSource[]): MessageContent {
75
+ return { type: 'marker', creator: 'triggers', data: { event: 'suppressed', cause, reason, triggers } };
76
+ }
77
+ function retractFiredMarker(triggers: FiredSource[]): MessageContent {
78
+ return { type: 'marker', creator: 'triggers', data: { event: 'retract-fired', triggers } };
79
+ }
80
+
81
+ // True when the latest turn is a retract-redo: a `matbot-retraction` marker sits after the last genuine
82
+ // (non-robo) user message — i.e. this user message was already processed on the original attempt, so
83
+ // user-phase (ephemeral/contextual) triggers and their side effects already fired. Re-firing them on the
84
+ // redo is the duplicate-side-effect bug; skip them.
85
+ function isRetractRedo(messages: Message[]): boolean {
86
+ const lastUser = messages.findLastIndex(m => m.role === 'user' && !m.content.every(c => c.origin === 'robo'));
87
+ if (lastUser < 0) return false;
88
+ return messages.slice(lastUser + 1).some(m => m.content.some(c => c.type === 'marker' && c.creator === RETRACTION_CREATOR));
89
+ }
90
+
91
+ // Retract trigger ids that were ACTIVE on the PREVIOUS turn (the region between the second-to-last and
92
+ // last genuine user messages) — active meaning the rule either fired a retract OR was itself held off by
93
+ // the convergence guard. Counting suppressions too is what makes the guard *converge* rather than
94
+ // oscillate: a rule that keeps matching stays held off turn after turn (each suppression re-arms the
95
+ // guard), instead of firing every other turn because a suppressed turn left no trace. It un-sticks only
96
+ // when the rule genuinely stops matching for a turn (no marker), after which it may fire fresh. A
97
+ // well-behaved rule self-terminates and never lands here.
98
+ function retractActiveLastTurn(messages: Message[]): Set<string> {
99
+ const userIdxs: number[] = [];
100
+ messages.forEach((m, i) => { if (m.role === 'user' && !m.content.every(c => c.origin === 'robo')) userIdxs.push(i); });
101
+ const cur = userIdxs.at(-1);
102
+ if (cur === undefined) return new Set();
103
+ const prev = userIdxs.at(-2) ?? -1;
104
+ const ids = new Set<string>();
105
+ for (let i = prev + 1; i < cur; i++) {
106
+ for (const c of messages[i]!.content) {
107
+ if (c.type !== 'marker' || c.creator !== 'triggers') continue;
108
+ const d = c.data as { event?: unknown; cause?: unknown; triggers?: unknown };
109
+ const active = d?.event === 'retract-fired' || (d?.event === 'suppressed' && d?.cause === 'retract-convergence');
110
+ if (active && Array.isArray(d.triggers)) {
111
+ for (const t of d.triggers) {
112
+ if (typeof t === 'object' && t !== null && typeof (t as { id?: unknown }).id === 'string') ids.add((t as { id: string }).id);
113
+ }
114
+ }
115
+ }
116
+ }
117
+ return ids;
118
+ }
119
+
120
+ function textOf(msg: Message | undefined): string {
121
+ return msg?.content.filter(c => c.type === 'text').map(c => c.text).join('\n') ?? '';
122
+ }
123
+
124
+ /**
125
+ * Wire the triggers subsystem: build the {@link TriggerManager}, load persisted triggers, register
126
+ * the `Triggers` service and the `trigger_action` tool, then install the two evaluation hooks:
127
+ *
128
+ * user — a `screen` hook (pre-response) that judges `user`-phase conditions against the incoming
129
+ * message, invokes each fired trigger's tool, and delivers the result per the fired
130
+ * condition's `kind`: `ephemeral` (inject for this turn only, never persisted) or
131
+ * `contextual` (fold DURABLY onto the user message — persisted + visible — so it updates
132
+ * the conversation rather than informing one answer).
133
+ * agent — a `followup` hook (post-commit) that judges agent-surface conditions against the
134
+ * assistant's response, invokes each fired trigger's tool, and delivers the result per the
135
+ * fired condition's `kind`: `retract` (discard the response and re-run with the result
136
+ * injected) or `followup` (keep the response and resubmit the result as a robo turn).
137
+ *
138
+ * Both phases fence the injected payload (see {@link fence}) so the model reads it as system-supplied
139
+ * context, not a user utterance.
140
+ *
141
+ * Returns the manager so a specialization (a node watcher, say) could attach to the same instance.
142
+ * Uses only web-platform APIs.
143
+ */
144
+ export async function setupTriggers(services: MatbotMachine): Promise<TriggerManager> {
145
+ // Idempotent on the registered service, not a module flag (a re-import would reset a flag; the
146
+ // registry persists for the process) — a second setupTriggers hands back the live manager.
147
+ if (services.Triggers) return services.Triggers as TriggerManager;
148
+
149
+ const store = services.createStore<Trigger>('triggers') as Store<Trigger>;
150
+ const manager = new TriggerManager(store, services);
151
+ await manager.load();
152
+ // Re-read after a deferred StorageBackend swap lands: the new backend's `triggers` namespace replaces
153
+ // the old in-memory set. No `replay` — the initial load is the boot load above; this reacts only to
154
+ // future swaps. Ends with the manager (teardown aborts manager.signal).
155
+ services.mounted.consume({ key: 'StorageBackend', signal: manager.signal }, () => void manager.load());
156
+ await services.register('Triggers', manager);
157
+
158
+ services.tools.register(createTriggerActionTool(manager));
159
+ services.tools.register(createTriggersConfigTool(services));
160
+
161
+ // user phase — pre-response. Judges the incoming user message and delivers each fired trigger's tool
162
+ // result by its kind: `ephemeral` informs this response only; `contextual` folds durably onto the
163
+ // user turn so it persists into the conversation.
164
+ services.hooks.register({
165
+ on: 'screen',
166
+ async handler(ctx) {
167
+ const lastUser = ctx.session.messages.findLast(l => l.role === 'user');
168
+ // Skip our own agent-phase robo resubmissions — they are not real user turns.
169
+ if (!lastUser || lastUser.content.every(c => c.origin === 'robo')) return;
170
+
171
+ // Guard: a retract-redo re-runs this same user turn. The user-phase (ephemeral/contextual)
172
+ // triggers already fired — and their side effects (e.g. remember_fact's store write, or a
173
+ // contextual fold persisted onto the user message) already happened — on the original attempt,
174
+ // so re-firing here double-applies them. Hold off, and record why (suppression is never silent).
175
+ // The redo still gets the retract correction via the injected context.
176
+ if (isRetractRedo(ctx.session.messages)) {
177
+ return { markers: [suppressedMarker('user-redo', 'user-phase triggers held off: retract-redo (user message already processed on the original attempt)', [])] };
178
+ }
179
+
180
+ const fired = await manager.evaluate(
181
+ 'user',
182
+ { label: 'latest user message', text: textOf(lastUser) },
183
+ { label: 'preceding assistant message', text: textOf(ctx.session.messages.findLast(
184
+ l => l.role === 'assistant' && l.content.some(c => c.type === 'text'),
185
+ )) },
186
+ ctx.signal,
187
+ ctx.config.provider,
188
+ );
189
+ if (fired.length === 0) return;
190
+
191
+ // Partition fired triggers' output by delivery. A trigger can carry both kinds on this surface;
192
+ // if any `contextual` condition fired the output goes durable, since a durable fold is also sent
193
+ // on this very turn (it lands in the user message before the provider call) — it is a superset of
194
+ // ephemeral, so "contextual dominates" loses nothing, mirroring retract-over-followup on the
195
+ // agent surface. Markers are collected regardless — they persist whichever path runs.
196
+ const ephemeralBodies: string[] = [];
197
+ const ephemeralSources: FiredSource[] = [];
198
+ const durableBodies: string[] = [];
199
+ const durableSources: FiredSource[] = [];
200
+ const markers: MessageContent[] = [];
201
+ for (const { trigger, kinds, matched } of fired) {
202
+ const out = await dispatchTrigger(services, trigger, { session: ctx.session, signal: ctx.signal, provider: ctx.config.provider, ...(ctx.prompt !== undefined ? { prompt: ctx.prompt } : {}) });
203
+ if (out.hadResult) {
204
+ if (kinds.includes('contextual')) {
205
+ durableBodies.push(renderResult(out.result));
206
+ durableSources.push({ id: trigger.id, matched: matched.filter(m => m.kind === 'contextual') });
207
+ } else {
208
+ ephemeralBodies.push(renderResult(out.result));
209
+ ephemeralSources.push({ id: trigger.id, matched: matched.filter(m => m.kind === 'ephemeral') });
210
+ }
211
+ }
212
+ markers.push(...out.markers);
213
+ }
214
+ // Trace each injection. The ephemeral text is never otherwise persisted, so the marker carries it;
215
+ // the durable text rides the user message itself, so its marker records only the firing sources.
216
+ if (ephemeralBodies.length > 0) markers.push(injectionMarker('ephemeral-inject', ephemeralSources, ephemeralBodies.join(JOIN)));
217
+ if (durableBodies.length > 0) markers.push(injectionMarker('durable-inject', durableSources));
218
+ if (ephemeralBodies.length === 0 && durableBodies.length === 0 && markers.length === 0) return;
219
+
220
+ return {
221
+ // The dispatcher appends markers to the session AND emits them live (consistent draw/reload).
222
+ // `ephemeral` informs only this turn; `durable` folds onto the user turn (origin: 'robo'),
223
+ // persists, and is carried live as a robo-user event.
224
+ ...(markers.length > 0 ? { markers } : {}),
225
+ ...(ephemeralBodies.length > 0 ? { ephemeral: [{ type: 'text', text: fence(ephemeralBodies.join(JOIN)) }] } : {}),
226
+ ...(durableBodies.length > 0 ? { durable: [{ type: 'text', text: fence(durableBodies.join(JOIN)), origin: 'robo' as const }] } : {}),
227
+ };
228
+ },
229
+ });
230
+
231
+ // agent phase — post-commit. Judges the assistant's response (with the preceding user message as
232
+ // relational context), invokes fired triggers, and delivers each one per its condition's `kind`:
233
+ // retract — the response was WRONG: pop it and re-run the user turn with the output injected.
234
+ // followup — the response STANDS but needs a steer: keep it, resubmit a robo turn carrying the
235
+ // output (so the response remains in context for the steer to make sense).
236
+ // Skips our own redos/resubmits (resubmitDepth > 0) so a fired trigger can't re-fire on its output.
237
+ services.hooks.register({
238
+ on: 'followup',
239
+ async handler(ctx) {
240
+ if (ctx.resubmitDepth > 0) return;
241
+
242
+ const fired = await manager.evaluate(
243
+ 'agent',
244
+ { label: 'assistant response', text: textOf(ctx.session.messages.findLast(
245
+ l => l.role === 'assistant' && l.content.some(c => c.type === 'text'),
246
+ )) },
247
+ { label: 'preceding user message', text: textOf(ctx.session.messages.findLast(l => l.role === 'user')) },
248
+ ctx.signal,
249
+ ctx.config.provider,
250
+ );
251
+ if (fired.length === 0) return;
252
+
253
+ // Convergence guard: a retract rule that already retracted on the PREVIOUS turn and is about to
254
+ // again has not converged (the redo didn't dissolve its condition). Hold those rules off so a
255
+ // mis-tuned retract can't pop-and-redo every turn forever. A well-behaved rule self-terminates
256
+ // (its redo cures the defect, so it won't fire next turn) and never lands here.
257
+ const heldOff = retractActiveLastTurn(ctx.session.messages);
258
+
259
+ // Partition fired triggers' output by their kind. A trigger can carry both retract and followup
260
+ // conditions; if any retract condition fired, the trigger is treated as retract (a wrong answer
261
+ // can't be merely steered). Markers are collected regardless — they persist whichever path runs.
262
+ const retractBodies: string[] = [];
263
+ const retractSources: FiredSource[] = [];
264
+ const followupBodies: string[] = [];
265
+ const followupSources: FiredSource[] = [];
266
+ const suppressed: FiredSource[] = [];
267
+ const markers: MessageContent[] = [];
268
+ for (const { trigger, kinds, matched } of fired) {
269
+ if (kinds.includes('retract') && heldOff.has(trigger.id)) {
270
+ suppressed.push({ id: trigger.id, matched: matched.filter(m => m.kind === 'retract') });
271
+ continue;
272
+ }
273
+ const out = await dispatchTrigger(services, trigger, { session: ctx.session, signal: ctx.signal, provider: ctx.config.provider, ...(ctx.prompt !== undefined ? { prompt: ctx.prompt } : {}) });
274
+ if (out.hadResult) {
275
+ if (kinds.includes('retract')) {
276
+ retractBodies.push(renderResult(out.result));
277
+ retractSources.push({ id: trigger.id, matched: matched.filter(m => m.kind === 'retract') });
278
+ } else {
279
+ followupBodies.push(renderResult(out.result));
280
+ followupSources.push({ id: trigger.id, matched: matched.filter(m => m.kind === 'followup') });
281
+ }
282
+ }
283
+ markers.push(...out.markers);
284
+ }
285
+ // Suppression is never silent — record which rules were held off and why.
286
+ if (suppressed.length > 0) {
287
+ markers.push(suppressedMarker('retract-convergence', 'retract held off: this rule was active on the previous turn and is still matching (non-converging)', suppressed));
288
+ }
289
+
290
+ // Retract dominates: if any trigger judged the response WRONG, the response is discarded, so a
291
+ // steer that critiques it has lost its subject — skip the followup bodies this turn. That skip is
292
+ // a suppression too, so it leaves a `suppressed` marker (not just a log): a followup that "didn't
293
+ // fire" because a retract preempted it is answerable from the session. The redo's injected context
294
+ // is recorded by the core retraction marker; the `retract-fired` marker lets the next turn's
295
+ // convergence guard see this firing.
296
+ if (retractBodies.length > 0) {
297
+ if (followupBodies.length > 0) {
298
+ markers.push(suppressedMarker('followup-shadowed', 'followup steer skipped: a retract on the same turn supersedes the response it would critique', followupSources));
299
+ }
300
+ markers.push(retractFiredMarker(retractSources));
301
+ return {
302
+ retractAndRerun: { context: [{ type: 'text', text: fence(retractBodies.join(JOIN)) }] },
303
+ markers,
304
+ };
305
+ }
306
+ if (followupBodies.length > 0) {
307
+ return {
308
+ resubmit: { content: [{ type: 'text', origin: 'robo', text: fence(followupBodies.join(JOIN)) }] },
309
+ ...(markers.length > 0 ? { markers } : {}),
310
+ };
311
+ }
312
+ // No model-facing result from any trigger — only markers (silent side-effects), if any.
313
+ return markers.length > 0 ? { markers } : undefined;
314
+ },
315
+ });
316
+
317
+ return manager;
318
+ }
319
+
320
+ /**
321
+ * The cross-runtime triggers plugin: stored conditions that, when an LLM classifier judges them
322
+ * matched, invoke a tool. CRUD via `trigger_action`; the `agent`/`user` conditions are evaluated by a
323
+ * classifier provider (pinned via `triggers_config`, else the turn's own provider). Runs in both Node
324
+ * and the browser.
325
+ */
326
+ export function createTriggersPlugin(): MatbotPluginSpec {
327
+ let manager: TriggerManager | undefined;
328
+ let captured: MatbotMachine | undefined; // captured in setup() so installationMessage() can probe
329
+
330
+ const base =
331
+ 'Triggers are active (trigger_action). A trigger fires a tool when an LLM classifier judges one of ' +
332
+ 'its conditions matched against the current turn.';
333
+
334
+ return {
335
+ apiVersion: PLUGIN_API_VERSION,
336
+ manifest: {
337
+ description: 'Data-driven hooks: stored conditions that invoke a tool when an LLM classifier judges them matched. CRUD via trigger_action. Cross-runtime (node + browser).',
338
+ },
339
+
340
+ async installationMessage() {
341
+ if (!captured) return base;
342
+ const pinned = await captured.settings().get<string>('classifierProvider');
343
+ const available = [...captured.providers.keys()];
344
+ const legacy = captured.providers.has('skills-classifier');
345
+ if (pinned === undefined) {
346
+ return base +
347
+ (legacy
348
+ ? '\n\nThe classifier uses the provider "skills-classifier" (the legacy default present in this install).'
349
+ : '\n\nNo classifier provider is pinned, so the classifier uses whatever provider the current turn ' +
350
+ 'runs on — triggers work out of the box.') +
351
+ ' To pin a small/fast model instead, use the triggers_config tool (action "set"). ' +
352
+ `Available providers: ${available.join(', ') || '(none)'}.`;
353
+ }
354
+ const probe = await testProvider(captured, pinned);
355
+ return base +
356
+ `\n\nThe classifier is pinned to "${pinned}" (triggers_config), which ` +
357
+ (probe.ok
358
+ ? 'responded to a test prompt.'
359
+ : `did NOT respond: ${probe.error}. It falls back to the turn's own provider until fixed.`);
360
+ },
361
+
362
+ async setup(services) {
363
+ captured = services;
364
+ manager = await setupTriggers(services);
365
+ },
366
+
367
+ async teardown() {
368
+ manager?.clear();
369
+ },
370
+ };
371
+ }
372
+
373
+ export const plugin: MatbotPluginSpec = createTriggersPlugin();
package/src/tools.ts ADDED
@@ -0,0 +1,230 @@
1
+ import type { Tool, ToolExecutor, ToolContext, ToolEvent, MatbotMachine } from '@matatbread/matbot-plugin-api';
2
+ import type { TriggerManager } from './manager.js';
3
+ import type { TriggerCondition, TriggerKind } from './types.js';
4
+
5
+ const KINDS: readonly TriggerKind[] = ['ephemeral', 'contextual', 'retract', 'followup'];
6
+ const isKind = (x: unknown): x is TriggerKind => typeof x === 'string' && (KINDS as readonly string[]).includes(x);
7
+
8
+ const GUIDANCE =
9
+ 'A trigger is a data-driven hook: a list of `conditions` and the single tool call (`invoke`) to ' +
10
+ 'make when ANY condition matches. The conditions are the OR — different ways to recognise the ' +
11
+ 'situation — and the invocation is the one consequence.\n\n' +
12
+ 'Each condition has a `kind` and a `rule`:\n' +
13
+ '• The `rule` is a CONDITION on the FORM or SENTIMENT of a message, NOT its topic (topical ' +
14
+ 'relevance is found by search, so keyword/entity rules are redundant noise — do not write them). ' +
15
+ 'Phrase it as a single LLM-judged rubric "MATCH if the message is …; DO NOT MATCH if …".\n' +
16
+ '• `kind` fixes WHAT is judged and WHAT happens when it matches:\n' +
17
+ ' - "ephemeral": judge the USER MESSAGE (e.g. frustration, a fact worth surfacing now); run the ' +
18
+ 'tool and inject its output into the response about to be written — for THIS turn only, not kept.\n' +
19
+ ' - "contextual": judge the USER MESSAGE; run the tool and fold its output DURABLY onto the user ' +
20
+ "turn, so it persists into the conversation and every later turn sees it. Choose ephemeral vs " +
21
+ 'contextual by whether a match means "use this for this answer" vs "this should become part of the ' +
22
+ 'session".\n' +
23
+ ' - "retract": judge the ASSISTANT RESPONSE; the response is WRONG, so discard it and regenerate ' +
24
+ 'the turn with the output as context (e.g. a corrected field name invalidates everything downstream).\n' +
25
+ ' - "followup": judge the ASSISTANT RESPONSE; the response STANDS but needs a steer or ' +
26
+ 'verification, so keep it and add a follow-up turn carrying the output (e.g. critique/verify the ' +
27
+ 'existing answer — it must remain in context). Choose retract vs followup by whether a match means ' +
28
+ '"this is wrong" vs "look at this again".\n\n' +
29
+ '`invoke` names a tool and its params, run verbatim when the trigger fires. If the tool produces ' +
30
+ 'a result, the model is woken with it; a pure side-effect tool (no result) runs silently. An ' +
31
+ 'invoke naming a tool that is not present fails soft — the trigger simply does nothing until it ' +
32
+ 'is. To fire a skill on a condition, invoke `skill_action` with `{ action: "use", name }` — `use` ' +
33
+ 'applies the skill as a directive (the firing case). (`{ action: "load" }` returns raw content for ' +
34
+ 'reading/editing, not for firing — a `load` result is bare text the model may misread as the user speaking.)';
35
+
36
+ type TriggerActionInput =
37
+ | { action: 'list' }
38
+ | { action: 'query'; tool?: string; params?: unknown }
39
+ | { action: 'get'; id: string }
40
+ | { action: 'add'; conditions: TriggerCondition[]; tool: string; params?: unknown; enabled?: boolean }
41
+ | { action: 'update'; id: string; conditions?: TriggerCondition[]; tool?: string; params?: unknown; enabled?: boolean }
42
+ | { action: 'remove'; id: string };
43
+
44
+ // A condition is valid if it has a recognised `kind` and a string `rule`.
45
+ function validConditions(x: unknown): x is TriggerCondition[] {
46
+ return Array.isArray(x) && x.every(c =>
47
+ c !== null && typeof c === 'object' &&
48
+ isKind((c as { kind?: unknown }).kind) &&
49
+ typeof (c as { rule?: unknown }).rule === 'string');
50
+ }
51
+
52
+ export function createTriggerActionTool(manager: TriggerManager): Tool {
53
+ const executor: ToolExecutor = {
54
+ async *execute(input: unknown, _ctx: ToolContext): AsyncIterable<ToolEvent> {
55
+ const args = input as Partial<TriggerActionInput> & { action?: string };
56
+
57
+ switch (args.action) {
58
+ case 'list': {
59
+ yield { type: 'result', value: { triggers: manager.all() } };
60
+ return;
61
+ }
62
+
63
+ case 'query': {
64
+ 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
+ }) } };
69
+ return;
70
+ }
71
+
72
+ case 'get': {
73
+ const { id } = args as Extract<TriggerActionInput, { action: 'get' }>;
74
+ if (!id) { yield { type: 'error', message: 'action "get" requires "id".' }; return; }
75
+ const t = manager.get(id);
76
+ if (!t) { yield { type: 'error', message: `Trigger not found: "${id}"` }; return; }
77
+ yield { type: 'result', value: t };
78
+ return;
79
+ }
80
+
81
+ case 'add': {
82
+ const a = args as Extract<TriggerActionInput, { action: 'add' }>;
83
+ if (!validConditions(a.conditions)) { yield { type: 'error', message: `action "add" requires "conditions": [{ kind: ${KINDS.join('|')}, rule: string }].` }; return; }
84
+ if (a.conditions.length === 0) { yield { type: 'error', message: 'action "add" requires at least one condition.' }; return; }
85
+ if (typeof a.tool !== 'string') { yield { type: 'error', message: 'action "add" requires a string "tool" to invoke.' }; return; }
86
+ const t = await manager.add({
87
+ conditions: a.conditions,
88
+ invoke: { tool: a.tool, ...(a.params !== undefined ? { params: a.params } : {}) },
89
+ ...(a.enabled !== undefined ? { enabled: a.enabled } : {}),
90
+ });
91
+ yield { type: 'result', value: { id: t.id } };
92
+ return;
93
+ }
94
+
95
+ case 'update': {
96
+ const a = args as Extract<TriggerActionInput, { action: 'update' }>;
97
+ if (!a.id) { yield { type: 'error', message: 'action "update" requires "id".' }; return; }
98
+ if (a.conditions !== undefined && !validConditions(a.conditions)) { yield { type: 'error', message: '"conditions" must be [{ kind, rule }].' }; return; }
99
+ if (a.tool !== undefined && typeof a.tool !== 'string') { yield { type: 'error', message: '"tool" must be a string.' }; return; }
100
+ const t = await manager.update(a.id, {
101
+ ...(a.conditions !== undefined ? { conditions: a.conditions } : {}),
102
+ ...(a.tool !== undefined || a.params !== undefined
103
+ ? { invoke: { tool: a.tool ?? manager.get(a.id)?.invoke.tool ?? '', ...(a.params !== undefined ? { params: a.params } : {}) } }
104
+ : {}),
105
+ ...(a.enabled !== undefined ? { enabled: a.enabled } : {}),
106
+ });
107
+ if (t === undefined) { yield { type: 'error', message: `Trigger not found: "${a.id}"` }; return; }
108
+ yield { type: 'result', value: t };
109
+ return;
110
+ }
111
+
112
+ case 'remove': {
113
+ const { id } = args as Extract<TriggerActionInput, { action: 'remove' }>;
114
+ if (!id) { yield { type: 'error', message: 'action "remove" requires "id".' }; return; }
115
+ const ok = await manager.remove(id);
116
+ if (!ok) { yield { type: 'error', message: `Trigger not found: "${id}"` }; return; }
117
+ yield { type: 'result', value: { id } };
118
+ return;
119
+ }
120
+
121
+ default:
122
+ yield { type: 'error', message: `Unknown action "${String(args.action)}". Expected one of: list, query, get, add, update, remove.` };
123
+ }
124
+ },
125
+ };
126
+
127
+ return {
128
+ name: 'trigger_action',
129
+ description:
130
+ 'Manage triggers — data-driven hooks that invoke a tool when an LLM classifier judges one of ' +
131
+ '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' +
133
+ GUIDANCE + '\n\n' +
134
+ 'A trigger has a stable `id` — address it by that, never by its conditions. To change one, ' +
135
+ '"get" or "list" first to read the id, then "update" by id.\n\n' +
136
+ 'Parameters depend on `action` (TypeScript):\n' +
137
+ '```ts\n' +
138
+ "type TriggerKind = 'ephemeral' | 'contextual' | 'retract' | 'followup'; // ephemeral=judge user msg+inject for this turn; contextual=judge user msg+fold durably onto it; retract=wrong, redo; followup=stands, add steer turn\n" +
139
+ 'type TriggerCondition = { kind: TriggerKind; rule: string };\n' +
140
+ 'type TriggerAction =\n' +
141
+ " | { action: 'list' } // -> { triggers: [...] }\n" +
142
+ " | { action: 'query'; tool?: string; params?: object } // triggers invoking that tool -> { triggers: [...] }\n" +
143
+ " | { action: 'get'; id: string } // -> the trigger\n" +
144
+ " | { action: 'add'; conditions: TriggerCondition[]; tool: string; params?: object; enabled?: boolean } // -> { id }\n" +
145
+ " | { action: 'update'; id: string; conditions?: TriggerCondition[]; tool?: string; params?: object; enabled?: boolean } // edit by id\n" +
146
+ " | { action: 'remove'; id: string }; // -> { id }\n" +
147
+ '```',
148
+ inputSchema: {
149
+ type: 'object',
150
+ required: ['action'],
151
+ properties: {
152
+ action: { type: 'string', enum: ['list', 'query', 'get', 'add', 'update', 'remove'], description: 'The operation to perform.' },
153
+ id: { type: 'string', description: 'Trigger id. Required for get/update/remove.' },
154
+ conditions: { type: 'array', description: 'Conditions [{ kind: "ephemeral"|"contextual"|"retract"|"followup", rule: string }]. Required for add.',
155
+ 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.' },
158
+ enabled: { type: 'boolean', description: 'Set false to keep but disable the trigger.' },
159
+ },
160
+ },
161
+ executor,
162
+ };
163
+ }
164
+
165
+ /**
166
+ * Get/set which provider the trigger classifier uses to judge conditions. The provider is an alias for
167
+ * one of the already-configured providers, not a new one: unset, the classifier uses the current turn's
168
+ * own provider (so triggers work with zero config); set it to pin a small/fast model. Resolved per
169
+ * evaluation, so a change takes effect on the next turn without a restart.
170
+ */
171
+ export function createTriggersConfigTool(services: MatbotMachine): Tool {
172
+ const KEY = 'classifierProvider';
173
+ const executor: ToolExecutor = {
174
+ async *execute(input: unknown, _ctx: ToolContext): AsyncIterable<ToolEvent> {
175
+ const args = input as { action?: string; provider?: string };
176
+ const settings = services.settings();
177
+ const available = [...services.providers.keys()];
178
+ switch (args.action) {
179
+ case 'get': {
180
+ const pinned = await settings.get<string>(KEY);
181
+ yield { type: 'result', value: { classifierProvider: pinned ?? null, available } };
182
+ return;
183
+ }
184
+ case 'set': {
185
+ if (!args.provider) { yield { type: 'error', message: 'action "set" requires "provider".' }; return; }
186
+ if (!services.providers.has(args.provider)) {
187
+ yield { type: 'error', message: `Unknown provider "${args.provider}". Configured providers: ${available.join(', ') || '(none)'}.` };
188
+ return;
189
+ }
190
+ await settings.set(KEY, args.provider);
191
+ yield { type: 'result', value: { classifierProvider: args.provider } };
192
+ return;
193
+ }
194
+ case 'clear': {
195
+ await settings.delete(KEY);
196
+ yield { type: 'result', value: { classifierProvider: null } };
197
+ return;
198
+ }
199
+ default:
200
+ yield { type: 'error', message: `Unknown action "${String(args.action)}". Expected one of: get, set, clear.` };
201
+ }
202
+ },
203
+ };
204
+
205
+ return {
206
+ name: 'triggers_config',
207
+ description:
208
+ 'Configure the triggers subsystem. Currently one setting: `classifierProvider` — which configured ' +
209
+ 'provider judges trigger conditions. It is an alias for an existing provider, not a new one. Unset, ' +
210
+ "the classifier uses the current turn's own provider; set it to pin a small/fast model. `get` " +
211
+ 'reports the current pin and the available provider names; `set` pins one (it must already be ' +
212
+ 'configured — see the provider tool); `clear` reverts to the turn provider.\n\n' +
213
+ 'Parameters (TypeScript):\n' +
214
+ '```ts\n' +
215
+ "type TriggersConfig =\n" +
216
+ " | { action: 'get' } // -> { classifierProvider: string | null, available }\n" +
217
+ " | { action: 'set'; provider: string } // pin a provider -> { classifierProvider }\n" +
218
+ " | { action: 'clear' }; // revert to the turn provider -> { classifierProvider: null }\n" +
219
+ '```',
220
+ inputSchema: {
221
+ type: 'object',
222
+ required: ['action'],
223
+ properties: {
224
+ action: { type: 'string', enum: ['get', 'set', 'clear'], description: 'The operation to perform.' },
225
+ provider: { type: 'string', description: 'Name of an already-configured provider — required for "set".' },
226
+ },
227
+ },
228
+ executor,
229
+ };
230
+ }
package/src/types.ts ADDED
@@ -0,0 +1,106 @@
1
+ /**
2
+ * A trigger is a data-driven hook: a set of natural-language conditions and the single tool call to
3
+ * make when any of them is judged to match. The conditions are the OR — many ways to recognise the
4
+ * situation — and `invoke` is the one consequence. There is no skill coupling: a trigger names a
5
+ * tool, not a skill, so firing a skill is just `invoke: skill_action({ action: 'use', … })` like
6
+ * any other tool call (`use` applies the skill as a directive; `load` returns raw content, which is
7
+ * for reading/editing, not firing). What the model sees afterwards is decided observationally — see dispatch.ts.
8
+ */
9
+
10
+ /**
11
+ * What a trigger does when one of its conditions matches. A single discriminator: it fixes the
12
+ * surface judged, the hook, AND how the fired tool's output reaches the model — because those are
13
+ * not independent (delivery determines surface). The four are disjoint and exhaustive, two per
14
+ * surface:
15
+ *
16
+ * `ephemeral` — judge the USER message (pre-response `screen` hook); run the tool and inject its
17
+ * output for THIS turn only — never persisted, byte-stable prefix preserved. The one-shot "route
18
+ * knowledge in for this answer" case (was named `augment`).
19
+ * `contextual` — judge the USER message (pre-response `screen` hook); run the tool and fold its
20
+ * output DURABLY onto the user turn (an `origin: 'robo'` block, persisted + visible), so it
21
+ * updates the conversation rather than informing one answer. Use when a match means "this should
22
+ * become part of the session", not "correct just this turn". The durable twin of `ephemeral`.
23
+ * `retract` — judge the ASSISTANT response (post-commit `followup` hook); the response is treated
24
+ * as WRONG — pop it into a retraction marker and re-run the user turn with the output injected, so
25
+ * the bad answer does NOT remain (e.g. "the field is `pgdwell`, not `dwell`": everything built on
26
+ * the wrong field is void, so regenerate from scratch with the correction).
27
+ * `followup` — judge the ASSISTANT response (post-commit `followup` hook); the response STANDS but
28
+ * warrants a steer or verification — keep it and resubmit the output as a robo turn, so the
29
+ * response stays in context for the steer to make sense (Inner Voice / Verify Assumptions /
30
+ * Bicameral — critiques *of* the standing answer, meaningless without it).
31
+ *
32
+ * The author picks the kind because only they know whether a match means "read this for now" /
33
+ * "remember this from now on" / "this is wrong" / "look again".
34
+ */
35
+ export type TriggerKind = 'ephemeral' | 'contextual' | 'retract' | 'followup';
36
+
37
+ /** The conversational surface a `kind` is judged against, and which hook does the judging:
38
+ * `ephemeral`/`contextual` read the user message (pre-response `screen` hook); `retract`/`followup`
39
+ * read the assistant response (post-commit `followup` hook). Derived from `kind` — see
40
+ * {@link surfaceOfKind}. */
41
+ export type TriggerSurface = 'user' | 'agent';
42
+
43
+ export function surfaceOfKind(kind: TriggerKind): TriggerSurface {
44
+ return kind === 'ephemeral' || kind === 'contextual' ? 'user' : 'agent';
45
+ }
46
+
47
+ /** A trigger condition: a `kind` (what a match does, and the surface it's judged on) plus a `rule`. */
48
+ export interface TriggerCondition {
49
+ kind: TriggerKind;
50
+ /** A single LLM-judged rubric, e.g. "MATCH if …; DO NOT MATCH if …", judged against the turn. */
51
+ rule: string;
52
+ }
53
+
54
+ /** One condition the classifier judged matched, for tracing *why* a trigger fired (not just that it
55
+ * did): `index` addresses it within the owning trigger's `conditions` array, `rule` is the rubric
56
+ * text at the time of evaluation, and `why` is the classifier's one-line justification (absent if it
57
+ * didn't supply one). */
58
+ export interface FiredCondition {
59
+ index: number;
60
+ kind: TriggerKind;
61
+ rule: string;
62
+ why?: string;
63
+ }
64
+
65
+ /** The tool call a matched trigger makes. `params` is passed verbatim as the tool's input. */
66
+ export interface TriggerInvoke {
67
+ tool: string;
68
+ params?: unknown;
69
+ }
70
+
71
+ export interface Trigger {
72
+ id: string;
73
+ version: string;
74
+ conditions: TriggerCondition[];
75
+ invoke: TriggerInvoke;
76
+ /** Absent ⇒ enabled. A disabled trigger is kept but never evaluated. */
77
+ enabled?: boolean;
78
+ createdAt: string;
79
+ updatedAt: string;
80
+ }
81
+
82
+ /** Fields a caller supplies when creating or replacing a trigger; identity/versioning is the store's. */
83
+ export interface TriggerSpec {
84
+ conditions: TriggerCondition[];
85
+ invoke: TriggerInvoke;
86
+ enabled?: boolean;
87
+ }
88
+
89
+ /**
90
+ * The registry interface other plugins consume (`services.Triggers`) to create triggers without
91
+ * knowing who owns the store or the hooks. CRUD only — evaluation and dispatch are internal to the
92
+ * triggers plugin, which owns the hooks that drive them.
93
+ */
94
+ export interface Triggers {
95
+ all(): Trigger[];
96
+ get(id: string): Trigger | undefined;
97
+ /** Triggers whose invocation matches the filter — `tool` (if given) equals `invoke.tool`, `params`
98
+ * (if given) deep-equals `invoke.params`. The "which trigger(s) fire tool X" lookup. */
99
+ query(filter: { tool?: string; params?: unknown }): Trigger[];
100
+ add(spec: TriggerSpec): Promise<Trigger>;
101
+ update(id: string, patch: Partial<TriggerSpec>): Promise<Trigger | undefined>;
102
+ remove(id: string): Promise<boolean>;
103
+ /** Seed idempotently: no-op (returns the existing trigger) when one with the same `invoke`
104
+ * (tool + params) is already stored. Identity is the invocation, since triggers carry no name. */
105
+ importIfAbsent(spec: TriggerSpec): Promise<Trigger>;
106
+ }