@matatbread/matbot-triggers 0.3.7 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +3 -3
- package/src/index.ts +1 -1
- package/src/manager.ts +3 -1
- package/src/plugin.ts +104 -4
- package/src/tools.ts +41 -8
- package/src/types.ts +28 -0
package/package.json
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
"node",
|
|
5
5
|
"browser"
|
|
6
6
|
],
|
|
7
|
-
"version": "0.
|
|
7
|
+
"version": "0.4.0",
|
|
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.
|
|
36
|
+
"@matatbread/matbot-plugin-api": "^0.4.0"
|
|
37
37
|
},
|
|
38
38
|
"devDependencies": {
|
|
39
39
|
"typescript": "^6.0.3",
|
|
40
|
-
"@matatbread/matbot-plugin-api": "^0.
|
|
40
|
+
"@matatbread/matbot-plugin-api": "^0.4.0"
|
|
41
41
|
},
|
|
42
42
|
"scripts": {
|
|
43
43
|
"typecheck": "tsc --noEmit"
|
package/src/index.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
export type { Trigger, TriggerCondition, TriggerInvoke, TriggerKind, TriggerSurface, TriggerSpec, Triggers, FiredCondition } from './types.js';
|
|
1
|
+
export type { Trigger, TriggerCondition, TriggerCooldown, TriggerInvoke, TriggerKind, TriggerSurface, TriggerSpec, Triggers, FiredCondition } from './types.js';
|
|
2
2
|
export { surfaceOfKind } from './types.js';
|
|
3
3
|
export { TriggerManager } from './manager.js';
|
|
4
4
|
export { dispatchTrigger, renderResult } from './dispatch.js';
|
package/src/manager.ts
CHANGED
|
@@ -84,7 +84,8 @@ export class TriggerManager implements Triggers {
|
|
|
84
84
|
version: Date.now().toString(),
|
|
85
85
|
conditions: spec.conditions,
|
|
86
86
|
invoke: spec.invoke,
|
|
87
|
-
...(spec.enabled
|
|
87
|
+
...(spec.enabled !== undefined ? { enabled: spec.enabled } : {}),
|
|
88
|
+
...(spec.cooldown !== undefined ? { cooldown: spec.cooldown } : {}),
|
|
88
89
|
createdAt: now,
|
|
89
90
|
updatedAt: now,
|
|
90
91
|
};
|
|
@@ -100,6 +101,7 @@ export class TriggerManager implements Triggers {
|
|
|
100
101
|
...(patch.conditions !== undefined ? { conditions: patch.conditions } : {}),
|
|
101
102
|
...(patch.invoke !== undefined ? { invoke: patch.invoke } : {}),
|
|
102
103
|
...(patch.enabled !== undefined ? { enabled: patch.enabled } : {}),
|
|
104
|
+
...(patch.cooldown !== undefined ? { cooldown: patch.cooldown } : {}),
|
|
103
105
|
version: Date.now().toString(),
|
|
104
106
|
updatedAt: new Date().toISOString(),
|
|
105
107
|
}));
|
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 } from './dispatch.js';
|
|
5
5
|
import { createTriggerActionTool, createTriggersConfigTool } from './tools.js';
|
|
6
|
-
import type { Trigger, Triggers, FiredCondition } from './types.js';
|
|
6
|
+
import type { Trigger, Triggers, FiredCondition, TriggerCooldown } 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
|
|
@@ -113,7 +113,7 @@ const RETRACTION_CREATOR = 'matbot-retraction';
|
|
|
113
113
|
// `retractFiredMarker` records which triggers/conditions caused a retract. Both are LLM-invisible
|
|
114
114
|
// diagnostics. The convergence guard reads back BOTH retract-fired AND its own `retract-convergence`
|
|
115
115
|
// suppressions (see retractActiveLastTurn).
|
|
116
|
-
type SuppressCause = 'user-redo' | 'retract-convergence' | 'followup-shadowed';
|
|
116
|
+
type SuppressCause = 'user-redo' | 'retract-convergence' | 'followup-shadowed' | 'cooldown';
|
|
117
117
|
function suppressedMarker(cause: SuppressCause, reason: string, triggers: FiredSource[]): MessageContent {
|
|
118
118
|
return { type: 'marker', creator: 'triggers', data: { event: 'suppressed', cause, reason, triggers } };
|
|
119
119
|
}
|
|
@@ -170,6 +170,92 @@ function retractActiveLastTurn(messages: Message[]): Set<string> {
|
|
|
170
170
|
return ids;
|
|
171
171
|
}
|
|
172
172
|
|
|
173
|
+
// Every marker event that records a trigger ACTUALLY firing (as opposed to being held off), across both
|
|
174
|
+
// surfaces and all three delivery paths. This is the durable fire log the cool-down is counted from —
|
|
175
|
+
// there is no in-memory counter, so a cool-down holds across restart, reload and a backend swap.
|
|
176
|
+
const FIRE_EVENTS = new Set(['retract-fired', 'followup-fired', 'user-insitu-fired', 'user-retract-fired']);
|
|
177
|
+
|
|
178
|
+
/** Per-trigger firing history, in turns. `thisTurn` counts fires inside the current turn region (since
|
|
179
|
+
* the last genuine user message; a redo or robo resubmit belongs to that same turn). `since` is how
|
|
180
|
+
* many genuine user turns ago each trigger last fired — 0 meaning this turn. Absent from `since` ⇒
|
|
181
|
+
* never fired in the retained history. */
|
|
182
|
+
interface FireHistory {
|
|
183
|
+
thisTurn: Map<string, number>;
|
|
184
|
+
since: Map<string, number>;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
// One backward scan of the message tail, counting genuine (non-robo) user messages as turn boundaries.
|
|
188
|
+
// Deliberately counts only fires, NOT cool-down suppressions: a quiet period is a fixed window that must
|
|
189
|
+
// expire on its own, unlike the retract convergence guard (which re-arms on suppression precisely so a
|
|
190
|
+
// still-matching rule stays held off indefinitely).
|
|
191
|
+
function fireHistory(messages: Message[]): FireHistory {
|
|
192
|
+
const thisTurn = new Map<string, number>();
|
|
193
|
+
const since = new Map<string, number>();
|
|
194
|
+
let turnsBack = 0;
|
|
195
|
+
for (let i = messages.length - 1; i >= 0; i--) {
|
|
196
|
+
const m = messages[i]!;
|
|
197
|
+
if (m.role === 'user' && !m.content.every(c => c.origin === 'robo')) { turnsBack++; continue; }
|
|
198
|
+
for (const c of m.content) {
|
|
199
|
+
if (c.type !== 'marker' || c.creator !== 'triggers') continue;
|
|
200
|
+
const d = c.data as { event?: unknown; triggers?: unknown };
|
|
201
|
+
if (typeof d?.event !== 'string' || !FIRE_EVENTS.has(d.event) || !Array.isArray(d.triggers)) continue;
|
|
202
|
+
for (const t of d.triggers) {
|
|
203
|
+
const id = (t as { id?: unknown })?.id;
|
|
204
|
+
if (typeof id !== 'string') continue;
|
|
205
|
+
if (!since.has(id)) since.set(id, turnsBack);
|
|
206
|
+
if (turnsBack === 0) thisTurn.set(id, (thisTurn.get(id) ?? 0) + 1);
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
return { thisTurn, since };
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
// The second of the two guards that read the same marker stream (see retractActiveLastTurn above),
|
|
214
|
+
// and they are NOT redundant: the convergence guard is a diagnosis — this retract rule did not
|
|
215
|
+
// converge, so hold it until it stops matching, re-arming on its own suppressions so it stays held —
|
|
216
|
+
// while a cool-down is a declared rate, indifferent to whether the rule is well-behaved and expiring
|
|
217
|
+
// on a fixed schedule. One is the machine noticing a defect; the other is an author's policy.
|
|
218
|
+
//
|
|
219
|
+
// The reason a trigger's own cool-down holds it back this turn, or undefined to let it fire. Reported
|
|
220
|
+
// rather than returned as a boolean because a suppression is never silent — the reason lands in the
|
|
221
|
+
// marker, so "why didn't the inner voice fire?" is answerable from the session months later.
|
|
222
|
+
function cooldownHold(cooldown: TriggerCooldown | undefined, id: string, hist: FireHistory): string | undefined {
|
|
223
|
+
if (!cooldown) return undefined;
|
|
224
|
+
const { maxPerTurn, quietTurns } = cooldown;
|
|
225
|
+
if (maxPerTurn !== undefined) {
|
|
226
|
+
const fired = hist.thisTurn.get(id) ?? 0;
|
|
227
|
+
if (fired >= maxPerTurn) return `already fired ${fired}× this turn (maxPerTurn ${maxPerTurn})`;
|
|
228
|
+
}
|
|
229
|
+
// Counts COMPLETED turns only (`since >= 1`): the quiet period governs later turns, while repeats
|
|
230
|
+
// within the turn that fired are `maxPerTurn`'s business. Otherwise the two limits would fight —
|
|
231
|
+
// a quiet period of 1 would make any maxPerTurn above 1 unreachable.
|
|
232
|
+
if (quietTurns !== undefined && quietTurns > 0) {
|
|
233
|
+
const since = hist.since.get(id);
|
|
234
|
+
if (since !== undefined && since >= 1 && since <= quietTurns) {
|
|
235
|
+
return `fired ${since} turn(s) ago, inside its ${quietTurns}-turn quiet period`;
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
return undefined;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/** Split what the classifier fired into the triggers that may act and the suppression markers for
|
|
242
|
+
* those their own cool-down holds back. Both phases run this identically — the cool-down governs a
|
|
243
|
+
* trigger, not a surface, so the user and agent surfaces share one budget and must share one
|
|
244
|
+
* decision. Generic in the fired element so each caller keeps its own extra fields (`kinds`). */
|
|
245
|
+
function applyCooldown<T extends { trigger: Trigger; matched: FiredCondition[] }>(
|
|
246
|
+
fired: T[],
|
|
247
|
+
hist: FireHistory,
|
|
248
|
+
): { kept: T[]; markers: MessageContent[] } {
|
|
249
|
+
const kept: T[] = [];
|
|
250
|
+
const markers: MessageContent[] = [];
|
|
251
|
+
for (const f of fired) {
|
|
252
|
+
const hold = cooldownHold(f.trigger.cooldown, f.trigger.id, hist);
|
|
253
|
+
if (hold === undefined) kept.push(f);
|
|
254
|
+
else markers.push(suppressedMarker('cooldown', `trigger held off by its own cool-down: ${hold}`, [{ id: f.trigger.id, matched: f.matched }]));
|
|
255
|
+
}
|
|
256
|
+
return { kept, markers };
|
|
257
|
+
}
|
|
258
|
+
|
|
173
259
|
function textOf(msg: Message | undefined): string {
|
|
174
260
|
return msg?.content.filter(c => c.type === 'text').map(c => c.text).join('\n') ?? '';
|
|
175
261
|
}
|
|
@@ -249,7 +335,11 @@ export async function setupTriggers(services: MatbotMachine): Promise<TriggerMan
|
|
|
249
335
|
const durableBodies: string[] = [];
|
|
250
336
|
const sources: FiredSource[] = [];
|
|
251
337
|
const markers: MessageContent[] = [];
|
|
252
|
-
|
|
338
|
+
// A per-trigger cool-down applies to both surfaces: matching says the trigger is relevant, the
|
|
339
|
+
// cool-down says whether it may act again this soon. Held-off triggers are traced, never silent.
|
|
340
|
+
const cooled = applyCooldown(fired, fireHistory(ctx.session.messages));
|
|
341
|
+
markers.push(...cooled.markers);
|
|
342
|
+
for (const { trigger, kinds, matched } of cooled.kept) {
|
|
253
343
|
const out = await dispatchTrigger(services, trigger, { session: ctx.session, signal: ctx.signal, provider: ctx.provider, ...(ctx.prompt !== undefined ? { prompt: ctx.prompt } : {}) });
|
|
254
344
|
if (out.hadResult) {
|
|
255
345
|
// Contextual dominates: a trigger carrying both kinds folds durable (a superset of ephemeral —
|
|
@@ -377,7 +467,17 @@ export async function setupTriggers(services: MatbotMachine): Promise<TriggerMan
|
|
|
377
467
|
const followupSources: FiredSource[] = [];
|
|
378
468
|
const suppressed: FiredSource[] = [];
|
|
379
469
|
const markers: MessageContent[] = [...userOut.markers];
|
|
380
|
-
|
|
470
|
+
// The raced user-phase fires belong to THIS turn but aren't in the committed messages yet (their
|
|
471
|
+
// markers ride out on this hook's return), so fold them in before counting — otherwise a trigger
|
|
472
|
+
// that fired on the user surface would look untouched to its own per-turn cap.
|
|
473
|
+
const hist = fireHistory(ctx.session.messages);
|
|
474
|
+
for (const s of userOut.sources) {
|
|
475
|
+
hist.thisTurn.set(s.id, (hist.thisTurn.get(s.id) ?? 0) + 1);
|
|
476
|
+
hist.since.set(s.id, 0);
|
|
477
|
+
}
|
|
478
|
+
const cooled = applyCooldown(fired, hist);
|
|
479
|
+
markers.push(...cooled.markers);
|
|
480
|
+
for (const { trigger, kinds, matched } of cooled.kept) {
|
|
381
481
|
if (kinds.includes('retract') && heldOff.has(trigger.id)) {
|
|
382
482
|
suppressed.push({ id: trigger.id, matched: matched.filter(m => m.kind === 'retract') });
|
|
383
483
|
continue;
|
package/src/tools.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import type { Tool, ToolExecutor, ToolContract, ToolResultOf, ToolContext, MatbotMachine } from '@matatbread/matbot-plugin-api';
|
|
2
2
|
import type { TriggerManager } from './manager.js';
|
|
3
|
-
import type { TriggerCondition, TriggerKind, Trigger } from './types.js';
|
|
3
|
+
import type { TriggerCondition, TriggerCooldown, TriggerKind, Trigger } from './types.js';
|
|
4
4
|
|
|
5
5
|
declare module '@matatbread/matbot-plugin-api' {
|
|
6
6
|
interface ToolContracts {
|
|
@@ -10,8 +10,8 @@ declare module '@matatbread/matbot-plugin-api' {
|
|
|
10
10
|
| ToolContract<{ triggers: Trigger[] }, { action: 'list' }>
|
|
11
11
|
| ToolContract<{ triggers: Trigger[] }, { action: 'query'; tool?: string; params?: object }>
|
|
12
12
|
| ToolContract<Trigger, { action: 'get'; id: string }>
|
|
13
|
-
| ToolContract<{ id: string }, { action: 'add'; conditions: TriggerCondition[]; tool: string; params?: object; enabled?: boolean }>
|
|
14
|
-
| ToolContract<Trigger, { action: 'update'; id: string; conditions?: TriggerCondition[]; tool?: string; params?: object; enabled?: boolean }>
|
|
13
|
+
| ToolContract<{ id: string }, { action: 'add'; conditions: TriggerCondition[]; tool: string; params?: object; enabled?: boolean; cooldown?: TriggerCooldown }>
|
|
14
|
+
| ToolContract<Trigger, { action: 'update'; id: string; conditions?: TriggerCondition[]; tool?: string; params?: object; enabled?: boolean; cooldown?: TriggerCooldown }>
|
|
15
15
|
| ToolContract<Trigger, { action: 'disable'; id: string }>
|
|
16
16
|
| ToolContract<Trigger, { action: 'enable'; id: string }>
|
|
17
17
|
| ToolContract<{ id: string }, { action: 'remove'; id: string }>
|
|
@@ -53,14 +53,22 @@ const GUIDANCE =
|
|
|
53
53
|
'invoke naming a tool that is not present fails soft — the trigger simply does nothing until it ' +
|
|
54
54
|
'is. To fire a skill on a condition, invoke `skill_action` with `{ action: "use", name }` — `use` ' +
|
|
55
55
|
'applies the skill as a directive (the firing case). (`{ action: "load" }` returns raw content for ' +
|
|
56
|
-
'reading/editing, not for firing — a `load` result is bare text the model may misread as the user speaking.)'
|
|
56
|
+
'reading/editing, not for firing — a `load` result is bare text the model may misread as the user speaking.)\n\n' +
|
|
57
|
+
'An optional `cooldown` rate-limits firing independently of matching — `{ maxPerTurn, quietTurns }`. ' +
|
|
58
|
+
'A turn is one genuine user message and everything that follows it (a retract redo or a follow-up ' +
|
|
59
|
+
'robo turn belongs to the turn that caused it). `maxPerTurn` caps fires within a turn across both ' +
|
|
60
|
+
'surfaces; `quietTurns` holds the trigger off for that many LATER turns after a fire (1 ⇒ never on ' +
|
|
61
|
+
'consecutive turns). Absent ⇒ unlimited, which is right for most triggers: a trigger that captures ' +
|
|
62
|
+
'what the user just said SHOULD fire every turn, and rate-limiting it silently loses data. Use a ' +
|
|
63
|
+
'cool-down for triggers whose consequence is a self-directed steer (critique, verify, reconsider), ' +
|
|
64
|
+
'where matching every turn is correct but acting every turn is a spin.';
|
|
57
65
|
|
|
58
66
|
type TriggerActionInput =
|
|
59
67
|
| { action: 'list' }
|
|
60
68
|
| { action: 'query'; tool?: string; params?: unknown }
|
|
61
69
|
| { action: 'get'; id: string }
|
|
62
|
-
| { action: 'add'; conditions: TriggerCondition[]; tool: string; params?: unknown; enabled?: boolean }
|
|
63
|
-
| { action: 'update'; id: string; conditions?: TriggerCondition[]; tool?: string; params?: unknown; enabled?: boolean }
|
|
70
|
+
| { action: 'add'; conditions: TriggerCondition[]; tool: string; params?: unknown; enabled?: boolean; cooldown?: unknown }
|
|
71
|
+
| { action: 'update'; id: string; conditions?: TriggerCondition[]; tool?: string; params?: unknown; enabled?: boolean; cooldown?: unknown }
|
|
64
72
|
| { action: 'disable'; id: string }
|
|
65
73
|
| { action: 'enable'; id: string }
|
|
66
74
|
| { action: 'remove'; id: string }
|
|
@@ -75,6 +83,23 @@ function queryFilter(a: { tool?: unknown; params?: unknown }): { tool?: string;
|
|
|
75
83
|
};
|
|
76
84
|
}
|
|
77
85
|
|
|
86
|
+
// A cool-down is valid if every field it carries is a whole number at or above its own minimum, which
|
|
87
|
+
// differ by polarity: `maxPerTurn` counts PERMITTED fires, so 0 would mean "never fires" — an alias
|
|
88
|
+
// for `disable` reached through a rate limit, silently and without the suspend flag a reader would
|
|
89
|
+
// look for — while `quietTurns` counts BLOCKED turns, where 0 is an honest "no delay". `null` clears
|
|
90
|
+
// the whole thing (the only way back to unlimited through a patch, since an omitted field means
|
|
91
|
+
// "leave unchanged").
|
|
92
|
+
function validCooldown(x: unknown): x is TriggerCooldown {
|
|
93
|
+
if (x === null || typeof x !== 'object') return false;
|
|
94
|
+
const c = x as { maxPerTurn?: unknown; quietTurns?: unknown };
|
|
95
|
+
const ok = (v: unknown, min: number): boolean => v === undefined || (typeof v === 'number' && Number.isInteger(v) && v >= min);
|
|
96
|
+
return ok(c.maxPerTurn, 1) && ok(c.quietTurns, 0);
|
|
97
|
+
}
|
|
98
|
+
const COOLDOWN_ERR =
|
|
99
|
+
'"cooldown" must be { maxPerTurn?: integer >= 1, quietTurns?: integer >= 0 }. ' +
|
|
100
|
+
'maxPerTurn 0 is rejected: a trigger that may never fire is a suspended trigger — use the ' +
|
|
101
|
+
'"disable" action, which keeps the conditions and is visible as such.';
|
|
102
|
+
|
|
78
103
|
// A condition is valid if it has a recognised `kind` and a string `rule`.
|
|
79
104
|
function validConditions(x: unknown): x is TriggerCondition[] {
|
|
80
105
|
return Array.isArray(x) && x.every(c =>
|
|
@@ -114,10 +139,12 @@ export function createTriggerActionTool(manager: TriggerManager): Tool<ToolResul
|
|
|
114
139
|
if (!validConditions(a.conditions)) { yield { type: 'error', message: `action "add" requires "conditions": [{ kind: ${KINDS.join('|')}, rule: string }].` }; return; }
|
|
115
140
|
if (a.conditions.length === 0) { yield { type: 'error', message: 'action "add" requires at least one condition.' }; return; }
|
|
116
141
|
if (typeof a.tool !== 'string') { yield { type: 'error', message: 'action "add" requires a string "tool" to invoke.' }; return; }
|
|
142
|
+
if (a.cooldown !== undefined && !validCooldown(a.cooldown)) { yield { type: 'error', message: COOLDOWN_ERR }; return; }
|
|
117
143
|
const t = await manager.add({
|
|
118
144
|
conditions: a.conditions,
|
|
119
145
|
invoke: { tool: a.tool, ...(a.params !== undefined ? { params: a.params } : {}) },
|
|
120
|
-
...(a.enabled
|
|
146
|
+
...(a.enabled !== undefined ? { enabled: a.enabled } : {}),
|
|
147
|
+
...(a.cooldown !== undefined ? { cooldown: a.cooldown as TriggerCooldown } : {}),
|
|
121
148
|
});
|
|
122
149
|
yield { type: 'result', value: { id: t.id } };
|
|
123
150
|
return;
|
|
@@ -128,6 +155,7 @@ export function createTriggerActionTool(manager: TriggerManager): Tool<ToolResul
|
|
|
128
155
|
if (!a.id) { yield { type: 'error', message: 'action "update" requires "id".' }; return; }
|
|
129
156
|
if (a.conditions !== undefined && !validConditions(a.conditions)) { yield { type: 'error', message: '"conditions" must be [{ kind, rule }].' }; return; }
|
|
130
157
|
if (a.tool !== undefined && typeof a.tool !== 'string') { yield { type: 'error', message: '"tool" must be a string.' }; return; }
|
|
158
|
+
if (a.cooldown !== undefined && a.cooldown !== null && !validCooldown(a.cooldown)) { yield { type: 'error', message: COOLDOWN_ERR + ' Pass null to remove every limit.' }; return; }
|
|
131
159
|
const priorTool = (a.tool !== undefined || a.params !== undefined)
|
|
132
160
|
? (await manager.get(a.id))?.invoke.tool ?? ''
|
|
133
161
|
: '';
|
|
@@ -137,6 +165,8 @@ export function createTriggerActionTool(manager: TriggerManager): Tool<ToolResul
|
|
|
137
165
|
? { invoke: { tool: a.tool ?? priorTool, ...(a.params !== undefined ? { params: a.params } : {}) } }
|
|
138
166
|
: {}),
|
|
139
167
|
...(a.enabled !== undefined ? { enabled: a.enabled } : {}),
|
|
168
|
+
// null clears every limit; an omitted `cooldown` leaves the stored one untouched.
|
|
169
|
+
...(a.cooldown !== undefined ? { cooldown: (a.cooldown === null ? {} : a.cooldown) as TriggerCooldown } : {}),
|
|
140
170
|
});
|
|
141
171
|
if (t === undefined) { yield { type: 'error', message: `Trigger not found: "${a.id}"` }; return; }
|
|
142
172
|
yield { type: 'result', value: t };
|
|
@@ -189,7 +219,8 @@ export function createTriggerActionTool(manager: TriggerManager): Tool<ToolResul
|
|
|
189
219
|
triggers.push(await manager.add({
|
|
190
220
|
conditions: t.conditions,
|
|
191
221
|
invoke,
|
|
192
|
-
...(t.enabled
|
|
222
|
+
...(t.enabled !== undefined ? { enabled: t.enabled } : {}),
|
|
223
|
+
...(t.cooldown !== undefined ? { cooldown: t.cooldown } : {}),
|
|
193
224
|
}));
|
|
194
225
|
}
|
|
195
226
|
yield { type: 'result', value: { triggers } };
|
|
@@ -231,6 +262,8 @@ export function createTriggerActionTool(manager: TriggerManager): Tool<ToolResul
|
|
|
231
262
|
toTool: { type: 'string', description: 'New tool the selected triggers should invoke. Required for move/copy.' },
|
|
232
263
|
toParams: { type: 'object', description: 'New params for the selected triggers\' invocation (move/copy).' },
|
|
233
264
|
enabled: { type: 'boolean', description: 'On add/update, set false to keep but suspend the trigger (absent ⇒ enabled). To toggle an existing trigger, prefer the dedicated "disable"/"enable" actions.' },
|
|
265
|
+
cooldown: { type: ['object', 'null'], description: 'On add/update, rate-limit firing: { maxPerTurn, quietTurns }. Omit to leave unchanged (absent ⇒ unlimited); null on update removes every limit.',
|
|
266
|
+
properties: { maxPerTurn: { type: 'integer', minimum: 1, description: 'Most fires allowed within one turn (>= 1; to stop a trigger firing at all, use "disable").' }, quietTurns: { type: 'integer', minimum: 0, description: 'Later turns held off after a fire; 1 ⇒ never on consecutive turns, 0 ⇒ no delay.' } } },
|
|
234
267
|
},
|
|
235
268
|
},
|
|
236
269
|
executor,
|
package/src/types.ts
CHANGED
|
@@ -68,11 +68,38 @@ export interface TriggerInvoke {
|
|
|
68
68
|
params?: unknown;
|
|
69
69
|
}
|
|
70
70
|
|
|
71
|
+
/**
|
|
72
|
+
* An optional rate limit on ONE trigger, independent of whether its conditions match. Conditions say
|
|
73
|
+
* *when* a trigger is relevant; a cool-down says *how often* it may act on that — the two are
|
|
74
|
+
* separate because a rule can be genuinely, correctly matched turn after turn (a critic rule matches
|
|
75
|
+
* every uncertain answer) while acting on it every time is a self-critique spin, not a service.
|
|
76
|
+
*
|
|
77
|
+
* Both limits are counted from the durable fire markers already in the session (see the marker
|
|
78
|
+
* helpers in plugin.ts), so a cool-down survives restarts, session reload and a moved backend without
|
|
79
|
+
* any in-memory state. A *turn* here is a genuine (non-robo) user message and everything that follows
|
|
80
|
+
* it — a retract redo or a followup robo turn belongs to the turn that caused it, not a new one.
|
|
81
|
+
*
|
|
82
|
+
* Absent ⇒ no limit, which stays the default: most triggers (e.g. capturing a stated fact) SHOULD
|
|
83
|
+
* fire on consecutive turns, and rate-limiting them would silently lose data.
|
|
84
|
+
*/
|
|
85
|
+
export interface TriggerCooldown {
|
|
86
|
+
/** Most fires allowed within one turn, counting both surfaces. At least 1: the two fields have
|
|
87
|
+
* opposite polarity — this one counts PERMITTED fires, so 0 would mean "never fires", which is
|
|
88
|
+
* `enabled: false` reached silently through a rate limit and is rejected. Absent ⇒ unlimited. */
|
|
89
|
+
maxPerTurn?: number;
|
|
90
|
+
/** Genuine user turns the trigger stays held off for after a fire. 1 ⇒ never on consecutive turns
|
|
91
|
+
* (a fire on turn N is held off on N+1, allowed again on N+2). This one counts BLOCKED turns, so
|
|
92
|
+
* 0 is an honest "no delay" and means the same as absent. */
|
|
93
|
+
quietTurns?: number;
|
|
94
|
+
}
|
|
95
|
+
|
|
71
96
|
export interface Trigger {
|
|
72
97
|
id: string;
|
|
73
98
|
version: string;
|
|
74
99
|
conditions: TriggerCondition[];
|
|
75
100
|
invoke: TriggerInvoke;
|
|
101
|
+
/** Rate limit on firing, independent of matching. Absent ⇒ unlimited — see {@link TriggerCooldown}. */
|
|
102
|
+
cooldown?: TriggerCooldown;
|
|
76
103
|
/** Absent ⇒ enabled. A disabled trigger is kept but never evaluated. */
|
|
77
104
|
enabled?: boolean;
|
|
78
105
|
createdAt: string;
|
|
@@ -84,6 +111,7 @@ export interface TriggerSpec {
|
|
|
84
111
|
conditions: TriggerCondition[];
|
|
85
112
|
invoke: TriggerInvoke;
|
|
86
113
|
enabled?: boolean;
|
|
114
|
+
cooldown?: TriggerCooldown;
|
|
87
115
|
}
|
|
88
116
|
|
|
89
117
|
/**
|