@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 +47 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.js +356 -41
- package/openclaw.plugin.json +39 -0
- package/package.json +1 -1
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`, `
|
|
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
|
|
1831
|
-
if (
|
|
1832
|
-
return
|
|
1851
|
+
const normalized2 = value.replace(/\s+/g, " ").trim();
|
|
1852
|
+
if (normalized2.length <= maxLength) {
|
|
1853
|
+
return normalized2;
|
|
1833
1854
|
}
|
|
1834
|
-
return `${
|
|
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(
|
|
2789
|
-
|
|
2790
|
-
|
|
2791
|
-
return void 0;
|
|
2792
|
-
|
|
2793
|
-
|
|
2794
|
-
|
|
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
|
-
|
|
2798
|
-
|
|
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 {
|
package/openclaw.plugin.json
CHANGED
|
@@ -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