openclaw-triage-gate 1.0.1 → 1.1.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/ROADMAP.md ADDED
@@ -0,0 +1,33 @@
1
+ # Roadmap
2
+
3
+ Future enhancements planned for openclaw-triage-gate. None of these are committed to a timeline — they'll be built as needed.
4
+
5
+ ## Implemented
6
+
7
+ ### Keyword bypass list
8
+ Always respond to messages containing certain keywords regardless of triage (e.g. "help", "urgent", the bot's name). Simple case-insensitive substring matching — no model call needed. Configure via `bypassKeywords` in the plugin config.
9
+
10
+ ### Confidence scores with configurable threshold
11
+ Instead of binary RESPOND/SKIP, the triage model returns a confidence score (1-10). Users configure a threshold — messages scoring at or above it proceed to the main model. Enable with `useConfidenceScores: true` and tune via `confidenceThreshold` (default: 5).
12
+
13
+ ### Recent message history in triage context
14
+ Include the last N messages from the group conversation in the triage prompt. This gives the triage model better context for deciding whether the bot should respond (e.g. understanding ongoing threads, follow-up questions). Configure via `historyCount` (default: 0, max: 20). Trade-off: increases triage token cost by ~500-1000 tokens per call.
15
+
16
+ ## Planned
17
+
18
+ ### Custom per-group triage prompts
19
+ Different groups may need different triage criteria. A caregiving group might want the bot to respond more aggressively, while a social group should be more conservative. Allow `triagePrompt` to be overridden per group ID.
20
+
21
+ ### Analytics and metrics
22
+ Track triage decisions over time: hit/miss ratio, tokens saved, false negatives, response times. Expose via a CLI command or dashboard. Helps users tune their triage prompt and verify cost savings.
23
+
24
+ ### Rate-based bypass
25
+ In quiet groups (low message rate), skip triage and always respond. The cost savings from triage matter most in active groups. Configurable threshold (e.g. "if fewer than 5 messages in the last hour, skip triage").
26
+
27
+ ### Feedback loop
28
+ Let users mark false negatives ("the bot should have responded to this") via a reaction or command. Store these examples and optionally include them in the triage prompt as few-shot examples.
29
+
30
+ ## Considered but not planned
31
+
32
+ ### Non-Anthropic/OpenAI provider formats
33
+ Currently supports Anthropic Messages API and OpenAI-compatible Chat Completions API. Other provider formats (e.g. Google Gemini, Cohere) could be added to `providers.ts` if there's demand.
@@ -31,6 +31,31 @@
31
31
  "logDecisions": {
32
32
  "type": "boolean",
33
33
  "description": "Whether to log each triage decision."
34
+ },
35
+ "bypassKeywords": {
36
+ "type": "array",
37
+ "items": { "type": "string" },
38
+ "description": "Keywords that bypass triage entirely. Messages containing any keyword (case-insensitive) always get a response."
39
+ },
40
+ "useConfidenceScores": {
41
+ "type": "boolean",
42
+ "description": "When true, the triage model returns a 1-10 confidence score instead of binary RESPOND/SKIP."
43
+ },
44
+ "confidenceThreshold": {
45
+ "type": "number",
46
+ "minimum": 1,
47
+ "maximum": 10,
48
+ "description": "Confidence threshold (1-10). Messages scoring at or above this value proceed to the main model. Only used when useConfidenceScores is true."
49
+ },
50
+ "historyCount": {
51
+ "type": "number",
52
+ "minimum": 0,
53
+ "maximum": 20,
54
+ "description": "Number of recent group messages to include in the triage prompt for context. Default: 0 (disabled), max: 20."
55
+ },
56
+ "botName": {
57
+ "type": "string",
58
+ "description": "The bot's name. Messages containing this name bypass triage and always get a response."
34
59
  }
35
60
  }
36
61
  },
@@ -58,6 +83,26 @@
58
83
  "logDecisions": {
59
84
  "label": "Log Decisions",
60
85
  "help": "Log each triage decision to the plugin logger (default: true)"
86
+ },
87
+ "bypassKeywords": {
88
+ "label": "Bypass Keywords",
89
+ "help": "Keywords that bypass triage entirely — messages containing any keyword always get a response (case-insensitive)"
90
+ },
91
+ "useConfidenceScores": {
92
+ "label": "Use Confidence Scores",
93
+ "help": "Use 1-10 confidence scoring instead of binary RESPOND/SKIP (default: false)"
94
+ },
95
+ "confidenceThreshold": {
96
+ "label": "Confidence Threshold",
97
+ "help": "Minimum confidence score (1-10) required for the bot to respond (default: 5)"
98
+ },
99
+ "historyCount": {
100
+ "label": "History Count",
101
+ "help": "Number of recent messages to include in the triage prompt for context (default: 0, max: 20)"
102
+ },
103
+ "botName": {
104
+ "label": "Bot Name",
105
+ "help": "The bot's name — messages containing this name always get a response (e.g. 'Nox')"
61
106
  }
62
107
  }
63
108
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "openclaw-triage-gate",
3
- "version": "1.0.1",
3
+ "version": "1.1.0",
4
4
  "description": "A lightweight triage gate for OpenClaw group chats. Uses a cheap model to decide if the bot should respond before the expensive main model runs.",
5
5
  "type": "module",
6
6
  "main": "src/index.ts",
@@ -31,5 +31,8 @@
31
31
  "install": {
32
32
  "npmSpec": "openclaw-triage-gate"
33
33
  }
34
+ },
35
+ "devDependencies": {
36
+ "vitest": "^4.1.2"
34
37
  }
35
38
  }
package/src/config.ts CHANGED
@@ -51,6 +51,45 @@ export type TriageGateConfig = {
51
51
  * Default: true
52
52
  */
53
53
  logDecisions?: boolean;
54
+
55
+ /**
56
+ * Keywords that bypass triage entirely. When a message contains any of
57
+ * these keywords (case-insensitive substring match), the bot always
58
+ * responds without calling the triage model.
59
+ */
60
+ bypassKeywords?: string[];
61
+
62
+ /**
63
+ * When true, the triage model returns a 1-10 confidence score instead
64
+ * of binary RESPOND/SKIP. Messages scoring at or above the threshold
65
+ * proceed to the main model.
66
+ * Default: false
67
+ */
68
+ useConfidenceScores?: boolean;
69
+
70
+ /**
71
+ * Confidence threshold (1-10). Messages with a score at or above this
72
+ * value proceed to the main model. Only used when useConfidenceScores
73
+ * is true.
74
+ * Default: 5
75
+ */
76
+ confidenceThreshold?: number;
77
+
78
+ /**
79
+ * Number of recent group messages to include in the triage prompt for
80
+ * additional context. Helps the model make better decisions by seeing
81
+ * the conversation flow. The plugin maintains its own in-memory buffer
82
+ * per group (resets on plugin restart).
83
+ * Default: 0 (disabled), Max: 20
84
+ */
85
+ historyCount?: number;
86
+
87
+ /**
88
+ * The bot's name. When set, messages containing this name (case-insensitive)
89
+ * bypass triage and always get a response — similar to an @mention.
90
+ * Example: "Nox"
91
+ */
92
+ botName?: string;
54
93
  };
55
94
 
56
95
  /** The default triage model when none is configured. */
@@ -80,3 +119,15 @@ SKIP when:
80
119
  - A response would just be acknowledgment ("nice", "yeah", "lol")
81
120
  - The conversation is flowing fine without the bot
82
121
  - The message is a reaction, emoji, or sticker`;
122
+
123
+ /** Default confidence threshold when useConfidenceScores is enabled. */
124
+ export const DEFAULT_CONFIDENCE_THRESHOLD = 5;
125
+
126
+ /** Default number of recent messages to include in triage context. */
127
+ export const DEFAULT_HISTORY_COUNT = 0;
128
+
129
+ /**
130
+ * The built-in confidence-scoring prompt. Instructs the model to reply
131
+ * with a single number 1-10 indicating response likelihood.
132
+ */
133
+ export const DEFAULT_CONFIDENCE_PROMPT = `Reply with a single number 1-10 indicating how likely the bot should respond. 1 = definitely skip, 10 = definitely respond. Reply with ONLY the number.`;
package/src/index.ts CHANGED
@@ -10,9 +10,62 @@
10
10
  */
11
11
 
12
12
  import { definePluginEntry, type OpenClawPluginApi } from "openclaw/plugin-sdk/plugin-entry";
13
- import { evaluateMessage } from "./triage.js";
13
+ import { evaluateMessage, containsBypassKeyword } from "./triage.js";
14
14
  import { type TriageGateConfig } from "./config.js";
15
15
 
16
+ // Guard against multiple registrations. OpenClaw calls register() for each
17
+ // agent context, but we only need one before_dispatch hook globally.
18
+ let registered = false;
19
+
20
+ /**
21
+ * In-memory ring buffer that accumulates recent messages per group.
22
+ * Keyed by sessionKey (group ID). Resets on plugin restart — this is
23
+ * acceptable for triage context since it's best-effort.
24
+ */
25
+ const groupHistoryBuffers = new Map<
26
+ string,
27
+ Array<{ role: string; content: string; ts: number }>
28
+ >();
29
+
30
+ /** Evict groups with no activity in the last hour to prevent unbounded growth. */
31
+ const EVICT_AFTER_MS = 60 * 60 * 1000;
32
+
33
+ function pushToBuffer(
34
+ groupId: string,
35
+ senderId: string,
36
+ content: string,
37
+ maxSize: number,
38
+ ): void {
39
+ let buffer = groupHistoryBuffers.get(groupId);
40
+ if (!buffer) {
41
+ buffer = [];
42
+ groupHistoryBuffers.set(groupId, buffer);
43
+ }
44
+ buffer.push({ role: senderId, content, ts: Date.now() });
45
+ // Keep only the last maxSize entries
46
+ if (buffer.length > maxSize) {
47
+ buffer.splice(0, buffer.length - maxSize);
48
+ }
49
+ }
50
+
51
+ function getBufferedHistory(
52
+ groupId: string,
53
+ count: number,
54
+ ): Array<{ role: string; content: string }> | undefined {
55
+ const buffer = groupHistoryBuffers.get(groupId);
56
+ if (!buffer?.length) return undefined;
57
+ return buffer.slice(-count).map(({ role, content }) => ({ role, content }));
58
+ }
59
+
60
+ function evictStaleBuffers(): void {
61
+ const cutoff = Date.now() - EVICT_AFTER_MS;
62
+ for (const [key, buffer] of groupHistoryBuffers) {
63
+ if (!buffer.length || buffer[buffer.length - 1].ts < cutoff) {
64
+ groupHistoryBuffers.delete(key);
65
+ }
66
+ }
67
+ }
68
+
16
69
  export default definePluginEntry({
17
70
  id: "openclaw-triage-gate",
18
71
  name: "Triage Gate",
@@ -20,8 +73,17 @@ export default definePluginEntry({
20
73
  "Uses a cheap model to decide if the bot should respond in group chats, saving 75-90% of group chat token costs.",
21
74
 
22
75
  register(api: OpenClawPluginApi) {
76
+ if (registered) return;
77
+ registered = true;
78
+
23
79
  const config = (api.pluginConfig ?? {}) as TriageGateConfig;
24
80
  const logDecisions = config.logDecisions !== false; // default: true
81
+ const historyCount = Math.min(Math.max(config.historyCount ?? 0, 0), 20);
82
+
83
+ // Resolve bot name: explicit config > first agent's identity.name from OpenClaw config
84
+ const ocConfig = api.config as { agents?: { list?: Array<{ identity?: { name?: string } }> } };
85
+ const agentIdentityName = ocConfig.agents?.list?.[0]?.identity?.name;
86
+ const botNameLower = (config.botName ?? agentIdentityName ?? "").toLowerCase();
25
87
 
26
88
  // Pre-compute the set of groups to include/exclude for fast lookups
27
89
  const includeGroups = config.groups?.length
@@ -31,6 +93,11 @@ export default definePluginEntry({
31
93
  ? new Set(config.excludeGroups)
32
94
  : null;
33
95
 
96
+ // Periodically evict stale group buffers (every 10 minutes)
97
+ if (historyCount > 0) {
98
+ setInterval(evictStaleBuffers, 10 * 60 * 1000);
99
+ }
100
+
34
101
  /**
35
102
  * Resolve an API key for a provider/model using OpenClaw's auth system.
36
103
  * This keeps the plugin model-agnostic — it works with any provider
@@ -69,18 +136,60 @@ export default definePluginEntry({
69
136
  return { handled: true }; // Skip silently
70
137
  }
71
138
 
139
+ // Always respond when the bot's name is mentioned in the message
140
+ if (botNameLower && event.content.toLowerCase().includes(botNameLower)) {
141
+ if (logDecisions) {
142
+ api.logger.info?.(`triage-gate: RESPOND (bot name mentioned) — "${event.content.slice(0, 80)}"`);
143
+ }
144
+ // Still record in history buffer before passing through
145
+ if (historyCount > 0) {
146
+ pushToBuffer(groupId, event.senderId ?? "unknown", event.content, historyCount);
147
+ }
148
+ return; // let message through without triage
149
+ }
150
+
151
+ // Check for bypass keywords — if matched, skip triage entirely
152
+ if (config.bypassKeywords?.length) {
153
+ const matched = containsBypassKeyword(event.content, config.bypassKeywords);
154
+ if (matched) {
155
+ if (logDecisions) {
156
+ api.logger.info?.(`triage-gate: BYPASS (keyword: ${matched})`);
157
+ }
158
+ if (historyCount > 0) {
159
+ pushToBuffer(groupId, event.senderId ?? "unknown", event.content, historyCount);
160
+ }
161
+ return; // undefined = let message through without triage
162
+ }
163
+ }
164
+
165
+ // Get recent messages from the in-memory buffer
166
+ const recentMessages = historyCount > 0
167
+ ? getBufferedHistory(groupId, historyCount)
168
+ : undefined;
169
+
170
+ // Record this message in the buffer (after reading history so this
171
+ // message isn't included as "recent" context for itself)
172
+ if (historyCount > 0) {
173
+ pushToBuffer(groupId, event.senderId ?? "unknown", event.content, historyCount);
174
+ }
175
+
72
176
  // Run the triage model
73
177
  const result = await evaluateMessage({
74
178
  content: event.content,
179
+ senderName: event.senderId,
75
180
  config,
76
181
  resolveApiKey,
77
182
  logger: logDecisions ? api.logger : undefined,
183
+ recentMessages,
78
184
  });
79
185
 
80
186
  if (logDecisions) {
81
187
  const decision = result.shouldRespond ? "RESPOND" : "SKIP";
188
+ const scoreInfo = result.confidenceScore != null
189
+ ? `score: ${result.confidenceScore}/10, `
190
+ : "";
82
191
  api.logger.info?.(
83
- `triage-gate: ${decision} (${result.durationMs}ms) — "${event.content.slice(0, 80)}"`,
192
+ `triage-gate: ${decision} (${scoreInfo}${result.durationMs}ms) — "${event.content.slice(0, 80)}"`,
84
193
  );
85
194
  }
86
195
 
package/src/triage.ts CHANGED
@@ -12,6 +12,8 @@ import {
12
12
  DEFAULT_TRIAGE_MODEL,
13
13
  DEFAULT_TRIAGE_PROMPT,
14
14
  DEFAULT_MAX_TRIAGE_TOKENS,
15
+ DEFAULT_CONFIDENCE_THRESHOLD,
16
+ DEFAULT_CONFIDENCE_PROMPT,
15
17
  type TriageGateConfig,
16
18
  } from "./config.js";
17
19
  import { parseModelString, getProviderAdapter } from "./providers.js";
@@ -24,6 +26,9 @@ type TriageParams = {
24
26
  /** The message content to evaluate. */
25
27
  content: string;
26
28
 
29
+ /** Sender identifier (when available). */
30
+ senderName?: string;
31
+
27
32
  /** Plugin config. */
28
33
  config: TriageGateConfig;
29
34
 
@@ -38,6 +43,9 @@ type TriageParams = {
38
43
  info?: (msg: string) => void;
39
44
  warn?: (msg: string) => void;
40
45
  };
46
+
47
+ /** Recent messages from the group conversation for additional context. */
48
+ recentMessages?: Array<{ role: string; content: string }>;
41
49
  };
42
50
 
43
51
  export type TriageResult = {
@@ -49,6 +57,9 @@ export type TriageResult = {
49
57
 
50
58
  /** How long the triage call took in milliseconds. */
51
59
  durationMs: number;
60
+
61
+ /** Confidence score 1-10 when useConfidenceScores is enabled. */
62
+ confidenceScore?: number;
52
63
  };
53
64
 
54
65
  // ---------------------------------------------------------------------------
@@ -66,12 +77,14 @@ export type TriageResult = {
66
77
  * silently drop messages.
67
78
  */
68
79
  export async function evaluateMessage(params: TriageParams): Promise<TriageResult> {
69
- const { content, config, resolveApiKey, logger } = params;
80
+ const { content, senderName, config, resolveApiKey, logger, recentMessages } = params;
70
81
  const startTime = Date.now();
71
82
 
72
83
  const modelString = config.triageModel ?? DEFAULT_TRIAGE_MODEL;
73
84
  const { provider, model } = parseModelString(modelString);
74
- const prompt = config.triagePrompt ?? DEFAULT_TRIAGE_PROMPT;
85
+ const useConfidence = config.useConfidenceScores === true;
86
+ const prompt = config.triagePrompt
87
+ ?? (useConfidence ? DEFAULT_CONFIDENCE_PROMPT : DEFAULT_TRIAGE_PROMPT);
75
88
  const maxTokens = config.maxTriageTokens ?? DEFAULT_MAX_TRIAGE_TOKENS;
76
89
 
77
90
  try {
@@ -85,6 +98,9 @@ export async function evaluateMessage(params: TriageParams): Promise<TriageResul
85
98
  // Get the right adapter for this provider's API format
86
99
  const adapter = getProviderAdapter(provider);
87
100
 
101
+ // Build the user message with available context
102
+ const userMessage = buildTriageUserMessage({ content, senderName, recentMessages });
103
+
88
104
  // Make the API call
89
105
  const response = await fetch(adapter.endpoint, {
90
106
  method: "POST",
@@ -92,7 +108,7 @@ export async function evaluateMessage(params: TriageParams): Promise<TriageResul
92
108
  body: adapter.buildRequestBody({
93
109
  model,
94
110
  systemPrompt: prompt,
95
- userMessage: `Message: ${content}`,
111
+ userMessage,
96
112
  maxTokens,
97
113
  }),
98
114
  signal: AbortSignal.timeout(5000), // 5s timeout — triage should be fast
@@ -108,8 +124,15 @@ export async function evaluateMessage(params: TriageParams): Promise<TriageResul
108
124
 
109
125
  const body = await response.json();
110
126
  const rawResponse = adapter.extractResponse(body);
111
- const shouldRespond = parseTriageDecision(rawResponse);
112
127
 
128
+ if (useConfidence) {
129
+ const confidenceScore = parseConfidenceScore(rawResponse);
130
+ const threshold = config.confidenceThreshold ?? DEFAULT_CONFIDENCE_THRESHOLD;
131
+ const shouldRespond = confidenceScore >= threshold;
132
+ return { shouldRespond, rawResponse, durationMs: Date.now() - startTime, confidenceScore };
133
+ }
134
+
135
+ const shouldRespond = parseTriageDecision(rawResponse);
113
136
  return { shouldRespond, rawResponse, durationMs: Date.now() - startTime };
114
137
  } catch (error) {
115
138
  // On any error, default to letting the message through.
@@ -119,6 +142,92 @@ export async function evaluateMessage(params: TriageParams): Promise<TriageResul
119
142
  }
120
143
  }
121
144
 
145
+ // ---------------------------------------------------------------------------
146
+ // User message construction
147
+ // ---------------------------------------------------------------------------
148
+
149
+ /**
150
+ * Build the user message sent to the triage model, including available context.
151
+ * This gives the triage model enough information to make informed decisions
152
+ * about messages like "yes please" or "can you elaborate?" that only make
153
+ * sense in context.
154
+ */
155
+ export function buildTriageUserMessage(params: {
156
+ content: string;
157
+ senderName?: string;
158
+ recentMessages?: Array<{ role: string; content: string }>;
159
+ }): string {
160
+ const parts: string[] = [];
161
+
162
+ if (params.senderName) {
163
+ parts.push(`From: ${params.senderName}`);
164
+ }
165
+
166
+ parts.push(`Message: ${params.content}`);
167
+
168
+ if (params.recentMessages?.length) {
169
+ parts.push("");
170
+ parts.push(formatMessageHistory(params.recentMessages));
171
+ }
172
+
173
+ return parts.join("\n");
174
+ }
175
+
176
+ // ---------------------------------------------------------------------------
177
+ // Bypass keyword check
178
+ // ---------------------------------------------------------------------------
179
+
180
+ /**
181
+ * Check whether the message content contains any of the configured bypass
182
+ * keywords. Matching is case-insensitive and uses substring containment.
183
+ *
184
+ * @returns The first matched keyword (lowercased), or `null` if none match.
185
+ */
186
+ export function containsBypassKeyword(
187
+ content: string,
188
+ keywords: string[],
189
+ ): string | null {
190
+ if (!content || keywords.length === 0) return null;
191
+
192
+ const lowerContent = content.toLowerCase();
193
+ for (const keyword of keywords) {
194
+ const lowerKeyword = keyword.toLowerCase();
195
+ if (lowerContent.includes(lowerKeyword)) {
196
+ return lowerKeyword;
197
+ }
198
+ }
199
+ return null;
200
+ }
201
+
202
+ // ---------------------------------------------------------------------------
203
+ // Message history formatting
204
+ // ---------------------------------------------------------------------------
205
+
206
+ const MAX_MESSAGE_CONTENT_LENGTH = 200;
207
+
208
+ /**
209
+ * Format an array of recent messages into a human-readable string for the
210
+ * triage prompt. Each message is rendered as "- [role]: [content]".
211
+ *
212
+ * Message content longer than 200 characters is truncated with "...".
213
+ * Returns an empty string for an empty array.
214
+ */
215
+ export function formatMessageHistory(
216
+ messages: Array<{ role: string; content: string }>,
217
+ ): string {
218
+ if (messages.length === 0) return "";
219
+
220
+ const lines = messages.map(({ role, content }) => {
221
+ const truncated =
222
+ content.length > MAX_MESSAGE_CONTENT_LENGTH
223
+ ? content.slice(0, MAX_MESSAGE_CONTENT_LENGTH) + "..."
224
+ : content;
225
+ return `- ${role}: ${truncated}`;
226
+ });
227
+
228
+ return `Recent conversation:\n${lines.join("\n")}`;
229
+ }
230
+
122
231
  // ---------------------------------------------------------------------------
123
232
  // Helpers
124
233
  // ---------------------------------------------------------------------------
@@ -140,3 +249,30 @@ export function parseTriageDecision(response: string): boolean {
140
249
  // are worse than false positives (bot responds when it didn't need to).
141
250
  return true;
142
251
  }
252
+
253
+ /**
254
+ * Parse a confidence score (1-10) from the triage model's response.
255
+ *
256
+ * Extraction strategy:
257
+ * 1. Look for the first integer 1-10 in the text
258
+ * 2. Fall back to keyword matching: "RESPOND" -> 10, "SKIP" -> 1
259
+ * 3. Default to 10 for ambiguous/empty responses (safe default — respond)
260
+ */
261
+ export function parseConfidenceScore(response: string): number {
262
+ const trimmed = response.trim();
263
+
264
+ // Try to extract the first integer 1-10 from the response
265
+ const match = trimmed.match(/\b(10|[1-9])\b/);
266
+ if (match) {
267
+ return parseInt(match[1], 10);
268
+ }
269
+
270
+ // Backward compatibility: map RESPOND/SKIP keywords to scores
271
+ const upper = trimmed.toUpperCase();
272
+ if (upper.startsWith("RESPOND")) return 10;
273
+ if (upper.startsWith("SKIP")) return 1;
274
+
275
+ // Default to 10 (respond) for ambiguous or empty input.
276
+ // Same philosophy as parseTriageDecision: better to respond than drop.
277
+ return 10;
278
+ }