@coffeexdev/openclaw-sentinel 0.6.0 → 0.7.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/README.md +17 -6
- package/dist/callbackEnvelope.js +6 -0
- package/dist/index.js +102 -24
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -154,10 +154,10 @@ Use `sentinel_control`:
|
|
|
154
154
|
1. Sentinel evaluates conditions.
|
|
155
155
|
2. On match, it dispatches a generic callback envelope (`type: "sentinel.callback"`) to `localDispatchBase + webhookPath`.
|
|
156
156
|
3. The envelope includes stable keys (`intent`, `context`, `watcher`, `trigger`, bounded `payload`, `deliveryTargets`, `deliveryContext`, `source`) so downstream agent behavior is workflow-agnostic.
|
|
157
|
-
4. For `/hooks/sentinel`, Sentinel enqueues an instruction-prefixed system event
|
|
157
|
+
4. For `/hooks/sentinel`, Sentinel enqueues an instruction-prefixed system event with a **structured callback prompt context** (`watcher`, `trigger`, `source`, `deliveryTargets`, `deliveryContext`, `context`, `payload`) plus the full envelope, then requests an immediate `cron:sentinel-callback` wake (avoids heartbeat-poll prompting).
|
|
158
158
|
5. The hook route creates a **response-delivery contract** keyed by callback dedupe key, preserving original chat/session context (`deliveryContext`) and intended relay targets.
|
|
159
159
|
6. OpenClaw processes each callback in an isolated hook session: per-watcher by default, or grouped when `hookSessionGroup` / `fire.sessionGroup` is set. Shared global hook-session mode is intentionally not supported.
|
|
160
|
-
7.
|
|
160
|
+
7. Relay guardrails suppress control-token outputs (`NO_REPLY`, `HEARTBEAT_OK`, empty variants). If model output is unusable, Sentinel emits a concise contextual fallback message. Timeout fallback behavior still follows `hookResponseFallbackMode`.
|
|
161
161
|
|
|
162
162
|
The `/hooks/sentinel` route is auto-registered on plugin startup (idempotent). Response contracts are dedupe-aware by callback dedupe key (`hookResponseDedupeWindowMs`).
|
|
163
163
|
|
|
@@ -169,7 +169,17 @@ Sample emitted envelope:
|
|
|
169
169
|
"version": "1",
|
|
170
170
|
"intent": "price_threshold_review",
|
|
171
171
|
"actionable": true,
|
|
172
|
-
"watcher": {
|
|
172
|
+
"watcher": {
|
|
173
|
+
"id": "eth-price-watch",
|
|
174
|
+
"skillId": "skills.alerts",
|
|
175
|
+
"eventName": "eth_target_hit",
|
|
176
|
+
"intent": "price_threshold_review",
|
|
177
|
+
"strategy": "http-poll",
|
|
178
|
+
"endpoint": "https://api.coingecko.com/api/v3/simple/price?ids=ethereum&vs_currencies=usd",
|
|
179
|
+
"match": "all",
|
|
180
|
+
"conditions": [{ "path": "ethereum.usd", "op": "gte", "value": 5000 }],
|
|
181
|
+
"fireOnce": false
|
|
182
|
+
},
|
|
173
183
|
"trigger": {
|
|
174
184
|
"matchedAt": "2026-03-04T15:00:00.000Z",
|
|
175
185
|
"dedupeKey": "<sha256>",
|
|
@@ -363,10 +373,11 @@ Precedence: **watcher override > global setting**.
|
|
|
363
373
|
1. Callback is enqueued to isolated hook session.
|
|
364
374
|
2. Contract captures original delivery context (`deliveryContext` + resolved `deliveryTargets`).
|
|
365
375
|
3. First assistant-authored `llm_output` for that pending callback is relayed to target chat.
|
|
366
|
-
4.
|
|
376
|
+
4. Reserved control outputs are never relayed (`NO_REPLY`, `HEARTBEAT_OK`, empty variants). If output is unusable, Sentinel sends a concise contextual guardrail fallback.
|
|
377
|
+
5. If no assistant output arrives in time (`hookResponseTimeoutMs`), timeout fallback is configurable:
|
|
367
378
|
- `hookResponseFallbackMode: "concise"` (default) sends a short fail-safe relay.
|
|
368
|
-
- `hookResponseFallbackMode: "none"` suppresses fallback.
|
|
369
|
-
|
|
379
|
+
- `hookResponseFallbackMode: "none"` suppresses timeout fallback.
|
|
380
|
+
6. Repeated callbacks with same dedupe key are idempotent within `hookResponseDedupeWindowMs`.
|
|
370
381
|
|
|
371
382
|
Example config:
|
|
372
383
|
|
package/dist/callbackEnvelope.js
CHANGED
|
@@ -108,6 +108,12 @@ export function createCallbackEnvelope(args) {
|
|
|
108
108
|
id: watcher.id,
|
|
109
109
|
skillId: watcher.skillId,
|
|
110
110
|
eventName: watcher.fire.eventName,
|
|
111
|
+
intent,
|
|
112
|
+
strategy: watcher.strategy,
|
|
113
|
+
endpoint: watcher.endpoint,
|
|
114
|
+
match: watcher.match,
|
|
115
|
+
conditions: watcher.conditions,
|
|
116
|
+
fireOnce: watcher.fireOnce ?? false,
|
|
111
117
|
},
|
|
112
118
|
trigger: {
|
|
113
119
|
matchedAt,
|
package/dist/index.js
CHANGED
|
@@ -14,8 +14,8 @@ const MAX_SENTINEL_WEBHOOK_TEXT_CHARS = 8000;
|
|
|
14
14
|
const MAX_SENTINEL_PAYLOAD_JSON_CHARS = 2500;
|
|
15
15
|
const SENTINEL_CALLBACK_WAKE_REASON = "cron:sentinel-callback";
|
|
16
16
|
const SENTINEL_CALLBACK_CONTEXT_KEY = "cron:sentinel-callback";
|
|
17
|
-
const
|
|
18
|
-
const SENTINEL_EVENT_INSTRUCTION_PREFIX = "SENTINEL_TRIGGER: This system event came from /hooks/sentinel.
|
|
17
|
+
const RESERVED_CONTROL_TOKEN_PATTERN = /\b(?:NO[\s_-]*REPLY|HEARTBEAT[\s_-]*OK)\b/gi;
|
|
18
|
+
const SENTINEL_EVENT_INSTRUCTION_PREFIX = "SENTINEL_TRIGGER: This system event came from /hooks/sentinel. Use watcher + payload context to decide safe follow-up actions and produce a user-facing response.";
|
|
19
19
|
const SUPPORTED_DELIVERY_CHANNELS = new Set([
|
|
20
20
|
"telegram",
|
|
21
21
|
"discord",
|
|
@@ -165,25 +165,26 @@ function extractDeliveryContext(payload) {
|
|
|
165
165
|
context.deliveryTargets = deliveryTargets;
|
|
166
166
|
return Object.keys(context).length > 0 ? context : undefined;
|
|
167
167
|
}
|
|
168
|
+
function asBoolean(value) {
|
|
169
|
+
return typeof value === "boolean" ? value : undefined;
|
|
170
|
+
}
|
|
168
171
|
function buildSentinelEventEnvelope(payload) {
|
|
172
|
+
const watcherRecord = isRecord(payload.watcher) ? payload.watcher : undefined;
|
|
173
|
+
const triggerRecord = isRecord(payload.trigger) ? payload.trigger : undefined;
|
|
169
174
|
const watcherId = asString(payload.watcherId) ??
|
|
170
|
-
|
|
175
|
+
asString(watcherRecord?.id) ??
|
|
171
176
|
getNestedString(payload, ["context", "watcherId"]);
|
|
172
177
|
const eventName = asString(payload.eventName) ??
|
|
173
|
-
|
|
178
|
+
asString(watcherRecord?.eventName) ??
|
|
174
179
|
getNestedString(payload, ["event", "name"]);
|
|
175
180
|
const skillId = asString(payload.skillId) ??
|
|
176
|
-
|
|
181
|
+
asString(watcherRecord?.skillId) ??
|
|
177
182
|
getNestedString(payload, ["context", "skillId"]) ??
|
|
178
183
|
undefined;
|
|
179
184
|
const matchedAt = asIsoString(payload.matchedAt) ??
|
|
180
185
|
asIsoString(payload.timestamp) ??
|
|
181
|
-
asIsoString(
|
|
186
|
+
asIsoString(triggerRecord?.matchedAt) ??
|
|
182
187
|
new Date().toISOString();
|
|
183
|
-
const rawPayload = payload.payload ??
|
|
184
|
-
(isRecord(payload.event) ? (payload.event.payload ?? payload.event.data) : undefined) ??
|
|
185
|
-
payload;
|
|
186
|
-
const boundedPayload = clipPayloadForPrompt(rawPayload);
|
|
187
188
|
const dedupeSeed = JSON.stringify({
|
|
188
189
|
watcherId: watcherId ?? null,
|
|
189
190
|
eventName: eventName ?? null,
|
|
@@ -193,8 +194,15 @@ function buildSentinelEventEnvelope(payload) {
|
|
|
193
194
|
const dedupeKey = asString(payload.dedupeKey) ??
|
|
194
195
|
asString(payload.correlationId) ??
|
|
195
196
|
asString(payload.correlationID) ??
|
|
196
|
-
|
|
197
|
+
asString(triggerRecord?.dedupeKey) ??
|
|
197
198
|
generatedDedupe;
|
|
199
|
+
const rawPayload = payload.payload ??
|
|
200
|
+
(isRecord(payload.event) ? (payload.event.payload ?? payload.event.data) : undefined) ??
|
|
201
|
+
payload;
|
|
202
|
+
const rawContext = payload.context ??
|
|
203
|
+
(isRecord(rawPayload) ? rawPayload.context : undefined) ??
|
|
204
|
+
(isRecord(payload.event) ? payload.event.context : undefined) ??
|
|
205
|
+
null;
|
|
198
206
|
const deliveryTargets = Array.isArray(payload.deliveryTargets)
|
|
199
207
|
? payload.deliveryTargets.filter(isDeliveryTarget)
|
|
200
208
|
: undefined;
|
|
@@ -202,13 +210,41 @@ function buildSentinelEventEnvelope(payload) {
|
|
|
202
210
|
const sourcePlugin = getNestedString(payload, ["source", "plugin"]) ?? "openclaw-sentinel";
|
|
203
211
|
const hookSessionGroup = asString(payload.hookSessionGroup) ??
|
|
204
212
|
asString(payload.sessionGroup) ??
|
|
205
|
-
|
|
213
|
+
asString(watcherRecord?.sessionGroup);
|
|
206
214
|
const deliveryContext = extractDeliveryContext(payload);
|
|
215
|
+
const watcherIntent = asString(payload.intent) ?? asString(watcherRecord?.intent) ?? null;
|
|
216
|
+
const watcherStrategy = asString(watcherRecord?.strategy) ?? asString(payload.strategy) ?? null;
|
|
217
|
+
const watcherEndpoint = asString(watcherRecord?.endpoint) ?? asString(payload.endpoint) ?? null;
|
|
218
|
+
const watcherMatch = asString(watcherRecord?.match) ?? asString(payload.match) ?? null;
|
|
219
|
+
const watcherConditions = Array.isArray(watcherRecord?.conditions)
|
|
220
|
+
? watcherRecord.conditions
|
|
221
|
+
: Array.isArray(payload.conditions)
|
|
222
|
+
? payload.conditions
|
|
223
|
+
: [];
|
|
224
|
+
const watcherFireOnce = asBoolean(watcherRecord?.fireOnce ?? payload.fireOnce) ?? null;
|
|
225
|
+
const triggerPriority = asString(payload.priority) ?? asString(triggerRecord?.priority) ?? null;
|
|
207
226
|
const envelope = {
|
|
208
227
|
watcherId: watcherId ?? null,
|
|
209
228
|
eventName: eventName ?? null,
|
|
210
229
|
matchedAt,
|
|
211
|
-
|
|
230
|
+
watcher: {
|
|
231
|
+
id: watcherId ?? null,
|
|
232
|
+
skillId: skillId ?? null,
|
|
233
|
+
eventName: eventName ?? null,
|
|
234
|
+
intent: watcherIntent,
|
|
235
|
+
strategy: watcherStrategy,
|
|
236
|
+
endpoint: watcherEndpoint,
|
|
237
|
+
match: watcherMatch,
|
|
238
|
+
conditions: watcherConditions,
|
|
239
|
+
fireOnce: watcherFireOnce,
|
|
240
|
+
},
|
|
241
|
+
trigger: {
|
|
242
|
+
matchedAt,
|
|
243
|
+
dedupeKey,
|
|
244
|
+
priority: triggerPriority,
|
|
245
|
+
},
|
|
246
|
+
context: clipPayloadForPrompt(rawContext),
|
|
247
|
+
payload: clipPayloadForPrompt(rawPayload),
|
|
212
248
|
dedupeKey,
|
|
213
249
|
correlationId: dedupeKey,
|
|
214
250
|
source: {
|
|
@@ -227,8 +263,26 @@ function buildSentinelEventEnvelope(payload) {
|
|
|
227
263
|
return envelope;
|
|
228
264
|
}
|
|
229
265
|
function buildSentinelSystemEvent(envelope) {
|
|
230
|
-
const
|
|
231
|
-
|
|
266
|
+
const callbackContext = {
|
|
267
|
+
watcher: envelope.watcher,
|
|
268
|
+
trigger: envelope.trigger,
|
|
269
|
+
source: envelope.source,
|
|
270
|
+
deliveryTargets: envelope.deliveryTargets ?? [],
|
|
271
|
+
deliveryContext: envelope.deliveryContext ?? null,
|
|
272
|
+
context: envelope.context,
|
|
273
|
+
payload: envelope.payload,
|
|
274
|
+
};
|
|
275
|
+
const text = [
|
|
276
|
+
SENTINEL_EVENT_INSTRUCTION_PREFIX,
|
|
277
|
+
"Callback handling requirements:",
|
|
278
|
+
"- Base actions on watcher intent/event/skill plus the callback context and payload.",
|
|
279
|
+
"- Return a concise user-facing response that reflects what triggered and what to do next.",
|
|
280
|
+
"- Never emit control tokens such as NO_REPLY or HEARTBEAT_OK.",
|
|
281
|
+
"SENTINEL_CALLBACK_CONTEXT_JSON:",
|
|
282
|
+
JSON.stringify(callbackContext, null, 2),
|
|
283
|
+
"SENTINEL_ENVELOPE_JSON:",
|
|
284
|
+
JSON.stringify(envelope, null, 2),
|
|
285
|
+
].join("\n");
|
|
232
286
|
return trimText(text, MAX_SENTINEL_WEBHOOK_TEXT_CHARS);
|
|
233
287
|
}
|
|
234
288
|
function normalizeDeliveryTargets(targets) {
|
|
@@ -329,9 +383,11 @@ function summarizeContext(value) {
|
|
|
329
383
|
function buildRelayMessage(envelope) {
|
|
330
384
|
const title = envelope.eventName ? `Sentinel alert: ${envelope.eventName}` : "Sentinel alert";
|
|
331
385
|
const watcher = envelope.watcherId ? `watcher ${envelope.watcherId}` : "watcher unknown";
|
|
332
|
-
const
|
|
333
|
-
const contextSummary = summarizeContext(
|
|
386
|
+
const intent = envelope.watcher.intent ? `intent ${envelope.watcher.intent}` : undefined;
|
|
387
|
+
const contextSummary = summarizeContext(envelope.context) ?? summarizeContext(envelope.payload);
|
|
334
388
|
const lines = [title, `${watcher} · ${envelope.matchedAt}`];
|
|
389
|
+
if (intent)
|
|
390
|
+
lines.push(intent);
|
|
335
391
|
if (contextSummary)
|
|
336
392
|
lines.push(contextSummary);
|
|
337
393
|
const text = lines.join("\n").trim();
|
|
@@ -339,12 +395,25 @@ function buildRelayMessage(envelope) {
|
|
|
339
395
|
? text
|
|
340
396
|
: "Sentinel callback received, but no assistant detail was generated.";
|
|
341
397
|
}
|
|
398
|
+
function normalizeControlTokenCandidate(value) {
|
|
399
|
+
return value.replace(/[^a-zA-Z]/g, "").toUpperCase();
|
|
400
|
+
}
|
|
401
|
+
function sanitizeAssistantRelaySegment(value) {
|
|
402
|
+
if (typeof value !== "string")
|
|
403
|
+
return "";
|
|
404
|
+
const tokenCandidate = normalizeControlTokenCandidate(value.trim());
|
|
405
|
+
if (tokenCandidate === "NOREPLY" || tokenCandidate === "HEARTBEATOK")
|
|
406
|
+
return "";
|
|
407
|
+
const withoutTokens = value.replace(RESERVED_CONTROL_TOKEN_PATTERN, " ").trim();
|
|
408
|
+
if (!withoutTokens)
|
|
409
|
+
return "";
|
|
410
|
+
const collapsed = withoutTokens.replace(/\s+/g, " ").trim();
|
|
411
|
+
return /[a-zA-Z0-9]/.test(collapsed) ? collapsed : "";
|
|
412
|
+
}
|
|
342
413
|
function normalizeAssistantRelayText(assistantTexts) {
|
|
343
414
|
if (!Array.isArray(assistantTexts) || assistantTexts.length === 0)
|
|
344
415
|
return undefined;
|
|
345
|
-
const parts = assistantTexts
|
|
346
|
-
.map((value) => value.replace(HEARTBEAT_ACK_TOKEN_PATTERN, "").trim())
|
|
347
|
-
.filter(Boolean);
|
|
416
|
+
const parts = assistantTexts.map(sanitizeAssistantRelaySegment).filter(Boolean);
|
|
348
417
|
if (parts.length === 0)
|
|
349
418
|
return undefined;
|
|
350
419
|
return trimText(parts.join("\n\n"), MAX_SENTINEL_WEBHOOK_TEXT_CHARS);
|
|
@@ -573,8 +642,7 @@ class HookResponseRelayManager {
|
|
|
573
642
|
async handleLlmOutput(sessionKey, assistantTexts) {
|
|
574
643
|
if (!sessionKey)
|
|
575
644
|
return;
|
|
576
|
-
|
|
577
|
-
if (!assistantMessage)
|
|
645
|
+
if (!Array.isArray(assistantTexts) || assistantTexts.length === 0)
|
|
578
646
|
return;
|
|
579
647
|
const dedupeKey = this.popNextPendingDedupe(sessionKey);
|
|
580
648
|
if (!dedupeKey)
|
|
@@ -582,7 +650,12 @@ class HookResponseRelayManager {
|
|
|
582
650
|
const pending = this.pendingByDedupe.get(dedupeKey);
|
|
583
651
|
if (!pending || pending.state !== "pending")
|
|
584
652
|
return;
|
|
585
|
-
|
|
653
|
+
const assistantMessage = normalizeAssistantRelayText(assistantTexts);
|
|
654
|
+
if (assistantMessage) {
|
|
655
|
+
await this.completeWithMessage(pending, assistantMessage, "assistant");
|
|
656
|
+
return;
|
|
657
|
+
}
|
|
658
|
+
await this.completeWithMessage(pending, pending.fallbackMessage, "guardrail");
|
|
586
659
|
}
|
|
587
660
|
dispose() {
|
|
588
661
|
if (this.disposed)
|
|
@@ -675,7 +748,12 @@ class HookResponseRelayManager {
|
|
|
675
748
|
async completeWithMessage(pending, message, source) {
|
|
676
749
|
const delivery = await deliverMessageToTargets(this.api, pending.relayTargets, message);
|
|
677
750
|
this.markClosed(pending, source === "assistant" ? "completed" : "timed_out");
|
|
678
|
-
|
|
751
|
+
const action = source === "assistant"
|
|
752
|
+
? "Relayed assistant response"
|
|
753
|
+
: source === "guardrail"
|
|
754
|
+
? "Sent guardrail fallback"
|
|
755
|
+
: "Sent timeout fallback";
|
|
756
|
+
this.api.logger?.info?.(`[openclaw-sentinel] ${action} for dedupe=${pending.dedupeKey} delivered=${delivery.delivered} failed=${delivery.failed}`);
|
|
679
757
|
}
|
|
680
758
|
markClosed(pending, state) {
|
|
681
759
|
pending.state = state;
|