@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 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 plus structured JSON envelope with a cron-tagged callback context, then requests an immediate `cron:sentinel-callback` wake (avoids heartbeat-poll prompting).
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. When hook-session LLM output arrives, Sentinel relays assistant-authored text to the original chat context. If no assistant output arrives before `hookResponseTimeoutMs`, optional fallback relay behavior is applied (`hookResponseFallbackMode`).
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": { "id": "eth-price-watch", "skillId": "skills.alerts", "eventName": "eth_target_hit" },
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. If no assistant output arrives in time (`hookResponseTimeoutMs`), fallback is configurable:
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
- 5. Repeated callbacks with same dedupe key are idempotent within `hookResponseDedupeWindowMs`.
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
 
@@ -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 HEARTBEAT_ACK_TOKEN_PATTERN = /\bHEARTBEAT_OK\b/gi;
18
- const SENTINEL_EVENT_INSTRUCTION_PREFIX = "SENTINEL_TRIGGER: This system event came from /hooks/sentinel. Evaluate action policy, decide whether to notify configured deliveryTargets, and execute safe follow-up actions.";
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
- getNestedString(payload, ["watcher", "id"]) ??
175
+ asString(watcherRecord?.id) ??
171
176
  getNestedString(payload, ["context", "watcherId"]);
172
177
  const eventName = asString(payload.eventName) ??
173
- getNestedString(payload, ["watcher", "eventName"]) ??
178
+ asString(watcherRecord?.eventName) ??
174
179
  getNestedString(payload, ["event", "name"]);
175
180
  const skillId = asString(payload.skillId) ??
176
- getNestedString(payload, ["watcher", "skillId"]) ??
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(getNestedString(payload, ["trigger", "matchedAt"])) ??
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
- getNestedString(payload, ["trigger", "dedupeKey"]) ??
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
- getNestedString(payload, ["watcher", "sessionGroup"]);
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
- payload: boundedPayload,
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 jsonEnvelope = JSON.stringify(envelope, null, 2);
231
- const text = `${SENTINEL_EVENT_INSTRUCTION_PREFIX}\nSENTINEL_ENVELOPE_JSON:\n${jsonEnvelope}`;
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 payloadRecord = isRecord(envelope.payload) ? envelope.payload : undefined;
333
- const contextSummary = summarizeContext(payloadRecord && isRecord(payloadRecord.context) ? payloadRecord.context : payloadRecord);
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
- const assistantMessage = normalizeAssistantRelayText(assistantTexts);
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
- await this.completeWithMessage(pending, assistantMessage, "assistant");
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
- this.api.logger?.info?.(`[openclaw-sentinel] ${source === "assistant" ? "Relayed assistant response" : "Sent timeout fallback"} for dedupe=${pending.dedupeKey} delivered=${delivery.delivered} failed=${delivery.failed}`);
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;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@coffeexdev/openclaw-sentinel",
3
- "version": "0.6.0",
3
+ "version": "0.7.0",
4
4
  "description": "Secure declarative gateway-native watcher plugin for OpenClaw",
5
5
  "keywords": [
6
6
  "openclaw",