@clawling/clawchat-plugin-openclaw 2026.9.14-1 → 2026.9.16-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 +52 -7
- package/dist/src/config.js +3 -0
- package/dist/src/friend-greeting.js +80 -0
- package/dist/src/protocol-types.js +1 -0
- package/dist/src/refresh-manager.js +77 -10
- package/dist/src/runtime.js +126 -1
- package/dist/src/skill-update.js +1 -1
- package/dist/src/tools-schema.js +6 -0
- package/dist/src/tools.js +32 -1
- package/dist/src/ws-client.js +5 -0
- package/openclaw.plugin.json +5 -0
- package/package.json +1 -1
- package/skills/clawchat-core/SKILL.md +3 -2
- package/skills/clawchat-set-greeting/SKILL.md +23 -4
- package/skills/manifest.json +12 -12
- package/src/api-client.ts +65 -8
- package/src/config.ts +7 -0
- package/src/friend-greeting.ts +91 -0
- package/src/protocol-types.ts +1 -0
- package/src/refresh-manager.ts +89 -10
- package/src/runtime.ts +145 -1
- package/src/skill-update.ts +1 -1
- package/src/tools-schema.ts +8 -0
- package/src/tools.ts +37 -0
- package/src/ws-client.ts +4 -0
package/dist/src/api-client.js
CHANGED
|
@@ -42,13 +42,23 @@ const CODE_INTERNAL = 1; // CodeInternal — transient.
|
|
|
42
42
|
const CODE_BAD_REQUEST = 400; // bad body / device id — permanent (client bug).
|
|
43
43
|
/**
|
|
44
44
|
* CodeInvalidRefresh — returned for BOTH a genuinely revoked/invalid refresh
|
|
45
|
-
* token AND a
|
|
46
|
-
*
|
|
47
|
-
*
|
|
48
|
-
* `
|
|
49
|
-
*
|
|
45
|
+
* token AND a refresh token that was already CONSUMED by a prior successful
|
|
46
|
+
* rotation (a duplicate-supervisor / concurrent-refresh / stale-store race), once
|
|
47
|
+
* the backend's short replay grace window no longer covers it. The stateless
|
|
48
|
+
* `authRefresh` cannot tell the two apart, so it reports `permanent`; the
|
|
49
|
+
* stateful `RefreshManager` re-classifies a consumed-rotation race back to
|
|
50
|
+
* transient (see §B race) before any auto-logout.
|
|
50
51
|
*/
|
|
51
52
|
export const CODE_INVALID_REFRESH = 10003;
|
|
53
|
+
/**
|
|
54
|
+
* §B attempt deadline — total wall clock for one refresh attempt (request AND
|
|
55
|
+
* response body). A hit aborts the request and is TRANSIENT. Kept below
|
|
56
|
+
* `MIN_REFRESH_INTERVAL_MS` (30s) so the min-interval floor, not the deadline,
|
|
57
|
+
* sets the retry cadence: a retry of the same refresh token then lands inside
|
|
58
|
+
* the backend refresh grace window if the server rotated but the response was
|
|
59
|
+
* lost.
|
|
60
|
+
*/
|
|
61
|
+
export const REFRESH_REQUEST_TIMEOUT_MS = 20_000;
|
|
52
62
|
/**
|
|
53
63
|
* §0/§B — call `POST /v1/auth/refresh` to rotate the access+refresh token.
|
|
54
64
|
*
|
|
@@ -58,6 +68,11 @@ export const CODE_INVALID_REFRESH = 10003;
|
|
|
58
68
|
* always HTTP 200 — branch on the envelope `code`, NOT on HTTP status. This is
|
|
59
69
|
* a standalone function (not a method on the token-bearing client) precisely
|
|
60
70
|
* because no bearer token participates.
|
|
71
|
+
*
|
|
72
|
+
* The whole attempt is bounded by `REFRESH_REQUEST_TIMEOUT_MS` (§B attempt
|
|
73
|
+
* deadline): the request is aborted via its signal, and the deadline is also
|
|
74
|
+
* raced against the attempt so a fetch implementation that ignores the signal
|
|
75
|
+
* cannot hang the caller.
|
|
61
76
|
*/
|
|
62
77
|
export async function authRefresh(opts, params) {
|
|
63
78
|
const baseUrl = opts.baseUrl.replace(/\/+$/, "");
|
|
@@ -65,6 +80,26 @@ export async function authRefresh(opts, params) {
|
|
|
65
80
|
if (!params.refreshToken?.trim()) {
|
|
66
81
|
return { kind: "permanent", code: CODE_INVALID_REFRESH, message: "missing refresh token" };
|
|
67
82
|
}
|
|
83
|
+
const timeoutMs = opts.timeoutMs ?? REFRESH_REQUEST_TIMEOUT_MS;
|
|
84
|
+
const controller = new AbortController();
|
|
85
|
+
let timer;
|
|
86
|
+
const deadline = new Promise((resolve) => {
|
|
87
|
+
timer = setTimeout(() => {
|
|
88
|
+
controller.abort(new Error("refresh request timed out"));
|
|
89
|
+
resolve({ kind: "transient", message: `refresh request timed out after ${timeoutMs}ms` });
|
|
90
|
+
}, timeoutMs);
|
|
91
|
+
});
|
|
92
|
+
try {
|
|
93
|
+
return await Promise.race([
|
|
94
|
+
authRefreshAttempt(baseUrl, fetchImpl, params, controller.signal),
|
|
95
|
+
deadline,
|
|
96
|
+
]);
|
|
97
|
+
}
|
|
98
|
+
finally {
|
|
99
|
+
clearTimeout(timer);
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
async function authRefreshAttempt(baseUrl, fetchImpl, params, signal) {
|
|
68
103
|
let res;
|
|
69
104
|
try {
|
|
70
105
|
res = await fetchImpl(`${baseUrl}/v1/auth/refresh`, {
|
|
@@ -75,6 +110,8 @@ export async function authRefresh(opts, params) {
|
|
|
75
110
|
"x-device-id": params.deviceId,
|
|
76
111
|
},
|
|
77
112
|
body: JSON.stringify({ refresh_token: params.refreshToken.trim() }),
|
|
113
|
+
// Still in effect while the body is read below.
|
|
114
|
+
signal,
|
|
78
115
|
});
|
|
79
116
|
}
|
|
80
117
|
catch (err) {
|
|
@@ -117,8 +154,9 @@ export async function authRefresh(opts, params) {
|
|
|
117
154
|
const refreshToken = typeof data.refresh_token === "string" ? data.refresh_token : "";
|
|
118
155
|
if (!accessToken || !refreshToken) {
|
|
119
156
|
// Rotation succeeded server-side but the body is malformed — transient so
|
|
120
|
-
// we retry; the
|
|
121
|
-
//
|
|
157
|
+
// we retry; inside the backend grace window the retry redeems the old
|
|
158
|
+
// token again, after it the retry returns 10003 and escalates to
|
|
159
|
+
// permanent (§B transient→permanent).
|
|
122
160
|
return { kind: "transient", status: 200, message: "refresh: rotation body incomplete" };
|
|
123
161
|
}
|
|
124
162
|
return { kind: "success", accessToken, refreshToken };
|
|
@@ -396,6 +434,13 @@ export function createOpenclawClawlingApiClient(opts) {
|
|
|
396
434
|
async deleteMomentComment(params) {
|
|
397
435
|
return await call("DELETE", `/v1/moments/${encodeURIComponent(String(params.momentId))}/comments/${encodeURIComponent(String(params.commentId))}`);
|
|
398
436
|
},
|
|
437
|
+
async getDirectConversation(peerId) {
|
|
438
|
+
assertNonBlankId(peerId, "getDirectConversation: peerId");
|
|
439
|
+
return await call("POST", "/v1/conversations/direct", {
|
|
440
|
+
body: JSON.stringify({ peer_id: peerId.trim() }),
|
|
441
|
+
headers: { "content-type": "application/json" },
|
|
442
|
+
});
|
|
443
|
+
},
|
|
399
444
|
async getConversation(conversationId) {
|
|
400
445
|
return await call("GET", `/v1/conversations/${encodeURIComponent(conversationId)}`);
|
|
401
446
|
},
|
package/dist/src/config.js
CHANGED
|
@@ -83,6 +83,7 @@ export const openclawClawlingAccountConfigSchema = {
|
|
|
83
83
|
forwardToolCalls: { type: "boolean" },
|
|
84
84
|
richInteractions: { type: "boolean" },
|
|
85
85
|
awarenessNote: { type: "boolean" },
|
|
86
|
+
friendGreeting: { type: "boolean" },
|
|
86
87
|
livewareSample: { type: "boolean" },
|
|
87
88
|
reconnect: {
|
|
88
89
|
type: "object",
|
|
@@ -448,6 +449,7 @@ export function resolveOpenclawClawlingAccount(cfg, accountId, env = process.env
|
|
|
448
449
|
const forwardToolCalls = typeof channel.forwardToolCalls === "boolean" ? channel.forwardToolCalls : false;
|
|
449
450
|
const richInteractions = typeof channel.richInteractions === "boolean" ? channel.richInteractions : false;
|
|
450
451
|
const awarenessNote = typeof channel.awarenessNote === "boolean" ? channel.awarenessNote : false;
|
|
452
|
+
const friendGreeting = typeof channel.friendGreeting === "boolean" ? channel.friendGreeting : true;
|
|
451
453
|
const livewareSample = typeof channel.livewareSample === "boolean" ? channel.livewareSample : true;
|
|
452
454
|
return {
|
|
453
455
|
accountId: resolvedAccountId,
|
|
@@ -475,6 +477,7 @@ export function resolveOpenclawClawlingAccount(cfg, accountId, env = process.env
|
|
|
475
477
|
forwardToolCalls,
|
|
476
478
|
richInteractions,
|
|
477
479
|
awarenessNote,
|
|
480
|
+
friendGreeting,
|
|
478
481
|
livewareSample,
|
|
479
482
|
allowFrom: [],
|
|
480
483
|
reconnect: readReconnect(channel.reconnect),
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* First message to a newly added NON-owner friend.
|
|
3
|
+
*
|
|
4
|
+
* `friend.added` used to be a pure awareness event. The server creates the
|
|
5
|
+
* direct conversation inside the friend-accept transaction, but the signal
|
|
6
|
+
* only carries the counterparty `usr_…` — so the runtime resolves the
|
|
7
|
+
* conversation through `POST /v1/conversations/direct` and then feeds ONE
|
|
8
|
+
* synthetic inbound turn (built here) into the normal dispatch path, the same
|
|
9
|
+
* way the activation bootstrap greets the owner.
|
|
10
|
+
*
|
|
11
|
+
* The prompt is deliberately distinct from the owner activation prompt: this
|
|
12
|
+
* reader is a stranger, so "you are connected and ready" makes no sense and
|
|
13
|
+
* the agent must say whose agent it is instead.
|
|
14
|
+
*/
|
|
15
|
+
import fs from "node:fs";
|
|
16
|
+
import os from "node:os";
|
|
17
|
+
import path from "node:path";
|
|
18
|
+
import { EVENT } from "./protocol-types.js";
|
|
19
|
+
export const FRIEND_GREETING_FALLBACK = [
|
|
20
|
+
"A ClawChat user has just become your friend. You are now in a direct conversation with them; they are not your owner.",
|
|
21
|
+
"Reply now with one short, friendly greeting message in this conversation: introduce yourself by name, say you are an AI agent acting on behalf of your owner, and invite them to tell you what they need.",
|
|
22
|
+
"Send it as a normal chat reply. Do not write or create any files or notes, and do not call tools just to greet.",
|
|
23
|
+
"Do not share your owner's private information, and do not ask the user for personal information.",
|
|
24
|
+
].join("\n");
|
|
25
|
+
// Cross-plugin, user-editable override read lazily so edits apply on the next
|
|
26
|
+
// friend without a restart. Any read failure falls back to the built-in text.
|
|
27
|
+
// Mirrors `buildActivationBootstrapText` (`~/clawchat/greeting.md`).
|
|
28
|
+
export function buildFriendGreetingText(homeDir = os.homedir()) {
|
|
29
|
+
const greetingPath = path.join(homeDir, "clawchat", "friend-greeting.md");
|
|
30
|
+
try {
|
|
31
|
+
const raw = fs.readFileSync(greetingPath);
|
|
32
|
+
const override = new TextDecoder("utf-8", { fatal: true }).decode(raw).trim();
|
|
33
|
+
if (override.length > 0) {
|
|
34
|
+
return override;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
catch (error) {
|
|
38
|
+
const code = error.code;
|
|
39
|
+
if (code !== "ENOENT") {
|
|
40
|
+
console.warn(`clawchat.friend-greeting failed to read override ${greetingPath}:`, error);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
return FRIEND_GREETING_FALLBACK;
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* Synthetic inbound envelope for the friend greeting turn. Same invariant as
|
|
47
|
+
* `buildActivationBootstrapEnvelope`: `conversationId` MUST be a conversation
|
|
48
|
+
* idcode — the agent's reply inherits this chat_id, and anything else is
|
|
49
|
+
* refused at the outbound boundary after a full LLM turn has been spent.
|
|
50
|
+
*/
|
|
51
|
+
export function buildFriendGreetingEnvelope(params) {
|
|
52
|
+
const { account, conversationId, friendUserId } = params;
|
|
53
|
+
const text = buildFriendGreetingText();
|
|
54
|
+
const now = Date.now();
|
|
55
|
+
return {
|
|
56
|
+
version: "2",
|
|
57
|
+
event: EVENT.MESSAGE_SEND,
|
|
58
|
+
trace_id: `clawchat-plugin-openclaw-friend-greeting-${now}`,
|
|
59
|
+
emitted_at: now,
|
|
60
|
+
chat_id: conversationId,
|
|
61
|
+
chat_type: "direct",
|
|
62
|
+
to: { id: account.userId, type: "direct" },
|
|
63
|
+
sender: { id: friendUserId, type: "direct", nick_name: "" },
|
|
64
|
+
payload: {
|
|
65
|
+
message_id: `clawchat-plugin-openclaw-friend-greeting-${conversationId}-${now}`,
|
|
66
|
+
message_mode: "normal",
|
|
67
|
+
message: {
|
|
68
|
+
body: { fragments: [{ kind: "text", text }] },
|
|
69
|
+
context: { mentions: [], reply: null },
|
|
70
|
+
streaming: {
|
|
71
|
+
status: "static",
|
|
72
|
+
sequence: 0,
|
|
73
|
+
mutation_policy: "sealed",
|
|
74
|
+
started_at: null,
|
|
75
|
+
completed_at: null,
|
|
76
|
+
},
|
|
77
|
+
},
|
|
78
|
+
},
|
|
79
|
+
};
|
|
80
|
+
}
|
|
@@ -17,6 +17,7 @@ export const EVENT = {
|
|
|
17
17
|
CHAT_METADATA_INVALIDATED: "chat.metadata.invalidated",
|
|
18
18
|
NOTIFY_SIGNAL: "notify.signal",
|
|
19
19
|
REPLAY_DONE: "replay.done",
|
|
20
|
+
HISTORY_TRUNCATED: "history.truncated",
|
|
20
21
|
OFFLINE_BATCH: "offline.batch",
|
|
21
22
|
OFFLINE_ACK: "offline.ack",
|
|
22
23
|
OFFLINE_DONE: "offline.done",
|
|
@@ -22,6 +22,15 @@ const HOUR_MS = 60 * MINUTE_MS;
|
|
|
22
22
|
export const MIN_REFRESH_INTERVAL_MS = 30_000;
|
|
23
23
|
/** §A.1 — proactive jitter (±5min). */
|
|
24
24
|
export const PROACTIVE_JITTER_MS = 5 * MINUTE_MS;
|
|
25
|
+
/**
|
|
26
|
+
* §B retry within the grace window — jitter bounds for the one-shot retry after
|
|
27
|
+
* a transient proactive refresh. The retry is due `MIN_REFRESH_INTERVAL_MS` +
|
|
28
|
+
* [1s, 5s] after the failed attempt BEGAN (31–35s): far above the backend's
|
|
29
|
+
* minimum replay age, inside its 90s replay window, and never faster than the
|
|
30
|
+
* min-interval floor (which would skip it).
|
|
31
|
+
*/
|
|
32
|
+
export const PROACTIVE_RETRY_JITTER_MIN_MS = 1_000;
|
|
33
|
+
export const PROACTIVE_RETRY_JITTER_MAX_MS = 5_000;
|
|
25
34
|
/** §A.0 — fallback access-token TTL when `exp` is unparseable. */
|
|
26
35
|
export const ACCESS_TOKEN_TTL_MS = 24 * HOUR_MS;
|
|
27
36
|
/**
|
|
@@ -70,6 +79,8 @@ export class RefreshManager {
|
|
|
70
79
|
/** §A.3 — epoch-ms of the last refresh attempt (any token). */
|
|
71
80
|
lastAttemptAt = 0;
|
|
72
81
|
proactiveTimer = null;
|
|
82
|
+
/** §B — the pending one-shot retry after a transient proactive refresh. */
|
|
83
|
+
proactiveRetryTimer = null;
|
|
73
84
|
stopped = false;
|
|
74
85
|
constructor(ports) {
|
|
75
86
|
this.ports = ports;
|
|
@@ -133,9 +144,10 @@ export class RefreshManager {
|
|
|
133
144
|
// sqlite-sourced agent must not keep a now-dead refresh token in its row
|
|
134
145
|
// while running on the rotated token. Treat as transient so the WS stays
|
|
135
146
|
// in backoff with the CURRENT tokens and the next attempt retries. The
|
|
136
|
-
// server already rotated
|
|
137
|
-
//
|
|
138
|
-
//
|
|
147
|
+
// server already rotated: inside the backend grace window the retry
|
|
148
|
+
// redeems the old token again; after it the retry returns `code:10003`
|
|
149
|
+
// (which escalates to permanent per §B) — the accepted hazard, not a
|
|
150
|
+
// silent brick.
|
|
139
151
|
try {
|
|
140
152
|
await this.ports.persistRotatedTokens({
|
|
141
153
|
accessToken: result.accessToken,
|
|
@@ -160,8 +172,9 @@ export class RefreshManager {
|
|
|
160
172
|
return { kind: "success", accessToken: result.accessToken, refreshToken: result.refreshToken };
|
|
161
173
|
}
|
|
162
174
|
if (result.kind === "permanent") {
|
|
163
|
-
// §B race — a `code:10003` is also returned for a
|
|
164
|
-
//
|
|
175
|
+
// §B race — a `code:10003` is also returned for a refresh token already
|
|
176
|
+
// CONSUMED by a prior successful rotation (once the backend grace window
|
|
177
|
+
// no longer covers it). Before auto-logging-out
|
|
165
178
|
// (which wipes credentials and bricks the agent), distinguish that race
|
|
166
179
|
// from a genuine revocation. It is a race when EITHER the submitted token
|
|
167
180
|
// is one we already rotated away from, OR the live store refresh token has
|
|
@@ -234,13 +247,26 @@ export class RefreshManager {
|
|
|
234
247
|
* success, hands the rotated token to the runtime's `onProactiveRefreshed` port
|
|
235
248
|
* so the live WS is closed and reconnected with the new token (the in-memory
|
|
236
249
|
* swap alone does NOT reach the running socket, which captured the old token at
|
|
237
|
-
* `connect` time). Transient/skipped outcomes leave the WS untouched
|
|
238
|
-
*
|
|
250
|
+
* `connect` time). Transient/skipped outcomes leave the WS untouched.
|
|
251
|
+
*
|
|
252
|
+
* §B retry within the grace window — a TRANSIENT outcome (e.g. the attempt
|
|
253
|
+
* deadline hit after the server may already have rotated) arms ONE retry, due
|
|
254
|
+
* 31–35s after that attempt began, so a replay of the same refresh token lands
|
|
255
|
+
* inside the backend grace window instead of waiting for the next arm /
|
|
256
|
+
* hello-fail / 401, possibly hours later. The retry itself never arms another.
|
|
257
|
+
* Success, permanent and skipped outcomes arm nothing (skipped means another
|
|
258
|
+
* in-flight attempt or a latch already owns the token).
|
|
239
259
|
*/
|
|
240
|
-
async runProactiveRefresh() {
|
|
241
|
-
const
|
|
260
|
+
async runProactiveRefresh(isRetry = false) {
|
|
261
|
+
const accessTokenAtAttempt = this.ports.getAccessToken();
|
|
262
|
+
const outcome = await this.refresh(isRetry ? "proactive-retry" : "proactive-timer");
|
|
242
263
|
if (this.stopped)
|
|
243
264
|
return;
|
|
265
|
+
if (outcome.kind === "transient") {
|
|
266
|
+
if (!isRetry)
|
|
267
|
+
this.armProactiveRetry(accessTokenAtAttempt);
|
|
268
|
+
return;
|
|
269
|
+
}
|
|
244
270
|
if (outcome.kind !== "success")
|
|
245
271
|
return;
|
|
246
272
|
if (this.ports.onProactiveRefreshed) {
|
|
@@ -255,6 +281,42 @@ export class RefreshManager {
|
|
|
255
281
|
}
|
|
256
282
|
}
|
|
257
283
|
}
|
|
284
|
+
/**
|
|
285
|
+
* §B — arm the one-shot retry, measured from the failed attempt's start
|
|
286
|
+
* (`lastAttemptAt`). It runs through `refresh()`, so single-flight dedupe, the
|
|
287
|
+
* rejected-token latch and the min-interval floor all still apply. It is
|
|
288
|
+
* skipped when the access token changed meanwhile (someone else rotated).
|
|
289
|
+
*/
|
|
290
|
+
armProactiveRetry(accessTokenAtAttempt) {
|
|
291
|
+
this.clearProactiveRetry();
|
|
292
|
+
const rawJitter = (this.ports.proactiveRetryJitter ?? defaultProactiveRetryJitter)();
|
|
293
|
+
const jitterMs = Math.min(PROACTIVE_RETRY_JITTER_MAX_MS, Math.max(PROACTIVE_RETRY_JITTER_MIN_MS, rawJitter));
|
|
294
|
+
const dueAtMs = this.lastAttemptAt + MIN_REFRESH_INTERVAL_MS + jitterMs;
|
|
295
|
+
const delayMs = Math.max(0, dueAtMs - this.now());
|
|
296
|
+
this.ports.log?.info?.(`clawchat-plugin-openclaw proactive refresh transient; one-shot retry in ${delayMs}ms`);
|
|
297
|
+
this.proactiveRetryTimer = this.setTimer(() => {
|
|
298
|
+
this.proactiveRetryTimer = null;
|
|
299
|
+
if (this.stopped)
|
|
300
|
+
return;
|
|
301
|
+
if (this.ports.getAccessToken() !== accessTokenAtAttempt) {
|
|
302
|
+
this.ports.log?.debug?.("clawchat-plugin-openclaw proactive retry skipped (access token already changed)");
|
|
303
|
+
return;
|
|
304
|
+
}
|
|
305
|
+
void this.runProactiveRefresh(true);
|
|
306
|
+
}, delayMs);
|
|
307
|
+
}
|
|
308
|
+
clearProactiveRetry() {
|
|
309
|
+
if (this.proactiveRetryTimer != null) {
|
|
310
|
+
this.clearTimer(this.proactiveRetryTimer);
|
|
311
|
+
this.proactiveRetryTimer = null;
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
/**
|
|
315
|
+
* Clear the proactive `refresh_at` timer (called on WS disconnect). The §B
|
|
316
|
+
* one-shot retry is deliberately NOT cleared here: a refresh timeout usually
|
|
317
|
+
* coincides with the network drop that also closes the socket, and refresh
|
|
318
|
+
* does not need the socket. `stop()` clears both.
|
|
319
|
+
*/
|
|
258
320
|
disarmProactiveTimer() {
|
|
259
321
|
if (this.proactiveTimer != null) {
|
|
260
322
|
this.clearTimer(this.proactiveTimer);
|
|
@@ -279,10 +341,11 @@ export class RefreshManager {
|
|
|
279
341
|
});
|
|
280
342
|
return this.now() >= refreshAtMs;
|
|
281
343
|
}
|
|
282
|
-
/** Stop the manager — clears the proactive
|
|
344
|
+
/** Stop the manager — clears the proactive + retry timers; nothing further arms. */
|
|
283
345
|
stop() {
|
|
284
346
|
this.stopped = true;
|
|
285
347
|
this.disarmProactiveTimer();
|
|
348
|
+
this.clearProactiveRetry();
|
|
286
349
|
}
|
|
287
350
|
/** Test/inspection seam — the latched (rejected) access token, if any. */
|
|
288
351
|
getRejectedToken() {
|
|
@@ -327,3 +390,7 @@ function decodeJwtIat(token) {
|
|
|
327
390
|
function defaultJitter() {
|
|
328
391
|
return (Math.random() * 2 - 1) * PROACTIVE_JITTER_MS;
|
|
329
392
|
}
|
|
393
|
+
function defaultProactiveRetryJitter() {
|
|
394
|
+
return (PROACTIVE_RETRY_JITTER_MIN_MS +
|
|
395
|
+
Math.random() * (PROACTIVE_RETRY_JITTER_MAX_MS - PROACTIVE_RETRY_JITTER_MIN_MS));
|
|
396
|
+
}
|
package/dist/src/runtime.js
CHANGED
|
@@ -5,6 +5,7 @@ import { createPluginRuntimeStore } from "openclaw/plugin-sdk/runtime-store";
|
|
|
5
5
|
import { createOpenclawClawlingClient, resolveOpenclawClawlingDeviceId } from "./client.js";
|
|
6
6
|
import { createOpenclawClawlingApiClient } from "./api-client.js";
|
|
7
7
|
import { buildActivationBootstrapText } from "./activation-greeting.js";
|
|
8
|
+
import { buildFriendGreetingEnvelope } from "./friend-greeting.js";
|
|
8
9
|
import { reportPluginVersionSafe, resolvePluginVersion } from "./plugin-report.js";
|
|
9
10
|
import { ensureLivewareCli, livewareCliHomeDir, livewareSampleRootDir, resolveLivewarePath, } from "./liveware-cli.js";
|
|
10
11
|
import { LivewareSampleSupervisor, } from "./liveware-sample.js";
|
|
@@ -1941,6 +1942,38 @@ export async function startOpenclawClawlingGateway(params) {
|
|
|
1941
1942
|
],
|
|
1942
1943
|
}));
|
|
1943
1944
|
}
|
|
1945
|
+
else if (env.event === "history.truncated") {
|
|
1946
|
+
// Replay-start boundary frame (docs/client-integration.md §11.7): part
|
|
1947
|
+
// of the retained history will not be replayed to this device. This
|
|
1948
|
+
// plugin advertises no reliable-delivery flag, so the server does not
|
|
1949
|
+
// send it today; if one arrives it is logged explicitly. Never acked (no
|
|
1950
|
+
// seq/dseq), never dispatched, no user-visible action. An absent or
|
|
1951
|
+
// unrecognised `reason` means "pruned"; `oldest_seq` is an opaque token.
|
|
1952
|
+
const current = wsLogContext();
|
|
1953
|
+
const truncatedPayload = env.payload && typeof env.payload === "object"
|
|
1954
|
+
? env.payload
|
|
1955
|
+
: undefined;
|
|
1956
|
+
const rawReason = truncatedPayload?.reason;
|
|
1957
|
+
const knownReason = rawReason === "pruned" || rawReason === "cursor_started_above_zero";
|
|
1958
|
+
const fields = [
|
|
1959
|
+
["event_name", env.event],
|
|
1960
|
+
["trace_id", env.trace_id],
|
|
1961
|
+
["oldest_seq", typeof truncatedPayload?.oldest_seq === "number" ? truncatedPayload.oldest_seq : "-"],
|
|
1962
|
+
["reason", knownReason ? rawReason : "pruned"],
|
|
1963
|
+
];
|
|
1964
|
+
if (!knownReason && typeof rawReason === "string" && rawReason) {
|
|
1965
|
+
fields.push(["raw_reason", rawReason]);
|
|
1966
|
+
}
|
|
1967
|
+
log?.info?.(formatWsLog({
|
|
1968
|
+
event: "inbound_control",
|
|
1969
|
+
accountId,
|
|
1970
|
+
attempt: current.attempt,
|
|
1971
|
+
reconnectCount: current.reconnectCount,
|
|
1972
|
+
state: "ready",
|
|
1973
|
+
action: "history_truncated",
|
|
1974
|
+
fields,
|
|
1975
|
+
}));
|
|
1976
|
+
}
|
|
1944
1977
|
else if (env.event !== "ping" && env.event !== "pong") {
|
|
1945
1978
|
const current = wsLogContext();
|
|
1946
1979
|
log?.info?.(formatWsLog({
|
|
@@ -2088,6 +2121,77 @@ export async function startOpenclawClawlingGateway(params) {
|
|
|
2088
2121
|
// A single queueMicrotask coalesces all awareness signals that arrive in
|
|
2089
2122
|
// the same synchronous turn into exactly one handleInboundEnvelope call.
|
|
2090
2123
|
let pendingAwarenessNote = false;
|
|
2124
|
+
/**
|
|
2125
|
+
* First message to a newly added NON-owner friend (`friend.added`).
|
|
2126
|
+
*
|
|
2127
|
+
* The signal only names the counterparty; the direct conversation the
|
|
2128
|
+
* server created inside the accept transaction is resolved through
|
|
2129
|
+
* `POST /v1/conversations/direct` and ONE synthetic greeting turn runs in
|
|
2130
|
+
* it. Dedupe is persisted through the message ledger keyed on the signal's
|
|
2131
|
+
* `event_id` (`message_id` is `notify:friend.added:<userId>` and would
|
|
2132
|
+
* collide on remove + re-add, so it is only the fallback key) — a
|
|
2133
|
+
* reconnect replay of the same signal cannot greet twice. The claim uses
|
|
2134
|
+
* kind "message" because the ledger's once-only unique index is partial on
|
|
2135
|
+
* kind = 'message'; chat_id stays null so the row never feeds a transcript.
|
|
2136
|
+
* The owner's own `friend.added` is skipped: the activation bootstrap greets
|
|
2137
|
+
* the owner.
|
|
2138
|
+
*/
|
|
2139
|
+
const scheduleFriendGreeting = (payload) => {
|
|
2140
|
+
if (!account.friendGreeting)
|
|
2141
|
+
return;
|
|
2142
|
+
const friendUserId = typeof payload?.entity_id === "string" ? payload.entity_id.trim() : "";
|
|
2143
|
+
if (!friendUserId)
|
|
2144
|
+
return;
|
|
2145
|
+
const ownerUserId = account.ownerUserId?.trim();
|
|
2146
|
+
if (ownerUserId && friendUserId === ownerUserId)
|
|
2147
|
+
return;
|
|
2148
|
+
const eventId = typeof payload?.event_id === "string" ? payload.event_id.trim() : "";
|
|
2149
|
+
const messageId = typeof payload?.message_id === "string" ? payload.message_id.trim() : "";
|
|
2150
|
+
const dedupeKey = eventId || messageId;
|
|
2151
|
+
if (!dedupeKey) {
|
|
2152
|
+
log?.info?.(`[${accountId}] clawchat-plugin-openclaw friend greeting skipped friend=${friendUserId} reason=no_dedupe_key`);
|
|
2153
|
+
return;
|
|
2154
|
+
}
|
|
2155
|
+
if (store?.claimMessageOnce) {
|
|
2156
|
+
const claimed = recordConnection("friend greeting claim", () => store.claimMessageOnce?.({
|
|
2157
|
+
platform: "openclaw",
|
|
2158
|
+
accountId,
|
|
2159
|
+
kind: "message",
|
|
2160
|
+
direction: "inbound",
|
|
2161
|
+
eventType: "friend.greeting",
|
|
2162
|
+
traceId: null,
|
|
2163
|
+
chatId: null,
|
|
2164
|
+
messageId: `friend.greeting:${dedupeKey}`,
|
|
2165
|
+
text: null,
|
|
2166
|
+
raw: { friend_user_id: friendUserId },
|
|
2167
|
+
}));
|
|
2168
|
+
if (claimed !== true) {
|
|
2169
|
+
// false = already greeted (replay); null = ledger undecided — fail
|
|
2170
|
+
// closed rather than risk a duplicate unsolicited message.
|
|
2171
|
+
log?.info?.(`[${accountId}] clawchat-plugin-openclaw friend greeting skipped friend=${friendUserId} key=${dedupeKey} claimed=${String(claimed)}`);
|
|
2172
|
+
return;
|
|
2173
|
+
}
|
|
2174
|
+
}
|
|
2175
|
+
void (async () => {
|
|
2176
|
+
let conversationId = "";
|
|
2177
|
+
try {
|
|
2178
|
+
const result = await getConversationApiClient().getDirectConversation(friendUserId);
|
|
2179
|
+
conversationId = result?.conversation?.id?.trim() ?? "";
|
|
2180
|
+
}
|
|
2181
|
+
catch (err) {
|
|
2182
|
+
log?.error?.(`[${accountId}] clawchat-plugin-openclaw friend greeting conversation lookup failed friend=${friendUserId}: ${err instanceof Error ? err.message : String(err)}`);
|
|
2183
|
+
return;
|
|
2184
|
+
}
|
|
2185
|
+
if (!isValidChatId(conversationId)) {
|
|
2186
|
+
log?.error?.(`[${accountId}] clawchat-plugin-openclaw friend greeting skipped friend=${friendUserId} reason=no_conversation_id`);
|
|
2187
|
+
return;
|
|
2188
|
+
}
|
|
2189
|
+
log?.info?.(`[${accountId}] clawchat-plugin-openclaw friend greeting dispatch friend=${friendUserId} chat_id=${conversationId}`);
|
|
2190
|
+
await handleInboundEnvelope(buildFriendGreetingEnvelope({ account, conversationId, friendUserId }));
|
|
2191
|
+
})().catch((err) => {
|
|
2192
|
+
log?.error?.(`[${accountId}] clawchat-plugin-openclaw friend greeting failed friend=${friendUserId}: ${err instanceof Error ? err.message : String(err)}`);
|
|
2193
|
+
});
|
|
2194
|
+
};
|
|
2091
2195
|
client.on("notify:signal", (env) => {
|
|
2092
2196
|
// §9.4 reliable system notification. The plugin holds no friend/roster
|
|
2093
2197
|
// cache (friends are fetched on demand via REST tools), so there is nothing
|
|
@@ -2177,6 +2281,9 @@ export async function startOpenclawClawlingGateway(params) {
|
|
|
2177
2281
|
void handleInboundEnvelope(buildAwarenessNoteEnvelope({ account, ownerConversationId }));
|
|
2178
2282
|
});
|
|
2179
2283
|
}
|
|
2284
|
+
if (type === "friend.added") {
|
|
2285
|
+
scheduleFriendGreeting(payload);
|
|
2286
|
+
}
|
|
2180
2287
|
}
|
|
2181
2288
|
}
|
|
2182
2289
|
});
|
|
@@ -2373,7 +2480,22 @@ export async function startOpenclawClawlingGateway(params) {
|
|
|
2373
2480
|
log?.info?.(`[${accountId}] clawchat-plugin-openclaw skip duplicate stored msg=${turn.messageId}`);
|
|
2374
2481
|
return "skipped";
|
|
2375
2482
|
}
|
|
2483
|
+
if (claimed !== true) {
|
|
2484
|
+
// Fail closed: a claim that could not be decided (store disabled, SQLite
|
|
2485
|
+
// error, a throw converted to undefined by recordConnection) must not
|
|
2486
|
+
// dispatch. Live delivery and device replay can carry the same message,
|
|
2487
|
+
// and the outbound reply path (outbound.ts / reply-dispatcher.ts) already
|
|
2488
|
+
// refuses to send without a claim — so dispatching here only re-ran the
|
|
2489
|
+
// LLM/tools without delivering, or double-replied. Logged at error level
|
|
2490
|
+
// with reason=claim_unavailable so a store outage is visible and is not
|
|
2491
|
+
// mistaken for dedup. Accepted cost: a one-off transient claim failure
|
|
2492
|
+
// drops that message.
|
|
2493
|
+
log?.error?.(`[${accountId}] clawchat-plugin-openclaw inbound skipped msg=${turn.messageId} chat_id=${turn.peer.id} event=${String(env.event)} reason=claim_unavailable result=${String(claimed)}`);
|
|
2494
|
+
return "skipped";
|
|
2495
|
+
}
|
|
2376
2496
|
}
|
|
2497
|
+
// No store wired at all (injected test transport, or store construction
|
|
2498
|
+
// threw at startup — which is itself logged) keeps the store-less pass-through.
|
|
2377
2499
|
return "claimed";
|
|
2378
2500
|
};
|
|
2379
2501
|
const dispatchTurnToAgent = async (turn) => {
|
|
@@ -2730,7 +2852,10 @@ export async function startOpenclawClawlingGateway(params) {
|
|
|
2730
2852
|
// replayed — permanently lost. Claiming first makes it durable; the abort
|
|
2731
2853
|
// check below then returns without dispatching, but the frame is kept.
|
|
2732
2854
|
// `claimInboundTurn` is INSERT OR IGNORE, so a genuine duplicate (same
|
|
2733
|
-
// message_id) is still deduped here exactly once.
|
|
2855
|
+
// message_id) is still deduped here exactly once. The claim fails CLOSED:
|
|
2856
|
+
// only a successful claim proceeds; an undecided claim (store unavailable)
|
|
2857
|
+
// skips with reason=claim_unavailable, because outbound replies also refuse
|
|
2858
|
+
// to send without a claim, so dispatching would only waste the turn.
|
|
2734
2859
|
const claimed = claimInboundTurn(turn);
|
|
2735
2860
|
if (claimed === "skipped")
|
|
2736
2861
|
return "skipped";
|
package/dist/src/skill-update.js
CHANGED
|
@@ -66,7 +66,7 @@ export const OFFICIAL_SKILLS_BASE = "https://raw.githubusercontent.com/clawling/
|
|
|
66
66
|
* in the install-cli repo, bump this constant, ship it. `liveware-sample.ts`
|
|
67
67
|
* imports the same ref, so the `livewares` tree at that tag is pinned too.
|
|
68
68
|
*/
|
|
69
|
-
export const DEFAULT_SKILLS_REF = "skills-v1.
|
|
69
|
+
export const DEFAULT_SKILLS_REF = "skills-v1.9.0";
|
|
70
70
|
/** Refuse to treat an absurdly large response as a skill file (defence in depth). */
|
|
71
71
|
export const MAX_SKILL_BYTES = 256 * 1024;
|
|
72
72
|
/** This adapter's host target inside `skills/manifest.json`. */
|
package/dist/src/tools-schema.js
CHANGED
|
@@ -123,6 +123,12 @@ export const ClawchatGetConversationSchema = Type.Object({
|
|
|
123
123
|
description: "Concrete ClawChat conversation id to fetch",
|
|
124
124
|
}),
|
|
125
125
|
});
|
|
126
|
+
export const ClawchatGetDirectConversationSchema = Type.Object({
|
|
127
|
+
userId: Type.String({
|
|
128
|
+
minLength: 1,
|
|
129
|
+
description: "Concrete ClawChat user id (usr_...) of the friend",
|
|
130
|
+
}),
|
|
131
|
+
});
|
|
126
132
|
export const ClawchatLeaveGroupSchema = Type.Object({
|
|
127
133
|
conversationId: Type.String({
|
|
128
134
|
description: "Concrete ClawChat group conversation id to leave",
|
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, ClawchatGetMomentSchema, ClawchatGetUserProfileSchema, ClawchatLeaveGroupSchema, ClawchatAddGroupMemberSchema, 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, ClawchatGetDirectConversationSchema, ClawchatGetMomentSchema, ClawchatGetUserProfileSchema, ClawchatLeaveGroupSchema, ClawchatAddGroupMemberSchema, 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;
|
|
@@ -840,6 +840,37 @@ export function registerOpenclawClawlingTools(api, options = {}) {
|
|
|
840
840
|
},
|
|
841
841
|
};
|
|
842
842
|
}, { name: "clawchat_get_conversation" });
|
|
843
|
+
api.registerTool((ctx) => {
|
|
844
|
+
const accountId = resolveOpenclawClawlingToolAccountId(ctx);
|
|
845
|
+
return {
|
|
846
|
+
name: "clawchat_get_direct_conversation",
|
|
847
|
+
label: "Get ClawChat Direct Conversation With User",
|
|
848
|
+
description: toolDescription("Resolve the direct (1:1) ClawChat conversation with a specific user to its conversation id (cnv_...), creating it if needed. " +
|
|
849
|
+
"TRIGGER - invoke when you need to send a message to a ClawChat user and only know their userId (usr_...), for example to start a conversation with a newly added friend. " +
|
|
850
|
+
"The user must already be your friend; otherwise the server rejects the call. " +
|
|
851
|
+
"Use the returned conversation.id as chatId for clawchat_mention_message. " +
|
|
852
|
+
"Never pass a userId or a name as a chatId."),
|
|
853
|
+
parameters: ClawchatGetDirectConversationSchema,
|
|
854
|
+
async execute(_callId, params) {
|
|
855
|
+
return await recordClawchatToolCall(accountId, "clawchat_get_direct_conversation", params, async () => {
|
|
856
|
+
const p = params;
|
|
857
|
+
const built = buildClient(accountId);
|
|
858
|
+
if (!built.ok)
|
|
859
|
+
return built.error;
|
|
860
|
+
try {
|
|
861
|
+
const data = await built.client.getDirectConversation(p.userId);
|
|
862
|
+
return jsonResponse(data);
|
|
863
|
+
}
|
|
864
|
+
catch (err) {
|
|
865
|
+
if (err instanceof ClawlingApiError) {
|
|
866
|
+
return apiError(err);
|
|
867
|
+
}
|
|
868
|
+
return genericError(err);
|
|
869
|
+
}
|
|
870
|
+
});
|
|
871
|
+
},
|
|
872
|
+
};
|
|
873
|
+
}, { name: "clawchat_get_direct_conversation" });
|
|
843
874
|
api.registerTool((ctx) => {
|
|
844
875
|
const accountId = resolveOpenclawClawlingToolAccountId(ctx);
|
|
845
876
|
return {
|
package/dist/src/ws-client.js
CHANGED
|
@@ -643,6 +643,11 @@ export class ClawChatClient extends EventEmitter {
|
|
|
643
643
|
this.emit("notify:signal", env);
|
|
644
644
|
if (env.event === EVENT.REPLAY_DONE)
|
|
645
645
|
this.emit("replay:done", env);
|
|
646
|
+
// Replay-start boundary frame (docs/client-integration.md §11.7). Control
|
|
647
|
+
// only: it carries no seq/dseq, so it is never acked and never becomes a
|
|
648
|
+
// `message`. Its own emitter keeps it out of the unknown-event path.
|
|
649
|
+
if (env.event === EVENT.HISTORY_TRUNCATED)
|
|
650
|
+
this.emit("history:truncated", env);
|
|
646
651
|
if (env.event === EVENT.OFFLINE_DONE)
|
|
647
652
|
this.emit("offline:done");
|
|
648
653
|
}
|