@clawling/clawchat-plugin-openclaw 2026.9.14-2 → 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 +107 -0
- 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 +124 -0
- 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
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: clawchat-core
|
|
3
|
-
version: 1.
|
|
3
|
+
version: 1.3.0
|
|
4
4
|
description: Use when a request involves ClawChat profile, friends, user search, moments/dynamics, comments, reactions, avatar, media, memory, output visibility, read-only conversation lookup, sending an image, file, or voice/audio clip into a conversation, or plugin install/update/activation.
|
|
5
5
|
---
|
|
6
6
|
|
|
@@ -88,6 +88,7 @@ Tool descriptions are authoritative. These routing hints resolve common ambiguit
|
|
|
88
88
|
| Accept/reject a friend request | `clawchat_accept_friend_request` or `clawchat_reject_friend_request` with exact `requestId`; list incoming requests first when ambiguous |
|
|
89
89
|
| Remove/unfriend contact | `clawchat_remove_friend` with exact `friendUserId`; list friends first when ambiguous |
|
|
90
90
|
| Inspect one conversation or group by exact id | `clawchat_get_conversation` |
|
|
91
|
+
| Message a ClawChat user you only know by `userId` (e.g. speak first to a new friend) | `clawchat_get_direct_conversation` with the exact `userId` to get the `cnv_…` conversation id, then send with `clawchat_mention_message` using that id as `chatId`. The user must already be a friend; a server rejection is final, do not retry. Never pass a `userId` or a name as `chatId` |
|
|
91
92
|
| View/browse moments or dynamics | `clawchat_list_moments` |
|
|
92
93
|
| Read one moment and its visible comments by exact id | `clawchat_get_moment` with exact `momentId`; read-only, use after a `moment.comment.created`/`moment.comment.replied` awareness note to read the new comment before deciding whether to reply |
|
|
93
94
|
| Create a moment/dynamic | `clawchat_create_moment`; upload local images first and pass URLs |
|
|
@@ -129,6 +130,6 @@ For avatar changes, save the returned `avatar_url` back to the identity file aft
|
|
|
129
130
|
|
|
130
131
|
For moments/dynamics, list first when the user refers to "this", "latest", "that post", "the one from earlier", or another ambiguous target. Use exact ids returned by the tools. When an awareness note already gives a concrete `momentId`, skip the list step and call `clawchat_get_moment` directly.
|
|
131
132
|
|
|
132
|
-
For conversations/groups, use only `clawchat_get_conversation` to inspect existing conversation information when the exact conversation id is known.
|
|
133
|
+
For conversations/groups, use only `clawchat_get_conversation` to inspect existing conversation information when the exact conversation id is known. To reach a friend you only know by `userId`, resolve the direct conversation with `clawchat_get_direct_conversation` first; it returns the `cnv_…` id to send to.
|
|
133
134
|
|
|
134
135
|
Do not invent invite codes, tokens, moment ids, comment ids, user ids, emoji reactions, image URLs, or file paths.
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: clawchat-set-greeting
|
|
3
|
-
version: 1.
|
|
4
|
-
description: Use when the user wants to customize, change, set, or reset this agent's first-load / activation greeting
|
|
3
|
+
version: 1.1.0
|
|
4
|
+
description: Use when the user wants to customize, change, set, or reset this agent's greetings — the first-load / activation greeting to the owner (~/clawchat/greeting.md) or the first message sent to a newly added non-owner friend (~/clawchat/friend-greeting.md).
|
|
5
5
|
---
|
|
6
6
|
|
|
7
7
|
# Set the ClawChat first-load greeting
|
|
@@ -38,7 +38,26 @@ one sentence." — not the finished greeting sentence itself.
|
|
|
38
38
|
To restore the built-in greeting, delete `~/clawchat/greeting.md` (or empty it). With the
|
|
39
39
|
file absent or empty, the plugin falls back to its built-in greeting instruction.
|
|
40
40
|
|
|
41
|
+
## The other greeting: first message to a new friend
|
|
42
|
+
|
|
43
|
+
When someone who is **not** the owner becomes this agent's ClawChat friend (either side
|
|
44
|
+
sent the request), the plugin speaks first in the new direct conversation using a second,
|
|
45
|
+
separate instruction. The built-in one says to introduce yourself by name, say you are an
|
|
46
|
+
AI agent acting on behalf of your owner, and invite them to say what they need — and never
|
|
47
|
+
to share the owner's private information.
|
|
48
|
+
|
|
49
|
+
Override it the same way with **`~/clawchat/friend-greeting.md`**: same rules as above (it
|
|
50
|
+
is an instruction to you, not the literal message; keep it short; no secrets). Delete or
|
|
51
|
+
empty the file to restore the built-in instruction. The owner can turn this greeting off
|
|
52
|
+
entirely in the plugin config (`friend_greeting: false` for Hermes, `friendGreeting: false`
|
|
53
|
+
for OpenClaw); it is not something you can disable from chat.
|
|
54
|
+
|
|
55
|
+
When the user asks about "the greeting" without saying which, ask whether they mean the
|
|
56
|
+
owner activation greeting or the new-friend greeting.
|
|
57
|
+
|
|
41
58
|
## Notes
|
|
42
59
|
|
|
43
|
-
-
|
|
44
|
-
-
|
|
60
|
+
- `greeting.md` affects only the **first-load** activation greeting to the owner;
|
|
61
|
+
`friend-greeting.md` affects only the first message to a newly added non-owner friend.
|
|
62
|
+
Neither changes later replies.
|
|
63
|
+
- Both files are honored by both ClawChat agent runtimes (Hermes and OpenClaw).
|
package/skills/manifest.json
CHANGED
|
@@ -3,10 +3,10 @@
|
|
|
3
3
|
"skills": {
|
|
4
4
|
"openclaw": {
|
|
5
5
|
"clawchat-core": {
|
|
6
|
-
"version": "1.
|
|
6
|
+
"version": "1.3.0",
|
|
7
7
|
"path": "openclaw/clawchat-core/SKILL.md",
|
|
8
|
-
"sha256": "
|
|
9
|
-
"bytes":
|
|
8
|
+
"sha256": "38468122ef085f652fbcffd6241803019e0242086d2a9c039152591c8d306976",
|
|
9
|
+
"bytes": 11650
|
|
10
10
|
},
|
|
11
11
|
"clawchat-liveware": {
|
|
12
12
|
"version": "1.2.2",
|
|
@@ -21,10 +21,10 @@
|
|
|
21
21
|
"bytes": 8892
|
|
22
22
|
},
|
|
23
23
|
"clawchat-set-greeting": {
|
|
24
|
-
"version": "1.
|
|
24
|
+
"version": "1.1.0",
|
|
25
25
|
"path": "shared/clawchat-set-greeting/SKILL.md",
|
|
26
|
-
"sha256": "
|
|
27
|
-
"bytes":
|
|
26
|
+
"sha256": "cf21e94513e3de5bfe882e55be639ce37a6db5d874d988b8e1e18a908a46252d",
|
|
27
|
+
"bytes": 3475
|
|
28
28
|
},
|
|
29
29
|
"clawchat-liveware-sample": {
|
|
30
30
|
"version": "2.0.0",
|
|
@@ -35,10 +35,10 @@
|
|
|
35
35
|
},
|
|
36
36
|
"hermes": {
|
|
37
37
|
"clawchat-core": {
|
|
38
|
-
"version": "1.
|
|
38
|
+
"version": "1.9.0",
|
|
39
39
|
"path": "hermes/clawchat-core/SKILL.md",
|
|
40
|
-
"sha256": "
|
|
41
|
-
"bytes":
|
|
40
|
+
"sha256": "fd00ff36b58385c774a9268229136523a73a16c258d9af046aaa5bfa7d30a90f",
|
|
41
|
+
"bytes": 18632
|
|
42
42
|
},
|
|
43
43
|
"clawchat-liveware": {
|
|
44
44
|
"version": "1.2.2",
|
|
@@ -53,10 +53,10 @@
|
|
|
53
53
|
"bytes": 8892
|
|
54
54
|
},
|
|
55
55
|
"clawchat-set-greeting": {
|
|
56
|
-
"version": "1.
|
|
56
|
+
"version": "1.1.0",
|
|
57
57
|
"path": "shared/clawchat-set-greeting/SKILL.md",
|
|
58
|
-
"sha256": "
|
|
59
|
-
"bytes":
|
|
58
|
+
"sha256": "cf21e94513e3de5bfe882e55be639ce37a6db5d874d988b8e1e18a908a46252d",
|
|
59
|
+
"bytes": 3475
|
|
60
60
|
},
|
|
61
61
|
"clawchat-liveware-sample": {
|
|
62
62
|
"version": "2.0.0",
|
package/src/api-client.ts
CHANGED
|
@@ -133,6 +133,8 @@ export interface OpenclawClawlingApiClient {
|
|
|
133
133
|
text: string;
|
|
134
134
|
}): Promise<{ comment: MomentComment }>;
|
|
135
135
|
deleteMomentComment(params: { momentId: number; commentId: number }): Promise<{ ok: boolean }>;
|
|
136
|
+
/** Find-or-create the direct conversation with a friend (`POST /v1/conversations/direct`); 19012 when not a friend. */
|
|
137
|
+
getDirectConversation(peerId: string): Promise<{ conversation: { id: string; type: string } }>;
|
|
136
138
|
getConversation(conversationId: string): Promise<{ conversation: ConversationDetails }>;
|
|
137
139
|
leaveConversation(conversationId: string): Promise<{ ok: boolean }>;
|
|
138
140
|
addConversationMember(
|
|
@@ -211,14 +213,25 @@ const CODE_INTERNAL = 1; // CodeInternal — transient.
|
|
|
211
213
|
const CODE_BAD_REQUEST = 400; // bad body / device id — permanent (client bug).
|
|
212
214
|
/**
|
|
213
215
|
* CodeInvalidRefresh — returned for BOTH a genuinely revoked/invalid refresh
|
|
214
|
-
* token AND a
|
|
215
|
-
*
|
|
216
|
-
*
|
|
217
|
-
* `
|
|
218
|
-
*
|
|
216
|
+
* token AND a refresh token that was already CONSUMED by a prior successful
|
|
217
|
+
* rotation (a duplicate-supervisor / concurrent-refresh / stale-store race), once
|
|
218
|
+
* the backend's short replay grace window no longer covers it. The stateless
|
|
219
|
+
* `authRefresh` cannot tell the two apart, so it reports `permanent`; the
|
|
220
|
+
* stateful `RefreshManager` re-classifies a consumed-rotation race back to
|
|
221
|
+
* transient (see §B race) before any auto-logout.
|
|
219
222
|
*/
|
|
220
223
|
export const CODE_INVALID_REFRESH = 10003;
|
|
221
224
|
|
|
225
|
+
/**
|
|
226
|
+
* §B attempt deadline — total wall clock for one refresh attempt (request AND
|
|
227
|
+
* response body). A hit aborts the request and is TRANSIENT. Kept below
|
|
228
|
+
* `MIN_REFRESH_INTERVAL_MS` (30s) so the min-interval floor, not the deadline,
|
|
229
|
+
* sets the retry cadence: a retry of the same refresh token then lands inside
|
|
230
|
+
* the backend refresh grace window if the server rotated but the response was
|
|
231
|
+
* lost.
|
|
232
|
+
*/
|
|
233
|
+
export const REFRESH_REQUEST_TIMEOUT_MS = 20_000;
|
|
234
|
+
|
|
222
235
|
/**
|
|
223
236
|
* §0/§B — call `POST /v1/auth/refresh` to rotate the access+refresh token.
|
|
224
237
|
*
|
|
@@ -228,9 +241,14 @@ export const CODE_INVALID_REFRESH = 10003;
|
|
|
228
241
|
* always HTTP 200 — branch on the envelope `code`, NOT on HTTP status. This is
|
|
229
242
|
* a standalone function (not a method on the token-bearing client) precisely
|
|
230
243
|
* because no bearer token participates.
|
|
244
|
+
*
|
|
245
|
+
* The whole attempt is bounded by `REFRESH_REQUEST_TIMEOUT_MS` (§B attempt
|
|
246
|
+
* deadline): the request is aborted via its signal, and the deadline is also
|
|
247
|
+
* raced against the attempt so a fetch implementation that ignores the signal
|
|
248
|
+
* cannot hang the caller.
|
|
231
249
|
*/
|
|
232
250
|
export async function authRefresh(
|
|
233
|
-
opts: { baseUrl: string; fetchImpl?: typeof fetch },
|
|
251
|
+
opts: { baseUrl: string; fetchImpl?: typeof fetch; timeoutMs?: number },
|
|
234
252
|
params: AuthRefreshParams,
|
|
235
253
|
): Promise<AuthRefreshResult> {
|
|
236
254
|
const baseUrl = opts.baseUrl.replace(/\/+$/, "");
|
|
@@ -238,6 +256,31 @@ export async function authRefresh(
|
|
|
238
256
|
if (!params.refreshToken?.trim()) {
|
|
239
257
|
return { kind: "permanent", code: CODE_INVALID_REFRESH, message: "missing refresh token" };
|
|
240
258
|
}
|
|
259
|
+
const timeoutMs = opts.timeoutMs ?? REFRESH_REQUEST_TIMEOUT_MS;
|
|
260
|
+
const controller = new AbortController();
|
|
261
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
262
|
+
const deadline = new Promise<AuthRefreshResult>((resolve) => {
|
|
263
|
+
timer = setTimeout(() => {
|
|
264
|
+
controller.abort(new Error("refresh request timed out"));
|
|
265
|
+
resolve({ kind: "transient", message: `refresh request timed out after ${timeoutMs}ms` });
|
|
266
|
+
}, timeoutMs);
|
|
267
|
+
});
|
|
268
|
+
try {
|
|
269
|
+
return await Promise.race([
|
|
270
|
+
authRefreshAttempt(baseUrl, fetchImpl, params, controller.signal),
|
|
271
|
+
deadline,
|
|
272
|
+
]);
|
|
273
|
+
} finally {
|
|
274
|
+
clearTimeout(timer);
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
async function authRefreshAttempt(
|
|
279
|
+
baseUrl: string,
|
|
280
|
+
fetchImpl: typeof fetch,
|
|
281
|
+
params: AuthRefreshParams,
|
|
282
|
+
signal: AbortSignal,
|
|
283
|
+
): Promise<AuthRefreshResult> {
|
|
241
284
|
let res: Response;
|
|
242
285
|
try {
|
|
243
286
|
res = await fetchImpl(`${baseUrl}/v1/auth/refresh`, {
|
|
@@ -248,6 +291,8 @@ export async function authRefresh(
|
|
|
248
291
|
"x-device-id": params.deviceId,
|
|
249
292
|
},
|
|
250
293
|
body: JSON.stringify({ refresh_token: params.refreshToken.trim() }),
|
|
294
|
+
// Still in effect while the body is read below.
|
|
295
|
+
signal,
|
|
251
296
|
});
|
|
252
297
|
} catch (err) {
|
|
253
298
|
// Network error / timeout / DNS — TRANSIENT (no rotation committed).
|
|
@@ -292,8 +337,9 @@ export async function authRefresh(
|
|
|
292
337
|
const refreshToken = typeof data.refresh_token === "string" ? data.refresh_token : "";
|
|
293
338
|
if (!accessToken || !refreshToken) {
|
|
294
339
|
// Rotation succeeded server-side but the body is malformed — transient so
|
|
295
|
-
// we retry; the
|
|
296
|
-
//
|
|
340
|
+
// we retry; inside the backend grace window the retry redeems the old
|
|
341
|
+
// token again, after it the retry returns 10003 and escalates to
|
|
342
|
+
// permanent (§B transient→permanent).
|
|
297
343
|
return { kind: "transient", status: 200, message: "refresh: rotation body incomplete" };
|
|
298
344
|
}
|
|
299
345
|
return { kind: "success", accessToken, refreshToken };
|
|
@@ -639,6 +685,17 @@ export function createOpenclawClawlingApiClient(opts: ApiClientOptions): Opencla
|
|
|
639
685
|
`/v1/moments/${encodeURIComponent(String(params.momentId))}/comments/${encodeURIComponent(String(params.commentId))}`,
|
|
640
686
|
);
|
|
641
687
|
},
|
|
688
|
+
async getDirectConversation(peerId): Promise<{ conversation: { id: string; type: string } }> {
|
|
689
|
+
assertNonBlankId(peerId, "getDirectConversation: peerId");
|
|
690
|
+
return await call<{ conversation: { id: string; type: string } }>(
|
|
691
|
+
"POST",
|
|
692
|
+
"/v1/conversations/direct",
|
|
693
|
+
{
|
|
694
|
+
body: JSON.stringify({ peer_id: peerId.trim() }),
|
|
695
|
+
headers: { "content-type": "application/json" },
|
|
696
|
+
},
|
|
697
|
+
);
|
|
698
|
+
},
|
|
642
699
|
async getConversation(conversationId): Promise<{ conversation: ConversationDetails }> {
|
|
643
700
|
return await call<{ conversation: ConversationDetails }>(
|
|
644
701
|
"GET",
|
package/src/config.ts
CHANGED
|
@@ -114,6 +114,8 @@ export type OpenclawClawlingAccountConfig = {
|
|
|
114
114
|
richInteractions?: boolean;
|
|
115
115
|
/** Emit ONE consolidated awareness note to the agent when friend/conversation signals arrive. */
|
|
116
116
|
awarenessNote?: boolean;
|
|
117
|
+
/** Speak first to a newly added non-owner friend (default true). */
|
|
118
|
+
friendGreeting?: boolean;
|
|
117
119
|
/** Auto-install the Liveware Sample demo app when no liveware app is registered (default true). */
|
|
118
120
|
livewareSample?: boolean;
|
|
119
121
|
reconnect?: OpenclawClawlingReconnectConfig;
|
|
@@ -174,6 +176,7 @@ export const openclawClawlingAccountConfigSchema = {
|
|
|
174
176
|
forwardToolCalls: { type: "boolean" },
|
|
175
177
|
richInteractions: { type: "boolean" },
|
|
176
178
|
awarenessNote: { type: "boolean" },
|
|
179
|
+
friendGreeting: { type: "boolean" },
|
|
177
180
|
livewareSample: { type: "boolean" },
|
|
178
181
|
reconnect: {
|
|
179
182
|
type: "object",
|
|
@@ -311,6 +314,7 @@ export type ResolvedOpenclawClawlingAccount = {
|
|
|
311
314
|
forwardToolCalls: boolean;
|
|
312
315
|
richInteractions: boolean;
|
|
313
316
|
awarenessNote: boolean;
|
|
317
|
+
friendGreeting: boolean;
|
|
314
318
|
livewareSample: boolean;
|
|
315
319
|
allowFrom: string[];
|
|
316
320
|
reconnect: Required<OpenclawClawlingReconnectConfig>;
|
|
@@ -649,6 +653,8 @@ export function resolveOpenclawClawlingAccount(
|
|
|
649
653
|
typeof channel.richInteractions === "boolean" ? channel.richInteractions : false;
|
|
650
654
|
const awarenessNote =
|
|
651
655
|
typeof channel.awarenessNote === "boolean" ? channel.awarenessNote : false;
|
|
656
|
+
const friendGreeting =
|
|
657
|
+
typeof channel.friendGreeting === "boolean" ? channel.friendGreeting : true;
|
|
652
658
|
const livewareSample =
|
|
653
659
|
typeof channel.livewareSample === "boolean" ? channel.livewareSample : true;
|
|
654
660
|
|
|
@@ -678,6 +684,7 @@ export function resolveOpenclawClawlingAccount(
|
|
|
678
684
|
forwardToolCalls,
|
|
679
685
|
richInteractions,
|
|
680
686
|
awarenessNote,
|
|
687
|
+
friendGreeting,
|
|
681
688
|
livewareSample,
|
|
682
689
|
allowFrom: [],
|
|
683
690
|
reconnect: readReconnect(channel.reconnect),
|
|
@@ -0,0 +1,91 @@
|
|
|
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, type Envelope } from "./protocol-types.ts";
|
|
19
|
+
import type { ResolvedOpenclawClawlingAccount } from "./config.ts";
|
|
20
|
+
|
|
21
|
+
export const FRIEND_GREETING_FALLBACK = [
|
|
22
|
+
"A ClawChat user has just become your friend. You are now in a direct conversation with them; they are not your owner.",
|
|
23
|
+
"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.",
|
|
24
|
+
"Send it as a normal chat reply. Do not write or create any files or notes, and do not call tools just to greet.",
|
|
25
|
+
"Do not share your owner's private information, and do not ask the user for personal information.",
|
|
26
|
+
].join("\n");
|
|
27
|
+
|
|
28
|
+
// Cross-plugin, user-editable override read lazily so edits apply on the next
|
|
29
|
+
// friend without a restart. Any read failure falls back to the built-in text.
|
|
30
|
+
// Mirrors `buildActivationBootstrapText` (`~/clawchat/greeting.md`).
|
|
31
|
+
export function buildFriendGreetingText(homeDir: string = os.homedir()): string {
|
|
32
|
+
const greetingPath = path.join(homeDir, "clawchat", "friend-greeting.md");
|
|
33
|
+
try {
|
|
34
|
+
const raw = fs.readFileSync(greetingPath);
|
|
35
|
+
const override = new TextDecoder("utf-8", { fatal: true }).decode(raw).trim();
|
|
36
|
+
if (override.length > 0) {
|
|
37
|
+
return override;
|
|
38
|
+
}
|
|
39
|
+
} catch (error) {
|
|
40
|
+
const code = (error as NodeJS.ErrnoException).code;
|
|
41
|
+
if (code !== "ENOENT") {
|
|
42
|
+
console.warn(`clawchat.friend-greeting failed to read override ${greetingPath}:`, error);
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
return FRIEND_GREETING_FALLBACK;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export interface BuildFriendGreetingEnvelopeParams {
|
|
49
|
+
account: ResolvedOpenclawClawlingAccount;
|
|
50
|
+
/** The resolved direct conversation (`cnv_…`) shared with the new friend. */
|
|
51
|
+
conversationId: string;
|
|
52
|
+
/** The new friend's `usr_…` id — becomes the sender so the turn's session and sender metadata resolve to them. */
|
|
53
|
+
friendUserId: string;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Synthetic inbound envelope for the friend greeting turn. Same invariant as
|
|
58
|
+
* `buildActivationBootstrapEnvelope`: `conversationId` MUST be a conversation
|
|
59
|
+
* idcode — the agent's reply inherits this chat_id, and anything else is
|
|
60
|
+
* refused at the outbound boundary after a full LLM turn has been spent.
|
|
61
|
+
*/
|
|
62
|
+
export function buildFriendGreetingEnvelope(params: BuildFriendGreetingEnvelopeParams): Envelope {
|
|
63
|
+
const { account, conversationId, friendUserId } = params;
|
|
64
|
+
const text = buildFriendGreetingText();
|
|
65
|
+
const now = Date.now();
|
|
66
|
+
return {
|
|
67
|
+
version: "2",
|
|
68
|
+
event: EVENT.MESSAGE_SEND,
|
|
69
|
+
trace_id: `clawchat-plugin-openclaw-friend-greeting-${now}`,
|
|
70
|
+
emitted_at: now,
|
|
71
|
+
chat_id: conversationId,
|
|
72
|
+
chat_type: "direct",
|
|
73
|
+
to: { id: account.userId, type: "direct" },
|
|
74
|
+
sender: { id: friendUserId, type: "direct", nick_name: "" },
|
|
75
|
+
payload: {
|
|
76
|
+
message_id: `clawchat-plugin-openclaw-friend-greeting-${conversationId}-${now}`,
|
|
77
|
+
message_mode: "normal",
|
|
78
|
+
message: {
|
|
79
|
+
body: { fragments: [{ kind: "text", text }] },
|
|
80
|
+
context: { mentions: [], reply: null },
|
|
81
|
+
streaming: {
|
|
82
|
+
status: "static",
|
|
83
|
+
sequence: 0,
|
|
84
|
+
mutation_policy: "sealed",
|
|
85
|
+
started_at: null,
|
|
86
|
+
completed_at: null,
|
|
87
|
+
},
|
|
88
|
+
},
|
|
89
|
+
},
|
|
90
|
+
} as unknown as Envelope;
|
|
91
|
+
}
|
package/src/protocol-types.ts
CHANGED
|
@@ -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",
|
package/src/refresh-manager.ts
CHANGED
|
@@ -30,6 +30,15 @@ const HOUR_MS = 60 * MINUTE_MS;
|
|
|
30
30
|
export const MIN_REFRESH_INTERVAL_MS = 30_000;
|
|
31
31
|
/** §A.1 — proactive jitter (±5min). */
|
|
32
32
|
export const PROACTIVE_JITTER_MS = 5 * MINUTE_MS;
|
|
33
|
+
/**
|
|
34
|
+
* §B retry within the grace window — jitter bounds for the one-shot retry after
|
|
35
|
+
* a transient proactive refresh. The retry is due `MIN_REFRESH_INTERVAL_MS` +
|
|
36
|
+
* [1s, 5s] after the failed attempt BEGAN (31–35s): far above the backend's
|
|
37
|
+
* minimum replay age, inside its 90s replay window, and never faster than the
|
|
38
|
+
* min-interval floor (which would skip it).
|
|
39
|
+
*/
|
|
40
|
+
export const PROACTIVE_RETRY_JITTER_MIN_MS = 1_000;
|
|
41
|
+
export const PROACTIVE_RETRY_JITTER_MAX_MS = 5_000;
|
|
33
42
|
/** §A.0 — fallback access-token TTL when `exp` is unparseable. */
|
|
34
43
|
export const ACCESS_TOKEN_TTL_MS = 24 * HOUR_MS;
|
|
35
44
|
/**
|
|
@@ -86,6 +95,8 @@ export interface RefreshManagerPorts {
|
|
|
86
95
|
clearTimer?: (handle: TimerHandle) => void;
|
|
87
96
|
/** Test override for jitter in [-PROACTIVE_JITTER_MS, +PROACTIVE_JITTER_MS]. */
|
|
88
97
|
jitter?: () => number;
|
|
98
|
+
/** Test override for the proactive-retry jitter (clamped to [1s, 5s]). */
|
|
99
|
+
proactiveRetryJitter?: () => number;
|
|
89
100
|
log?: { debug?: (m: string) => void; info?: (m: string) => void; error?: (m: string) => void };
|
|
90
101
|
}
|
|
91
102
|
|
|
@@ -136,6 +147,8 @@ export class RefreshManager {
|
|
|
136
147
|
/** §A.3 — epoch-ms of the last refresh attempt (any token). */
|
|
137
148
|
private lastAttemptAt = 0;
|
|
138
149
|
private proactiveTimer: TimerHandle | null = null;
|
|
150
|
+
/** §B — the pending one-shot retry after a transient proactive refresh. */
|
|
151
|
+
private proactiveRetryTimer: TimerHandle | null = null;
|
|
139
152
|
private stopped = false;
|
|
140
153
|
|
|
141
154
|
constructor(private readonly ports: RefreshManagerPorts) {}
|
|
@@ -214,9 +227,10 @@ export class RefreshManager {
|
|
|
214
227
|
// sqlite-sourced agent must not keep a now-dead refresh token in its row
|
|
215
228
|
// while running on the rotated token. Treat as transient so the WS stays
|
|
216
229
|
// in backoff with the CURRENT tokens and the next attempt retries. The
|
|
217
|
-
// server already rotated
|
|
218
|
-
//
|
|
219
|
-
//
|
|
230
|
+
// server already rotated: inside the backend grace window the retry
|
|
231
|
+
// redeems the old token again; after it the retry returns `code:10003`
|
|
232
|
+
// (which escalates to permanent per §B) — the accepted hazard, not a
|
|
233
|
+
// silent brick.
|
|
220
234
|
try {
|
|
221
235
|
await this.ports.persistRotatedTokens({
|
|
222
236
|
accessToken: result.accessToken,
|
|
@@ -243,8 +257,9 @@ export class RefreshManager {
|
|
|
243
257
|
}
|
|
244
258
|
|
|
245
259
|
if (result.kind === "permanent") {
|
|
246
|
-
// §B race — a `code:10003` is also returned for a
|
|
247
|
-
//
|
|
260
|
+
// §B race — a `code:10003` is also returned for a refresh token already
|
|
261
|
+
// CONSUMED by a prior successful rotation (once the backend grace window
|
|
262
|
+
// no longer covers it). Before auto-logging-out
|
|
248
263
|
// (which wipes credentials and bricks the agent), distinguish that race
|
|
249
264
|
// from a genuine revocation. It is a race when EITHER the submitted token
|
|
250
265
|
// is one we already rotated away from, OR the live store refresh token has
|
|
@@ -331,12 +346,24 @@ export class RefreshManager {
|
|
|
331
346
|
* success, hands the rotated token to the runtime's `onProactiveRefreshed` port
|
|
332
347
|
* so the live WS is closed and reconnected with the new token (the in-memory
|
|
333
348
|
* swap alone does NOT reach the running socket, which captured the old token at
|
|
334
|
-
* `connect` time). Transient/skipped outcomes leave the WS untouched
|
|
335
|
-
*
|
|
349
|
+
* `connect` time). Transient/skipped outcomes leave the WS untouched.
|
|
350
|
+
*
|
|
351
|
+
* §B retry within the grace window — a TRANSIENT outcome (e.g. the attempt
|
|
352
|
+
* deadline hit after the server may already have rotated) arms ONE retry, due
|
|
353
|
+
* 31–35s after that attempt began, so a replay of the same refresh token lands
|
|
354
|
+
* inside the backend grace window instead of waiting for the next arm /
|
|
355
|
+
* hello-fail / 401, possibly hours later. The retry itself never arms another.
|
|
356
|
+
* Success, permanent and skipped outcomes arm nothing (skipped means another
|
|
357
|
+
* in-flight attempt or a latch already owns the token).
|
|
336
358
|
*/
|
|
337
|
-
private async runProactiveRefresh(): Promise<void> {
|
|
338
|
-
const
|
|
359
|
+
private async runProactiveRefresh(isRetry = false): Promise<void> {
|
|
360
|
+
const accessTokenAtAttempt = this.ports.getAccessToken();
|
|
361
|
+
const outcome = await this.refresh(isRetry ? "proactive-retry" : "proactive-timer");
|
|
339
362
|
if (this.stopped) return;
|
|
363
|
+
if (outcome.kind === "transient") {
|
|
364
|
+
if (!isRetry) this.armProactiveRetry(accessTokenAtAttempt);
|
|
365
|
+
return;
|
|
366
|
+
}
|
|
340
367
|
if (outcome.kind !== "success") return;
|
|
341
368
|
if (this.ports.onProactiveRefreshed) {
|
|
342
369
|
try {
|
|
@@ -352,6 +379,50 @@ export class RefreshManager {
|
|
|
352
379
|
}
|
|
353
380
|
}
|
|
354
381
|
|
|
382
|
+
/**
|
|
383
|
+
* §B — arm the one-shot retry, measured from the failed attempt's start
|
|
384
|
+
* (`lastAttemptAt`). It runs through `refresh()`, so single-flight dedupe, the
|
|
385
|
+
* rejected-token latch and the min-interval floor all still apply. It is
|
|
386
|
+
* skipped when the access token changed meanwhile (someone else rotated).
|
|
387
|
+
*/
|
|
388
|
+
private armProactiveRetry(accessTokenAtAttempt: string): void {
|
|
389
|
+
this.clearProactiveRetry();
|
|
390
|
+
const rawJitter = (this.ports.proactiveRetryJitter ?? defaultProactiveRetryJitter)();
|
|
391
|
+
const jitterMs = Math.min(
|
|
392
|
+
PROACTIVE_RETRY_JITTER_MAX_MS,
|
|
393
|
+
Math.max(PROACTIVE_RETRY_JITTER_MIN_MS, rawJitter),
|
|
394
|
+
);
|
|
395
|
+
const dueAtMs = this.lastAttemptAt + MIN_REFRESH_INTERVAL_MS + jitterMs;
|
|
396
|
+
const delayMs = Math.max(0, dueAtMs - this.now());
|
|
397
|
+
this.ports.log?.info?.(
|
|
398
|
+
`clawchat-plugin-openclaw proactive refresh transient; one-shot retry in ${delayMs}ms`,
|
|
399
|
+
);
|
|
400
|
+
this.proactiveRetryTimer = this.setTimer(() => {
|
|
401
|
+
this.proactiveRetryTimer = null;
|
|
402
|
+
if (this.stopped) return;
|
|
403
|
+
if (this.ports.getAccessToken() !== accessTokenAtAttempt) {
|
|
404
|
+
this.ports.log?.debug?.(
|
|
405
|
+
"clawchat-plugin-openclaw proactive retry skipped (access token already changed)",
|
|
406
|
+
);
|
|
407
|
+
return;
|
|
408
|
+
}
|
|
409
|
+
void this.runProactiveRefresh(true);
|
|
410
|
+
}, delayMs);
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
private clearProactiveRetry(): void {
|
|
414
|
+
if (this.proactiveRetryTimer != null) {
|
|
415
|
+
this.clearTimer(this.proactiveRetryTimer);
|
|
416
|
+
this.proactiveRetryTimer = null;
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
/**
|
|
421
|
+
* Clear the proactive `refresh_at` timer (called on WS disconnect). The §B
|
|
422
|
+
* one-shot retry is deliberately NOT cleared here: a refresh timeout usually
|
|
423
|
+
* coincides with the network drop that also closes the socket, and refresh
|
|
424
|
+
* does not need the socket. `stop()` clears both.
|
|
425
|
+
*/
|
|
355
426
|
disarmProactiveTimer(): void {
|
|
356
427
|
if (this.proactiveTimer != null) {
|
|
357
428
|
this.clearTimer(this.proactiveTimer);
|
|
@@ -377,10 +448,11 @@ export class RefreshManager {
|
|
|
377
448
|
return this.now() >= refreshAtMs;
|
|
378
449
|
}
|
|
379
450
|
|
|
380
|
-
/** Stop the manager — clears the proactive
|
|
451
|
+
/** Stop the manager — clears the proactive + retry timers; nothing further arms. */
|
|
381
452
|
stop(): void {
|
|
382
453
|
this.stopped = true;
|
|
383
454
|
this.disarmProactiveTimer();
|
|
455
|
+
this.clearProactiveRetry();
|
|
384
456
|
}
|
|
385
457
|
|
|
386
458
|
/** Test/inspection seam — the latched (rejected) access token, if any. */
|
|
@@ -435,3 +507,10 @@ function decodeJwtIat(token: string): number | null {
|
|
|
435
507
|
function defaultJitter(): number {
|
|
436
508
|
return (Math.random() * 2 - 1) * PROACTIVE_JITTER_MS;
|
|
437
509
|
}
|
|
510
|
+
|
|
511
|
+
function defaultProactiveRetryJitter(): number {
|
|
512
|
+
return (
|
|
513
|
+
PROACTIVE_RETRY_JITTER_MIN_MS +
|
|
514
|
+
Math.random() * (PROACTIVE_RETRY_JITTER_MAX_MS - PROACTIVE_RETRY_JITTER_MIN_MS)
|
|
515
|
+
);
|
|
516
|
+
}
|