@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.
@@ -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 single-use refresh token that was already CONSUMED by a prior
46
- * successful rotation (a duplicate-supervisor / concurrent-refresh / stale-store
47
- * race). The stateless `authRefresh` cannot tell the two apart, so it reports
48
- * `permanent`; the stateful `RefreshManager` re-classifies a consumed-rotation
49
- * race back to transient (see §B race) before any auto-logout.
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 next attempt will return 10003 (rotation single-use) and
121
- // escalate to permanent (§B transient→permanent).
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
  },
@@ -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, so the next attempt may return `code:10003`
137
- // (which escalates to permanent per §B) — that is the accepted hazard, not
138
- // a silent brick.
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 single-use refresh token
164
- // already CONSUMED by a prior successful rotation. Before auto-logging-out
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 — the next
238
- * proactive arm (or a reactive hello-fail) handles it.
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 outcome = await this.refresh("proactive-timer");
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 timer; no further refreshes arm. */
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
+ }
@@ -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";
@@ -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.8.0";
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`. */
@@ -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 {
@@ -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
  }