@coffeexdev/openclaw-sentinel 0.2.1 → 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/README.md CHANGED
@@ -58,6 +58,14 @@ openclaw gateway restart
58
58
  "fire": {
59
59
  "webhookPath": "/hooks/sentinel",
60
60
  "eventName": "eth_target_hit",
61
+ "intent": "price_threshold_review",
62
+ "contextTemplate": {
63
+ "asset": "ETH",
64
+ "priceUsd": "${payload.ethereum.usd}",
65
+ "workflow": "alerts"
66
+ },
67
+ "priority": "high",
68
+ "deadlineTemplate": "${timestamp}",
61
69
  "payloadTemplate": {
62
70
  "event": "${event.name}",
63
71
  "price": "${payload.ethereum.usd}",
@@ -87,12 +95,35 @@ Use `sentinel_control`:
87
95
  ## What happens when a watcher fires?
88
96
 
89
97
  1. Sentinel evaluates conditions.
90
- 2. On match, it dispatches to `localDispatchBase + webhookPath`.
91
- 3. For `/hooks/sentinel`, the plugin route enqueues a system event and requests heartbeat wake.
92
- 4. OpenClaw wakes and processes that event in the configured session (`hookSessionKey`, default `agent:main:main`).
98
+ 2. On match, it dispatches a generic callback envelope (`type: "sentinel.callback"`) to `localDispatchBase + webhookPath`.
99
+ 3. The envelope includes stable keys (`intent`, `context`, `watcher`, `trigger`, bounded `payload`, `deliveryTargets`, `source`) so downstream agent behavior is workflow-agnostic.
100
+ 4. It also sends a notification message to each configured `deliveryTargets` destination (defaults to the current chat context when watcher is created from a channel session).
101
+ 5. For `/hooks/sentinel`, the plugin route enqueues an instruction-prefixed system event plus structured JSON envelope and requests heartbeat wake.
102
+ 6. OpenClaw wakes and processes that event in the configured session (`hookSessionKey`, default `agent:main:main`).
93
103
 
94
104
  The `/hooks/sentinel` route is auto-registered on plugin startup (idempotent).
95
105
 
106
+ Sample emitted envelope:
107
+
108
+ ```json
109
+ {
110
+ "type": "sentinel.callback",
111
+ "version": "1",
112
+ "intent": "price_threshold_review",
113
+ "actionable": true,
114
+ "watcher": { "id": "eth-price-watch", "skillId": "skills.alerts", "eventName": "eth_target_hit" },
115
+ "trigger": {
116
+ "matchedAt": "2026-03-04T15:00:00.000Z",
117
+ "dedupeKey": "<sha256>",
118
+ "priority": "high"
119
+ },
120
+ "context": { "asset": "ETH", "priceUsd": 5001, "workflow": "alerts" },
121
+ "payload": { "ethereum": { "usd": 5001 } },
122
+ "deliveryTargets": [{ "channel": "telegram", "to": "5613673222" }],
123
+ "source": { "plugin": "openclaw-sentinel", "route": "/hooks/sentinel" }
124
+ }
125
+ ```
126
+
96
127
  ## Why Sentinel
97
128
 
98
129
  Sentinel runs watcher lifecycles inside the gateway with fixed strategies and declarative conditions.
@@ -143,11 +174,17 @@ It **does not** execute user-authored code from watcher definitions.
143
174
  "ts": "${timestamp}"
144
175
  }
145
176
  },
146
- "retry": { "maxRetries": 5, "baseMs": 250, "maxMs": 5000 }
177
+ "retry": { "maxRetries": 5, "baseMs": 250, "maxMs": 5000 },
178
+ "deliveryTargets": [
179
+ { "channel": "telegram", "to": "5613673222" },
180
+ { "channel": "discord", "to": "123456789012345678", "accountId": "main" }
181
+ ]
147
182
  }
148
183
  }
149
184
  ```
150
185
 
186
+ `deliveryTargets` is optional. If omitted on `create`, Sentinel infers a default target from the current tool/session context (channel + current peer).
187
+
151
188
  ## Runtime controls
152
189
 
153
190
  ```json
@@ -185,10 +222,10 @@ openclaw-sentinel audit
185
222
  ## Development
186
223
 
187
224
  ```bash
188
- npm i
189
- npm run lint
190
- npm run test
191
- npm run build
225
+ pnpm install
226
+ pnpm run lint
227
+ pnpm test
228
+ pnpm run build
192
229
  ```
193
230
 
194
231
  ## License
@@ -0,0 +1,8 @@
1
+ import { WatcherDefinition } from "./types.js";
2
+ export declare function createCallbackEnvelope(args: {
3
+ watcher: WatcherDefinition;
4
+ payload: unknown;
5
+ payloadBody: Record<string, unknown>;
6
+ matchedAt: string;
7
+ webhookPath: string;
8
+ }): Record<string, unknown>;
@@ -0,0 +1,100 @@
1
+ import { createHash } from "node:crypto";
2
+ import { renderTemplate } from "./template.js";
3
+ const MAX_PAYLOAD_JSON_CHARS = 4000;
4
+ function toIntent(eventName) {
5
+ return (eventName
6
+ .replace(/[^a-zA-Z0-9]+/g, " ")
7
+ .trim()
8
+ .toLowerCase()
9
+ .replace(/\s+/g, "_") || "sentinel_event");
10
+ }
11
+ function summarizePayload(payload) {
12
+ if (!payload || typeof payload !== "object" || Array.isArray(payload)) {
13
+ return { summary: String(payload) };
14
+ }
15
+ const obj = payload;
16
+ const out = {};
17
+ for (const key of Object.keys(obj).slice(0, 12))
18
+ out[key] = obj[key];
19
+ return out;
20
+ }
21
+ function truncatePayload(payload) {
22
+ const serialized = JSON.stringify(payload);
23
+ if (!serialized)
24
+ return payload;
25
+ if (serialized.length <= MAX_PAYLOAD_JSON_CHARS)
26
+ return payload;
27
+ return {
28
+ truncated: true,
29
+ maxChars: MAX_PAYLOAD_JSON_CHARS,
30
+ preview: serialized.slice(0, MAX_PAYLOAD_JSON_CHARS),
31
+ };
32
+ }
33
+ function getPath(obj, path) {
34
+ return path.split(".").reduce((acc, part) => acc?.[part], obj);
35
+ }
36
+ function getTemplateString(value, context) {
37
+ if (!value)
38
+ return undefined;
39
+ if (!value.includes("${"))
40
+ return value;
41
+ if (/^\$\{[^}]+\}$/.test(value)) {
42
+ const rendered = renderTemplate({ value }, context);
43
+ const resolved = rendered.value;
44
+ if (resolved === undefined || resolved === null)
45
+ return undefined;
46
+ return String(resolved);
47
+ }
48
+ return value.replaceAll(/\$\{([^}]+)\}/g, (_full, path) => {
49
+ if (!/^(watcher\.(id|skillId)|event\.(name)|payload\.[a-zA-Z0-9_.-]+|timestamp)$/.test(path)) {
50
+ throw new Error(`Template placeholder not allowed: $\{${path}\}`);
51
+ }
52
+ const resolved = getPath(context, path);
53
+ if (resolved === undefined || resolved === null) {
54
+ throw new Error(`Template placeholder unresolved: $\{${path}\}`);
55
+ }
56
+ return String(resolved);
57
+ });
58
+ }
59
+ export function createCallbackEnvelope(args) {
60
+ const { watcher, payload, payloadBody, matchedAt, webhookPath } = args;
61
+ const context = {
62
+ watcher,
63
+ event: { name: watcher.fire.eventName },
64
+ payload,
65
+ timestamp: matchedAt,
66
+ };
67
+ const intent = watcher.fire.intent ?? toIntent(watcher.fire.eventName);
68
+ const renderedContext = watcher.fire.contextTemplate
69
+ ? renderTemplate(watcher.fire.contextTemplate, context)
70
+ : payloadBody;
71
+ const priority = watcher.fire.priority ?? "normal";
72
+ const deadline = getTemplateString(watcher.fire.deadlineTemplate, context);
73
+ const dedupeSeed = getTemplateString(watcher.fire.dedupeKeyTemplate, context) ??
74
+ `${watcher.id}|${watcher.fire.eventName}|${matchedAt}`;
75
+ const dedupeKey = createHash("sha256").update(dedupeSeed).digest("hex");
76
+ return {
77
+ type: "sentinel.callback",
78
+ version: "1",
79
+ intent,
80
+ actionable: true,
81
+ watcher: {
82
+ id: watcher.id,
83
+ skillId: watcher.skillId,
84
+ eventName: watcher.fire.eventName,
85
+ },
86
+ trigger: {
87
+ matchedAt,
88
+ dedupeKey,
89
+ priority,
90
+ ...(deadline ? { deadline } : {}),
91
+ },
92
+ context: renderedContext ?? summarizePayload(payload),
93
+ payload: truncatePayload(payload),
94
+ deliveryTargets: watcher.deliveryTargets ?? [],
95
+ source: {
96
+ plugin: "openclaw-sentinel",
97
+ route: webhookPath,
98
+ },
99
+ };
100
+ }
package/dist/cli.js CHANGED
File without changes
@@ -87,7 +87,6 @@ export const sentinelConfigSchema = {
87
87
  },
88
88
  localDispatchBase: {
89
89
  type: "string",
90
- format: "uri",
91
90
  description: "Base URL for internal webhook dispatch",
92
91
  default: "http://127.0.0.1:18789",
93
92
  },
package/dist/index.js CHANGED
@@ -1,3 +1,4 @@
1
+ import { createHash } from "node:crypto";
1
2
  import { sentinelConfigSchema } from "./configSchema.js";
2
3
  import { registerSentinelControl } from "./tool.js";
3
4
  import { DEFAULT_SENTINEL_WEBHOOK_PATH } from "./types.js";
@@ -5,14 +6,9 @@ import { WatcherManager } from "./watcherManager.js";
5
6
  const registeredWebhookPathsByRegistrar = new WeakMap();
6
7
  const DEFAULT_HOOK_SESSION_KEY = "agent:main:main";
7
8
  const MAX_SENTINEL_WEBHOOK_BODY_BYTES = 64 * 1024;
8
- const MAX_SENTINEL_WEBHOOK_TEXT_CHARS = 2000;
9
- function normalizePath(path) {
10
- const trimmed = path.trim();
11
- if (!trimmed)
12
- return DEFAULT_SENTINEL_WEBHOOK_PATH;
13
- const withSlash = trimmed.startsWith("/") ? trimmed : `/${trimmed}`;
14
- return withSlash.length > 1 && withSlash.endsWith("/") ? withSlash.slice(0, -1) : withSlash;
15
- }
9
+ const MAX_SENTINEL_WEBHOOK_TEXT_CHARS = 8000;
10
+ const MAX_SENTINEL_PAYLOAD_JSON_CHARS = 2500;
11
+ 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.";
16
12
  function trimText(value, max) {
17
13
  return value.length <= max ? value : `${value.slice(0, max)}…`;
18
14
  }
@@ -22,22 +18,93 @@ function asString(value) {
22
18
  const trimmed = value.trim();
23
19
  return trimmed.length > 0 ? trimmed : undefined;
24
20
  }
21
+ function asIsoString(value) {
22
+ const text = asString(value);
23
+ if (!text)
24
+ return undefined;
25
+ const timestamp = Date.parse(text);
26
+ return Number.isNaN(timestamp) ? undefined : new Date(timestamp).toISOString();
27
+ }
25
28
  function isRecord(value) {
26
29
  return !!value && typeof value === "object" && !Array.isArray(value);
27
30
  }
28
- function buildSentinelSystemEvent(payload) {
29
- const text = asString(payload.text) ?? asString(payload.message);
30
- if (text)
31
- return trimText(text, MAX_SENTINEL_WEBHOOK_TEXT_CHARS);
32
- const watcherId = asString(payload.watcherId);
31
+ function isDeliveryTarget(value) {
32
+ return (isRecord(value) &&
33
+ typeof value.channel === "string" &&
34
+ typeof value.to === "string" &&
35
+ (value.accountId === undefined || typeof value.accountId === "string"));
36
+ }
37
+ function normalizePath(path) {
38
+ const trimmed = path.trim();
39
+ if (!trimmed)
40
+ return DEFAULT_SENTINEL_WEBHOOK_PATH;
41
+ const withSlash = trimmed.startsWith("/") ? trimmed : `/${trimmed}`;
42
+ return withSlash.length > 1 && withSlash.endsWith("/") ? withSlash.slice(0, -1) : withSlash;
43
+ }
44
+ function clipPayloadForPrompt(value) {
45
+ const serialized = JSON.stringify(value);
46
+ if (!serialized)
47
+ return value;
48
+ if (serialized.length <= MAX_SENTINEL_PAYLOAD_JSON_CHARS)
49
+ return value;
50
+ const clipped = serialized.slice(0, MAX_SENTINEL_PAYLOAD_JSON_CHARS);
51
+ const overflow = serialized.length - clipped.length;
52
+ return {
53
+ __truncated: true,
54
+ truncatedChars: overflow,
55
+ maxChars: MAX_SENTINEL_PAYLOAD_JSON_CHARS,
56
+ preview: `${clipped}…`,
57
+ };
58
+ }
59
+ function buildSentinelEventEnvelope(payload) {
60
+ const watcherId = asString(payload.watcherId) ??
61
+ (isRecord(payload.watcher) ? asString(payload.watcher.id) : undefined);
33
62
  const eventName = asString(payload.eventName) ??
34
63
  (isRecord(payload.event) ? asString(payload.event.name) : undefined);
35
- const labels = ["Sentinel webhook received"];
36
- if (eventName)
37
- labels.push(`event=${eventName}`);
38
- if (watcherId)
39
- labels.push(`watcher=${watcherId}`);
40
- return trimText(labels.join(" "), MAX_SENTINEL_WEBHOOK_TEXT_CHARS);
64
+ const skillId = asString(payload.skillId) ??
65
+ (isRecord(payload.watcher) ? asString(payload.watcher.skillId) : undefined) ??
66
+ undefined;
67
+ const matchedAt = asIsoString(payload.matchedAt) ?? asIsoString(payload.timestamp) ?? new Date().toISOString();
68
+ const rawPayload = payload.payload ??
69
+ (isRecord(payload.event) ? (payload.event.payload ?? payload.event.data) : undefined) ??
70
+ payload;
71
+ const boundedPayload = clipPayloadForPrompt(rawPayload);
72
+ const dedupeSeed = JSON.stringify({
73
+ watcherId: watcherId ?? null,
74
+ eventName: eventName ?? null,
75
+ matchedAt,
76
+ });
77
+ const generatedDedupe = createHash("sha256").update(dedupeSeed).digest("hex").slice(0, 16);
78
+ const dedupeKey = asString(payload.dedupeKey) ??
79
+ asString(payload.correlationId) ??
80
+ asString(payload.correlationID) ??
81
+ generatedDedupe;
82
+ const deliveryTargets = Array.isArray(payload.deliveryTargets)
83
+ ? payload.deliveryTargets.filter(isDeliveryTarget)
84
+ : undefined;
85
+ const envelope = {
86
+ watcherId: watcherId ?? null,
87
+ eventName: eventName ?? null,
88
+ matchedAt,
89
+ payload: boundedPayload,
90
+ dedupeKey,
91
+ correlationId: dedupeKey,
92
+ source: {
93
+ route: DEFAULT_SENTINEL_WEBHOOK_PATH,
94
+ plugin: "openclaw-sentinel",
95
+ },
96
+ };
97
+ if (skillId)
98
+ envelope.skillId = skillId;
99
+ if (deliveryTargets && deliveryTargets.length > 0)
100
+ envelope.deliveryTargets = deliveryTargets;
101
+ return envelope;
102
+ }
103
+ function buildSentinelSystemEvent(payload) {
104
+ const envelope = buildSentinelEventEnvelope(payload);
105
+ const jsonEnvelope = JSON.stringify(envelope, null, 2);
106
+ const text = `${SENTINEL_EVENT_INSTRUCTION_PREFIX}\nSENTINEL_ENVELOPE_JSON:\n${jsonEnvelope}`;
107
+ return trimText(text, MAX_SENTINEL_WEBHOOK_TEXT_CHARS);
41
108
  }
42
109
  async function readSentinelWebhookPayload(req) {
43
110
  const preParsed = req.body;
@@ -70,6 +137,47 @@ async function readSentinelWebhookPayload(req) {
70
137
  }
71
138
  return parsed;
72
139
  }
140
+ async function notifyDeliveryTarget(api, target, message) {
141
+ switch (target.channel) {
142
+ case "telegram":
143
+ await api.runtime.channel.telegram.sendMessageTelegram(target.to, message, {
144
+ accountId: target.accountId,
145
+ });
146
+ return;
147
+ case "discord":
148
+ await api.runtime.channel.discord.sendMessageDiscord(target.to, message, {
149
+ accountId: target.accountId,
150
+ });
151
+ return;
152
+ case "slack":
153
+ await api.runtime.channel.slack.sendMessageSlack(target.to, message, {
154
+ accountId: target.accountId,
155
+ });
156
+ return;
157
+ case "signal":
158
+ await api.runtime.channel.signal.sendMessageSignal(target.to, message, {
159
+ accountId: target.accountId,
160
+ });
161
+ return;
162
+ case "imessage":
163
+ await api.runtime.channel.imessage.sendMessageIMessage(target.to, message, {
164
+ accountId: target.accountId,
165
+ });
166
+ return;
167
+ case "whatsapp":
168
+ await api.runtime.channel.whatsapp.sendMessageWhatsApp(target.to, message, {
169
+ accountId: target.accountId,
170
+ });
171
+ return;
172
+ case "line":
173
+ await api.runtime.channel.line.sendMessageLine(target.to, message, {
174
+ accountId: target.accountId,
175
+ });
176
+ return;
177
+ default:
178
+ throw new Error(`Unsupported delivery target channel: ${target.channel}`);
179
+ }
180
+ }
73
181
  export function createSentinelPlugin(overrides) {
74
182
  const config = {
75
183
  allowedHosts: [],
@@ -102,6 +210,11 @@ export function createSentinelPlugin(overrides) {
102
210
  await manager.init();
103
211
  },
104
212
  register(api) {
213
+ manager.setNotifier({
214
+ async notify(target, message) {
215
+ await notifyDeliveryTarget(api, target, message);
216
+ },
217
+ });
105
218
  registerSentinelControl(api.registerTool.bind(api), manager);
106
219
  const path = normalizePath(DEFAULT_SENTINEL_WEBHOOK_PATH);
107
220
  if (!api.registerHttpRoute) {
@@ -1,2 +1,5 @@
1
1
  import { WatcherDefinition } from "../types.js";
2
- export type StrategyHandler = (watcher: WatcherDefinition, onPayload: (payload: unknown) => Promise<void>, onError: (error: unknown) => Promise<void>) => Promise<() => void>;
2
+ export interface StrategyCallbacks {
3
+ onConnect?: () => void;
4
+ }
5
+ export type StrategyHandler = (watcher: WatcherDefinition, onPayload: (payload: unknown) => Promise<void>, onError: (error: unknown) => Promise<void>, callbacks?: StrategyCallbacks) => Promise<() => void>;
@@ -1,9 +1,16 @@
1
1
  import WebSocket from "ws";
2
- export const websocketStrategy = async (watcher, onPayload, onError) => {
2
+ export const websocketStrategy = async (watcher, onPayload, onError, callbacks) => {
3
3
  let active = true;
4
4
  let ws = null;
5
5
  const connect = () => {
6
+ let pendingError = null;
7
+ let failureReported = false;
6
8
  ws = new WebSocket(watcher.endpoint, { headers: watcher.headers });
9
+ ws.on("open", () => {
10
+ if (!active)
11
+ return;
12
+ callbacks?.onConnect?.();
13
+ });
7
14
  ws.on("message", async (data) => {
8
15
  if (!active)
9
16
  return;
@@ -18,12 +25,14 @@ export const websocketStrategy = async (watcher, onPayload, onError) => {
18
25
  ws.on("error", (err) => {
19
26
  if (!active)
20
27
  return;
21
- void onError(err);
28
+ pendingError = err instanceof Error ? err : new Error(String(err));
22
29
  });
23
30
  ws.on("close", (code) => {
24
- if (!active)
31
+ if (!active || failureReported)
25
32
  return;
26
- void onError(new Error(`websocket closed: ${code}`));
33
+ failureReported = true;
34
+ const reason = pendingError?.message ?? `websocket closed: ${code}`;
35
+ void onError(new Error(reason));
27
36
  });
28
37
  };
29
38
  connect();
@@ -1 +1,4 @@
1
- export declare function renderTemplate(template: Record<string, string | number | boolean | null>, context: Record<string, unknown>): Record<string, unknown>;
1
+ export type TemplateValue = string | number | boolean | null | {
2
+ [key: string]: TemplateValue;
3
+ } | TemplateValue[];
4
+ export declare function renderTemplate(template: Record<string, TemplateValue>, context: Record<string, unknown>): Record<string, unknown>;
package/dist/template.js CHANGED
@@ -2,17 +2,12 @@ const placeholderPattern = /^\$\{(watcher\.(id|skillId)|event\.(name)|payload\.[
2
2
  function getPath(obj, path) {
3
3
  return path.split(".").reduce((acc, part) => acc?.[part], obj);
4
4
  }
5
- export function renderTemplate(template, context) {
6
- const out = {};
7
- for (const [key, value] of Object.entries(template)) {
8
- if (typeof value !== "string") {
9
- out[key] = value;
10
- continue;
11
- }
12
- if (!value.startsWith("${")) {
13
- out[key] = value;
14
- continue;
15
- }
5
+ function renderValue(value, context) {
6
+ if (value === null || typeof value === "number" || typeof value === "boolean")
7
+ return value;
8
+ if (typeof value === "string") {
9
+ if (!value.startsWith("${"))
10
+ return value;
16
11
  if (!placeholderPattern.test(value)) {
17
12
  throw new Error(`Template placeholder not allowed: ${value}`);
18
13
  }
@@ -20,7 +15,16 @@ export function renderTemplate(template, context) {
20
15
  const resolved = getPath(context, path);
21
16
  if (resolved === undefined)
22
17
  throw new Error(`Template placeholder unresolved: ${value}`);
23
- out[key] = resolved;
18
+ return resolved;
19
+ }
20
+ if (Array.isArray(value))
21
+ return value.map((item) => renderValue(item, context));
22
+ const out = {};
23
+ for (const [key, child] of Object.entries(value)) {
24
+ out[key] = renderValue(child, context);
24
25
  }
25
26
  return out;
26
27
  }
28
+ export function renderTemplate(template, context) {
29
+ return renderValue(template, context);
30
+ }
package/dist/tool.d.ts CHANGED
@@ -3,6 +3,12 @@ import type { Static } from "@sinclair/typebox";
3
3
  import { WatcherManager } from "./watcherManager.js";
4
4
  import { SentinelToolSchema } from "./toolSchema.js";
5
5
  export type SentinelToolParams = Static<typeof SentinelToolSchema>;
6
- type RegisterToolFn = (tool: AnyAgentTool) => void;
6
+ type SentinelToolContext = {
7
+ messageChannel?: string;
8
+ requesterSenderId?: string;
9
+ agentAccountId?: string;
10
+ sessionKey?: string;
11
+ };
12
+ type RegisterToolFn = (tool: AnyAgentTool | ((ctx: SentinelToolContext) => AnyAgentTool)) => void;
7
13
  export declare function registerSentinelControl(registerTool: RegisterToolFn, manager: WatcherManager): void;
8
14
  export {};
package/dist/tool.js CHANGED
@@ -11,8 +11,22 @@ function validateParams(params) {
11
11
  }
12
12
  return candidate;
13
13
  }
14
+ function inferDefaultDeliveryTargets(ctx) {
15
+ const channel = ctx.messageChannel?.trim();
16
+ if (!channel)
17
+ return [];
18
+ const fromSender = ctx.requesterSenderId?.trim();
19
+ if (fromSender) {
20
+ return [{ channel, to: fromSender, accountId: ctx.agentAccountId }];
21
+ }
22
+ const sessionPeer = ctx.sessionKey?.split(":").at(-1)?.trim();
23
+ if (sessionPeer) {
24
+ return [{ channel, to: sessionPeer, accountId: ctx.agentAccountId }];
25
+ }
26
+ return [];
27
+ }
14
28
  export function registerSentinelControl(registerTool, manager) {
15
- registerTool({
29
+ registerTool((ctx) => ({
16
30
  name: "sentinel_control",
17
31
  label: "sentinel_control",
18
32
  description: "Create/manage sentinel watchers",
@@ -21,7 +35,9 @@ export function registerSentinelControl(registerTool, manager) {
21
35
  const payload = validateParams(params);
22
36
  switch (payload.action) {
23
37
  case "create":
24
- return jsonResult(await manager.create(payload.watcher));
38
+ return jsonResult(await manager.create(payload.watcher, {
39
+ deliveryTargets: inferDefaultDeliveryTargets(ctx),
40
+ }));
25
41
  case "enable":
26
42
  return jsonResult(await manager.enable(payload.id ?? ""));
27
43
  case "disable":
@@ -34,5 +50,5 @@ export function registerSentinelControl(registerTool, manager) {
34
50
  return jsonResult(manager.list());
35
51
  }
36
52
  },
37
- });
53
+ }));
38
54
  }
@@ -21,7 +21,12 @@ export declare const SentinelToolSchema: import("@sinclair/typebox").TObject<{
21
21
  fire: import("@sinclair/typebox").TObject<{
22
22
  webhookPath: import("@sinclair/typebox").TString;
23
23
  eventName: import("@sinclair/typebox").TString;
24
- payloadTemplate: import("@sinclair/typebox").TRecord<import("@sinclair/typebox").TString, import("@sinclair/typebox").TUnion<[import("@sinclair/typebox").TString, import("@sinclair/typebox").TNumber, import("@sinclair/typebox").TBoolean, import("@sinclair/typebox").TNull]>>;
24
+ payloadTemplate: import("@sinclair/typebox").TRecord<import("@sinclair/typebox").TString, any>;
25
+ intent: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TString>;
26
+ contextTemplate: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TRecord<import("@sinclair/typebox").TString, any>>;
27
+ priority: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TUnion<[import("@sinclair/typebox").TLiteral<"low">, import("@sinclair/typebox").TLiteral<"normal">, import("@sinclair/typebox").TLiteral<"high">, import("@sinclair/typebox").TLiteral<"critical">]>>;
28
+ deadlineTemplate: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TString>;
29
+ dedupeKeyTemplate: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TString>;
25
30
  }>;
26
31
  retry: import("@sinclair/typebox").TObject<{
27
32
  maxRetries: import("@sinclair/typebox").TNumber;
@@ -29,6 +34,11 @@ export declare const SentinelToolSchema: import("@sinclair/typebox").TObject<{
29
34
  maxMs: import("@sinclair/typebox").TNumber;
30
35
  }>;
31
36
  fireOnce: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TBoolean>;
37
+ deliveryTargets: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TArray<import("@sinclair/typebox").TObject<{
38
+ channel: import("@sinclair/typebox").TString;
39
+ to: import("@sinclair/typebox").TString;
40
+ accountId: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TString>;
41
+ }>>>;
32
42
  metadata: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TRecord<import("@sinclair/typebox").TString, import("@sinclair/typebox").TString>>;
33
43
  }>>;
34
44
  }>;
@@ -1,4 +1,12 @@
1
1
  import { Type } from "@sinclair/typebox";
2
+ const TemplateValueSchema = Type.Recursive((Self) => Type.Union([
3
+ Type.String(),
4
+ Type.Number(),
5
+ Type.Boolean(),
6
+ Type.Null(),
7
+ Type.Array(Self),
8
+ Type.Record(Type.String(), Self),
9
+ ]));
2
10
  const ConditionSchema = Type.Object({
3
11
  path: Type.String({ description: "JSONPath expression to evaluate against the response" }),
4
12
  op: Type.Union([
@@ -23,15 +31,27 @@ const FireConfigSchema = Type.Object({
23
31
  description: "Path appended to localDispatchBase for webhook delivery",
24
32
  }),
25
33
  eventName: Type.String({ description: "Event name included in the dispatched payload" }),
26
- payloadTemplate: Type.Record(Type.String(), Type.Union([Type.String(), Type.Number(), Type.Boolean(), Type.Null()]), {
34
+ payloadTemplate: Type.Record(Type.String(), TemplateValueSchema, {
27
35
  description: "Key-value template for the webhook payload. Supports ${...} interpolation from matched response data.",
28
36
  }),
37
+ intent: Type.Optional(Type.String({ description: "Generic callback intent for downstream agent routing" })),
38
+ contextTemplate: Type.Optional(Type.Record(Type.String(), TemplateValueSchema, {
39
+ description: "Structured callback context template. Supports ${...} interpolation from matched response data.",
40
+ })),
41
+ priority: Type.Optional(Type.Union([Type.Literal("low"), Type.Literal("normal"), Type.Literal("high"), Type.Literal("critical")], { description: "Callback urgency hint" })),
42
+ deadlineTemplate: Type.Optional(Type.String({ description: "Optional templated deadline string for callback consumers" })),
43
+ dedupeKeyTemplate: Type.Optional(Type.String({ description: "Optional template to derive deterministic trigger dedupe key" })),
29
44
  });
30
45
  const RetryPolicySchema = Type.Object({
31
46
  maxRetries: Type.Number({ description: "Maximum number of retry attempts" }),
32
47
  baseMs: Type.Number({ description: "Base delay in milliseconds for exponential backoff" }),
33
48
  maxMs: Type.Number({ description: "Maximum delay cap in milliseconds" }),
34
49
  });
50
+ const DeliveryTargetSchema = Type.Object({
51
+ channel: Type.String({ description: "Channel/provider id (e.g. telegram, discord)" }),
52
+ to: Type.String({ description: "Destination id within the channel" }),
53
+ accountId: Type.Optional(Type.String({ description: "Optional account id for multi-account channels" })),
54
+ }, { additionalProperties: false });
35
55
  const WatcherSchema = Type.Object({
36
56
  id: Type.String({ description: "Unique watcher identifier" }),
37
57
  skillId: Type.String({ description: "ID of the skill that owns this watcher" }),
@@ -61,6 +81,9 @@ const WatcherSchema = Type.Object({
61
81
  fire: FireConfigSchema,
62
82
  retry: RetryPolicySchema,
63
83
  fireOnce: Type.Optional(Type.Boolean({ description: "If true, the watcher disables itself after firing once" })),
84
+ deliveryTargets: Type.Optional(Type.Array(DeliveryTargetSchema, {
85
+ description: "Optional notification delivery targets. Defaults to the current chat/session context when omitted.",
86
+ })),
64
87
  metadata: Type.Optional(Type.Record(Type.String(), Type.String(), { description: "Arbitrary key-value metadata" })),
65
88
  }, { description: "Full watcher definition" });
66
89
  export const SentinelToolSchema = Type.Object({
package/dist/types.d.ts CHANGED
@@ -6,16 +6,27 @@ export interface Condition {
6
6
  value?: unknown;
7
7
  }
8
8
  export declare const DEFAULT_SENTINEL_WEBHOOK_PATH = "/hooks/sentinel";
9
+ export type PriorityLevel = "low" | "normal" | "high" | "critical";
9
10
  export interface FireConfig {
10
11
  webhookPath?: string;
11
12
  eventName: string;
12
- payloadTemplate: Record<string, string | number | boolean | null>;
13
+ payloadTemplate: Record<string, import("./template.js").TemplateValue>;
14
+ intent?: string;
15
+ contextTemplate?: Record<string, import("./template.js").TemplateValue>;
16
+ priority?: PriorityLevel;
17
+ deadlineTemplate?: string;
18
+ dedupeKeyTemplate?: string;
13
19
  }
14
20
  export interface RetryPolicy {
15
21
  maxRetries: number;
16
22
  baseMs: number;
17
23
  maxMs: number;
18
24
  }
25
+ export interface DeliveryTarget {
26
+ channel: string;
27
+ to: string;
28
+ accountId?: string;
29
+ }
19
30
  export interface WatcherDefinition {
20
31
  id: string;
21
32
  skillId: string;
@@ -32,6 +43,7 @@ export interface WatcherDefinition {
32
43
  fire: FireConfig;
33
44
  retry: RetryPolicy;
34
45
  fireOnce?: boolean;
46
+ deliveryTargets?: DeliveryTarget[];
35
47
  metadata?: Record<string, string>;
36
48
  }
37
49
  export interface WatcherRuntimeState {
@@ -39,9 +51,22 @@ export interface WatcherRuntimeState {
39
51
  lastError?: string;
40
52
  lastResponseAt?: string;
41
53
  consecutiveFailures: number;
54
+ reconnectAttempts: number;
42
55
  lastPayloadHash?: string;
43
56
  lastPayload?: unknown;
44
57
  lastEvaluated?: string;
58
+ lastConnectAt?: string;
59
+ lastDisconnectAt?: string;
60
+ lastDisconnectReason?: string;
61
+ lastDelivery?: {
62
+ attemptedAt: string;
63
+ successCount: number;
64
+ failureCount: number;
65
+ failures?: Array<{
66
+ target: DeliveryTarget;
67
+ error: string;
68
+ }>;
69
+ };
45
70
  }
46
71
  export interface SentinelStateFile {
47
72
  watchers: WatcherDefinition[];
package/dist/validator.js CHANGED
@@ -3,6 +3,14 @@ import { Value } from "@sinclair/typebox/value";
3
3
  import { DEFAULT_SENTINEL_WEBHOOK_PATH } from "./types.js";
4
4
  const codeyKeyPattern = /(script|code|eval|handler|function|import|require)/i;
5
5
  const codeyValuePattern = /(=>|\bfunction\b|\bimport\s+|\brequire\s*\(|\beval\s*\()/i;
6
+ const TemplateValueSchema = Type.Recursive((Self) => Type.Union([
7
+ Type.String(),
8
+ Type.Number(),
9
+ Type.Boolean(),
10
+ Type.Null(),
11
+ Type.Array(Self),
12
+ Type.Record(Type.String(), Self),
13
+ ]));
6
14
  const ConditionSchema = Type.Object({
7
15
  path: Type.String({ minLength: 1 }),
8
16
  op: Type.Union([
@@ -41,7 +49,17 @@ const WatcherSchema = Type.Object({
41
49
  fire: Type.Object({
42
50
  webhookPath: Type.Optional(Type.String({ pattern: "^/" })),
43
51
  eventName: Type.String({ minLength: 1 }),
44
- payloadTemplate: Type.Record(Type.String(), Type.Union([Type.String(), Type.Number(), Type.Boolean(), Type.Null()])),
52
+ payloadTemplate: Type.Record(Type.String(), TemplateValueSchema),
53
+ intent: Type.Optional(Type.String({ minLength: 1 })),
54
+ contextTemplate: Type.Optional(Type.Record(Type.String(), TemplateValueSchema)),
55
+ priority: Type.Optional(Type.Union([
56
+ Type.Literal("low"),
57
+ Type.Literal("normal"),
58
+ Type.Literal("high"),
59
+ Type.Literal("critical"),
60
+ ])),
61
+ deadlineTemplate: Type.Optional(Type.String({ minLength: 1 })),
62
+ dedupeKeyTemplate: Type.Optional(Type.String({ minLength: 1 })),
45
63
  }, { additionalProperties: false }),
46
64
  retry: Type.Object({
47
65
  maxRetries: Type.Integer({ minimum: 0, maximum: 20 }),
@@ -49,6 +67,11 @@ const WatcherSchema = Type.Object({
49
67
  maxMs: Type.Integer({ minimum: 100, maximum: 300000 }),
50
68
  }, { additionalProperties: false }),
51
69
  fireOnce: Type.Optional(Type.Boolean()),
70
+ deliveryTargets: Type.Optional(Type.Array(Type.Object({
71
+ channel: Type.String({ minLength: 1 }),
72
+ to: Type.String({ minLength: 1 }),
73
+ accountId: Type.Optional(Type.String({ minLength: 1 })),
74
+ }, { additionalProperties: false }), { minItems: 1 })),
52
75
  metadata: Type.Optional(Type.Record(Type.String(), Type.String())),
53
76
  }, { additionalProperties: false });
54
77
  function scanNoCodeLike(input, parentKey = "") {
@@ -1,18 +1,28 @@
1
- import { GatewayWebhookDispatcher, SentinelConfig, WatcherDefinition, WatcherRuntimeState } from "./types.js";
1
+ import { DeliveryTarget, GatewayWebhookDispatcher, SentinelConfig, WatcherDefinition, WatcherRuntimeState } from "./types.js";
2
+ export declare const RESET_BACKOFF_AFTER_MS = 60000;
3
+ export interface WatcherCreateContext {
4
+ deliveryTargets?: DeliveryTarget[];
5
+ }
6
+ export interface WatcherNotifier {
7
+ notify(target: DeliveryTarget, message: string): Promise<void>;
8
+ }
9
+ export declare const backoff: (base: number, max: number, failures: number) => number;
2
10
  export declare class WatcherManager {
3
11
  private config;
4
12
  private dispatcher;
13
+ private notifier?;
5
14
  private watchers;
6
15
  private runtime;
7
16
  private stops;
8
17
  private retryTimers;
9
18
  private statePath;
10
19
  private webhookRegistration;
11
- constructor(config: SentinelConfig, dispatcher: GatewayWebhookDispatcher);
20
+ constructor(config: SentinelConfig, dispatcher: GatewayWebhookDispatcher, notifier?: WatcherNotifier | undefined);
12
21
  init(): Promise<void>;
13
- create(input: unknown): Promise<WatcherDefinition>;
22
+ create(input: unknown, ctx?: WatcherCreateContext): Promise<WatcherDefinition>;
14
23
  list(): WatcherDefinition[];
15
24
  status(id: string): WatcherRuntimeState | undefined;
25
+ setNotifier(notifier: WatcherNotifier | undefined): void;
16
26
  setWebhookRegistrationStatus(status: "ok" | "error", message?: string, path?: string): void;
17
27
  enable(id: string): Promise<void>;
18
28
  disable(id: string): Promise<void>;
@@ -3,12 +3,14 @@ import { assertHostAllowed, assertWatcherLimits } from "./limits.js";
3
3
  import { defaultStatePath, loadState, saveState } from "./stateStore.js";
4
4
  import { renderTemplate } from "./template.js";
5
5
  import { validateWatcherDefinition } from "./validator.js";
6
+ import { createCallbackEnvelope } from "./callbackEnvelope.js";
6
7
  import { httpPollStrategy } from "./strategies/httpPoll.js";
7
8
  import { httpLongPollStrategy } from "./strategies/httpLongPoll.js";
8
9
  import { sseStrategy } from "./strategies/sse.js";
9
10
  import { websocketStrategy } from "./strategies/websocket.js";
10
11
  import { DEFAULT_SENTINEL_WEBHOOK_PATH, } from "./types.js";
11
- const backoff = (base, max, failures) => {
12
+ export const RESET_BACKOFF_AFTER_MS = 60_000;
13
+ export const backoff = (base, max, failures) => {
12
14
  const raw = Math.min(max, base * 2 ** failures);
13
15
  const jitter = Math.floor(raw * 0.25 * (Math.random() * 2 - 1));
14
16
  return Math.max(base, raw + jitter);
@@ -16,6 +18,7 @@ const backoff = (base, max, failures) => {
16
18
  export class WatcherManager {
17
19
  config;
18
20
  dispatcher;
21
+ notifier;
19
22
  watchers = new Map();
20
23
  runtime = {};
21
24
  stops = new Map();
@@ -25,9 +28,10 @@ export class WatcherManager {
25
28
  path: DEFAULT_SENTINEL_WEBHOOK_PATH,
26
29
  status: "pending",
27
30
  };
28
- constructor(config, dispatcher) {
31
+ constructor(config, dispatcher, notifier) {
29
32
  this.config = config;
30
33
  this.dispatcher = dispatcher;
34
+ this.notifier = notifier;
31
35
  this.statePath = config.stateFilePath ?? defaultStatePath();
32
36
  }
33
37
  async init() {
@@ -41,28 +45,33 @@ export class WatcherManager {
41
45
  this.watchers.set(watcher.id, watcher);
42
46
  }
43
47
  catch (err) {
48
+ const prev = this.runtime[rawWatcher.id];
44
49
  this.runtime[rawWatcher.id] = {
45
50
  id: rawWatcher.id,
46
- consecutiveFailures: (this.runtime[rawWatcher.id]?.consecutiveFailures ?? 0) + 1,
51
+ consecutiveFailures: (prev?.consecutiveFailures ?? 0) + 1,
52
+ reconnectAttempts: prev?.reconnectAttempts ?? 0,
47
53
  lastError: `Invalid persisted watcher: ${String(err?.message ?? err)}`,
48
- lastResponseAt: this.runtime[rawWatcher.id]?.lastResponseAt,
49
- lastEvaluated: this.runtime[rawWatcher.id]?.lastEvaluated,
50
- lastPayloadHash: this.runtime[rawWatcher.id]?.lastPayloadHash,
51
- lastPayload: this.runtime[rawWatcher.id]?.lastPayload,
54
+ lastResponseAt: prev?.lastResponseAt,
55
+ lastEvaluated: prev?.lastEvaluated,
56
+ lastPayloadHash: prev?.lastPayloadHash,
57
+ lastPayload: prev?.lastPayload,
52
58
  };
53
59
  }
54
60
  }
55
61
  for (const watcher of this.list().filter((w) => w.enabled))
56
62
  await this.startWatcher(watcher.id);
57
63
  }
58
- async create(input) {
64
+ async create(input, ctx) {
59
65
  const watcher = validateWatcherDefinition(input);
66
+ if (!watcher.deliveryTargets?.length && ctx?.deliveryTargets?.length) {
67
+ watcher.deliveryTargets = ctx.deliveryTargets;
68
+ }
60
69
  assertHostAllowed(this.config, watcher.endpoint);
61
70
  assertWatcherLimits(this.config, this.list(), watcher);
62
71
  if (this.watchers.has(watcher.id))
63
72
  throw new Error(`Watcher already exists: ${watcher.id}`);
64
73
  this.watchers.set(watcher.id, watcher);
65
- this.runtime[watcher.id] = { id: watcher.id, consecutiveFailures: 0 };
74
+ this.runtime[watcher.id] = { id: watcher.id, consecutiveFailures: 0, reconnectAttempts: 0 };
66
75
  if (watcher.enabled)
67
76
  await this.startWatcher(watcher.id);
68
77
  await this.persist();
@@ -74,6 +83,9 @@ export class WatcherManager {
74
83
  status(id) {
75
84
  return this.runtime[id];
76
85
  }
86
+ setNotifier(notifier) {
87
+ this.notifier = notifier;
88
+ }
77
89
  setWebhookRegistrationStatus(status, message, path) {
78
90
  this.webhookRegistration = {
79
91
  path: path ?? this.webhookRegistration.path,
@@ -117,9 +129,19 @@ export class WatcherManager {
117
129
  "http-long-poll": httpLongPollStrategy,
118
130
  }[watcher.strategy];
119
131
  const handleFailure = async (err) => {
120
- const rt = this.runtime[id] ?? { id, consecutiveFailures: 0 };
132
+ const rt = this.runtime[id] ?? { id, consecutiveFailures: 0, reconnectAttempts: 0 };
133
+ rt.reconnectAttempts ??= 0;
134
+ const errMsg = String(err instanceof Error ? err.message : err);
135
+ rt.lastDisconnectAt = new Date().toISOString();
136
+ rt.lastDisconnectReason = errMsg;
137
+ rt.lastError = errMsg;
138
+ if (rt.lastConnectAt) {
139
+ const connectedMs = Date.now() - new Date(rt.lastConnectAt).getTime();
140
+ if (connectedMs >= RESET_BACKOFF_AFTER_MS) {
141
+ rt.consecutiveFailures = 0;
142
+ }
143
+ }
121
144
  rt.consecutiveFailures += 1;
122
- rt.lastError = String(err?.message ?? err);
123
145
  this.runtime[id] = rt;
124
146
  if (this.retryTimers.has(id)) {
125
147
  await this.persist();
@@ -127,6 +149,7 @@ export class WatcherManager {
127
149
  }
128
150
  const delay = backoff(watcher.retry.baseMs, watcher.retry.maxMs, rt.consecutiveFailures);
129
151
  if (rt.consecutiveFailures <= watcher.retry.maxRetries && watcher.enabled) {
152
+ rt.reconnectAttempts += 1;
130
153
  await this.stopWatcher(id);
131
154
  const timer = setTimeout(() => {
132
155
  this.retryTimers.delete(id);
@@ -137,7 +160,7 @@ export class WatcherManager {
137
160
  await this.persist();
138
161
  };
139
162
  const stop = await handler(watcher, async (payload) => {
140
- const rt = this.runtime[id] ?? { id, consecutiveFailures: 0 };
163
+ const rt = this.runtime[id] ?? { id, consecutiveFailures: 0, reconnectAttempts: 0 };
141
164
  const previousPayload = rt.lastPayload;
142
165
  const matched = evaluateConditions(watcher.conditions, watcher.match, payload, previousPayload);
143
166
  rt.lastPayloadHash = hashPayload(payload);
@@ -145,23 +168,63 @@ export class WatcherManager {
145
168
  rt.lastResponseAt = new Date().toISOString();
146
169
  rt.lastEvaluated = rt.lastResponseAt;
147
170
  rt.consecutiveFailures = 0;
171
+ rt.reconnectAttempts = 0;
148
172
  rt.lastError = undefined;
149
173
  this.runtime[id] = rt;
150
174
  if (matched) {
151
- const body = renderTemplate(watcher.fire.payloadTemplate, {
175
+ const matchedAt = new Date().toISOString();
176
+ const payloadBody = renderTemplate(watcher.fire.payloadTemplate, {
152
177
  watcher,
153
178
  event: { name: watcher.fire.eventName },
154
179
  payload,
155
- timestamp: new Date().toISOString(),
180
+ timestamp: matchedAt,
156
181
  });
157
- await this.dispatcher.dispatch(watcher.fire.webhookPath ?? DEFAULT_SENTINEL_WEBHOOK_PATH, body);
182
+ const webhookPath = watcher.fire.webhookPath ?? DEFAULT_SENTINEL_WEBHOOK_PATH;
183
+ const body = createCallbackEnvelope({
184
+ watcher,
185
+ payload,
186
+ payloadBody,
187
+ matchedAt,
188
+ webhookPath,
189
+ });
190
+ await this.dispatcher.dispatch(webhookPath, body);
191
+ if (watcher.deliveryTargets?.length && this.notifier) {
192
+ const attemptedAt = new Date().toISOString();
193
+ const message = JSON.stringify(body);
194
+ const failures = [];
195
+ let successCount = 0;
196
+ await Promise.all(watcher.deliveryTargets.map(async (target) => {
197
+ try {
198
+ await this.notifier?.notify(target, message);
199
+ successCount += 1;
200
+ }
201
+ catch (err) {
202
+ failures.push({
203
+ target,
204
+ error: String(err?.message ?? err),
205
+ });
206
+ }
207
+ }));
208
+ rt.lastDelivery = {
209
+ attemptedAt,
210
+ successCount,
211
+ failureCount: failures.length,
212
+ failures: failures.length > 0 ? failures : undefined,
213
+ };
214
+ }
158
215
  if (watcher.fireOnce) {
159
216
  watcher.enabled = false;
160
217
  await this.stopWatcher(id);
161
218
  }
162
219
  }
163
220
  await this.persist();
164
- }, handleFailure);
221
+ }, handleFailure, {
222
+ onConnect: () => {
223
+ const rt = this.runtime[id] ?? { id, consecutiveFailures: 0, reconnectAttempts: 0 };
224
+ rt.lastConnectAt = new Date().toISOString();
225
+ this.runtime[id] = rt;
226
+ },
227
+ });
165
228
  this.stops.set(id, stop);
166
229
  }
167
230
  async stopWatcher(id) {
@@ -12,7 +12,6 @@
12
12
  },
13
13
  "localDispatchBase": {
14
14
  "type": "string",
15
- "format": "uri",
16
15
  "description": "Base URL for internal webhook dispatch",
17
16
  "default": "http://127.0.0.1:18789"
18
17
  },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@coffeexdev/openclaw-sentinel",
3
- "version": "0.2.1",
3
+ "version": "0.4.0",
4
4
  "description": "Secure declarative gateway-native watcher plugin for OpenClaw",
5
5
  "keywords": [
6
6
  "openclaw",
@@ -29,18 +29,6 @@
29
29
  "publishConfig": {
30
30
  "access": "public"
31
31
  },
32
- "scripts": {
33
- "build": "tsc -p tsconfig.json",
34
- "test": "vitest run",
35
- "lint": "tsc --noEmit",
36
- "format": "oxfmt --write .",
37
- "format:check": "oxfmt --check .",
38
- "changeset": "changeset",
39
- "version-packages": "changeset version",
40
- "release": "changeset publish",
41
- "prepack": "npm run build",
42
- "prepare": "husky"
43
- },
44
32
  "dependencies": {
45
33
  "@sinclair/typebox": "^0.34.48",
46
34
  "re2-wasm": "^1.0.2",
@@ -74,5 +62,16 @@
74
62
  "extensions": [
75
63
  "dist/index.js"
76
64
  ]
65
+ },
66
+ "scripts": {
67
+ "build": "tsc -p tsconfig.json",
68
+ "test": "vitest run",
69
+ "test:smoke-install": "./scripts/smoke-install.sh",
70
+ "lint": "tsc --noEmit",
71
+ "format": "oxfmt --write .",
72
+ "format:check": "oxfmt --check .",
73
+ "changeset": "changeset",
74
+ "version-packages": "changeset version",
75
+ "release": "changeset publish"
77
76
  }
78
- }
77
+ }