@matatbread/matbot-triggers 0.3.3 → 0.3.4

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.3.3",
7
+ "version": "0.3.4",
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.3.3"
36
+ "@matatbread/matbot-plugin-api": "^0.3.4"
37
37
  },
38
38
  "devDependencies": {
39
39
  "typescript": "^6.0.3",
40
- "@matatbread/matbot-plugin-api": "^0.3.3"
40
+ "@matatbread/matbot-plugin-api": "^0.3.4"
41
41
  },
42
42
  "scripts": {
43
43
  "typecheck": "tsc --noEmit"
package/src/manager.ts CHANGED
@@ -27,7 +27,6 @@ function invokeKey(t: { invoke: Trigger['invoke'] }): string {
27
27
  * Constructed only with web-platform primitives, so it runs in the browser too.
28
28
  */
29
29
  export class TriggerManager implements Triggers {
30
- private readonly triggers = new Map<string, Trigger>();
31
30
  private readonly store: Store<Trigger>;
32
31
  private readonly services: MatbotMachine;
33
32
  // Aborts on teardown (clear()), ending the mounted-swap subscription set up in setupTriggers.
@@ -38,8 +37,7 @@ export class TriggerManager implements Triggers {
38
37
  this.services = services;
39
38
  }
40
39
 
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. */
40
+ /** Aborts on teardown (clear()) — ends any in-flight classifier call the manager owns. */
43
41
  get signal(): AbortSignal { return this.lifecycle.signal; }
44
42
 
45
43
  // The classifier provider, resolved live per evaluation (so a triggers_config change takes effect on
@@ -53,26 +51,25 @@ export class TriggerManager implements Triggers {
53
51
  return turnProvider;
54
52
  }
55
53
 
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();
54
+ /** Read straight through the backing store on every call — no in-memory snapshot. The store is a
55
+ * swap-following proxy, so this always reflects the live backend AND the current principal's
56
+ * partition, and a shared backend's out-of-band writes are seen too. A snapshot here would be
57
+ * principal-blind and go stale under any second writer; caching, if a slow backend needs it,
58
+ * belongs in the StorageBackend, not in this consumer. */
59
+ async all(): Promise<Trigger[]> {
62
60
  const { items } = await this.store.query({});
63
- for (const t of items) {
64
- this.triggers.set(t.id, t);
65
- }
61
+ return items;
66
62
  }
67
63
 
68
- all(): Trigger[] { return [...this.triggers.values()]; }
69
- get(id: string): Trigger | undefined { return this.triggers.get(id); }
64
+ async get(id: string): Promise<Trigger | undefined> {
65
+ return (await this.store.get(id)) ?? undefined;
66
+ }
70
67
 
71
68
  /** Triggers whose invocation matches the filter: `tool` (if given) must equal `invoke.tool`, and
72
69
  * `params` (if given) must deep-equal `invoke.params`. The natural "which trigger(s) fire tool X
73
70
  * (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 => {
71
+ async query(filter: { tool?: string; params?: unknown }): Promise<Trigger[]> {
72
+ return (await this.all()).filter(t => {
76
73
  if (filter.tool !== undefined && t.invoke.tool !== filter.tool) return false;
77
74
  if (filter.params !== undefined &&
78
75
  JSON.stringify(t.invoke.params ?? null) !== JSON.stringify(filter.params)) return false;
@@ -92,13 +89,12 @@ export class TriggerManager implements Triggers {
92
89
  updatedAt: now,
93
90
  };
94
91
  await this.store.set(doc.id, doc);
95
- this.triggers.set(doc.id, doc);
96
92
  return doc;
97
93
  }
98
94
 
99
95
  async update(id: string, patch: Partial<TriggerSpec>): Promise<Trigger | undefined> {
100
- const cur = this.triggers.get(id);
101
- if (cur === undefined) return undefined;
96
+ const cur = await this.store.get(id);
97
+ if (cur === null) return undefined;
102
98
  return this.casMutate(cur, prev => ({
103
99
  ...prev,
104
100
  ...(patch.conditions !== undefined ? { conditions: patch.conditions } : {}),
@@ -110,21 +106,20 @@ export class TriggerManager implements Triggers {
110
106
  }
111
107
 
112
108
  async remove(id: string): Promise<boolean> {
113
- const cur = this.triggers.get(id);
114
- if (cur === undefined) return false;
109
+ const cur = await this.store.get(id);
110
+ if (cur === null) return false;
115
111
  await this.store.delete(id, cur.version);
116
- this.triggers.delete(id);
117
112
  return true;
118
113
  }
119
114
 
120
115
  async importIfAbsent(spec: TriggerSpec): Promise<Trigger> {
121
116
  const key = invokeKey(spec);
122
- const existing = this.all().find(t => invokeKey(t) === key);
117
+ const existing = (await this.all()).find(t => invokeKey(t) === key);
123
118
  if (existing !== undefined) return existing;
124
119
  return this.add(spec);
125
120
  }
126
121
 
127
- clear(): void { this.lifecycle.abort(); this.triggers.clear(); }
122
+ clear(): void { this.lifecycle.abort(); }
128
123
 
129
124
  /**
130
125
  * LLM-judge every enabled condition on `surface` against the current turn and return the distinct
@@ -148,7 +143,8 @@ export class TriggerManager implements Triggers {
148
143
  // Candidate key is `${triggerId}#${conditionIndex}` — addressing conditions by index is fine
149
144
  // because evaluation is per-turn and the trigger set is stable for its duration. The surface a
150
145
  // condition belongs to is derived from its `kind` (ephemeral/contextual→user, retract/followup→agent).
151
- const candidates = this.all()
146
+ const triggers = await this.all();
147
+ const candidates = triggers
152
148
  .filter(t => t.enabled !== false)
153
149
  .flatMap(t => t.conditions
154
150
  .map((c, i) => ({ triggerId: t.id, key: `${t.id}#${i}`, index: i, kind: c.kind, rule: c.rule }))
@@ -195,8 +191,9 @@ export class TriggerManager implements Triggers {
195
191
  list.push({ index: c.index, kind: c.kind, rule: c.rule, ...(why !== undefined ? { why } : {}) });
196
192
  firedByTrigger.set(c.triggerId, list);
197
193
  }
194
+ const byId = new Map(triggers.map(t => [t.id, t]));
198
195
  return [...firedByTrigger].flatMap(([id, matched]) => {
199
- const trigger = this.triggers.get(id);
196
+ const trigger = byId.get(id);
200
197
  return trigger ? [{ trigger, kinds: [...new Set(matched.map(m => m.kind))], matched }] : [];
201
198
  });
202
199
  }
@@ -206,11 +203,10 @@ export class TriggerManager implements Triggers {
206
203
  for (;;) {
207
204
  const next = mutate(cur);
208
205
  const r = await this.store.cas(cur.id, cur.version, next);
209
- if (r.ok) { this.triggers.set(next.id, next); return next; }
206
+ if (r.ok) return next;
210
207
  const fresh = await this.store.get(cur.id);
211
208
  if (fresh === null) {
212
209
  await this.store.set(next.id, next);
213
- this.triggers.set(next.id, next);
214
210
  return next;
215
211
  }
216
212
  cur = fresh;
package/src/plugin.ts CHANGED
@@ -148,11 +148,8 @@ export async function setupTriggers(services: MatbotMachine): Promise<TriggerMan
148
148
 
149
149
  const store = services.createStore<Trigger>('triggers') as Store<Trigger>;
150
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());
151
+ // No boot load and no StorageBackend-swap re-read: the manager reads straight through `store` (a
152
+ // swap-following proxy) on every call, so it always reflects the live backend and current partition.
156
153
  await services.register('Triggers', manager);
157
154
 
158
155
  services.tools.register(createTriggerActionTool(manager));
package/src/tools.ts CHANGED
@@ -90,20 +90,20 @@ export function createTriggerActionTool(manager: TriggerManager): Tool<ToolResul
90
90
 
91
91
  switch (args.action) {
92
92
  case 'list': {
93
- yield { type: 'result', value: { triggers: manager.all() } };
93
+ yield { type: 'result', value: { triggers: await manager.all() } };
94
94
  return;
95
95
  }
96
96
 
97
97
  case 'query': {
98
98
  const a = args as Extract<TriggerActionInput, { action: 'query' }>;
99
- yield { type: 'result', value: { triggers: manager.query(queryFilter(a)) } };
99
+ yield { type: 'result', value: { triggers: await manager.query(queryFilter(a)) } };
100
100
  return;
101
101
  }
102
102
 
103
103
  case 'get': {
104
104
  const { id } = args as Extract<TriggerActionInput, { action: 'get' }>;
105
105
  if (!id) { yield { type: 'error', message: 'action "get" requires "id".' }; return; }
106
- const t = manager.get(id);
106
+ const t = await manager.get(id);
107
107
  if (!t) { yield { type: 'error', message: `Trigger not found: "${id}"` }; return; }
108
108
  yield { type: 'result', value: t };
109
109
  return;
@@ -128,10 +128,13 @@ export function createTriggerActionTool(manager: TriggerManager): Tool<ToolResul
128
128
  if (!a.id) { yield { type: 'error', message: 'action "update" requires "id".' }; return; }
129
129
  if (a.conditions !== undefined && !validConditions(a.conditions)) { yield { type: 'error', message: '"conditions" must be [{ kind, rule }].' }; return; }
130
130
  if (a.tool !== undefined && typeof a.tool !== 'string') { yield { type: 'error', message: '"tool" must be a string.' }; return; }
131
+ const priorTool = (a.tool !== undefined || a.params !== undefined)
132
+ ? (await manager.get(a.id))?.invoke.tool ?? ''
133
+ : '';
131
134
  const t = await manager.update(a.id, {
132
135
  ...(a.conditions !== undefined ? { conditions: a.conditions } : {}),
133
136
  ...(a.tool !== undefined || a.params !== undefined
134
- ? { invoke: { tool: a.tool ?? manager.get(a.id)?.invoke.tool ?? '', ...(a.params !== undefined ? { params: a.params } : {}) } }
137
+ ? { invoke: { tool: a.tool ?? priorTool, ...(a.params !== undefined ? { params: a.params } : {}) } }
135
138
  : {}),
136
139
  ...(a.enabled !== undefined ? { enabled: a.enabled } : {}),
137
140
  });
@@ -168,7 +171,7 @@ export function createTriggerActionTool(manager: TriggerManager): Tool<ToolResul
168
171
  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; }
169
172
  const invoke = { tool: a.toTool, ...(a.toParams !== undefined ? { params: a.toParams } : {}) };
170
173
  const triggers: Trigger[] = [];
171
- for (const t of manager.query(queryFilter(a))) {
174
+ for (const t of await manager.query(queryFilter(a))) {
172
175
  const updated = await manager.update(t.id, { invoke });
173
176
  if (updated !== undefined) triggers.push(updated);
174
177
  }
@@ -182,7 +185,7 @@ export function createTriggerActionTool(manager: TriggerManager): Tool<ToolResul
182
185
  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; }
183
186
  const invoke = { tool: a.toTool, ...(a.toParams !== undefined ? { params: a.toParams } : {}) };
184
187
  const triggers: Trigger[] = [];
185
- for (const t of manager.query(queryFilter(a))) {
188
+ for (const t of await manager.query(queryFilter(a))) {
186
189
  triggers.push(await manager.add({
187
190
  conditions: t.conditions,
188
191
  invoke,
package/src/types.ts CHANGED
@@ -92,11 +92,11 @@ export interface TriggerSpec {
92
92
  * triggers plugin, which owns the hooks that drive them.
93
93
  */
94
94
  export interface Triggers {
95
- all(): Trigger[];
96
- get(id: string): Trigger | undefined;
95
+ all(): Promise<Trigger[]>;
96
+ get(id: string): Promise<Trigger | undefined>;
97
97
  /** Triggers whose invocation matches the filter — `tool` (if given) equals `invoke.tool`, `params`
98
98
  * (if given) deep-equals `invoke.params`. The "which trigger(s) fire tool X" lookup. */
99
- query(filter: { tool?: string; params?: unknown }): Trigger[];
99
+ query(filter: { tool?: string; params?: unknown }): Promise<Trigger[]>;
100
100
  add(spec: TriggerSpec): Promise<Trigger>;
101
101
  update(id: string, patch: Partial<TriggerSpec>): Promise<Trigger | undefined>;
102
102
  remove(id: string): Promise<boolean>;