@velanir/openclaw-participation-gate 0.1.1 → 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";
@@ -2209,23 +2210,30 @@ function messageIsThread(event, ctx) {
2209
2210
  event.messageThreadId || ctx.messageThreadId || event.parentSessionKey || ctx.parentSessionKey || event.threadStarterBody || threadHistoryText(event) || sessionKey.includes(":thread:") || sessionKey.includes("thread")
2210
2211
  );
2211
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
+ }
2212
2227
  function eventRecentMessages(event, maxMessages) {
2213
2228
  if (!Array.isArray(event.inboundHistory) || maxMessages <= 0) {
2214
2229
  return [];
2215
2230
  }
2216
- return event.inboundHistory.filter((message2) => message2.content.trim().length > 0).slice(-maxMessages);
2217
- }
2218
- function providerIsSlack(event, ctx) {
2219
- const provider = event.provider ?? ctx.provider ?? event.surface ?? ctx.surface ?? event.channel;
2220
- return provider?.toLowerCase() === "slack";
2231
+ return event.inboundHistory.map(normalizeInboundHistoryMessage).filter((message2) => Boolean(message2)).slice(-maxMessages);
2221
2232
  }
2222
2233
  function mergeRecentMessages(params) {
2223
2234
  if (params.inboundHistory.length === 0) {
2224
2235
  return params.localHistory;
2225
2236
  }
2226
- if (providerIsSlack(params.event, params.ctx)) {
2227
- return params.inboundHistory;
2228
- }
2229
2237
  const merged = [];
2230
2238
  const seen = /* @__PURE__ */ new Set();
2231
2239
  for (const message2 of [...params.localHistory, ...params.inboundHistory]) {
@@ -2272,8 +2280,6 @@ function buildClassifierInput(params) {
2272
2280
  historyBody: threadHistoryBody
2273
2281
  } : void 0,
2274
2282
  recentMessages: mergeRecentMessages({
2275
- event: params.event,
2276
- ctx: params.ctx,
2277
2283
  inboundHistory,
2278
2284
  localHistory: params.recentMessages,
2279
2285
  maxMessages: params.maxMessages
@@ -2340,6 +2346,12 @@ async function decideParticipation(params) {
2340
2346
  classifierOutputLength: classifierDecision.outputLength,
2341
2347
  classifierParseError: classifierDecision.parseError,
2342
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,
2343
2355
  classifierThreadContext: Boolean(classifierInput.thread?.starterBody || classifierInput.thread?.historyBody),
2344
2356
  classifierCurrentMessageLength: classifierInput.currentMessage.content.length,
2345
2357
  ...params.config.logging.classifierDebug ? {
@@ -2440,15 +2452,33 @@ function outboundKeys(event, ctx) {
2440
2452
  return keys.length > 0 ? keys : ["unknown"];
2441
2453
  }
2442
2454
  function publicMessages(messages, maxMessages) {
2443
- 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");
2444
2465
  }
2445
2466
  function createParticipationHistoryStore() {
2446
2467
  const messagesByConversation = /* @__PURE__ */ new Map();
2447
2468
  let nextSequence = 1;
2448
- 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
+ }
2449
2478
  const stored = {
2450
2479
  ...message2,
2451
- sequence: nextSequence
2480
+ sequence: nextSequence,
2481
+ ...dedupeKey ? { dedupeKey } : {}
2452
2482
  };
2453
2483
  nextSequence += 1;
2454
2484
  for (const key of keys) {
@@ -2459,6 +2489,7 @@ function createParticipationHistoryStore() {
2459
2489
  }
2460
2490
  messagesByConversation.set(key, current);
2461
2491
  }
2492
+ return true;
2462
2493
  }
2463
2494
  function recordInbound(event, ctx, maxMessages) {
2464
2495
  if (maxMessages <= 0 || event.isGroup !== true) {
@@ -2468,7 +2499,7 @@ function createParticipationHistoryStore() {
2468
2499
  if (!content) {
2469
2500
  return false;
2470
2501
  }
2471
- recordForKeys(
2502
+ return recordForKeys(
2472
2503
  inboundKeys(event, ctx),
2473
2504
  {
2474
2505
  role: "user",
@@ -2479,7 +2510,6 @@ function createParticipationHistoryStore() {
2479
2510
  },
2480
2511
  maxMessages
2481
2512
  );
2482
- return true;
2483
2513
  }
2484
2514
  return {
2485
2515
  recent(event, ctx, maxMessages) {
@@ -2507,7 +2537,7 @@ function createParticipationHistoryStore() {
2507
2537
  if (!content) {
2508
2538
  return false;
2509
2539
  }
2510
- recordForKeys(
2540
+ return recordForKeys(
2511
2541
  outboundKeys(event, ctx),
2512
2542
  {
2513
2543
  role: "assistant",
@@ -2515,9 +2545,9 @@ function createParticipationHistoryStore() {
2515
2545
  senderName: "This coworker",
2516
2546
  content
2517
2547
  },
2518
- maxMessages
2548
+ maxMessages,
2549
+ outboundDedupeKey(event, content)
2519
2550
  );
2520
- return true;
2521
2551
  },
2522
2552
  record(event, ctx, maxMessages) {
2523
2553
  return recordInbound(event, ctx, maxMessages);
@@ -2569,6 +2599,8 @@ function logParticipationDecision(params) {
2569
2599
  field("outputHash", params.decision.classifierOutputHash),
2570
2600
  field("outputLength", params.decision.classifierOutputLength),
2571
2601
  field("recentMessages", params.decision.classifierRecentMessageCount),
2602
+ field("recentUserMessages", params.decision.classifierRecentUserMessageCount),
2603
+ field("recentAssistantMessages", params.decision.classifierRecentAssistantMessageCount),
2572
2604
  field("threadContext", params.decision.classifierThreadContext),
2573
2605
  field("currentLength", params.decision.classifierCurrentMessageLength),
2574
2606
  contentLoggingEnabled(params.config) ? field("parseError", params.decision.classifierParseError) : void 0,
@@ -2590,6 +2622,8 @@ function logParticipationDecision(params) {
2590
2622
  outputHash: params.decision.classifierOutputHash,
2591
2623
  outputLength: params.decision.classifierOutputLength,
2592
2624
  recentMessages: params.decision.classifierRecentMessageCount,
2625
+ recentUserMessages: params.decision.classifierRecentUserMessageCount,
2626
+ recentAssistantMessages: params.decision.classifierRecentAssistantMessageCount,
2593
2627
  threadContext: params.decision.classifierThreadContext,
2594
2628
  currentMessageLength: params.decision.classifierCurrentMessageLength,
2595
2629
  outcome
@@ -2634,6 +2668,31 @@ function logParticipationHistoryEvent(params) {
2634
2668
 
2635
2669
  // src/index.ts
2636
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
+ }
2637
2696
  var index_default = definePluginEntry({
2638
2697
  id: PLUGIN_ID,
2639
2698
  name: "Velanir Participation Gate",
@@ -2713,6 +2772,39 @@ var index_default = definePluginEntry({
2713
2772
  });
2714
2773
  return void 0;
2715
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
+ });
2716
2808
  }
2717
2809
  });
2718
2810
  export {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@velanir/openclaw-participation-gate",
3
- "version": "0.1.1",
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
  }