@clawling/clawchat-plugin-openclaw 2026.7.29-3 → 2026.8.2-1
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/dist/src/api-client.js +3 -0
- package/dist/src/awareness-note-turn.js +26 -0
- package/dist/src/client.js +1 -0
- package/dist/src/liveware-sample.js +272 -41
- package/dist/src/outbound.js +81 -3
- package/dist/src/protocol-types.js +18 -0
- package/dist/src/runtime.js +96 -7
- package/dist/src/tools-schema.js +3 -0
- package/dist/src/tools.js +14 -2
- package/dist/src/ws-alignment.js +18 -1
- package/dist/src/ws-client.js +246 -34
- package/openclaw.plugin.json +1 -0
- package/package.json +1 -1
- package/skills/clawchat-core/SKILL.md +3 -2
- package/skills/manifest.json +6 -6
- package/src/api-client.ts +4 -0
- package/src/awareness-note-turn.ts +45 -0
- package/src/client.ts +3 -0
- package/src/liveware-sample.ts +287 -41
- package/src/outbound.ts +118 -4
- package/src/protocol-types.ts +19 -0
- package/src/runtime.ts +112 -7
- package/src/storage.ts +8 -2
- package/src/tools-schema.ts +5 -0
- package/src/tools.ts +21 -1
- package/src/ws-alignment.ts +28 -1
- package/src/ws-client.ts +267 -32
package/dist/src/runtime.js
CHANGED
|
@@ -8,7 +8,7 @@ import { buildActivationBootstrapText } from "./activation-greeting.js";
|
|
|
8
8
|
import { reportPluginVersionSafe, resolvePluginVersion } from "./plugin-report.js";
|
|
9
9
|
import path from "node:path";
|
|
10
10
|
import { ensureLivewareCli, resolveLivewarePath } from "./liveware-cli.js";
|
|
11
|
-
import { LivewareSampleSupervisor } from "./liveware-sample.js";
|
|
11
|
+
import { LivewareSampleSupervisor, } from "./liveware-sample.js";
|
|
12
12
|
import { ClawlingApiError } from "./api-types.js";
|
|
13
13
|
import { RefreshManager } from "./refresh-manager.js";
|
|
14
14
|
import { runOpenclawClawlingLogin, } from "./login.runtime.js";
|
|
@@ -32,11 +32,20 @@ import { createGroupMessageCoalescer } from "./group-message-coalescer.js";
|
|
|
32
32
|
import { openclawLlmContextDebug } from "./llm-context-debug.js";
|
|
33
33
|
import { GroupSettingsCache } from "./group-settings.js";
|
|
34
34
|
import { PermissionCache } from "./permissions.js";
|
|
35
|
-
import { buildAwarenessNoteEnvelope } from "./awareness-note-turn.js";
|
|
35
|
+
import { buildAwarenessNoteEnvelope, buildMomentCommentNoteEnvelope } from "./awareness-note-turn.js";
|
|
36
36
|
import { handlePermissionResult } from "./permission-result.js";
|
|
37
37
|
const { setRuntime: setOpenclawClawlingRuntime, getRuntime: getOpenclawClawlingRuntime } = createPluginRuntimeStore("clawchat-plugin-openclaw runtime not initialized");
|
|
38
38
|
export { setOpenclawClawlingRuntime, getOpenclawClawlingRuntime };
|
|
39
39
|
const activeClients = new Map();
|
|
40
|
+
// Live liveware-sample supervisors, keyed by the gateway's abortSignal and then by
|
|
41
|
+
// `platform:accountId`. startOpenclawClawlingGateway re-enters ITSELF (token-refresh
|
|
42
|
+
// reconnect, transport backoff, post-auth-failed re-activation) carrying the same
|
|
43
|
+
// `...params` — so the same abortSignal — and a per-call `new LivewareSampleSupervisor`
|
|
44
|
+
// left the previous one running: both then raced the bootstrap "account has zero apps"
|
|
45
|
+
// gate and each minted its own liveware app, leaving a duplicate app tile that no
|
|
46
|
+
// SQLite row tracks (the single-row PK means the loser is orphaned forever). Keyed by
|
|
47
|
+
// signal so an entry cannot outlive the channel it belongs to.
|
|
48
|
+
const livewareSampleSupervisors = new WeakMap();
|
|
40
49
|
const CLAWCHAT_PLUGIN_SLASH_COMMANDS = new Set(["clawchat-activate", "clawchat-output"]);
|
|
41
50
|
const ACTIVATION_CREDENTIAL_POLL_INTERVAL_MS = 2_000;
|
|
42
51
|
// Activation-bootstrap retry on the live connection. The bootstrap greeting is
|
|
@@ -791,7 +800,7 @@ export async function startOpenclawClawlingGateway(params) {
|
|
|
791
800
|
let livewareSampleNotify = async () => false;
|
|
792
801
|
if (store) {
|
|
793
802
|
const stateDirForSample = runtime.state?.resolveStateDir?.();
|
|
794
|
-
const
|
|
803
|
+
const livewareSampleDeps = {
|
|
795
804
|
platform: "openclaw",
|
|
796
805
|
accountId,
|
|
797
806
|
enabled: account.livewareSample,
|
|
@@ -819,9 +828,29 @@ export async function startOpenclawClawlingGateway(params) {
|
|
|
819
828
|
debug: (m) => log?.debug?.(m),
|
|
820
829
|
error: (m) => log?.error?.(m),
|
|
821
830
|
},
|
|
822
|
-
}
|
|
823
|
-
|
|
824
|
-
|
|
831
|
+
};
|
|
832
|
+
const supervisorsForSignal = livewareSampleSupervisors.get(abortSignal) ?? new Map();
|
|
833
|
+
livewareSampleSupervisors.set(abortSignal, supervisorsForSignal);
|
|
834
|
+
const supervisorKey = `openclaw:${accountId}`;
|
|
835
|
+
const runningSupervisor = supervisorsForSignal.get(supervisorKey);
|
|
836
|
+
if (runningSupervisor) {
|
|
837
|
+
// A gateway re-entry (refresh/backoff reconnect), not a fresh channel start:
|
|
838
|
+
// hand the live supervisor this closure's token + REST client + notify path
|
|
839
|
+
// instead of constructing a second one that would register a duplicate app.
|
|
840
|
+
log?.debug?.(`[${accountId}] clawchat-plugin-openclaw liveware-sample supervisor already live; re-pointing it at the re-entered gateway`);
|
|
841
|
+
runningSupervisor.adoptDeps(livewareSampleDeps);
|
|
842
|
+
}
|
|
843
|
+
else {
|
|
844
|
+
const livewareSampleSupervisor = new LivewareSampleSupervisor(livewareSampleDeps);
|
|
845
|
+
supervisorsForSignal.set(supervisorKey, livewareSampleSupervisor);
|
|
846
|
+
abortSignal.addEventListener("abort", () => {
|
|
847
|
+
livewareSampleSupervisor.stop();
|
|
848
|
+
if (supervisorsForSignal.get(supervisorKey) === livewareSampleSupervisor) {
|
|
849
|
+
supervisorsForSignal.delete(supervisorKey);
|
|
850
|
+
}
|
|
851
|
+
});
|
|
852
|
+
void livewareSampleSupervisor.start();
|
|
853
|
+
}
|
|
825
854
|
}
|
|
826
855
|
const resolveMutateConfigFile = () => {
|
|
827
856
|
if (params.mutateConfigFile)
|
|
@@ -1840,6 +1869,30 @@ export async function startOpenclawClawlingGateway(params) {
|
|
|
1840
1869
|
],
|
|
1841
1870
|
}));
|
|
1842
1871
|
}
|
|
1872
|
+
else if (env.event === "message.error") {
|
|
1873
|
+
// Never fold this into `inbound_ignored`: `code` + `chat_id` are the
|
|
1874
|
+
// only in-plugin evidence that an uplink was refused, and the
|
|
1875
|
+
// 2026-07-19 incident needed a server-log dig purely because this line
|
|
1876
|
+
// did not exist. `action=message_error` marks it as semantically seen.
|
|
1877
|
+
const current = wsLogContext();
|
|
1878
|
+
const errorPayload = env.payload && typeof env.payload === "object"
|
|
1879
|
+
? env.payload
|
|
1880
|
+
: undefined;
|
|
1881
|
+
log?.info?.(formatWsLog({
|
|
1882
|
+
event: "inbound_control",
|
|
1883
|
+
accountId,
|
|
1884
|
+
attempt: current.attempt,
|
|
1885
|
+
reconnectCount: current.reconnectCount,
|
|
1886
|
+
state: "ready",
|
|
1887
|
+
action: "message_error",
|
|
1888
|
+
fields: [
|
|
1889
|
+
["event_name", env.event],
|
|
1890
|
+
["trace_id", env.trace_id],
|
|
1891
|
+
["chat_id", env.chat_id],
|
|
1892
|
+
["code", typeof errorPayload?.code === "string" ? errorPayload.code : "-"],
|
|
1893
|
+
],
|
|
1894
|
+
}));
|
|
1895
|
+
}
|
|
1843
1896
|
else if (env.event !== "ping" && env.event !== "pong") {
|
|
1844
1897
|
const current = wsLogContext();
|
|
1845
1898
|
log?.info?.(formatWsLog({
|
|
@@ -1909,6 +1962,12 @@ export async function startOpenclawClawlingGateway(params) {
|
|
|
1909
1962
|
?.conversationId?.trim();
|
|
1910
1963
|
return conversationId || null;
|
|
1911
1964
|
};
|
|
1965
|
+
// R3: the owner's direct conversation is never gated by the dead-chat send
|
|
1966
|
+
// guard. If it were, the very message explaining why the agent went dark
|
|
1967
|
+
// (skill-update notices, auth/logout notices, group owner-attention
|
|
1968
|
+
// forwards) would be unreachable. Registered lazily — the activation
|
|
1969
|
+
// conversation may be recorded after the client is constructed.
|
|
1970
|
+
client.setOwnerDirectChatResolver(resolveOwnerDirectConversationId);
|
|
1912
1971
|
const sendOwnerSkillMessage = async (text) => {
|
|
1913
1972
|
const conversationId = resolveOwnerDirectConversationId();
|
|
1914
1973
|
if (!conversationId) {
|
|
@@ -1996,7 +2055,11 @@ export async function startOpenclawClawlingGateway(params) {
|
|
|
1996
2055
|
// The conversation is gone. Evict it so no further uplink is emitted
|
|
1997
2056
|
// for it. Falls through to the awareness branch so the owner is told.
|
|
1998
2057
|
const entityId = typeof payload?.entity_id === "string" ? payload.entity_id : "";
|
|
1999
|
-
|
|
2058
|
+
// "signal" tier: this gates typing only. The protocol forbids gating
|
|
2059
|
+
// message.send on a dissolved signal — only a server rejection
|
|
2060
|
+
// (message.error, see src/outbound.ts) may do that, and unlike this
|
|
2061
|
+
// one that rejection expires.
|
|
2062
|
+
const evicted = client.markChatDead(entityId, "signal");
|
|
2000
2063
|
log?.info?.(evicted
|
|
2001
2064
|
? `[${accountId}] clawchat-plugin-openclaw conversation.dissolved signal received; evicting ${entityId}`
|
|
2002
2065
|
: `[${accountId}] clawchat-plugin-openclaw conversation.dissolved signal received with missing/invalid entity_id; no eviction performed`);
|
|
@@ -2018,6 +2081,32 @@ export async function startOpenclawClawlingGateway(params) {
|
|
|
2018
2081
|
log?.error?.(`[${accountId}] clawchat-plugin-openclaw skill-update check failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
2019
2082
|
});
|
|
2020
2083
|
}
|
|
2084
|
+
else if (type === "moment.comment.created" || type === "moment.comment.replied") {
|
|
2085
|
+
// Content-free trigger: the note carries only the scenario + moment id
|
|
2086
|
+
// and points the agent at the get_moment tool — never the comment
|
|
2087
|
+
// text. Not gated behind account.awarenessNote and not debounced:
|
|
2088
|
+
// each moment-comment signal yields its own note (msghub already
|
|
2089
|
+
// coalesces multiple comments on one moment by entity_id).
|
|
2090
|
+
const momentId = typeof payload?.entity_id === "string" ? payload.entity_id : "";
|
|
2091
|
+
// No activation conversation ⇒ no routable chat_id. Skip the note
|
|
2092
|
+
// rather than address it to the owner's usr_… id, which
|
|
2093
|
+
// member-backend rejects as "invalid conversation id" (the agent's
|
|
2094
|
+
// in-turn replies are then dropped with no negative ack). Resolve
|
|
2095
|
+
// this FIRST, mirroring the friend.*/conversation.* awareness
|
|
2096
|
+
// branch's guard below.
|
|
2097
|
+
const ownerConversationId = resolveOwnerDirectConversationId();
|
|
2098
|
+
if (!ownerConversationId) {
|
|
2099
|
+
log?.info?.(`[${accountId}] clawchat-plugin-openclaw ${type} signal received but no owner conversation resolved; skipping moment awareness note (moment ${momentId})`);
|
|
2100
|
+
return;
|
|
2101
|
+
}
|
|
2102
|
+
log?.info?.(`[${accountId}] clawchat-plugin-openclaw ${type} signal received; synthesizing moment awareness note (moment ${momentId})`);
|
|
2103
|
+
void handleInboundEnvelope(buildMomentCommentNoteEnvelope({
|
|
2104
|
+
account,
|
|
2105
|
+
ownerConversationId,
|
|
2106
|
+
momentId,
|
|
2107
|
+
replied: type === "moment.comment.replied",
|
|
2108
|
+
}));
|
|
2109
|
+
}
|
|
2021
2110
|
else if (type === "friend.added" ||
|
|
2022
2111
|
type === "friend.removed" ||
|
|
2023
2112
|
type === "friend.profile_updated" ||
|
package/dist/src/tools-schema.js
CHANGED
|
@@ -198,6 +198,9 @@ export const ClawchatDeleteMomentSchema = Type.Object({
|
|
|
198
198
|
description: "Concrete ClawChat moment id to delete",
|
|
199
199
|
}),
|
|
200
200
|
});
|
|
201
|
+
export const ClawchatGetMomentSchema = Type.Object({
|
|
202
|
+
momentId: Type.Integer({ minimum: 1, description: "Concrete ClawChat moment id to fetch (with its visible comments)" }),
|
|
203
|
+
});
|
|
201
204
|
export const ClawchatToggleMomentReactionSchema = Type.Object({
|
|
202
205
|
momentId: Type.Integer({
|
|
203
206
|
minimum: 1,
|
package/dist/src/tools.js
CHANGED
|
@@ -12,7 +12,7 @@ import { getOpenclawClawlingClient, } from "./runtime.js";
|
|
|
12
12
|
import { markTerminalClawChatSend, getCurrentTerminalSendScope } from "./terminal-send.js";
|
|
13
13
|
import { editClawChatMemoryBody, readClawChatMemoryFile, resolveClawChatMemoryPath, searchClawChatMemory, writeClawChatMemoryBody, } from "./clawchat-memory.js";
|
|
14
14
|
import { pullGroupMetadata, pullOwnerMetadata, pullUserMetadata, pushMetadata, updateMetadata, } from "./clawchat-metadata.js";
|
|
15
|
-
import { ClawchatGetAccountProfileSchema, ClawchatGetConversationSchema, ClawchatGetUserProfileSchema, ClawchatLeaveGroupSchema, ClawchatAcceptFriendRequestSchema, ClawchatMemoryEditSchema, ClawchatMemoryReadSchema, ClawchatMemorySearchSchema, ClawchatMemoryWriteSchema, ClawchatMetadataSyncSchema, ClawchatMetadataUpdateSchema, ClawchatCreateMomentCommentSchema, ClawchatCreateMomentSchema, ClawchatDeleteMomentCommentSchema, ClawchatDeleteMomentSchema, ClawchatListAccountFriendsSchema, ClawchatListFriendRequestsSchema, ClawchatListMomentsSchema, ClawchatMentionMessageSchema, ClawchatReactMessageSchema, ClawchatReplyMomentCommentSchema, ClawchatRejectFriendRequestSchema, ClawchatRemoveFriendSchema, ClawchatSearchUsersSchema, ClawchatSendFriendRequestSchema, ClawchatToggleMomentReactionSchema, ClawchatUpdateAccountProfileSchema, ClawchatUploadAvatarImageSchema, ClawchatRegisterAppSchema, ClawchatListAppsSchema, ClawchatUnregisterAppSchema, ClawchatLivewareLoginSchema, } from "./tools-schema.js";
|
|
15
|
+
import { ClawchatGetAccountProfileSchema, ClawchatGetConversationSchema, ClawchatGetMomentSchema, ClawchatGetUserProfileSchema, ClawchatLeaveGroupSchema, ClawchatAcceptFriendRequestSchema, ClawchatMemoryEditSchema, ClawchatMemoryReadSchema, ClawchatMemorySearchSchema, ClawchatMemoryWriteSchema, ClawchatMetadataSyncSchema, ClawchatMetadataUpdateSchema, ClawchatCreateMomentCommentSchema, ClawchatCreateMomentSchema, ClawchatDeleteMomentCommentSchema, ClawchatDeleteMomentSchema, ClawchatListAccountFriendsSchema, ClawchatListFriendRequestsSchema, ClawchatListMomentsSchema, ClawchatMentionMessageSchema, ClawchatReactMessageSchema, ClawchatReplyMomentCommentSchema, ClawchatRejectFriendRequestSchema, ClawchatRemoveFriendSchema, ClawchatSearchUsersSchema, ClawchatSendFriendRequestSchema, ClawchatToggleMomentReactionSchema, ClawchatUpdateAccountProfileSchema, ClawchatUploadAvatarImageSchema, ClawchatRegisterAppSchema, ClawchatListAppsSchema, ClawchatUnregisterAppSchema, ClawchatLivewareLoginSchema, } from "./tools-schema.js";
|
|
16
16
|
const MAX_UPLOAD_BYTES = 20 * 1024 * 1024;
|
|
17
17
|
// Owner-approval gate business codes (must match the ClawChat backend's owner-approval codes).
|
|
18
18
|
const CODE_PENDING_APPROVAL = 21001;
|
|
@@ -935,6 +935,18 @@ export function registerOpenclawClawlingTools(api, options = {}) {
|
|
|
935
935
|
});
|
|
936
936
|
},
|
|
937
937
|
}, { name: "clawchat_list_moments" });
|
|
938
|
+
api.registerTool({
|
|
939
|
+
name: "clawchat_get_moment",
|
|
940
|
+
label: "Get ClawChat Moment",
|
|
941
|
+
description: toolDescription("Fetch a single ClawChat moment by id together with the comments visible to this agent. TRIGGER - after a moment.comment.created / moment.comment.replied awareness note, call this with the moment id to read the new comment before deciding whether to reply."),
|
|
942
|
+
parameters: ClawchatGetMomentSchema,
|
|
943
|
+
async execute(_callId, params) {
|
|
944
|
+
return await recordClawchatToolCall("clawchat_get_moment", params, async () => {
|
|
945
|
+
const p = (params ?? {});
|
|
946
|
+
return await withClient((c) => c.getMoment(p.momentId));
|
|
947
|
+
});
|
|
948
|
+
},
|
|
949
|
+
}, { name: "clawchat_get_moment" });
|
|
938
950
|
api.registerTool({
|
|
939
951
|
name: "clawchat_create_moment",
|
|
940
952
|
label: "Create ClawChat Moment",
|
|
@@ -1196,5 +1208,5 @@ export function registerOpenclawClawlingTools(api, options = {}) {
|
|
|
1196
1208
|
});
|
|
1197
1209
|
},
|
|
1198
1210
|
}, { name: "clawchat_liveware_login" });
|
|
1199
|
-
api.logger.debug?.("clawchat-plugin-openclaw: registered
|
|
1211
|
+
api.logger.debug?.("clawchat-plugin-openclaw: registered 31 clawchat_* tools (get_account_profile, get_user_profile, list_account_friends, send_friend_request, list_friend_requests, accept_friend_request, reject_friend_request, remove_friend, search_users, get_conversation, mention_message, list_moments, get_moment, create_moment, delete_moment, toggle_moment_reaction, create_moment_comment, reply_moment_comment, delete_moment_comment, update_account_profile, upload_avatar_image, memory_search, memory_read, memory_write, memory_edit, metadata_sync, metadata_update, register_app, list_apps, unregister_app, liveware_login)");
|
|
1200
1212
|
}
|
package/dist/src/ws-alignment.js
CHANGED
|
@@ -40,9 +40,26 @@ export function createAlignedWsQueue(options) {
|
|
|
40
40
|
queue.push(item);
|
|
41
41
|
logFrame("send_queued", "queue", item, [["queue_size", queue.length]]);
|
|
42
42
|
},
|
|
43
|
-
|
|
43
|
+
/**
|
|
44
|
+
* Drain the queue. `isBlocked` is consulted PER ITEM at write time, not at
|
|
45
|
+
* enqueue time: a conversation can be rejected by the server *after* a
|
|
46
|
+
* frame was queued, and this replay path never re-enters the send function
|
|
47
|
+
* that guards the live path. Without this hook the outbound gate is
|
|
48
|
+
* bypassable by any reconnect (and by any batch flush that happens to carry
|
|
49
|
+
* a live chat's frame).
|
|
50
|
+
*/
|
|
51
|
+
flush(write, options = {}) {
|
|
44
52
|
while (queue.length > 0) {
|
|
45
53
|
const item = queue[0];
|
|
54
|
+
if (options.isBlocked?.(item)) {
|
|
55
|
+
queue.shift();
|
|
56
|
+
logFrame("send_blocked", "drop", item, [
|
|
57
|
+
["reason", "chat_rejected_by_server"],
|
|
58
|
+
["remaining", queue.length],
|
|
59
|
+
]);
|
|
60
|
+
item.onBlock?.();
|
|
61
|
+
continue;
|
|
62
|
+
}
|
|
46
63
|
try {
|
|
47
64
|
write(item.wire);
|
|
48
65
|
queue.shift();
|
package/dist/src/ws-client.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { EventEmitter } from "node:events";
|
|
2
|
-
import { AckTimeoutError, AuthError, EVENT, MessageSendError, ProtocolError, StateError, TransportError, isBusinessDispatchEvent, } from "./protocol-types.js";
|
|
2
|
+
import { AckTimeoutError, AuthError, EVENT, MessageSendError, ProtocolError, StateError, TERMINAL_CHAT_CODES, TransportError, isBusinessDispatchEvent, } from "./protocol-types.js";
|
|
3
3
|
export function createWebSocketTransport(WebSocketCtor = globalThis.WebSocket) {
|
|
4
4
|
let currentState = "closed";
|
|
5
5
|
let socket;
|
|
@@ -103,6 +103,45 @@ export function buildConnectCapabilities() {
|
|
|
103
103
|
}
|
|
104
104
|
/** Bounded FIFO of conversations known to be dissolved. */
|
|
105
105
|
export const DEAD_CHATS_MAX = 512;
|
|
106
|
+
/**
|
|
107
|
+
* How long a server rejection keeps gating uplinks for a conversation.
|
|
108
|
+
*
|
|
109
|
+
* A rejection is a fact with a shelf life, NOT a permanent verdict: the server
|
|
110
|
+
* soft-deletes conversations and can revive one in place under the SAME `cnv_`
|
|
111
|
+
* id — which is exactly the path an agent takes when it is re-paired. A
|
|
112
|
+
* permanent mark would leave a re-paired agent silently mute forever.
|
|
113
|
+
*
|
|
114
|
+
* 10 minutes: an order of magnitude above the server's own 30s negative cache
|
|
115
|
+
* for unresolvable chats (so a post-TTL retry lands after the server has
|
|
116
|
+
* re-resolved rather than on a stale negative entry), while still bounding the
|
|
117
|
+
* waste to at most one frame per dead conversation per 10 minutes. Two faster
|
|
118
|
+
* clears (`noteChatAlive`) handle the common cases; this is only the backstop.
|
|
119
|
+
*
|
|
120
|
+
* MUST stay identical to the hermes plugin's value — cross-plugin parity is
|
|
121
|
+
* pinned by src/parity.test.ts.
|
|
122
|
+
*/
|
|
123
|
+
export const SERVER_REJECTION_TTL_MS = 600_000;
|
|
124
|
+
/**
|
|
125
|
+
* Every ClawChat conversation id is minted by member-backend with this prefix,
|
|
126
|
+
* and msghub resolves a chat_id only through member-backend. Anything else — a
|
|
127
|
+
* `usr_…` user idcode, a host-composed `direct:{self}:{peer}` key, a bare
|
|
128
|
+
* adapter name, a placeholder — is refused upstream with
|
|
129
|
+
* `code=400: invalid conversation id`.
|
|
130
|
+
*
|
|
131
|
+
* MUST stay identical to the hermes plugin's `CHAT_ID_PREFIX`.
|
|
132
|
+
*/
|
|
133
|
+
export const CHAT_ID_PREFIX = "cnv_";
|
|
134
|
+
/**
|
|
135
|
+
* Whether `chatId` can name a ClawChat conversation at all.
|
|
136
|
+
*
|
|
137
|
+
* A purely *static* check: it says nothing about whether the conversation
|
|
138
|
+
* exists, is alive, or admits this sender. Those are server-side questions
|
|
139
|
+
* answered by `message.error`; this one is answerable locally, and a frame that
|
|
140
|
+
* fails it is always wrong to put on the wire.
|
|
141
|
+
*/
|
|
142
|
+
export function isValidChatId(chatId) {
|
|
143
|
+
return typeof chatId === "string" && chatId.startsWith(CHAT_ID_PREFIX);
|
|
144
|
+
}
|
|
106
145
|
export class ClawChatClient extends EventEmitter {
|
|
107
146
|
opts;
|
|
108
147
|
currentState = "idle";
|
|
@@ -118,7 +157,12 @@ export class ClawChatClient extends EventEmitter {
|
|
|
118
157
|
pending = new Map();
|
|
119
158
|
handledMessageErrorTraces = new Set();
|
|
120
159
|
sendQueue = [];
|
|
121
|
-
|
|
160
|
+
// Tier 1 — inferred from `conversation.dissolved`. Gates typing only.
|
|
161
|
+
dissolvedBySignal = new Set();
|
|
162
|
+
// Tier 2 — server-CONFIRMED rejection → expiry timestamp. Gates sends.
|
|
163
|
+
// Disjoint storage from tier 1: neither write path touches the other set.
|
|
164
|
+
serverRejectedChats = new Map();
|
|
165
|
+
ownerDirectChatResolver;
|
|
122
166
|
constructor(opts) {
|
|
123
167
|
super();
|
|
124
168
|
this.opts = opts;
|
|
@@ -160,39 +204,131 @@ export class ClawChatClient extends EventEmitter {
|
|
|
160
204
|
this.opts.transport.close(1000, "client close");
|
|
161
205
|
}
|
|
162
206
|
/**
|
|
163
|
-
* Record a conversation as
|
|
207
|
+
* Record a conversation as dead so no further uplink is sent for it.
|
|
164
208
|
* Only `cnv_`-prefixed ids are accepted: a bad entity_id must never mute a
|
|
165
209
|
* live conversation. Returns whether the id was accepted (and thus actually
|
|
166
|
-
*
|
|
210
|
+
* recorded) so callers can log/act on rejection distinctly from acceptance.
|
|
167
211
|
*
|
|
168
|
-
*
|
|
169
|
-
*
|
|
170
|
-
*
|
|
171
|
-
*
|
|
172
|
-
*
|
|
212
|
+
* `source` selects WHICH STORE is written — see {@link DeadChatSource}. The
|
|
213
|
+
* two stores are disjoint; a signal never writes the server tier and a server
|
|
214
|
+
* rejection never writes the signal tier.
|
|
215
|
+
*
|
|
216
|
+
* Signal tier eviction is FIFO by first-mark order, not LRU: `Set` iteration
|
|
217
|
+
* order is insertion order and the `has()` early-return means re-marking does
|
|
218
|
+
* NOT refresh position. The server tier deliberately does the opposite — a
|
|
219
|
+
* re-rejection re-arms the TTL *and* moves the id to the tail — because its
|
|
220
|
+
* whole point is "how long since the server last said no".
|
|
173
221
|
*/
|
|
174
|
-
markChatDead(chatId) {
|
|
175
|
-
if (
|
|
222
|
+
markChatDead(chatId, source = "signal") {
|
|
223
|
+
if (!isValidChatId(chatId))
|
|
176
224
|
return false;
|
|
177
|
-
if (
|
|
225
|
+
if (source === "server") {
|
|
226
|
+
// Delete-then-set so the re-insert also moves the id to the FIFO tail.
|
|
227
|
+
this.serverRejectedChats.delete(chatId);
|
|
228
|
+
this.serverRejectedChats.set(chatId, this.opts.now() + SERVER_REJECTION_TTL_MS);
|
|
229
|
+
while (this.serverRejectedChats.size > DEAD_CHATS_MAX) {
|
|
230
|
+
const oldest = this.serverRejectedChats.keys().next().value;
|
|
231
|
+
if (oldest === undefined)
|
|
232
|
+
break;
|
|
233
|
+
this.serverRejectedChats.delete(oldest);
|
|
234
|
+
}
|
|
235
|
+
return true;
|
|
236
|
+
}
|
|
237
|
+
if (this.dissolvedBySignal.has(chatId))
|
|
178
238
|
return true;
|
|
179
|
-
this.
|
|
180
|
-
while (this.
|
|
181
|
-
const oldest = this.
|
|
239
|
+
this.dissolvedBySignal.add(chatId);
|
|
240
|
+
while (this.dissolvedBySignal.size > DEAD_CHATS_MAX) {
|
|
241
|
+
const oldest = this.dissolvedBySignal.values().next().value;
|
|
182
242
|
if (oldest === undefined)
|
|
183
243
|
break;
|
|
184
|
-
this.
|
|
244
|
+
this.dissolvedBySignal.delete(oldest);
|
|
185
245
|
}
|
|
186
246
|
return true;
|
|
187
247
|
}
|
|
248
|
+
/**
|
|
249
|
+
* True for a chat dead by EITHER tier — a derived union, not a third store.
|
|
250
|
+
* Gates `typing.update` only. Never gate message delivery on this.
|
|
251
|
+
*/
|
|
188
252
|
isChatDead(chatId) {
|
|
189
|
-
return this.
|
|
253
|
+
return this.dissolvedBySignal.has(chatId) || this.isChatRejectedByServer(chatId);
|
|
254
|
+
}
|
|
255
|
+
/**
|
|
256
|
+
* True only for a chat the SERVER rejected with a terminal code, and only
|
|
257
|
+
* while that rejection is still fresh. Expiry is lazy: the entry is dropped
|
|
258
|
+
* on the first read past its deadline, so there is no timer to leak.
|
|
259
|
+
*/
|
|
260
|
+
isChatRejectedByServer(chatId) {
|
|
261
|
+
const expiresAt = this.serverRejectedChats.get(chatId);
|
|
262
|
+
if (expiresAt === undefined)
|
|
263
|
+
return false;
|
|
264
|
+
if (this.opts.now() >= expiresAt) {
|
|
265
|
+
this.serverRejectedChats.delete(chatId);
|
|
266
|
+
return false;
|
|
267
|
+
}
|
|
268
|
+
return true;
|
|
269
|
+
}
|
|
270
|
+
/**
|
|
271
|
+
* The ONLY predicate allowed to gate an outbound `message.send` /
|
|
272
|
+
* `message.reply` / `message.reaction`. Narrower than `isChatRejectedByServer`
|
|
273
|
+
* by one exemption: the owner's direct conversation is never blocked, because
|
|
274
|
+
* it is the only channel through which the agent can explain why it went
|
|
275
|
+
* dark. The rejection is still RECORDED for the owner chat — the exemption
|
|
276
|
+
* decides whether to send, not whether to remember.
|
|
277
|
+
*/
|
|
278
|
+
isSendBlocked(chatId) {
|
|
279
|
+
if (!this.isChatRejectedByServer(chatId))
|
|
280
|
+
return false;
|
|
281
|
+
const ownerChatId = this.ownerDirectChatResolver?.();
|
|
282
|
+
return !(ownerChatId && ownerChatId === chatId);
|
|
283
|
+
}
|
|
284
|
+
/** Injected by the runtime once the owner's direct conversation is known. */
|
|
285
|
+
setOwnerDirectChatResolver(resolver) {
|
|
286
|
+
this.ownerDirectChatResolver = resolver;
|
|
287
|
+
}
|
|
288
|
+
/**
|
|
289
|
+
* Evidence that a conversation is reachable again — drop any server-rejection
|
|
290
|
+
* record for it. Called from the inbound path; see `dispatchInbound`.
|
|
291
|
+
*
|
|
292
|
+
* Only the SERVER tier is cleared. The signal tier is deliberately left
|
|
293
|
+
* alone: `conversation.dissolved` gates nothing but the cosmetic typing
|
|
294
|
+
* indicator, and un-muting it on a replayed frame would be churn with no
|
|
295
|
+
* user-visible benefit.
|
|
296
|
+
*/
|
|
297
|
+
noteChatAlive(chatId) {
|
|
298
|
+
if (typeof chatId !== "string" || !chatId)
|
|
299
|
+
return;
|
|
300
|
+
this.serverRejectedChats.delete(chatId);
|
|
301
|
+
}
|
|
302
|
+
/**
|
|
303
|
+
* `conversation.*` signals other than `dissolved` mean the server still has
|
|
304
|
+
* this conversation (created / updated / membership changed) — clear any
|
|
305
|
+
* stale rejection. `conversation.dissolved` is the opposite signal and must
|
|
306
|
+
* not clear anything.
|
|
307
|
+
*/
|
|
308
|
+
noteConversationSignalAlive(env) {
|
|
309
|
+
const payload = env.payload && typeof env.payload === "object"
|
|
310
|
+
? env.payload
|
|
311
|
+
: undefined;
|
|
312
|
+
const type = typeof payload?.type === "string" ? payload.type : "";
|
|
313
|
+
if (!type.startsWith("conversation.") || type === "conversation.dissolved")
|
|
314
|
+
return;
|
|
315
|
+
const entityId = typeof payload?.entity_id === "string" ? payload.entity_id : "";
|
|
316
|
+
if (entityId)
|
|
317
|
+
this.noteChatAlive(entityId);
|
|
190
318
|
}
|
|
191
319
|
/**
|
|
192
|
-
*
|
|
193
|
-
* `message.send` / `message.reply`) is deliberately left unguarded
|
|
194
|
-
* false-positive dead-mark must never block
|
|
195
|
-
* cosmetic `typing.update` indicator.
|
|
320
|
+
* Gated on `isChatDead` — BOTH tiers. `sendAckableEnvelope` (backing
|
|
321
|
+
* `message.send` / `message.reply`) is deliberately left unguarded against
|
|
322
|
+
* this predicate: a false-positive SIGNAL-derived dead-mark must never block
|
|
323
|
+
* message delivery, only the cosmetic `typing.update` indicator. The
|
|
324
|
+
* protocol states this explicitly for `conversation.dissolved` — "agents …
|
|
325
|
+
* stop sending typing.update for that chat_id; message.send MUST NOT be
|
|
326
|
+
* gated on this". Do not extend `isChatDead` to the send path.
|
|
327
|
+
*
|
|
328
|
+
* The send path IS gated, but on the narrower `isSendBlocked` (see
|
|
329
|
+
* src/outbound.ts) — a chat the server itself rejected is fact, not
|
|
330
|
+
* inference, so the false-positive argument above does not apply to it.
|
|
331
|
+
* That fact expires; this one does not.
|
|
196
332
|
*/
|
|
197
333
|
typing(chatId, isTyping = true) {
|
|
198
334
|
if (this.isChatDead(chatId))
|
|
@@ -227,10 +363,27 @@ export class ClawChatClient extends EventEmitter {
|
|
|
227
363
|
this.opts.transport.send(wire);
|
|
228
364
|
}
|
|
229
365
|
sendRawEnvelope(env) {
|
|
366
|
+
// Static addressing check. A frame carrying a chat_id that cannot name a
|
|
367
|
+
// conversation is refused by msghub with `invalid conversation id`, so it
|
|
368
|
+
// never had a recipient; dropping it here costs the caller nothing and
|
|
369
|
+
// keeps the error out of msghub's logs.
|
|
370
|
+
//
|
|
371
|
+
// Unlike the dead-chat gate — a revocable server state a queued frame can
|
|
372
|
+
// fall into, which is why `shouldDropQueuedWire` re-checks on replay — a
|
|
373
|
+
// malformed chat_id is wrong at construction time and can never become
|
|
374
|
+
// valid. Rejecting at this entry point is therefore sufficient: the frame
|
|
375
|
+
// never reaches the reconnect queue, so no replay path can resurrect it.
|
|
376
|
+
//
|
|
377
|
+
// Frames with no chat_id (connect / ping / pong) are untouched.
|
|
378
|
+
if (env.chat_id !== undefined && !isValidChatId(env.chat_id))
|
|
379
|
+
return;
|
|
230
380
|
this.sendWire(JSON.stringify(env), { bypassReconnectQueue: env.event === EVENT.CONNECT });
|
|
231
381
|
}
|
|
232
|
-
// Deliberately does NOT consult
|
|
233
|
-
//
|
|
382
|
+
// Deliberately does NOT consult the dead-chat state — see the comment on
|
|
383
|
+
// `typing()`. Signal-inferred death must never gate message delivery. (The
|
|
384
|
+
// narrower `isSendBlocked` gate lives on the aligned path in src/outbound.ts,
|
|
385
|
+
// which is what the reply dispatcher actually uses; this legacy ackable path
|
|
386
|
+
// is kept ungated so the two tiers stay distinguishable.)
|
|
234
387
|
async sendAckableEnvelope(params) {
|
|
235
388
|
const traceId = this.nextTraceId();
|
|
236
389
|
const env = {
|
|
@@ -332,6 +485,13 @@ export class ClawChatClient extends EventEmitter {
|
|
|
332
485
|
this.dispatchInbound(env);
|
|
333
486
|
}
|
|
334
487
|
dispatchInbound(env) {
|
|
488
|
+
// A frame the server routed to us for this chat proves the chat is
|
|
489
|
+
// reachable again — a deleted conversation can be revived in place under
|
|
490
|
+
// the same id. `message.error` is excluded: it IS the rejection, not
|
|
491
|
+
// evidence of life.
|
|
492
|
+
if (typeof env.chat_id === "string" && env.event !== EVENT.MESSAGE_ERROR) {
|
|
493
|
+
this.noteChatAlive(env.chat_id);
|
|
494
|
+
}
|
|
335
495
|
if (env.event === EVENT.CONNECT_CHALLENGE)
|
|
336
496
|
return this.onChallenge(env);
|
|
337
497
|
if (env.event === EVENT.HELLO_OK)
|
|
@@ -360,6 +520,8 @@ export class ClawChatClient extends EventEmitter {
|
|
|
360
520
|
this.emit("typing", env);
|
|
361
521
|
if (env.event === EVENT.CHAT_METADATA_INVALIDATED)
|
|
362
522
|
this.emit("metadata:invalidated", env);
|
|
523
|
+
if (env.event === EVENT.NOTIFY_SIGNAL)
|
|
524
|
+
this.noteConversationSignalAlive(env);
|
|
363
525
|
if (env.event === EVENT.NOTIFY_SIGNAL)
|
|
364
526
|
this.emit("notify:signal", env);
|
|
365
527
|
if (env.event === EVENT.REPLAY_DONE)
|
|
@@ -510,6 +672,20 @@ export class ClawChatClient extends EventEmitter {
|
|
|
510
672
|
entry.resolve(env);
|
|
511
673
|
}
|
|
512
674
|
onMessageError(env) {
|
|
675
|
+
const payload = env.payload && typeof env.payload === "object"
|
|
676
|
+
? env.payload
|
|
677
|
+
: undefined;
|
|
678
|
+
const code = typeof payload?.code === "string" && payload.code ? payload.code : "unknown";
|
|
679
|
+
// Mark BEFORE the pending-ack lookup: an unmatched error frame still
|
|
680
|
+
// carries an authoritative `chat_id` + `code` and must participate in the
|
|
681
|
+
// dead-chat decision. Unmatched is the NORMAL case here — the aligned queue
|
|
682
|
+
// owns its own ack matching (markMessageErrorHandled makes the branch below
|
|
683
|
+
// return silently) and a reconnect drops pending entries. Deferring the
|
|
684
|
+
// mark until after the lookup would make tier-2 feeding random.
|
|
685
|
+
// Parity: hermes notifies its dead-chat state at the same point.
|
|
686
|
+
if (typeof env.chat_id === "string" && TERMINAL_CHAT_CODES.has(code)) {
|
|
687
|
+
this.markChatDead(env.chat_id, "server");
|
|
688
|
+
}
|
|
513
689
|
const entry = this.pending.get(env.trace_id);
|
|
514
690
|
if (!entry) {
|
|
515
691
|
if (this.handledMessageErrorTraces.delete(env.trace_id))
|
|
@@ -519,10 +695,6 @@ export class ClawChatClient extends EventEmitter {
|
|
|
519
695
|
}
|
|
520
696
|
clearTimeout(entry.timer);
|
|
521
697
|
this.pending.delete(env.trace_id);
|
|
522
|
-
const payload = env.payload && typeof env.payload === "object"
|
|
523
|
-
? env.payload
|
|
524
|
-
: undefined;
|
|
525
|
-
const code = typeof payload?.code === "string" && payload.code ? payload.code : "unknown";
|
|
526
698
|
// §14.3: the human-readable hint is `reason` (fall back to legacy `message`).
|
|
527
699
|
const hint = typeof payload?.reason === "string" && payload.reason
|
|
528
700
|
? payload.reason
|
|
@@ -644,14 +816,22 @@ export class ClawChatClient extends EventEmitter {
|
|
|
644
816
|
}
|
|
645
817
|
flushSendQueue() {
|
|
646
818
|
while (this.sendQueue.length > 0) {
|
|
647
|
-
const wire = this.sendQueue
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
this.
|
|
653
|
-
|
|
819
|
+
const wire = this.sendQueue[0];
|
|
820
|
+
// Re-check the dead-chat gates at replay time: a conversation can be
|
|
821
|
+
// rejected AFTER a frame was buffered here, and this path bypasses every
|
|
822
|
+
// caller-side guard.
|
|
823
|
+
if (shouldDropQueuedWire(wire, {
|
|
824
|
+
isChatDead: (chatId) => this.isChatDead(chatId),
|
|
825
|
+
isSendBlocked: (chatId) => this.isSendBlocked(chatId),
|
|
826
|
+
})) {
|
|
827
|
+
this.sendQueue.shift();
|
|
828
|
+
continue;
|
|
654
829
|
}
|
|
830
|
+
// Read the head first and shift only after a successful write, so a
|
|
831
|
+
// failure leaves the frame at the head (same as the previous
|
|
832
|
+
// shift/unshift pair; `onHelloOk`'s catch closes and reconnects).
|
|
833
|
+
this.sendWire(wire);
|
|
834
|
+
this.sendQueue.shift();
|
|
655
835
|
}
|
|
656
836
|
}
|
|
657
837
|
isHandshaking() {
|
|
@@ -667,6 +847,37 @@ export class ClawChatClient extends EventEmitter {
|
|
|
667
847
|
return null;
|
|
668
848
|
}
|
|
669
849
|
}
|
|
850
|
+
/**
|
|
851
|
+
* Decide whether a wire string sitting in the transport-level reconnect queue
|
|
852
|
+
* must be dropped instead of replayed.
|
|
853
|
+
*
|
|
854
|
+
* This queue holds ALREADY-SERIALIZED frames, so the only way to see the
|
|
855
|
+
* `chat_id` is to parse. That is acceptable here and nowhere else: the queue
|
|
856
|
+
* fills only during a reconnect window and is drained exactly once per
|
|
857
|
+
* successful handshake — unlike `sendWire`, which is the hot path.
|
|
858
|
+
*
|
|
859
|
+
* Asymmetry mirrors the live path: `typing.update` is gated on EITHER tier
|
|
860
|
+
* (`isChatDead`), everything else only on a server-confirmed, non-owner
|
|
861
|
+
* rejection (`isSendBlocked`). Unparseable or chat-less frames are always kept:
|
|
862
|
+
* `connect` / `ping` / `pong` must never be dropped.
|
|
863
|
+
*
|
|
864
|
+
* Exported for direct unit testing; `flushSendQueue` is the only producer.
|
|
865
|
+
*/
|
|
866
|
+
export function shouldDropQueuedWire(wire, gates) {
|
|
867
|
+
let env;
|
|
868
|
+
try {
|
|
869
|
+
env = JSON.parse(wire);
|
|
870
|
+
}
|
|
871
|
+
catch {
|
|
872
|
+
return false;
|
|
873
|
+
}
|
|
874
|
+
const chatId = typeof env.chat_id === "string" ? env.chat_id : "";
|
|
875
|
+
if (!chatId)
|
|
876
|
+
return false;
|
|
877
|
+
if (env.event === EVENT.TYPING_UPDATE)
|
|
878
|
+
return gates.isChatDead(chatId);
|
|
879
|
+
return gates.isSendBlocked(chatId);
|
|
880
|
+
}
|
|
670
881
|
export function createClawChatClient(options) {
|
|
671
882
|
return new ClawChatClient({
|
|
672
883
|
...options,
|
|
@@ -689,5 +900,6 @@ export function createClawChatClient(options) {
|
|
|
689
900
|
timeout: options.ack?.timeout ?? 15000,
|
|
690
901
|
autoResendOnTimeout: options.ack?.autoResendOnTimeout ?? false,
|
|
691
902
|
},
|
|
903
|
+
now: options.now ?? (() => Date.now()),
|
|
692
904
|
});
|
|
693
905
|
}
|
package/openclaw.plugin.json
CHANGED