@velanir/openclaw-participation-gate 0.1.1 → 0.1.3

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.6.6`
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,40 +2210,60 @@ 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();
2239
+ const messageKey = (message2) => [
2240
+ message2.role ?? "user",
2241
+ message2.senderId ?? "",
2242
+ message2.senderName ?? "",
2243
+ message2.timestamp ?? "",
2244
+ message2.content
2245
+ ].join("\0");
2231
2246
  for (const message2 of [...params.localHistory, ...params.inboundHistory]) {
2232
- const key = [
2233
- message2.role ?? "user",
2234
- message2.senderId ?? "",
2235
- message2.senderName ?? "",
2236
- message2.timestamp ?? "",
2237
- message2.content
2238
- ].join("\0");
2247
+ const key = messageKey(message2);
2239
2248
  if (seen.has(key)) {
2240
2249
  continue;
2241
2250
  }
2242
2251
  seen.add(key);
2243
2252
  merged.push(message2);
2244
2253
  }
2245
- return merged.slice(-params.maxMessages);
2254
+ const selectedKeys = new Set(merged.slice(-params.maxMessages).map(messageKey));
2255
+ const localAssistantKeys = params.localHistory.filter((message2) => message2.role === "assistant").map(messageKey).filter((key, index, keys) => keys.indexOf(key) === index).slice(-params.maxMessages);
2256
+ for (const assistantKey of localAssistantKeys) {
2257
+ if (selectedKeys.has(assistantKey)) {
2258
+ continue;
2259
+ }
2260
+ const keyToDrop = merged.map(messageKey).find((key) => selectedKeys.has(key) && !localAssistantKeys.includes(key));
2261
+ if (keyToDrop) {
2262
+ selectedKeys.delete(keyToDrop);
2263
+ }
2264
+ selectedKeys.add(assistantKey);
2265
+ }
2266
+ return merged.filter((message2) => selectedKeys.has(messageKey(message2)));
2246
2267
  }
2247
2268
  function buildClassifierInput(params) {
2248
2269
  const inboundHistory = eventRecentMessages(params.event, params.maxMessages);
@@ -2272,8 +2293,6 @@ function buildClassifierInput(params) {
2272
2293
  historyBody: threadHistoryBody
2273
2294
  } : void 0,
2274
2295
  recentMessages: mergeRecentMessages({
2275
- event: params.event,
2276
- ctx: params.ctx,
2277
2296
  inboundHistory,
2278
2297
  localHistory: params.recentMessages,
2279
2298
  maxMessages: params.maxMessages
@@ -2340,6 +2359,12 @@ async function decideParticipation(params) {
2340
2359
  classifierOutputLength: classifierDecision.outputLength,
2341
2360
  classifierParseError: classifierDecision.parseError,
2342
2361
  classifierRecentMessageCount: classifierInput.recentMessages.length,
2362
+ classifierRecentUserMessageCount: classifierInput.recentMessages.filter(
2363
+ (message2) => message2.role !== "assistant"
2364
+ ).length,
2365
+ classifierRecentAssistantMessageCount: classifierInput.recentMessages.filter(
2366
+ (message2) => message2.role === "assistant"
2367
+ ).length,
2343
2368
  classifierThreadContext: Boolean(classifierInput.thread?.starterBody || classifierInput.thread?.historyBody),
2344
2369
  classifierCurrentMessageLength: classifierInput.currentMessage.content.length,
2345
2370
  ...params.config.logging.classifierDebug ? {
@@ -2440,15 +2465,33 @@ function outboundKeys(event, ctx) {
2440
2465
  return keys.length > 0 ? keys : ["unknown"];
2441
2466
  }
2442
2467
  function publicMessages(messages, maxMessages) {
2443
- return messages.sort((left, right) => left.sequence - right.sequence).slice(-maxMessages).map(({ sequence: _sequence, ...message2 }) => message2);
2468
+ return messages.sort((left, right) => left.sequence - right.sequence).slice(-maxMessages).map(({ sequence: _sequence, dedupeKey: _dedupeKey, ...message2 }) => message2);
2469
+ }
2470
+ function outboundDedupeKey(event, content) {
2471
+ if (event.runId) {
2472
+ return ["run", event.runId, content].join("\0");
2473
+ }
2474
+ if (event.sessionKey) {
2475
+ return ["session", event.sessionKey, content].join("\0");
2476
+ }
2477
+ return ["message", event.messageId ?? "", event.to ?? "", content].join("\0");
2444
2478
  }
2445
2479
  function createParticipationHistoryStore() {
2446
2480
  const messagesByConversation = /* @__PURE__ */ new Map();
2447
2481
  let nextSequence = 1;
2448
- function recordForKeys(keys, message2, maxMessages) {
2482
+ function recordForKeys(keys, message2, maxMessages, dedupeKey) {
2483
+ if (dedupeKey) {
2484
+ for (const key of keys) {
2485
+ const current = messagesByConversation.get(key) ?? [];
2486
+ if (current.some((entry) => entry.dedupeKey === dedupeKey)) {
2487
+ return false;
2488
+ }
2489
+ }
2490
+ }
2449
2491
  const stored = {
2450
2492
  ...message2,
2451
- sequence: nextSequence
2493
+ sequence: nextSequence,
2494
+ ...dedupeKey ? { dedupeKey } : {}
2452
2495
  };
2453
2496
  nextSequence += 1;
2454
2497
  for (const key of keys) {
@@ -2459,6 +2502,7 @@ function createParticipationHistoryStore() {
2459
2502
  }
2460
2503
  messagesByConversation.set(key, current);
2461
2504
  }
2505
+ return true;
2462
2506
  }
2463
2507
  function recordInbound(event, ctx, maxMessages) {
2464
2508
  if (maxMessages <= 0 || event.isGroup !== true) {
@@ -2468,7 +2512,7 @@ function createParticipationHistoryStore() {
2468
2512
  if (!content) {
2469
2513
  return false;
2470
2514
  }
2471
- recordForKeys(
2515
+ return recordForKeys(
2472
2516
  inboundKeys(event, ctx),
2473
2517
  {
2474
2518
  role: "user",
@@ -2479,7 +2523,6 @@ function createParticipationHistoryStore() {
2479
2523
  },
2480
2524
  maxMessages
2481
2525
  );
2482
- return true;
2483
2526
  }
2484
2527
  return {
2485
2528
  recent(event, ctx, maxMessages) {
@@ -2507,7 +2550,7 @@ function createParticipationHistoryStore() {
2507
2550
  if (!content) {
2508
2551
  return false;
2509
2552
  }
2510
- recordForKeys(
2553
+ return recordForKeys(
2511
2554
  outboundKeys(event, ctx),
2512
2555
  {
2513
2556
  role: "assistant",
@@ -2515,9 +2558,9 @@ function createParticipationHistoryStore() {
2515
2558
  senderName: "This coworker",
2516
2559
  content
2517
2560
  },
2518
- maxMessages
2561
+ maxMessages,
2562
+ outboundDedupeKey(event, content)
2519
2563
  );
2520
- return true;
2521
2564
  },
2522
2565
  record(event, ctx, maxMessages) {
2523
2566
  return recordInbound(event, ctx, maxMessages);
@@ -2569,6 +2612,8 @@ function logParticipationDecision(params) {
2569
2612
  field("outputHash", params.decision.classifierOutputHash),
2570
2613
  field("outputLength", params.decision.classifierOutputLength),
2571
2614
  field("recentMessages", params.decision.classifierRecentMessageCount),
2615
+ field("recentUserMessages", params.decision.classifierRecentUserMessageCount),
2616
+ field("recentAssistantMessages", params.decision.classifierRecentAssistantMessageCount),
2572
2617
  field("threadContext", params.decision.classifierThreadContext),
2573
2618
  field("currentLength", params.decision.classifierCurrentMessageLength),
2574
2619
  contentLoggingEnabled(params.config) ? field("parseError", params.decision.classifierParseError) : void 0,
@@ -2590,6 +2635,8 @@ function logParticipationDecision(params) {
2590
2635
  outputHash: params.decision.classifierOutputHash,
2591
2636
  outputLength: params.decision.classifierOutputLength,
2592
2637
  recentMessages: params.decision.classifierRecentMessageCount,
2638
+ recentUserMessages: params.decision.classifierRecentUserMessageCount,
2639
+ recentAssistantMessages: params.decision.classifierRecentAssistantMessageCount,
2593
2640
  threadContext: params.decision.classifierThreadContext,
2594
2641
  currentMessageLength: params.decision.classifierCurrentMessageLength,
2595
2642
  outcome
@@ -2634,6 +2681,31 @@ function logParticipationHistoryEvent(params) {
2634
2681
 
2635
2682
  // src/index.ts
2636
2683
  var PLUGIN_ID = "velanir-participation-gate";
2684
+ function replyPayloadContent(event) {
2685
+ const text = event.payload?.text;
2686
+ if (typeof text !== "string") {
2687
+ return void 0;
2688
+ }
2689
+ const trimmed = text.trim();
2690
+ return trimmed || void 0;
2691
+ }
2692
+ function messageSentEventFromReplyPayload(event, ctx, content) {
2693
+ return {
2694
+ to: ctx.conversationId ?? ctx.channelId ?? event.channel,
2695
+ content,
2696
+ success: true,
2697
+ sessionKey: event.sessionKey ?? ctx.sessionKey,
2698
+ runId: event.runId ?? ctx.runId
2699
+ };
2700
+ }
2701
+ function messageSentContextFromReplyPayload(event, ctx) {
2702
+ return {
2703
+ ...ctx,
2704
+ ...event.channel && !ctx.channelId ? { channelId: event.channel } : {},
2705
+ ...event.sessionKey && !ctx.sessionKey ? { sessionKey: event.sessionKey } : {},
2706
+ ...event.runId && !ctx.runId ? { runId: event.runId } : {}
2707
+ };
2708
+ }
2637
2709
  var index_default = definePluginEntry({
2638
2710
  id: PLUGIN_ID,
2639
2711
  name: "Velanir Participation Gate",
@@ -2713,6 +2785,39 @@ var index_default = definePluginEntry({
2713
2785
  });
2714
2786
  return void 0;
2715
2787
  });
2788
+ api.on("reply_payload_sending", (event, ctx) => {
2789
+ const replyEvent = event;
2790
+ if (replyEvent.kind !== void 0 && replyEvent.kind !== "final") {
2791
+ return void 0;
2792
+ }
2793
+ const content = replyPayloadContent(replyEvent);
2794
+ if (!content) {
2795
+ return void 0;
2796
+ }
2797
+ const replyContext = ctx;
2798
+ const messageSentEvent = messageSentEventFromReplyPayload(replyEvent, replyContext, content);
2799
+ const messageSentContext = messageSentContextFromReplyPayload(replyEvent, replyContext);
2800
+ const recorded = history.recordOutbound(
2801
+ messageSentEvent,
2802
+ messageSentContext,
2803
+ config.context.maxMessages
2804
+ );
2805
+ logParticipationHistoryEvent({
2806
+ logger: runtimeApi.logger,
2807
+ config,
2808
+ event: "reply_payload_outbound",
2809
+ fields: {
2810
+ recorded,
2811
+ kind: replyEvent.kind,
2812
+ channel: messageSentContext.channelId,
2813
+ conversation: messageSentContext.conversationId ?? messageSentEvent.to,
2814
+ session: messageSentContext.sessionKey ?? messageSentEvent.sessionKey,
2815
+ runId: messageSentEvent.runId
2816
+ },
2817
+ content
2818
+ });
2819
+ return void 0;
2820
+ });
2716
2821
  }
2717
2822
  });
2718
2823
  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.3",
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.6.6"
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.6.6",
61
+ "minGatewayVersion": "2026.6.6"
61
62
  }
62
63
  }
63
64
  }