@velanir/openclaw-participation-gate 0.1.0 → 0.1.2

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
@@ -8,9 +8,9 @@ The plugin is designed for the `requireMention: false` use case: the coworker ca
8
8
 
9
9
  - Package: `@velanir/openclaw-participation-gate`
10
10
  - OpenClaw plugin id: `velanir-participation-gate`
11
- - Runtime hooks: `before_dispatch`, `message_sent`
11
+ - Runtime hooks: `before_dispatch`, `message_sent`, `reply_payload_sending`
12
12
  - Default mode: `shadow`
13
- - Target OpenClaw plugin API: `>=2026.5.4`
13
+ - Target OpenClaw plugin API: `>=2026.5.5`
14
14
 
15
15
  The platform monorepo owns the source package in `packages/openclaw-plugins/participation-gate`. Customer machines install the built package into their OpenClaw runtime.
16
16
 
@@ -31,8 +31,10 @@ For group and channel messages, the plugin:
31
31
 
32
32
  Explicit mentions still bypass classifier enforcement, but the inbound turn is
33
33
  recorded before bypass so later unmentioned follow-ups have context. Successful
34
- `message_sent` events are also recorded as coworker replies, which lets the
35
- classifier recognize follow-ups after this coworker already answered.
34
+ `message_sent` events and final `reply_payload_sending` payloads are also
35
+ recorded as coworker replies, which lets the classifier recognize follow-ups
36
+ after this coworker already answered. Duplicate outbound hook records are
37
+ deduped before they reach classifier context.
36
38
 
37
39
  Any missing runtime dependency, missing context, invalid classifier output, classifier timeout, or classifier error fails open and lets the coworker respond. This is deliberate because false silence is worse than an occasional extra reply while the plugin is being rolled out.
38
40
 
@@ -58,9 +60,10 @@ logs `classifier_malformed` instead of treating it as a clean `classifier_true`.
58
60
 
59
61
  Normal decision logs include the participation score, threshold, classifier
60
62
  prompt version, prompt hash, input hash, parse status, attempt count, output
61
- hash, and output length. Exact rendered prompt/input/raw classifier output and
62
- parse errors are logged only when `logging.classifierDebug` and
63
- `logging.includeContent` are both enabled.
63
+ hash, output length, recent message count, and recent user/assistant role
64
+ counts. Exact rendered prompt/input/raw classifier output and parse errors are
65
+ logged only when `logging.classifierDebug` and `logging.includeContent` are
66
+ both enabled.
64
67
 
65
68
  ## Context Contract
66
69
 
package/dist/index.d.ts CHANGED
@@ -1,12 +1,16 @@
1
- import * as openclaw_plugin_sdk_plugin_entry from 'openclaw/plugin-sdk/plugin-entry';
1
+ type OpenClawPluginApi = {
2
+ on: (hook: "before_dispatch" | "message_sent" | "reply_payload_sending", handler: (event: unknown, ctx: unknown) => unknown | Promise<unknown>, options?: {
3
+ priority?: number;
4
+ timeoutMs?: number;
5
+ }) => void;
6
+ };
2
7
 
3
8
  declare const PLUGIN_ID = "velanir-participation-gate";
4
9
  declare const _default: {
5
10
  id: string;
6
11
  name: string;
7
12
  description: string;
8
- configSchema: openclaw_plugin_sdk_plugin_entry.OpenClawPluginConfigSchema;
9
- register: NonNullable<openclaw_plugin_sdk_plugin_entry.OpenClawPluginDefinition["register"]>;
10
- } & Pick<openclaw_plugin_sdk_plugin_entry.OpenClawPluginDefinition, "kind" | "reload" | "nodeHostCommands" | "securityAuditCollectors">;
13
+ register(api: OpenClawPluginApi): void;
14
+ };
11
15
 
12
16
  export { PLUGIN_ID, _default as default };
package/dist/index.js CHANGED
@@ -1,7 +1,8 @@
1
1
  // src/api.ts
2
- import {
3
- definePluginEntry
4
- } from "openclaw/plugin-sdk/plugin-entry";
2
+ import { definePluginEntry as defineOpenClawPluginEntry } from "openclaw/plugin-sdk/plugin-entry";
3
+ function definePluginEntry(entry) {
4
+ return defineOpenClawPluginEntry(entry);
5
+ }
5
6
 
6
7
  // ../../oct8-secrets/dist/index.js
7
8
  import { createHash, randomUUID } from "crypto";
@@ -2149,6 +2150,9 @@ function messageText(event) {
2149
2150
  function escapeRegExp(value) {
2150
2151
  return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
2151
2152
  }
2153
+ function decodeSlackMentions(content) {
2154
+ return content.replace(/<@[A-Z0-9]+>\s*\(([^)]+)\)/gi, (_match, displayName) => `@${displayName}`).replace(/<@[A-Z0-9]+\|([^>]+)>/gi, (_match, label) => `@${label}`).replace(/<@[A-Z0-9]+>/gi, " ");
2155
+ }
2152
2156
  function namePattern(name) {
2153
2157
  const trimmed = name.trim();
2154
2158
  if (trimmed.length < 2) {
@@ -2206,23 +2210,30 @@ function messageIsThread(event, ctx) {
2206
2210
  event.messageThreadId || ctx.messageThreadId || event.parentSessionKey || ctx.parentSessionKey || event.threadStarterBody || threadHistoryText(event) || sessionKey.includes(":thread:") || sessionKey.includes("thread")
2207
2211
  );
2208
2212
  }
2213
+ function normalizeInboundHistoryMessage(message2) {
2214
+ const content = typeof message2.content === "string" ? message2.content.trim() : typeof message2.body === "string" ? message2.body.trim() : "";
2215
+ if (!content) {
2216
+ return void 0;
2217
+ }
2218
+ const senderName = message2.senderName ?? message2.sender;
2219
+ return {
2220
+ ...message2.role ? { role: message2.role } : {},
2221
+ ...message2.senderId ? { senderId: message2.senderId } : {},
2222
+ ...senderName ? { senderName } : {},
2223
+ content,
2224
+ ...message2.timestamp !== void 0 ? { timestamp: message2.timestamp } : {}
2225
+ };
2226
+ }
2209
2227
  function eventRecentMessages(event, maxMessages) {
2210
2228
  if (!Array.isArray(event.inboundHistory) || maxMessages <= 0) {
2211
2229
  return [];
2212
2230
  }
2213
- return event.inboundHistory.filter((message2) => message2.content.trim().length > 0).slice(-maxMessages);
2214
- }
2215
- function providerIsSlack(event, ctx) {
2216
- const provider = event.provider ?? ctx.provider ?? event.surface ?? ctx.surface ?? event.channel;
2217
- return provider?.toLowerCase() === "slack";
2231
+ return event.inboundHistory.map(normalizeInboundHistoryMessage).filter((message2) => Boolean(message2)).slice(-maxMessages);
2218
2232
  }
2219
2233
  function mergeRecentMessages(params) {
2220
2234
  if (params.inboundHistory.length === 0) {
2221
2235
  return params.localHistory;
2222
2236
  }
2223
- if (providerIsSlack(params.event, params.ctx)) {
2224
- return params.inboundHistory;
2225
- }
2226
2237
  const merged = [];
2227
2238
  const seen = /* @__PURE__ */ new Set();
2228
2239
  for (const message2 of [...params.localHistory, ...params.inboundHistory]) {
@@ -2269,8 +2280,6 @@ function buildClassifierInput(params) {
2269
2280
  historyBody: threadHistoryBody
2270
2281
  } : void 0,
2271
2282
  recentMessages: mergeRecentMessages({
2272
- event: params.event,
2273
- ctx: params.ctx,
2274
2283
  inboundHistory,
2275
2284
  localHistory: params.recentMessages,
2276
2285
  maxMessages: params.maxMessages
@@ -2289,8 +2298,8 @@ async function decideParticipation(params) {
2289
2298
  if (params.event.isGroup !== true) {
2290
2299
  return decision(true, "dm", "rule", startedAt);
2291
2300
  }
2292
- const content = messageText(params.event);
2293
- if (!content) {
2301
+ const content = decodeSlackMentions(messageText(params.event));
2302
+ if (!content.trim()) {
2294
2303
  return decision(true, "empty_message", "rule", startedAt);
2295
2304
  }
2296
2305
  const recentMessages = params.history.recent(
@@ -2337,6 +2346,12 @@ async function decideParticipation(params) {
2337
2346
  classifierOutputLength: classifierDecision.outputLength,
2338
2347
  classifierParseError: classifierDecision.parseError,
2339
2348
  classifierRecentMessageCount: classifierInput.recentMessages.length,
2349
+ classifierRecentUserMessageCount: classifierInput.recentMessages.filter(
2350
+ (message2) => message2.role !== "assistant"
2351
+ ).length,
2352
+ classifierRecentAssistantMessageCount: classifierInput.recentMessages.filter(
2353
+ (message2) => message2.role === "assistant"
2354
+ ).length,
2340
2355
  classifierThreadContext: Boolean(classifierInput.thread?.starterBody || classifierInput.thread?.historyBody),
2341
2356
  classifierCurrentMessageLength: classifierInput.currentMessage.content.length,
2342
2357
  ...params.config.logging.classifierDebug ? {
@@ -2437,15 +2452,33 @@ function outboundKeys(event, ctx) {
2437
2452
  return keys.length > 0 ? keys : ["unknown"];
2438
2453
  }
2439
2454
  function publicMessages(messages, maxMessages) {
2440
- return messages.sort((left, right) => left.sequence - right.sequence).slice(-maxMessages).map(({ sequence: _sequence, ...message2 }) => message2);
2455
+ return messages.sort((left, right) => left.sequence - right.sequence).slice(-maxMessages).map(({ sequence: _sequence, dedupeKey: _dedupeKey, ...message2 }) => message2);
2456
+ }
2457
+ function outboundDedupeKey(event, content) {
2458
+ if (event.runId) {
2459
+ return ["run", event.runId, content].join("\0");
2460
+ }
2461
+ if (event.sessionKey) {
2462
+ return ["session", event.sessionKey, content].join("\0");
2463
+ }
2464
+ return ["message", event.messageId ?? "", event.to ?? "", content].join("\0");
2441
2465
  }
2442
2466
  function createParticipationHistoryStore() {
2443
2467
  const messagesByConversation = /* @__PURE__ */ new Map();
2444
2468
  let nextSequence = 1;
2445
- function recordForKeys(keys, message2, maxMessages) {
2469
+ function recordForKeys(keys, message2, maxMessages, dedupeKey) {
2470
+ if (dedupeKey) {
2471
+ for (const key of keys) {
2472
+ const current = messagesByConversation.get(key) ?? [];
2473
+ if (current.some((entry) => entry.dedupeKey === dedupeKey)) {
2474
+ return false;
2475
+ }
2476
+ }
2477
+ }
2446
2478
  const stored = {
2447
2479
  ...message2,
2448
- sequence: nextSequence
2480
+ sequence: nextSequence,
2481
+ ...dedupeKey ? { dedupeKey } : {}
2449
2482
  };
2450
2483
  nextSequence += 1;
2451
2484
  for (const key of keys) {
@@ -2456,6 +2489,7 @@ function createParticipationHistoryStore() {
2456
2489
  }
2457
2490
  messagesByConversation.set(key, current);
2458
2491
  }
2492
+ return true;
2459
2493
  }
2460
2494
  function recordInbound(event, ctx, maxMessages) {
2461
2495
  if (maxMessages <= 0 || event.isGroup !== true) {
@@ -2465,7 +2499,7 @@ function createParticipationHistoryStore() {
2465
2499
  if (!content) {
2466
2500
  return false;
2467
2501
  }
2468
- recordForKeys(
2502
+ return recordForKeys(
2469
2503
  inboundKeys(event, ctx),
2470
2504
  {
2471
2505
  role: "user",
@@ -2476,7 +2510,6 @@ function createParticipationHistoryStore() {
2476
2510
  },
2477
2511
  maxMessages
2478
2512
  );
2479
- return true;
2480
2513
  }
2481
2514
  return {
2482
2515
  recent(event, ctx, maxMessages) {
@@ -2504,7 +2537,7 @@ function createParticipationHistoryStore() {
2504
2537
  if (!content) {
2505
2538
  return false;
2506
2539
  }
2507
- recordForKeys(
2540
+ return recordForKeys(
2508
2541
  outboundKeys(event, ctx),
2509
2542
  {
2510
2543
  role: "assistant",
@@ -2512,9 +2545,9 @@ function createParticipationHistoryStore() {
2512
2545
  senderName: "This coworker",
2513
2546
  content
2514
2547
  },
2515
- maxMessages
2548
+ maxMessages,
2549
+ outboundDedupeKey(event, content)
2516
2550
  );
2517
- return true;
2518
2551
  },
2519
2552
  record(event, ctx, maxMessages) {
2520
2553
  return recordInbound(event, ctx, maxMessages);
@@ -2566,6 +2599,8 @@ function logParticipationDecision(params) {
2566
2599
  field("outputHash", params.decision.classifierOutputHash),
2567
2600
  field("outputLength", params.decision.classifierOutputLength),
2568
2601
  field("recentMessages", params.decision.classifierRecentMessageCount),
2602
+ field("recentUserMessages", params.decision.classifierRecentUserMessageCount),
2603
+ field("recentAssistantMessages", params.decision.classifierRecentAssistantMessageCount),
2569
2604
  field("threadContext", params.decision.classifierThreadContext),
2570
2605
  field("currentLength", params.decision.classifierCurrentMessageLength),
2571
2606
  contentLoggingEnabled(params.config) ? field("parseError", params.decision.classifierParseError) : void 0,
@@ -2587,6 +2622,8 @@ function logParticipationDecision(params) {
2587
2622
  outputHash: params.decision.classifierOutputHash,
2588
2623
  outputLength: params.decision.classifierOutputLength,
2589
2624
  recentMessages: params.decision.classifierRecentMessageCount,
2625
+ recentUserMessages: params.decision.classifierRecentUserMessageCount,
2626
+ recentAssistantMessages: params.decision.classifierRecentAssistantMessageCount,
2590
2627
  threadContext: params.decision.classifierThreadContext,
2591
2628
  currentMessageLength: params.decision.classifierCurrentMessageLength,
2592
2629
  outcome
@@ -2631,6 +2668,31 @@ function logParticipationHistoryEvent(params) {
2631
2668
 
2632
2669
  // src/index.ts
2633
2670
  var PLUGIN_ID = "velanir-participation-gate";
2671
+ function replyPayloadContent(event) {
2672
+ const text = event.payload?.text;
2673
+ if (typeof text !== "string") {
2674
+ return void 0;
2675
+ }
2676
+ const trimmed = text.trim();
2677
+ return trimmed || void 0;
2678
+ }
2679
+ function messageSentEventFromReplyPayload(event, ctx, content) {
2680
+ return {
2681
+ to: ctx.conversationId ?? ctx.channelId ?? event.channel,
2682
+ content,
2683
+ success: true,
2684
+ sessionKey: event.sessionKey ?? ctx.sessionKey,
2685
+ runId: event.runId ?? ctx.runId
2686
+ };
2687
+ }
2688
+ function messageSentContextFromReplyPayload(event, ctx) {
2689
+ return {
2690
+ ...ctx,
2691
+ ...event.channel && !ctx.channelId ? { channelId: event.channel } : {},
2692
+ ...event.sessionKey && !ctx.sessionKey ? { sessionKey: event.sessionKey } : {},
2693
+ ...event.runId && !ctx.runId ? { runId: event.runId } : {}
2694
+ };
2695
+ }
2634
2696
  var index_default = definePluginEntry({
2635
2697
  id: PLUGIN_ID,
2636
2698
  name: "Velanir Participation Gate",
@@ -2710,6 +2772,39 @@ var index_default = definePluginEntry({
2710
2772
  });
2711
2773
  return void 0;
2712
2774
  });
2775
+ api.on("reply_payload_sending", (event, ctx) => {
2776
+ const replyEvent = event;
2777
+ if (replyEvent.kind !== void 0 && replyEvent.kind !== "final") {
2778
+ return void 0;
2779
+ }
2780
+ const content = replyPayloadContent(replyEvent);
2781
+ if (!content) {
2782
+ return void 0;
2783
+ }
2784
+ const replyContext = ctx;
2785
+ const messageSentEvent = messageSentEventFromReplyPayload(replyEvent, replyContext, content);
2786
+ const messageSentContext = messageSentContextFromReplyPayload(replyEvent, replyContext);
2787
+ const recorded = history.recordOutbound(
2788
+ messageSentEvent,
2789
+ messageSentContext,
2790
+ config.context.maxMessages
2791
+ );
2792
+ logParticipationHistoryEvent({
2793
+ logger: runtimeApi.logger,
2794
+ config,
2795
+ event: "reply_payload_outbound",
2796
+ fields: {
2797
+ recorded,
2798
+ kind: replyEvent.kind,
2799
+ channel: messageSentContext.channelId,
2800
+ conversation: messageSentContext.conversationId ?? messageSentEvent.to,
2801
+ session: messageSentContext.sessionKey ?? messageSentEvent.sessionKey,
2802
+ runId: messageSentEvent.runId
2803
+ },
2804
+ content
2805
+ });
2806
+ return void 0;
2807
+ });
2713
2808
  }
2714
2809
  });
2715
2810
  export {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@velanir/openclaw-participation-gate",
3
- "version": "0.1.0",
3
+ "version": "0.1.2",
4
4
  "description": "OpenClaw plugin that gates group/channel participation for Velanir digital coworkers.",
5
5
  "license": "UNLICENSED",
6
6
  "type": "module",
@@ -34,7 +34,7 @@
34
34
  "test": "pnpm --filter=@oct8/oct8-secrets build && vitest run"
35
35
  },
36
36
  "peerDependencies": {
37
- "openclaw": ">=2026.5.4"
37
+ "openclaw": ">=2026.5.5"
38
38
  },
39
39
  "peerDependenciesMeta": {
40
40
  "openclaw": {
@@ -44,7 +44,7 @@
44
44
  "devDependencies": {
45
45
  "@oct8/oct8-secrets": "workspace:*",
46
46
  "@types/node": "^22.19.1",
47
- "openclaw": "2026.5.4",
47
+ "openclaw": "2026.6.6",
48
48
  "tsup": "^8.4.0",
49
49
  "typescript": "^5.7.0",
50
50
  "vitest": "^4.0.0"
@@ -57,7 +57,8 @@
57
57
  "./dist/index.js"
58
58
  ],
59
59
  "compat": {
60
- "pluginApi": ">=2026.5.4"
60
+ "pluginApi": ">=2026.5.5",
61
+ "minGatewayVersion": "2026.5.5"
61
62
  }
62
63
  }
63
64
  }