@velanir/openclaw-participation-gate 0.1.3 → 0.2.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
@@ -8,8 +8,10 @@ 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`, `reply_payload_sending`
11
+ - Runtime hooks: `before_dispatch`, `before_tool_call`, `message_sending`,
12
+ `message_sent`, `reply_payload_sending`
12
13
  - Default mode: `shadow`
14
+ - Default delivery mode: `passthrough`
13
15
  - Target OpenClaw plugin API: `>=2026.6.6`
14
16
 
15
17
  The platform monorepo owns the source package in `packages/openclaw-plugins/participation-gate`. Customer machines install the built package into their OpenClaw runtime.
@@ -38,6 +40,44 @@ deduped before they reach classifier context.
38
40
 
39
41
  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.
40
42
 
43
+ ## Turn Delivery Coalescer
44
+
45
+ The `delivery` config controls what the user actually sees for one inbound
46
+ turn. It is independent of the participation decision above.
47
+
48
+ - `passthrough` (default): the plugin does not alter delivery at all. This is
49
+ the rollback value.
50
+ - `coalesce`: the plugin tracks each inbound turn by its exact session key
51
+ (falling back to provider+conversation, then channel) and enforces
52
+ final-only delivery for that turn:
53
+ - Non-final reply payloads (`kind: "tool"` / `"block"`) are cancelled.
54
+ - At most `maxProgressMessages` (0 or 1) generic progress updates may
55
+ deliver, and only after `quietWindowMs` of silence. The progress text is
56
+ always the configured `progressText`; model-authored narration is never
57
+ exposed. `maxProgressMessages: 0` means fully silent until the final.
58
+ - Exactly one final reply payload is admitted per turn. Duplicate finals for
59
+ the same turn or the same `runId` are cancelled.
60
+ - A `message` tool `send` call targeting the active inbound conversation is
61
+ blocked before execution (`before_tool_call`), so the model cannot bypass
62
+ the final-reply boundary by messaging the conversation directly. Sends to
63
+ unrelated destinations, other providers, or from sessions with no tracked
64
+ inbound turn (proactive and scheduled sends) are not affected.
65
+ - Direct outbound egress (`message_sending`) to the active conversation is
66
+ allowed only when it corresponds to the final payload the coalescer
67
+ already admitted.
68
+ - Turn state expires after `turnTtlMs` so an abandoned turn cannot silence a
69
+ conversation indefinitely.
70
+
71
+ Delivery state is process-shared across plugin re-registers, so a gateway
72
+ config reload does not reset duplicate-final protection for an in-flight turn.
73
+
74
+ ### Rollback
75
+
76
+ Set `delivery.mode` back to `passthrough` (or remove the `delivery` block) and
77
+ refresh the runtime config. No uninstall or version change is required; every
78
+ delivery hook becomes a no-op. Anything other than an explicit
79
+ `"coalesce"` value normalizes to `passthrough`.
80
+
41
81
  ## Classifier Contract
42
82
 
43
83
  The classifier returns a scored participation decision:
@@ -135,6 +175,12 @@ Runtime mode expects the same runtime identity environment used by `oct8-secrets
135
175
  "baseUrl": "https://api.velanir.ai",
136
176
  "coworkerId": "coworker_albus"
137
177
  },
178
+ "delivery": {
179
+ "mode": "passthrough",
180
+ "quietWindowMs": 45000,
181
+ "maxProgressMessages": 1,
182
+ "turnTtlMs": 600000
183
+ },
138
184
  "logging": {
139
185
  "decisions": true,
140
186
  "includeContent": false,
package/dist/index.d.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  type OpenClawPluginApi = {
2
- on: (hook: "before_dispatch" | "message_sent" | "reply_payload_sending", handler: (event: unknown, ctx: unknown) => unknown | Promise<unknown>, options?: {
2
+ on: (hook: "before_dispatch" | "before_tool_call" | "message_sending" | "message_sent" | "reply_payload_sending", handler: (event: unknown, ctx: unknown) => unknown | Promise<unknown>, options?: {
3
3
  priority?: number;
4
4
  timeoutMs?: number;
5
5
  }) => void;
package/dist/index.js CHANGED
@@ -1625,6 +1625,10 @@ var DEFAULT_CLASSIFIER_THRESHOLD = 0.7;
1625
1625
  var DEFAULT_MAX_MESSAGES = 5;
1626
1626
  var DEFAULT_REFRESH_MS = 5 * 60 * 1e3;
1627
1627
  var DEFAULT_PLATFORM_ENDPOINT_PATH = "/v1/runtime/coworkers/{coworkerId}/participation-context";
1628
+ var DEFAULT_DELIVERY_QUIET_WINDOW_MS = 45e3;
1629
+ var DEFAULT_DELIVERY_MAX_PROGRESS_MESSAGES = 1;
1630
+ var DEFAULT_DELIVERY_PROGRESS_TEXT = "Still working on this \u2014 I\u2019ll send the result when it\u2019s complete.";
1631
+ var DEFAULT_DELIVERY_TURN_TTL_MS = 10 * 60 * 1e3;
1628
1632
  function isRecord4(value) {
1629
1633
  return typeof value === "object" && value !== null && !Array.isArray(value);
1630
1634
  }
@@ -1646,6 +1650,22 @@ function normalizeContextSource(value) {
1646
1650
  function normalizePlatformAuthMode(value) {
1647
1651
  return value === "static-token" ? "static-token" : "runtime";
1648
1652
  }
1653
+ function normalizeDeliveryMode(value) {
1654
+ return value === "coalesce" ? "coalesce" : "passthrough";
1655
+ }
1656
+ function normalizeDelivery(value) {
1657
+ const record = isRecord4(value) ? value : {};
1658
+ return {
1659
+ mode: normalizeDeliveryMode(record.mode),
1660
+ quietWindowMs: readPositiveInteger(record.quietWindowMs, DEFAULT_DELIVERY_QUIET_WINDOW_MS),
1661
+ maxProgressMessages: Math.min(
1662
+ 1,
1663
+ readPositiveInteger(record.maxProgressMessages, DEFAULT_DELIVERY_MAX_PROGRESS_MESSAGES)
1664
+ ),
1665
+ progressText: readString2(record.progressText) ?? DEFAULT_DELIVERY_PROGRESS_TEXT,
1666
+ turnTtlMs: readPositiveInteger(record.turnTtlMs, DEFAULT_DELIVERY_TURN_TTL_MS) || DEFAULT_DELIVERY_TURN_TTL_MS
1667
+ };
1668
+ }
1649
1669
  function normalizeClassifier(value) {
1650
1670
  const record = isRecord4(value) ? value : {};
1651
1671
  return {
@@ -1719,6 +1739,7 @@ function normalizeConfig(pluginConfig, env = process.env) {
1719
1739
  },
1720
1740
  platform: normalizePlatform(record.platform, env),
1721
1741
  staticContext: normalizeStaticContext(record.staticContext),
1742
+ delivery: normalizeDelivery(record.delivery),
1722
1743
  logging: {
1723
1744
  decisions: loggingRecord.decisions !== false,
1724
1745
  includeContent: loggingRecord.includeContent === true,
@@ -1827,11 +1848,11 @@ function stableStringify(value) {
1827
1848
  return JSON.stringify(value);
1828
1849
  }
1829
1850
  function trimForPrompt(value, maxLength = 1200) {
1830
- const normalized = value.replace(/\s+/g, " ").trim();
1831
- if (normalized.length <= maxLength) {
1832
- return normalized;
1851
+ const normalized2 = value.replace(/\s+/g, " ").trim();
1852
+ if (normalized2.length <= maxLength) {
1853
+ return normalized2;
1833
1854
  }
1834
- return `${normalized.slice(0, maxLength - 3)}...`;
1855
+ return `${normalized2.slice(0, maxLength - 3)}...`;
1835
1856
  }
1836
1857
  function formatIdentity(identity) {
1837
1858
  const role = identity.roleSummary ? ` Role: ${identity.roleSummary}` : "";
@@ -2400,6 +2421,203 @@ async function decideParticipation(params) {
2400
2421
  }
2401
2422
  }
2402
2423
 
2424
+ // src/delivery.ts
2425
+ function createTurnDeliveryState() {
2426
+ return {
2427
+ turns: /* @__PURE__ */ new Map(),
2428
+ deliveredRuns: /* @__PURE__ */ new Map()
2429
+ };
2430
+ }
2431
+ function sharedTurnDeliveryState() {
2432
+ const runtime = globalThis;
2433
+ runtime.__velanirParticipationGateDeliveryV1 ??= createTurnDeliveryState();
2434
+ return runtime.__velanirParticipationGateDeliveryV1;
2435
+ }
2436
+ function clean(value) {
2437
+ return typeof value === "string" && value.trim().length > 0 ? value.trim() : void 0;
2438
+ }
2439
+ function normalized(value) {
2440
+ return clean(value)?.toLowerCase();
2441
+ }
2442
+ function scopedKey(prefix, ...values) {
2443
+ const parts = values.map(clean);
2444
+ return parts.every(Boolean) ? `${prefix}:${parts.join(":")}` : void 0;
2445
+ }
2446
+ function normalizedTarget(value) {
2447
+ const target = normalized(value);
2448
+ if (!target) return void 0;
2449
+ return target.replace(/^msteams:conversation:/, "").replace(/^(?:conversation|user|channel):/, "").split(";messageid=", 1)[0];
2450
+ }
2451
+ function routeProvider(route) {
2452
+ return normalized(route.provider);
2453
+ }
2454
+ function targetMatchesRoute(target, route) {
2455
+ const candidate = normalizedTarget(target);
2456
+ if (!candidate) return true;
2457
+ return [route.conversationId, route.channelId, route.senderId].some(
2458
+ (value) => normalizedTarget(value) === candidate
2459
+ );
2460
+ }
2461
+ function deliveryRouteForInbound(event, ctx) {
2462
+ return {
2463
+ provider: clean(ctx.provider) ?? clean(event.provider) ?? clean(event.surface) ?? clean(event.channel),
2464
+ conversationId: clean(ctx.conversationId),
2465
+ channelId: clean(ctx.channelId) ?? clean(event.channel),
2466
+ senderId: clean(ctx.senderId) ?? clean(event.senderId)
2467
+ };
2468
+ }
2469
+ function deliveryScopeForInbound(event, ctx) {
2470
+ const sessionKey = clean(ctx.sessionKey) ?? clean(event.sessionKey);
2471
+ if (sessionKey) return `session:${sessionKey}`;
2472
+ const provider = clean(ctx.provider) ?? clean(event.provider) ?? clean(event.surface);
2473
+ const conversationId = clean(ctx.conversationId);
2474
+ if (provider && conversationId) {
2475
+ return scopedKey("conversation", provider, conversationId);
2476
+ }
2477
+ const channelId = clean(ctx.channelId) ?? clean(event.channel);
2478
+ return scopedKey("channel", provider, channelId);
2479
+ }
2480
+ function deliveryScopeForReply(event, ctx) {
2481
+ const sessionKey = clean(ctx.sessionKey) ?? clean(event.sessionKey);
2482
+ if (sessionKey) return `session:${sessionKey}`;
2483
+ const provider = clean(event.channel);
2484
+ const conversationId = clean(ctx.conversationId);
2485
+ if (provider && conversationId) {
2486
+ return scopedKey("conversation", provider, conversationId);
2487
+ }
2488
+ return scopedKey("channel", provider, ctx.channelId);
2489
+ }
2490
+ function deliveryScopeForTool(ctx) {
2491
+ const sessionKey = clean(ctx.sessionKey);
2492
+ return sessionKey ? `session:${sessionKey}` : void 0;
2493
+ }
2494
+ function deliveryScopeForMessage(ctx) {
2495
+ const sessionKey = clean(ctx.sessionKey);
2496
+ if (sessionKey) return `session:${sessionKey}`;
2497
+ return scopedKey("channel", ctx.channelId, ctx.conversationId);
2498
+ }
2499
+ function createTurnDeliveryStore(config, now = Date.now, state = createTurnDeliveryState()) {
2500
+ const { turns, deliveredRuns } = state;
2501
+ const prune = () => {
2502
+ const current = now();
2503
+ for (const [scope, turn] of turns) {
2504
+ if (current - turn.updatedAt > config.turnTtlMs) {
2505
+ turns.delete(scope);
2506
+ }
2507
+ }
2508
+ for (const [runId, deliveredAt] of deliveredRuns) {
2509
+ if (current - deliveredAt > config.turnTtlMs) {
2510
+ deliveredRuns.delete(runId);
2511
+ }
2512
+ }
2513
+ };
2514
+ return {
2515
+ beginTurn(scope, route = {}) {
2516
+ prune();
2517
+ if (!scope) return false;
2518
+ const timestamp = now();
2519
+ turns.set(scope, {
2520
+ startedAt: timestamp,
2521
+ updatedAt: timestamp,
2522
+ finalDelivered: false,
2523
+ finalEgressPending: false,
2524
+ progressMessages: 0,
2525
+ route
2526
+ });
2527
+ return true;
2528
+ },
2529
+ abandonTurn(scope) {
2530
+ prune();
2531
+ return scope ? turns.delete(scope) : false;
2532
+ },
2533
+ decide(event, ctx) {
2534
+ prune();
2535
+ const scope = deliveryScopeForReply(event, ctx);
2536
+ const turn = scope ? turns.get(scope) : void 0;
2537
+ const runId = clean(event.runId) ?? clean(ctx.runId);
2538
+ if (runId && deliveredRuns.has(runId)) {
2539
+ return {
2540
+ action: "suppress_duplicate_final",
2541
+ result: {
2542
+ cancel: true,
2543
+ reason: "participation_gate_duplicate_final"
2544
+ }
2545
+ };
2546
+ }
2547
+ if (!turn) {
2548
+ return { action: "allow_untracked" };
2549
+ }
2550
+ const timestamp = now();
2551
+ turn.updatedAt = timestamp;
2552
+ const isFinal = event.kind === void 0 || event.kind === "final";
2553
+ if (isFinal) {
2554
+ if (turn.finalDelivered) {
2555
+ return {
2556
+ action: "suppress_duplicate_final",
2557
+ result: {
2558
+ cancel: true,
2559
+ reason: "participation_gate_duplicate_final"
2560
+ }
2561
+ };
2562
+ }
2563
+ turn.finalDelivered = true;
2564
+ turn.finalEgressPending = true;
2565
+ if (runId) deliveredRuns.set(runId, timestamp);
2566
+ return { action: "allow_final" };
2567
+ }
2568
+ const canSendProgress = !turn.finalDelivered && timestamp - turn.startedAt >= config.quietWindowMs && turn.progressMessages < config.maxProgressMessages;
2569
+ if (canSendProgress) {
2570
+ turn.progressMessages += 1;
2571
+ const sourcePayload = event.payload;
2572
+ return {
2573
+ action: "allow_progress",
2574
+ result: {
2575
+ payload: {
2576
+ text: config.progressText,
2577
+ isStatusNotice: true,
2578
+ ...sourcePayload?.replyToId ? { replyToId: sourcePayload.replyToId } : {},
2579
+ ...sourcePayload?.replyToTag === true ? { replyToTag: true } : {},
2580
+ ...sourcePayload?.replyToCurrent === true ? { replyToCurrent: true } : {}
2581
+ }
2582
+ }
2583
+ };
2584
+ }
2585
+ return {
2586
+ action: "suppress_intermediate",
2587
+ result: {
2588
+ cancel: true,
2589
+ reason: "participation_gate_suppressed_intermediate"
2590
+ }
2591
+ };
2592
+ },
2593
+ isSameConversationSend(scope, send) {
2594
+ prune();
2595
+ if (!scope) return false;
2596
+ const turn = turns.get(scope);
2597
+ if (!turn) return false;
2598
+ const expectedProvider = routeProvider(turn.route);
2599
+ const actualProvider = normalized(send.provider);
2600
+ if (expectedProvider && actualProvider && expectedProvider !== actualProvider) {
2601
+ return false;
2602
+ }
2603
+ return targetMatchesRoute(send.target, turn.route);
2604
+ },
2605
+ consumeFinalEgress(scope) {
2606
+ prune();
2607
+ if (!scope) return false;
2608
+ const turn = turns.get(scope);
2609
+ if (!turn?.finalEgressPending) return false;
2610
+ turn.finalEgressPending = false;
2611
+ turn.updatedAt = now();
2612
+ return true;
2613
+ },
2614
+ activeTurnCount() {
2615
+ prune();
2616
+ return turns.size;
2617
+ }
2618
+ };
2619
+ }
2620
+
2403
2621
  // src/history.ts
2404
2622
  function normalizeKey2(value) {
2405
2623
  const trimmed = value?.trim();
@@ -2467,13 +2685,14 @@ function outboundKeys(event, ctx) {
2467
2685
  function publicMessages(messages, maxMessages) {
2468
2686
  return messages.sort((left, right) => left.sequence - right.sequence).slice(-maxMessages).map(({ sequence: _sequence, dedupeKey: _dedupeKey, ...message2 }) => message2);
2469
2687
  }
2470
- function outboundDedupeKey(event, content) {
2688
+ function outboundDedupeKey(event, ctx, content) {
2689
+ const primaryKey = outboundKeys(event, ctx)[0];
2690
+ if (primaryKey && primaryKey !== "unknown") {
2691
+ return ["conversation", primaryKey, content].join("\0");
2692
+ }
2471
2693
  if (event.runId) {
2472
2694
  return ["run", event.runId, content].join("\0");
2473
2695
  }
2474
- if (event.sessionKey) {
2475
- return ["session", event.sessionKey, content].join("\0");
2476
- }
2477
2696
  return ["message", event.messageId ?? "", event.to ?? "", content].join("\0");
2478
2697
  }
2479
2698
  function createParticipationHistoryStore() {
@@ -2559,7 +2778,7 @@ function createParticipationHistoryStore() {
2559
2778
  content
2560
2779
  },
2561
2780
  maxMessages,
2562
- outboundDedupeKey(event, content)
2781
+ outboundDedupeKey(event, ctx, content)
2563
2782
  );
2564
2783
  },
2565
2784
  record(event, ctx, maxMessages) {
@@ -2681,6 +2900,21 @@ function logParticipationHistoryEvent(params) {
2681
2900
 
2682
2901
  // src/index.ts
2683
2902
  var PLUGIN_ID = "velanir-participation-gate";
2903
+ var POST_EGRESS_REPLY_CAPTURE_PRIORITY = -100001;
2904
+ var PRE_EGRESS_DELIVERY_PRIORITY = 1e5;
2905
+ var SAME_CONVERSATION_MESSAGE_BLOCK_REASON = "Use the normal final reply for this conversation. Direct message sends to the active conversation are blocked.";
2906
+ function stringValue(value) {
2907
+ return typeof value === "string" && value.trim().length > 0 ? value.trim() : void 0;
2908
+ }
2909
+ function messageToolSend(event) {
2910
+ if (event.toolName.trim().toLowerCase() !== "message") return void 0;
2911
+ const action = stringValue(event.params.action)?.toLowerCase();
2912
+ if (action !== "send") return void 0;
2913
+ return {
2914
+ provider: stringValue(event.params.provider) ?? stringValue(event.params.channel),
2915
+ target: stringValue(event.params.to) ?? stringValue(event.params.target) ?? stringValue(event.params.recipient) ?? stringValue(event.params.conversationId)
2916
+ };
2917
+ }
2684
2918
  function replyPayloadContent(event) {
2685
2919
  const text = event.payload?.text;
2686
2920
  if (typeof text !== "string") {
@@ -2715,12 +2949,24 @@ var index_default = definePluginEntry({
2715
2949
  const config = normalizeConfig(runtimeApi.pluginConfig);
2716
2950
  const contextProvider = createParticipationContextProvider(config);
2717
2951
  const history = createParticipationHistoryStore();
2952
+ const deliveries = createTurnDeliveryStore(config.delivery, Date.now, sharedTurnDeliveryState());
2953
+ const beginDeliveryTurn = (event, ctx) => {
2954
+ if (config.delivery.mode !== "coalesce") return;
2955
+ const tracked = deliveries.beginTurn(
2956
+ deliveryScopeForInbound(event, ctx),
2957
+ deliveryRouteForInbound(event, ctx)
2958
+ );
2959
+ runtimeApi.logger?.info?.(
2960
+ `[${PLUGIN_ID}-delivery] action=begin_turn tracked=${tracked} active=${deliveries.activeTurnCount()}`
2961
+ );
2962
+ };
2718
2963
  api.on(
2719
2964
  "before_dispatch",
2720
2965
  async (event, ctx) => {
2721
2966
  const beforeDispatchEvent = event;
2722
2967
  const beforeDispatchContext = ctx;
2723
2968
  if (beforeDispatchEvent.wasMentioned === true || beforeDispatchContext.wasMentioned === true) {
2969
+ beginDeliveryTurn(beforeDispatchEvent, beforeDispatchContext);
2724
2970
  const recorded = history.recordInbound(
2725
2971
  beforeDispatchEvent,
2726
2972
  beforeDispatchContext,
@@ -2761,10 +3007,60 @@ var index_default = definePluginEntry({
2761
3007
  if (!decision2.shouldRespond && config.mode === "enforce") {
2762
3008
  return { handled: true };
2763
3009
  }
3010
+ beginDeliveryTurn(beforeDispatchEvent, beforeDispatchContext);
2764
3011
  return void 0;
2765
3012
  },
2766
3013
  { timeoutMs: config.classifier.timeoutMs + 2e3 }
2767
3014
  );
3015
+ api.on(
3016
+ "before_tool_call",
3017
+ (event, ctx) => {
3018
+ if (config.delivery.mode !== "coalesce") return void 0;
3019
+ const beforeToolCallEvent = event;
3020
+ const send = messageToolSend(beforeToolCallEvent);
3021
+ if (!send) return void 0;
3022
+ const beforeToolCallContext = ctx;
3023
+ const blocked = deliveries.isSameConversationSend(
3024
+ deliveryScopeForTool(beforeToolCallContext),
3025
+ send
3026
+ );
3027
+ if (!blocked) return void 0;
3028
+ runtimeApi.logger?.info?.(
3029
+ `[${PLUGIN_ID}-delivery] action=block_same_conversation_message_tool run=${beforeToolCallEvent.runId ?? beforeToolCallContext.runId ?? "unknown"} toolCall=${beforeToolCallEvent.toolCallId ?? beforeToolCallContext.toolCallId ?? "unknown"}`
3030
+ );
3031
+ return {
3032
+ block: true,
3033
+ blockReason: SAME_CONVERSATION_MESSAGE_BLOCK_REASON
3034
+ };
3035
+ },
3036
+ { priority: PRE_EGRESS_DELIVERY_PRIORITY }
3037
+ );
3038
+ api.on(
3039
+ "message_sending",
3040
+ (event, ctx) => {
3041
+ if (config.delivery.mode !== "coalesce") return void 0;
3042
+ const messageSendingEvent = event;
3043
+ const messageSendingContext = ctx;
3044
+ const scope = deliveryScopeForMessage(messageSendingContext);
3045
+ const sameConversation = deliveries.isSameConversationSend(scope, {
3046
+ provider: messageSendingEvent.metadata?.channel,
3047
+ target: messageSendingContext.conversationId ?? messageSendingEvent.to
3048
+ });
3049
+ if (!sameConversation) return void 0;
3050
+ if (deliveries.consumeFinalEgress(scope)) {
3051
+ runtimeApi.logger?.info?.(`[${PLUGIN_ID}-delivery] action=allow_final_message_egress`);
3052
+ return void 0;
3053
+ }
3054
+ runtimeApi.logger?.info?.(
3055
+ `[${PLUGIN_ID}-delivery] action=suppress_same_conversation_message_egress`
3056
+ );
3057
+ return {
3058
+ cancel: true,
3059
+ cancelReason: "participation_gate_suppressed_same_conversation_message"
3060
+ };
3061
+ },
3062
+ { priority: PRE_EGRESS_DELIVERY_PRIORITY }
3063
+ );
2768
3064
  api.on("message_sent", (event, ctx) => {
2769
3065
  const messageSentEvent = event;
2770
3066
  const messageSentContext = ctx;
@@ -2785,39 +3081,58 @@ var index_default = definePluginEntry({
2785
3081
  });
2786
3082
  return void 0;
2787
3083
  });
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) {
3084
+ api.on(
3085
+ "reply_payload_sending",
3086
+ (event, ctx) => {
3087
+ if (config.delivery.mode !== "coalesce") return void 0;
3088
+ const decision2 = deliveries.decide(
3089
+ event,
3090
+ ctx
3091
+ );
3092
+ runtimeApi.logger?.info?.(
3093
+ `[${PLUGIN_ID}-delivery] action=${decision2.action} kind=${event.kind ?? "legacy_final"}`
3094
+ );
3095
+ return decision2.result;
3096
+ },
3097
+ { priority: PRE_EGRESS_DELIVERY_PRIORITY }
3098
+ );
3099
+ api.on(
3100
+ "reply_payload_sending",
3101
+ (event, ctx) => {
3102
+ const replyEvent = event;
3103
+ if (replyEvent.kind !== void 0 && replyEvent.kind !== "final") {
3104
+ return void 0;
3105
+ }
3106
+ const content = replyPayloadContent(replyEvent);
3107
+ if (!content) {
3108
+ return void 0;
3109
+ }
3110
+ const replyContext = ctx;
3111
+ const messageSentEvent = messageSentEventFromReplyPayload(replyEvent, replyContext, content);
3112
+ const messageSentContext = messageSentContextFromReplyPayload(replyEvent, replyContext);
3113
+ const recorded = history.recordOutbound(
3114
+ messageSentEvent,
3115
+ messageSentContext,
3116
+ config.context.maxMessages
3117
+ );
3118
+ logParticipationHistoryEvent({
3119
+ logger: runtimeApi.logger,
3120
+ config,
3121
+ event: "reply_payload_outbound",
3122
+ fields: {
3123
+ recorded,
3124
+ kind: replyEvent.kind,
3125
+ channel: messageSentContext.channelId,
3126
+ conversation: messageSentContext.conversationId ?? messageSentEvent.to,
3127
+ session: messageSentContext.sessionKey ?? messageSentEvent.sessionKey,
3128
+ runId: messageSentEvent.runId
3129
+ },
3130
+ content
3131
+ });
2795
3132
  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
- });
3133
+ },
3134
+ { priority: POST_EGRESS_REPLY_CAPTURE_PRIORITY }
3135
+ );
2821
3136
  }
2822
3137
  });
2823
3138
  export {
@@ -83,6 +83,25 @@
83
83
  }
84
84
  }
85
85
  },
86
+ "delivery": {
87
+ "type": "object",
88
+ "additionalProperties": false,
89
+ "properties": {
90
+ "mode": {
91
+ "type": "string",
92
+ "enum": ["passthrough", "coalesce"],
93
+ "default": "passthrough"
94
+ },
95
+ "quietWindowMs": { "type": "number", "minimum": 0, "default": 45000 },
96
+ "maxProgressMessages": { "type": "number", "minimum": 0, "maximum": 1, "default": 1 },
97
+ "progressText": {
98
+ "type": "string",
99
+ "minLength": 1,
100
+ "default": "Still working on this — I’ll send the result when it’s complete."
101
+ },
102
+ "turnTtlMs": { "type": "number", "minimum": 1, "default": 600000 }
103
+ }
104
+ },
86
105
  "logging": {
87
106
  "type": "object",
88
107
  "additionalProperties": false,
@@ -151,6 +170,26 @@
151
170
  "logging.classifierDebug": {
152
171
  "label": "Log Classifier Debug",
153
172
  "help": "Default off. Emits structured classifier diagnostics; exact prompt/input/output still requires includeContent."
173
+ },
174
+ "delivery.mode": {
175
+ "label": "Delivery Mode",
176
+ "help": "coalesce withholds tool and progress narration for inbound turns and emits one final answer; passthrough preserves OpenClaw delivery."
177
+ },
178
+ "delivery.quietWindowMs": {
179
+ "label": "Delivery Quiet Window",
180
+ "help": "Milliseconds to keep an inbound turn silent before one bounded progress update may be delivered."
181
+ },
182
+ "delivery.maxProgressMessages": {
183
+ "label": "Maximum Progress Messages",
184
+ "help": "Maximum user-visible progress messages per inbound turn. Supported values are 0 or 1."
185
+ },
186
+ "delivery.progressText": {
187
+ "label": "Progress Message",
188
+ "help": "Generic text used for the single long-task progress update; model-authored tool narration is never exposed."
189
+ },
190
+ "delivery.turnTtlMs": {
191
+ "label": "Delivery Turn TTL",
192
+ "help": "Maximum lifetime for per-session delivery state and duplicate-final protection."
154
193
  }
155
194
  }
156
195
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@velanir/openclaw-participation-gate",
3
- "version": "0.1.3",
3
+ "version": "0.2.0",
4
4
  "description": "OpenClaw plugin that gates group/channel participation for Velanir digital coworkers.",
5
5
  "license": "UNLICENSED",
6
6
  "type": "module",