@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 +10 -7
- package/dist/index.d.ts +8 -4
- package/dist/index.js +118 -23
- package/package.json +5 -4
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.
|
|
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
|
|
35
|
-
classifier recognize follow-ups
|
|
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,
|
|
62
|
-
|
|
63
|
-
`logging.includeContent` are
|
|
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
|
-
|
|
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
|
-
|
|
9
|
-
|
|
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
|
-
|
|
4
|
-
|
|
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
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
60
|
+
"pluginApi": ">=2026.5.5",
|
|
61
|
+
"minGatewayVersion": "2026.5.5"
|
|
61
62
|
}
|
|
62
63
|
}
|
|
63
64
|
}
|