@clawling/clawchat-plugin-openclaw 2026.10.8-2 → 2026.10.9-2
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/connect-check.js +8 -1
- package/dist/src/connect-failure.js +78 -0
- package/dist/src/dm-recall-default.js +179 -0
- package/dist/src/login.runtime.js +31 -4
- package/dist/src/runtime.js +43 -0
- package/package.json +1 -1
- package/src/connect-check.ts +8 -1
- package/src/connect-failure.ts +109 -0
- package/src/dm-recall-default.ts +230 -0
- package/src/login.runtime.ts +36 -4
- package/src/runtime.ts +42 -0
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { ClawlingApiError } from "./api-types.js";
|
|
2
|
+
import { readConnectFailureGuidance } from "./connect-failure.js";
|
|
2
3
|
import { AGENTS_CONNECT_PLATFORM, RECONNECT_GUIDE_URL } from "./onboarding-context.js";
|
|
3
4
|
/**
|
|
4
5
|
* Non-consuming pre-check of a connect code. Records the "checked" funnel
|
|
@@ -29,8 +30,14 @@ export async function preCheckConnectCode(client, input, log) {
|
|
|
29
30
|
};
|
|
30
31
|
}
|
|
31
32
|
catch (err) {
|
|
33
|
+
// Still a degrade, never a gate: `/connect` re-evaluates the code and
|
|
34
|
+
// surfaces the same guidance on the error it throws. The guidance is only
|
|
35
|
+
// logged here so a rate-limit or dead code is visible early.
|
|
32
36
|
const kind = err instanceof ClawlingApiError ? err.kind : "error";
|
|
33
|
-
|
|
37
|
+
const guidance = err instanceof ClawlingApiError ? readConnectFailureGuidance(err.meta?.data) : {};
|
|
38
|
+
const action = guidance.nextAction ? `; next_action: ${guidance.nextAction}` : "";
|
|
39
|
+
const hint = guidance.hint ? `. ${guidance.hint}` : "";
|
|
40
|
+
log(`Connect-code pre-check unavailable (${kind}${action})${hint}; continuing without it.`);
|
|
34
41
|
return null;
|
|
35
42
|
}
|
|
36
43
|
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
/** Default wait for `wait_retry` when the server omits / garbles the delay. */
|
|
2
|
+
export const WAIT_RETRY_DEFAULT_SECONDS = 60;
|
|
3
|
+
/** Upper bound on any `wait_retry` wait, whatever the server asks for. */
|
|
4
|
+
export const WAIT_RETRY_MAX_SECONDS = 120;
|
|
5
|
+
function readNonBlankString(value) {
|
|
6
|
+
if (typeof value !== "string")
|
|
7
|
+
return undefined;
|
|
8
|
+
const trimmed = value.trim();
|
|
9
|
+
return trimmed ? trimmed : undefined;
|
|
10
|
+
}
|
|
11
|
+
export function readConnectFailureGuidance(data) {
|
|
12
|
+
if (!data || typeof data !== "object" || Array.isArray(data))
|
|
13
|
+
return {};
|
|
14
|
+
const record = data;
|
|
15
|
+
const out = {};
|
|
16
|
+
const hint = readNonBlankString(record.hint);
|
|
17
|
+
if (hint)
|
|
18
|
+
out.hint = hint;
|
|
19
|
+
const nextAction = readNonBlankString(record.next_action);
|
|
20
|
+
if (nextAction)
|
|
21
|
+
out.nextAction = nextAction;
|
|
22
|
+
const retryAfter = record.retry_after_seconds;
|
|
23
|
+
if (typeof retryAfter === "number" && Number.isFinite(retryAfter)) {
|
|
24
|
+
out.retryAfterSeconds = retryAfter;
|
|
25
|
+
}
|
|
26
|
+
return out;
|
|
27
|
+
}
|
|
28
|
+
/** Delay before the single `wait_retry` retry: server value, capped, with a default. */
|
|
29
|
+
export function waitRetryDelayMs(retryAfterSeconds) {
|
|
30
|
+
const seconds = typeof retryAfterSeconds === "number" && Number.isFinite(retryAfterSeconds) && retryAfterSeconds > 0
|
|
31
|
+
? Math.min(retryAfterSeconds, WAIT_RETRY_MAX_SECONDS)
|
|
32
|
+
: WAIT_RETRY_DEFAULT_SECONDS;
|
|
33
|
+
return Math.round(seconds * 1000);
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* Error thrown by the invite-code login when `/v1/agents/connect` fails.
|
|
37
|
+
* The message keeps the historical `agents/connect failed (<kind>): <msg>`
|
|
38
|
+
* prefix; when the server sent guidance it is appended so an agent reading
|
|
39
|
+
* the CLI / chat-command output knows what to do next.
|
|
40
|
+
*/
|
|
41
|
+
export class ConnectFailedError extends Error {
|
|
42
|
+
kind;
|
|
43
|
+
code;
|
|
44
|
+
status;
|
|
45
|
+
hint;
|
|
46
|
+
nextAction;
|
|
47
|
+
retryAfterSeconds;
|
|
48
|
+
constructor(kind, serverMessage, meta = {}) {
|
|
49
|
+
super(formatConnectFailureMessage(kind, serverMessage, meta));
|
|
50
|
+
this.name = "ConnectFailedError";
|
|
51
|
+
this.kind = kind;
|
|
52
|
+
if (meta.code !== undefined)
|
|
53
|
+
this.code = meta.code;
|
|
54
|
+
if (meta.status !== undefined)
|
|
55
|
+
this.status = meta.status;
|
|
56
|
+
if (meta.hint !== undefined)
|
|
57
|
+
this.hint = meta.hint;
|
|
58
|
+
if (meta.nextAction !== undefined)
|
|
59
|
+
this.nextAction = meta.nextAction;
|
|
60
|
+
if (meta.retryAfterSeconds !== undefined)
|
|
61
|
+
this.retryAfterSeconds = meta.retryAfterSeconds;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
function formatConnectFailureMessage(kind, serverMessage, guidance) {
|
|
65
|
+
let text = `agents/connect failed (${kind}): ${serverMessage}`;
|
|
66
|
+
if (guidance.nextAction)
|
|
67
|
+
text += ` (next_action: ${guidance.nextAction})`;
|
|
68
|
+
if (guidance.hint)
|
|
69
|
+
text += `. ${guidance.hint}`;
|
|
70
|
+
return text;
|
|
71
|
+
}
|
|
72
|
+
export function connectFailedError(err) {
|
|
73
|
+
return new ConnectFailedError(err.kind, err.message, {
|
|
74
|
+
...(err.meta?.code !== undefined ? { code: err.meta.code } : {}),
|
|
75
|
+
...(err.meta?.status !== undefined ? { status: err.meta.status } : {}),
|
|
76
|
+
...readConnectFailureGuidance(err.meta?.data),
|
|
77
|
+
});
|
|
78
|
+
}
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Turn off OpenClaw's cross-conversation recall for the agent ClawChat routes
|
|
3
|
+
* to, once, unless the owner already decided.
|
|
4
|
+
*
|
|
5
|
+
* OpenClaw >= 2026.8.1 has `memory.search.rememberAcrossConversations`. When it
|
|
6
|
+
* is not written anywhere, OpenClaw turns it on unless `session.dmScope` or a
|
|
7
|
+
* binding isolates DMs (`resolveRememberAcrossConversations`,
|
|
8
|
+
* packages/memory-host-sdk/src/host/config-utils.ts). The plugin isolates
|
|
9
|
+
* ClawChat DMs only in memory (`withClawChatSessionScope`), which OpenClaw
|
|
10
|
+
* cannot see, so recall stays on and a friend's DM can recall the owner's.
|
|
11
|
+
* Active Memory reads the live gateway config each turn, so a per-turn
|
|
12
|
+
* override does not reach it: the setting has to be in the config file.
|
|
13
|
+
*
|
|
14
|
+
* Where it is read (2026.8.1 / 2026.9.x): the agent's `agents.entries.<id>`
|
|
15
|
+
* or, when the roster is the legacy array, its `agents.list[]` entry (matched
|
|
16
|
+
* by `id`); then the global `memory.search`. With no roster in the file there
|
|
17
|
+
* is one implicit agent, so the global layer IS that agent's.
|
|
18
|
+
*
|
|
19
|
+
* Rules (relay openclaw-dm-recall, 10-08):
|
|
20
|
+
* - only on hosts >= 2026.8.1 (older ones have neither the key nor the recall);
|
|
21
|
+
* - only when neither layer sets it; an explicit `true` is the owner's choice:
|
|
22
|
+
* kept, warned once; an explicit `false` is kept silently;
|
|
23
|
+
* - only once per agent: a marker in the plugin's state dir records it, so
|
|
24
|
+
* whatever the owner changes later is never touched again.
|
|
25
|
+
*/
|
|
26
|
+
import fs from "node:fs";
|
|
27
|
+
import path from "node:path";
|
|
28
|
+
export const DM_RECALL_MARKER_FILENAME = "clawchat-dm-recall-default.json";
|
|
29
|
+
export const DM_RECALL_MIN_HOST = [2026, 8, 1];
|
|
30
|
+
function isRec(value) {
|
|
31
|
+
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
|
|
32
|
+
}
|
|
33
|
+
function normalizeId(id) {
|
|
34
|
+
return typeof id === "string" ? id.trim().toLowerCase() : "";
|
|
35
|
+
}
|
|
36
|
+
/** True for a release host >= 2026.8.1. Pre-releases and unknown strings are false. */
|
|
37
|
+
export function hostHasRememberAcrossConversations(version) {
|
|
38
|
+
const m = /^v?(\d+)\.(\d+)\.(\d+)$/.exec((version ?? "").trim());
|
|
39
|
+
if (!m)
|
|
40
|
+
return false;
|
|
41
|
+
const parts = [Number(m[1]), Number(m[2]), Number(m[3])];
|
|
42
|
+
for (let i = 0; i < 3; i += 1) {
|
|
43
|
+
if (parts[i] !== DM_RECALL_MIN_HOST[i])
|
|
44
|
+
return parts[i] > DM_RECALL_MIN_HOST[i];
|
|
45
|
+
}
|
|
46
|
+
return true;
|
|
47
|
+
}
|
|
48
|
+
function findAgentSlot(cfg, agentId) {
|
|
49
|
+
const agents = isRec(cfg.agents) ? cfg.agents : undefined;
|
|
50
|
+
const id = normalizeId(agentId);
|
|
51
|
+
if (agents && Object.hasOwn(agents, "entries") && agents.entries !== undefined) {
|
|
52
|
+
if (!isRec(agents.entries))
|
|
53
|
+
return { kind: "missing" };
|
|
54
|
+
for (const key of Object.keys(agents.entries)) {
|
|
55
|
+
const entry = agents.entries[key];
|
|
56
|
+
if (normalizeId(key) === id && isRec(entry))
|
|
57
|
+
return { kind: "entries", key, entry };
|
|
58
|
+
}
|
|
59
|
+
return { kind: "missing" };
|
|
60
|
+
}
|
|
61
|
+
if (agents && Object.hasOwn(agents, "list") && agents.list !== undefined) {
|
|
62
|
+
if (!Array.isArray(agents.list))
|
|
63
|
+
return { kind: "missing" };
|
|
64
|
+
const index = agents.list.findIndex((e) => isRec(e) && normalizeId(e.id) === id);
|
|
65
|
+
return index >= 0 ? { kind: "list", index, entry: agents.list[index] } : { kind: "missing" };
|
|
66
|
+
}
|
|
67
|
+
return { kind: "no-roster" };
|
|
68
|
+
}
|
|
69
|
+
function readFlag(holder) {
|
|
70
|
+
if (!isRec(holder) || !isRec(holder.memory) || !isRec(holder.memory.search))
|
|
71
|
+
return undefined;
|
|
72
|
+
const v = holder.memory.search.rememberAcrossConversations;
|
|
73
|
+
return typeof v === "boolean" ? v : undefined;
|
|
74
|
+
}
|
|
75
|
+
/** The value written at each layer, `undefined` where it is not written. */
|
|
76
|
+
export function readRememberAcrossConversations(cfg, agentId) {
|
|
77
|
+
const slot = findAgentSlot(cfg, agentId);
|
|
78
|
+
const agent = slot.kind === "entries" || slot.kind === "list" ? readFlag(slot.entry) : undefined;
|
|
79
|
+
return { agent, global: readFlag(cfg) };
|
|
80
|
+
}
|
|
81
|
+
function setFlagFalse(holder) {
|
|
82
|
+
const memory = isRec(holder.memory) ? holder.memory : {};
|
|
83
|
+
const search = isRec(memory.search) ? memory.search : {};
|
|
84
|
+
holder.memory = { ...memory, search: { ...search, rememberAcrossConversations: false } };
|
|
85
|
+
}
|
|
86
|
+
/**
|
|
87
|
+
* Write `rememberAcrossConversations: false` at the agent's own layer of
|
|
88
|
+
* `cfg` (mutated in place). Returns the dotted path written, or `null` when a
|
|
89
|
+
* roster exists but does not list the agent (nothing written).
|
|
90
|
+
*/
|
|
91
|
+
export function setAgentRememberAcrossConversationsFalse(cfg, agentId) {
|
|
92
|
+
const slot = findAgentSlot(cfg, agentId);
|
|
93
|
+
if (slot.kind === "entries") {
|
|
94
|
+
setFlagFalse(slot.entry);
|
|
95
|
+
return `agents.entries.${slot.key}.memory.search`;
|
|
96
|
+
}
|
|
97
|
+
if (slot.kind === "list") {
|
|
98
|
+
setFlagFalse(slot.entry);
|
|
99
|
+
return `agents.list[${slot.index}].memory.search`;
|
|
100
|
+
}
|
|
101
|
+
if (slot.kind === "no-roster") {
|
|
102
|
+
setFlagFalse(cfg);
|
|
103
|
+
return "memory.search";
|
|
104
|
+
}
|
|
105
|
+
return null;
|
|
106
|
+
}
|
|
107
|
+
function readMarker(file) {
|
|
108
|
+
try {
|
|
109
|
+
const parsed = JSON.parse(fs.readFileSync(file, "utf8"));
|
|
110
|
+
return isRec(parsed) ? parsed : {};
|
|
111
|
+
}
|
|
112
|
+
catch {
|
|
113
|
+
return {};
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
function writeMarker(file, agentId, outcome) {
|
|
117
|
+
const marker = readMarker(file);
|
|
118
|
+
const agents = isRec(marker.agents) ? marker.agents : {};
|
|
119
|
+
agents[normalizeId(agentId)] = { outcome, at: new Date().toISOString() };
|
|
120
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
121
|
+
fs.writeFileSync(file, `${JSON.stringify({ ...marker, agents }, null, 2)}\n`);
|
|
122
|
+
}
|
|
123
|
+
/** Apply the one-time default. Never throws. */
|
|
124
|
+
export async function applyDmRecallDefault(params) {
|
|
125
|
+
const { agentId, log } = params;
|
|
126
|
+
const markerFile = path.join(params.stateDir, DM_RECALL_MARKER_FILENAME);
|
|
127
|
+
try {
|
|
128
|
+
if (readMarker(markerFile).agents?.[normalizeId(agentId)])
|
|
129
|
+
return "already-handled";
|
|
130
|
+
if (!hostHasRememberAcrossConversations(params.hostVersion))
|
|
131
|
+
return "unsupported-host";
|
|
132
|
+
const cfg = params.readConfig();
|
|
133
|
+
const current = readRememberAcrossConversations(isRec(cfg) ? cfg : {}, agentId);
|
|
134
|
+
const explicit = current.agent ?? current.global;
|
|
135
|
+
if (explicit === true) {
|
|
136
|
+
log?.warn?.(`clawchat-plugin-openclaw: memory.search.rememberAcrossConversations is true for agent '${agentId}', so a ` +
|
|
137
|
+
"friend's ClawChat direct chat can recall what the owner said in theirs. Kept as set. To turn it off: " +
|
|
138
|
+
`openclaw config set memory.search.rememberAcrossConversations false (or set it to false on agent '${agentId}'), then restart the gateway.`);
|
|
139
|
+
writeMarker(markerFile, agentId, "kept-true");
|
|
140
|
+
return "kept-true";
|
|
141
|
+
}
|
|
142
|
+
if (explicit === false) {
|
|
143
|
+
writeMarker(markerFile, agentId, "kept-false");
|
|
144
|
+
return "kept-false";
|
|
145
|
+
}
|
|
146
|
+
if (findAgentSlot(isRec(cfg) ? cfg : {}, agentId).kind === "missing") {
|
|
147
|
+
log?.info?.(`clawchat-plugin-openclaw: agent '${agentId}' is not in the agents roster; rememberAcrossConversations left unset`);
|
|
148
|
+
return "agent-not-in-roster";
|
|
149
|
+
}
|
|
150
|
+
let written = null;
|
|
151
|
+
let raced;
|
|
152
|
+
await params.mutateConfigFile({
|
|
153
|
+
afterWrite: { mode: "none", reason: "clawchat-plugin-openclaw: no recall across ClawChat direct chats" },
|
|
154
|
+
mutate(draft) {
|
|
155
|
+
// Re-check on the locked, current file: someone may have set it meanwhile.
|
|
156
|
+
const now = readRememberAcrossConversations(draft, agentId);
|
|
157
|
+
raced = now.agent ?? now.global;
|
|
158
|
+
if (raced !== undefined)
|
|
159
|
+
return;
|
|
160
|
+
written = setAgentRememberAcrossConversationsFalse(draft, agentId);
|
|
161
|
+
},
|
|
162
|
+
});
|
|
163
|
+
if (written === null) {
|
|
164
|
+
if (raced === undefined)
|
|
165
|
+
return "agent-not-in-roster";
|
|
166
|
+
const outcome = raced ? "kept-true" : "kept-false";
|
|
167
|
+
writeMarker(markerFile, agentId, outcome);
|
|
168
|
+
return outcome;
|
|
169
|
+
}
|
|
170
|
+
writeMarker(markerFile, agentId, "wrote-false");
|
|
171
|
+
log?.info?.(`clawchat-plugin-openclaw: set ${written}.rememberAcrossConversations=false for agent '${agentId}' ` +
|
|
172
|
+
"(ClawChat direct chats with different people no longer recall each other)");
|
|
173
|
+
return "wrote-false";
|
|
174
|
+
}
|
|
175
|
+
catch (err) {
|
|
176
|
+
log?.error?.(`clawchat-plugin-openclaw: could not apply the rememberAcrossConversations default for agent '${agentId}': ${err instanceof Error ? err.message : String(err)}`);
|
|
177
|
+
return "write-failed";
|
|
178
|
+
}
|
|
179
|
+
}
|
|
@@ -8,10 +8,14 @@ import { CHANNEL_ID, mergeOpenclawClawchatRuntimePluginActivation, mergeOpenclaw
|
|
|
8
8
|
import { getClawChatStore } from "./storage.js";
|
|
9
9
|
import { buildOnboardingContext, RECONNECT_GUIDE_URL } from "./onboarding-context.js";
|
|
10
10
|
import { boundCodeNewIdentityMessage, preCheckConnectCode, unpairableMessage } from "./connect-check.js";
|
|
11
|
+
import { connectFailedError, readConnectFailureGuidance, waitRetryDelayMs } from "./connect-failure.js";
|
|
11
12
|
// Re-exported: `src/login.runtime.test.ts` and `src/commands.ts` import these
|
|
12
13
|
// from here, so the historical import path keeps working after the move.
|
|
13
14
|
import { AGENTS_CONNECT_PLATFORM, AGENTS_CONNECT_TYPE, AGENT_NOT_FOUND_CODE, } from "./onboarding-context.js";
|
|
14
15
|
export { AGENTS_CONNECT_PLATFORM, AGENTS_CONNECT_TYPE, AGENT_NOT_FOUND_CODE };
|
|
16
|
+
function defaultSleep(ms) {
|
|
17
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
18
|
+
}
|
|
15
19
|
/**
|
|
16
20
|
* Thrown when redeeming an invite code would silently replace a live activation.
|
|
17
21
|
*
|
|
@@ -284,8 +288,31 @@ export async function runOpenclawClawlingLogin(params) {
|
|
|
284
288
|
// A brand-new identity is precisely "do not replay" — the replay is the only
|
|
285
289
|
// thing that would bind this code to the incumbent agent.
|
|
286
290
|
const existingUserId = newAccount ? "" : storedUserId;
|
|
291
|
+
// Rate-limit (`next_action: wait_retry`) budget: one wait-and-retry per
|
|
292
|
+
// activation, shared across both requests below.
|
|
293
|
+
let waitRetryUsed = false;
|
|
294
|
+
const sleep = params.sleep ?? defaultSleep;
|
|
295
|
+
const redeem = async (body) => {
|
|
296
|
+
try {
|
|
297
|
+
return await apiClient.agentsConnect(body);
|
|
298
|
+
}
|
|
299
|
+
catch (err) {
|
|
300
|
+
if (!(err instanceof ClawlingApiError) || waitRetryUsed)
|
|
301
|
+
throw err;
|
|
302
|
+
const guidance = readConnectFailureGuidance(err.meta?.data);
|
|
303
|
+
// Only wait_retry is retried: a rate-limited request did not consume the
|
|
304
|
+
// code. Every other next_action (and any unknown one) is final.
|
|
305
|
+
if (guidance.nextAction !== "wait_retry")
|
|
306
|
+
throw err;
|
|
307
|
+
waitRetryUsed = true;
|
|
308
|
+
const delayMs = waitRetryDelayMs(guidance.retryAfterSeconds);
|
|
309
|
+
runtime.log(`Connect code redeem rate limited — retrying once in ${Math.round(delayMs / 1000)}s …`);
|
|
310
|
+
await sleep(delayMs);
|
|
311
|
+
return await apiClient.agentsConnect(body);
|
|
312
|
+
}
|
|
313
|
+
};
|
|
287
314
|
try {
|
|
288
|
-
result = await
|
|
315
|
+
result = await redeem({
|
|
289
316
|
code: inviteCode,
|
|
290
317
|
platform: AGENTS_CONNECT_PLATFORM,
|
|
291
318
|
type: AGENTS_CONNECT_TYPE,
|
|
@@ -306,7 +333,7 @@ export async function runOpenclawClawlingLogin(params) {
|
|
|
306
333
|
if (existingUserId && err.meta?.code === AGENT_NOT_FOUND_CODE) {
|
|
307
334
|
runtime.log(`Stored userId ${existingUserId} no longer exists on this server — retrying as a fresh pairing …`);
|
|
308
335
|
try {
|
|
309
|
-
result = await
|
|
336
|
+
result = await redeem({
|
|
310
337
|
code: inviteCode,
|
|
311
338
|
platform: AGENTS_CONNECT_PLATFORM,
|
|
312
339
|
type: AGENTS_CONNECT_TYPE,
|
|
@@ -315,13 +342,13 @@ export async function runOpenclawClawlingLogin(params) {
|
|
|
315
342
|
}
|
|
316
343
|
catch (retryErr) {
|
|
317
344
|
if (retryErr instanceof ClawlingApiError) {
|
|
318
|
-
throw
|
|
345
|
+
throw connectFailedError(retryErr);
|
|
319
346
|
}
|
|
320
347
|
throw retryErr;
|
|
321
348
|
}
|
|
322
349
|
}
|
|
323
350
|
else {
|
|
324
|
-
throw
|
|
351
|
+
throw connectFailedError(err);
|
|
325
352
|
}
|
|
326
353
|
}
|
|
327
354
|
else {
|
package/dist/src/runtime.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { applyDmRecallDefault } from "./dm-recall-default.js";
|
|
1
2
|
import { AckTimeoutError, AuthError, ProtocolError, StateError, TransportError, EVENT, recallTargetMessageId, } from "./protocol-types.js";
|
|
2
3
|
import { waitUntilAbort } from "openclaw/plugin-sdk/channel-lifecycle";
|
|
3
4
|
import { hasControlCommand } from "openclaw/plugin-sdk/command-detection";
|
|
@@ -937,6 +938,48 @@ export async function startOpenclawClawlingGateway(params) {
|
|
|
937
938
|
? runtimeConfig.mutateConfigFile
|
|
938
939
|
: undefined;
|
|
939
940
|
};
|
|
941
|
+
// OpenClaw >= 2026.8.1 recalls across a single agent's DMs unless DM isolation
|
|
942
|
+
// is visible in the gateway config; ours is in-memory only. Write the agent's
|
|
943
|
+
// `rememberAcrossConversations: false` once, unless the owner set it
|
|
944
|
+
// (dm-recall-default.ts). Fire-and-forget: never blocks or fails startup.
|
|
945
|
+
void (async () => {
|
|
946
|
+
const stateDir = runtime.state?.resolveStateDir?.();
|
|
947
|
+
const mutateConfigFile = resolveMutateConfigFile();
|
|
948
|
+
const readConfig = runtime.config?.current;
|
|
949
|
+
if (!stateDir || !mutateConfigFile || typeof readConfig !== "function")
|
|
950
|
+
return;
|
|
951
|
+
let agentId;
|
|
952
|
+
try {
|
|
953
|
+
agentId = runtime.channel.routing.resolveAgentRoute({
|
|
954
|
+
cfg: withClawChatSessionScope(cfg),
|
|
955
|
+
channel: CHANNEL_ID,
|
|
956
|
+
accountId,
|
|
957
|
+
peer: { kind: "direct", id: account.ownerUserId || "owner" },
|
|
958
|
+
}).agentId;
|
|
959
|
+
}
|
|
960
|
+
catch (err) {
|
|
961
|
+
log?.info?.(`[${accountId}] clawchat-plugin-openclaw rememberAcrossConversations default skipped: agent route unavailable (${err instanceof Error ? err.message : String(err)})`);
|
|
962
|
+
return;
|
|
963
|
+
}
|
|
964
|
+
await applyDmRecallDefault({
|
|
965
|
+
agentId,
|
|
966
|
+
hostVersion: runtime.version,
|
|
967
|
+
stateDir,
|
|
968
|
+
readConfig: () => readConfig.call(runtime.config),
|
|
969
|
+
mutateConfigFile: mutateConfigFile,
|
|
970
|
+
log: {
|
|
971
|
+
info: (m) => log?.info?.(`[${accountId}] ${m}`),
|
|
972
|
+
warn: (m) => {
|
|
973
|
+
const warn = log?.warn;
|
|
974
|
+
if (warn)
|
|
975
|
+
warn(`[${accountId}] ${m}`);
|
|
976
|
+
else
|
|
977
|
+
log?.info?.(`[${accountId}] WARNING ${m}`);
|
|
978
|
+
},
|
|
979
|
+
error: (m) => log?.error?.(`[${accountId}] ${m}`),
|
|
980
|
+
},
|
|
981
|
+
});
|
|
982
|
+
})();
|
|
940
983
|
// §0/§C.1 — write the account slot's `token`/`refreshToken` keys. `tokens=null`
|
|
941
984
|
// blanks them (auto-logout); otherwise persists the rotated pair.
|
|
942
985
|
const persistConfigTokens = async (tokens) => {
|
package/package.json
CHANGED
package/src/connect-check.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { ClawlingApiError } from "./api-types.ts";
|
|
2
2
|
import type { OpenclawClawlingApiClient } from "./api-client.ts";
|
|
3
|
+
import { readConnectFailureGuidance } from "./connect-failure.ts";
|
|
3
4
|
import { AGENTS_CONNECT_PLATFORM, RECONNECT_GUIDE_URL } from "./onboarding-context.ts";
|
|
4
5
|
|
|
5
6
|
export interface ConnectPreCheck {
|
|
@@ -41,8 +42,14 @@ export async function preCheckConnectCode(
|
|
|
41
42
|
userIdStatus: typeof res.user_id_status === "string" ? res.user_id_status : "",
|
|
42
43
|
};
|
|
43
44
|
} catch (err) {
|
|
45
|
+
// Still a degrade, never a gate: `/connect` re-evaluates the code and
|
|
46
|
+
// surfaces the same guidance on the error it throws. The guidance is only
|
|
47
|
+
// logged here so a rate-limit or dead code is visible early.
|
|
44
48
|
const kind = err instanceof ClawlingApiError ? err.kind : "error";
|
|
45
|
-
|
|
49
|
+
const guidance = err instanceof ClawlingApiError ? readConnectFailureGuidance(err.meta?.data) : {};
|
|
50
|
+
const action = guidance.nextAction ? `; next_action: ${guidance.nextAction}` : "";
|
|
51
|
+
const hint = guidance.hint ? `. ${guidance.hint}` : "";
|
|
52
|
+
log(`Connect-code pre-check unavailable (${kind}${action})${hint}; continuing without it.`);
|
|
46
53
|
return null;
|
|
47
54
|
}
|
|
48
55
|
}
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import type { ClawlingApiError, ClawlingApiErrorKind } from "./api-types.ts";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Machine-readable guidance the backend attaches to the error envelope's
|
|
5
|
+
* `data` on a failed `POST /v1/agents/connect` (and `/connect/check`). Every
|
|
6
|
+
* field is optional: older servers send none of them, and then the plugin
|
|
7
|
+
* behaves exactly as before.
|
|
8
|
+
*
|
|
9
|
+
* Known `next_action` values (closed enum on the server; any other value is
|
|
10
|
+
* surfaced verbatim and never retried):
|
|
11
|
+
* - `ask_owner_new_code` — the code is dead (unknown / expired); never retry.
|
|
12
|
+
* - `wait_retry` — rate limited; the code was NOT consumed. Wait
|
|
13
|
+
* `retry_after_seconds`, then retry once.
|
|
14
|
+
* - `reconnect` — redeemed from another device; the owner must send
|
|
15
|
+
* a reconnect prompt.
|
|
16
|
+
* - `stop` — an unchanged retry fails the same way.
|
|
17
|
+
*/
|
|
18
|
+
export type ConnectNextAction = "ask_owner_new_code" | "wait_retry" | "reconnect" | "stop";
|
|
19
|
+
|
|
20
|
+
export interface ConnectFailureGuidance {
|
|
21
|
+
hint?: string;
|
|
22
|
+
/** A `ConnectNextAction`, or an unknown value passed through verbatim. */
|
|
23
|
+
nextAction?: ConnectNextAction | (string & {});
|
|
24
|
+
retryAfterSeconds?: number;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** Default wait for `wait_retry` when the server omits / garbles the delay. */
|
|
28
|
+
export const WAIT_RETRY_DEFAULT_SECONDS = 60;
|
|
29
|
+
/** Upper bound on any `wait_retry` wait, whatever the server asks for. */
|
|
30
|
+
export const WAIT_RETRY_MAX_SECONDS = 120;
|
|
31
|
+
|
|
32
|
+
function readNonBlankString(value: unknown): string | undefined {
|
|
33
|
+
if (typeof value !== "string") return undefined;
|
|
34
|
+
const trimmed = value.trim();
|
|
35
|
+
return trimmed ? trimmed : undefined;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function readConnectFailureGuidance(data: unknown): ConnectFailureGuidance {
|
|
39
|
+
if (!data || typeof data !== "object" || Array.isArray(data)) return {};
|
|
40
|
+
const record = data as Record<string, unknown>;
|
|
41
|
+
const out: ConnectFailureGuidance = {};
|
|
42
|
+
const hint = readNonBlankString(record.hint);
|
|
43
|
+
if (hint) out.hint = hint;
|
|
44
|
+
const nextAction = readNonBlankString(record.next_action);
|
|
45
|
+
if (nextAction) out.nextAction = nextAction;
|
|
46
|
+
const retryAfter = record.retry_after_seconds;
|
|
47
|
+
if (typeof retryAfter === "number" && Number.isFinite(retryAfter)) {
|
|
48
|
+
out.retryAfterSeconds = retryAfter;
|
|
49
|
+
}
|
|
50
|
+
return out;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Delay before the single `wait_retry` retry: server value, capped, with a default. */
|
|
54
|
+
export function waitRetryDelayMs(retryAfterSeconds: number | undefined): number {
|
|
55
|
+
const seconds =
|
|
56
|
+
typeof retryAfterSeconds === "number" && Number.isFinite(retryAfterSeconds) && retryAfterSeconds > 0
|
|
57
|
+
? Math.min(retryAfterSeconds, WAIT_RETRY_MAX_SECONDS)
|
|
58
|
+
: WAIT_RETRY_DEFAULT_SECONDS;
|
|
59
|
+
return Math.round(seconds * 1000);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Error thrown by the invite-code login when `/v1/agents/connect` fails.
|
|
64
|
+
* The message keeps the historical `agents/connect failed (<kind>): <msg>`
|
|
65
|
+
* prefix; when the server sent guidance it is appended so an agent reading
|
|
66
|
+
* the CLI / chat-command output knows what to do next.
|
|
67
|
+
*/
|
|
68
|
+
export class ConnectFailedError extends Error {
|
|
69
|
+
readonly kind: ClawlingApiErrorKind;
|
|
70
|
+
readonly code?: number;
|
|
71
|
+
readonly status?: number;
|
|
72
|
+
readonly hint?: string;
|
|
73
|
+
readonly nextAction?: ConnectFailureGuidance["nextAction"];
|
|
74
|
+
readonly retryAfterSeconds?: number;
|
|
75
|
+
|
|
76
|
+
constructor(
|
|
77
|
+
kind: ClawlingApiErrorKind,
|
|
78
|
+
serverMessage: string,
|
|
79
|
+
meta: { code?: number; status?: number } & ConnectFailureGuidance = {},
|
|
80
|
+
) {
|
|
81
|
+
super(formatConnectFailureMessage(kind, serverMessage, meta));
|
|
82
|
+
this.name = "ConnectFailedError";
|
|
83
|
+
this.kind = kind;
|
|
84
|
+
if (meta.code !== undefined) this.code = meta.code;
|
|
85
|
+
if (meta.status !== undefined) this.status = meta.status;
|
|
86
|
+
if (meta.hint !== undefined) this.hint = meta.hint;
|
|
87
|
+
if (meta.nextAction !== undefined) this.nextAction = meta.nextAction;
|
|
88
|
+
if (meta.retryAfterSeconds !== undefined) this.retryAfterSeconds = meta.retryAfterSeconds;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function formatConnectFailureMessage(
|
|
93
|
+
kind: ClawlingApiErrorKind,
|
|
94
|
+
serverMessage: string,
|
|
95
|
+
guidance: ConnectFailureGuidance,
|
|
96
|
+
): string {
|
|
97
|
+
let text = `agents/connect failed (${kind}): ${serverMessage}`;
|
|
98
|
+
if (guidance.nextAction) text += ` (next_action: ${guidance.nextAction})`;
|
|
99
|
+
if (guidance.hint) text += `. ${guidance.hint}`;
|
|
100
|
+
return text;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export function connectFailedError(err: ClawlingApiError): ConnectFailedError {
|
|
104
|
+
return new ConnectFailedError(err.kind, err.message, {
|
|
105
|
+
...(err.meta?.code !== undefined ? { code: err.meta.code } : {}),
|
|
106
|
+
...(err.meta?.status !== undefined ? { status: err.meta.status } : {}),
|
|
107
|
+
...readConnectFailureGuidance(err.meta?.data),
|
|
108
|
+
});
|
|
109
|
+
}
|
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Turn off OpenClaw's cross-conversation recall for the agent ClawChat routes
|
|
3
|
+
* to, once, unless the owner already decided.
|
|
4
|
+
*
|
|
5
|
+
* OpenClaw >= 2026.8.1 has `memory.search.rememberAcrossConversations`. When it
|
|
6
|
+
* is not written anywhere, OpenClaw turns it on unless `session.dmScope` or a
|
|
7
|
+
* binding isolates DMs (`resolveRememberAcrossConversations`,
|
|
8
|
+
* packages/memory-host-sdk/src/host/config-utils.ts). The plugin isolates
|
|
9
|
+
* ClawChat DMs only in memory (`withClawChatSessionScope`), which OpenClaw
|
|
10
|
+
* cannot see, so recall stays on and a friend's DM can recall the owner's.
|
|
11
|
+
* Active Memory reads the live gateway config each turn, so a per-turn
|
|
12
|
+
* override does not reach it: the setting has to be in the config file.
|
|
13
|
+
*
|
|
14
|
+
* Where it is read (2026.8.1 / 2026.9.x): the agent's `agents.entries.<id>`
|
|
15
|
+
* or, when the roster is the legacy array, its `agents.list[]` entry (matched
|
|
16
|
+
* by `id`); then the global `memory.search`. With no roster in the file there
|
|
17
|
+
* is one implicit agent, so the global layer IS that agent's.
|
|
18
|
+
*
|
|
19
|
+
* Rules (relay openclaw-dm-recall, 10-08):
|
|
20
|
+
* - only on hosts >= 2026.8.1 (older ones have neither the key nor the recall);
|
|
21
|
+
* - only when neither layer sets it; an explicit `true` is the owner's choice:
|
|
22
|
+
* kept, warned once; an explicit `false` is kept silently;
|
|
23
|
+
* - only once per agent: a marker in the plugin's state dir records it, so
|
|
24
|
+
* whatever the owner changes later is never touched again.
|
|
25
|
+
*/
|
|
26
|
+
import fs from "node:fs";
|
|
27
|
+
import path from "node:path";
|
|
28
|
+
|
|
29
|
+
export const DM_RECALL_MARKER_FILENAME = "clawchat-dm-recall-default.json";
|
|
30
|
+
export const DM_RECALL_MIN_HOST: readonly [number, number, number] = [2026, 8, 1];
|
|
31
|
+
|
|
32
|
+
export type DmRecallOutcome =
|
|
33
|
+
| "wrote-false"
|
|
34
|
+
| "kept-true"
|
|
35
|
+
| "kept-false"
|
|
36
|
+
| "already-handled"
|
|
37
|
+
| "unsupported-host"
|
|
38
|
+
| "agent-not-in-roster"
|
|
39
|
+
| "write-failed";
|
|
40
|
+
|
|
41
|
+
type Rec = Record<string, unknown>;
|
|
42
|
+
|
|
43
|
+
type MutateConfigFile = (params: {
|
|
44
|
+
afterWrite: { mode: "none"; reason: string };
|
|
45
|
+
mutate: (draft: Rec) => void;
|
|
46
|
+
}) => Promise<unknown>;
|
|
47
|
+
|
|
48
|
+
type Log = {
|
|
49
|
+
info?: (msg: string) => void;
|
|
50
|
+
warn?: (msg: string) => void;
|
|
51
|
+
error?: (msg: string) => void;
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
function isRec(value: unknown): value is Rec {
|
|
55
|
+
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function normalizeId(id: unknown): string {
|
|
59
|
+
return typeof id === "string" ? id.trim().toLowerCase() : "";
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** True for a release host >= 2026.8.1. Pre-releases and unknown strings are false. */
|
|
63
|
+
export function hostHasRememberAcrossConversations(version: string | undefined | null): boolean {
|
|
64
|
+
const m = /^v?(\d+)\.(\d+)\.(\d+)$/.exec((version ?? "").trim());
|
|
65
|
+
if (!m) return false;
|
|
66
|
+
const parts = [Number(m[1]), Number(m[2]), Number(m[3])];
|
|
67
|
+
for (let i = 0; i < 3; i += 1) {
|
|
68
|
+
if (parts[i] !== DM_RECALL_MIN_HOST[i]) return parts[i] > DM_RECALL_MIN_HOST[i];
|
|
69
|
+
}
|
|
70
|
+
return true;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
type AgentSlot =
|
|
74
|
+
| { kind: "entries"; key: string; entry: Rec }
|
|
75
|
+
| { kind: "list"; index: number; entry: Rec }
|
|
76
|
+
| { kind: "no-roster" }
|
|
77
|
+
| { kind: "missing" };
|
|
78
|
+
|
|
79
|
+
function findAgentSlot(cfg: Rec, agentId: string): AgentSlot {
|
|
80
|
+
const agents = isRec(cfg.agents) ? cfg.agents : undefined;
|
|
81
|
+
const id = normalizeId(agentId);
|
|
82
|
+
if (agents && Object.hasOwn(agents, "entries") && agents.entries !== undefined) {
|
|
83
|
+
if (!isRec(agents.entries)) return { kind: "missing" };
|
|
84
|
+
for (const key of Object.keys(agents.entries)) {
|
|
85
|
+
const entry = agents.entries[key];
|
|
86
|
+
if (normalizeId(key) === id && isRec(entry)) return { kind: "entries", key, entry };
|
|
87
|
+
}
|
|
88
|
+
return { kind: "missing" };
|
|
89
|
+
}
|
|
90
|
+
if (agents && Object.hasOwn(agents, "list") && agents.list !== undefined) {
|
|
91
|
+
if (!Array.isArray(agents.list)) return { kind: "missing" };
|
|
92
|
+
const index = agents.list.findIndex((e) => isRec(e) && normalizeId(e.id) === id);
|
|
93
|
+
return index >= 0 ? { kind: "list", index, entry: agents.list[index] as Rec } : { kind: "missing" };
|
|
94
|
+
}
|
|
95
|
+
return { kind: "no-roster" };
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function readFlag(holder: unknown): boolean | undefined {
|
|
99
|
+
if (!isRec(holder) || !isRec(holder.memory) || !isRec(holder.memory.search)) return undefined;
|
|
100
|
+
const v = holder.memory.search.rememberAcrossConversations;
|
|
101
|
+
return typeof v === "boolean" ? v : undefined;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** The value written at each layer, `undefined` where it is not written. */
|
|
105
|
+
export function readRememberAcrossConversations(
|
|
106
|
+
cfg: Rec,
|
|
107
|
+
agentId: string,
|
|
108
|
+
): { agent: boolean | undefined; global: boolean | undefined } {
|
|
109
|
+
const slot = findAgentSlot(cfg, agentId);
|
|
110
|
+
const agent = slot.kind === "entries" || slot.kind === "list" ? readFlag(slot.entry) : undefined;
|
|
111
|
+
return { agent, global: readFlag(cfg) };
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function setFlagFalse(holder: Rec): void {
|
|
115
|
+
const memory = isRec(holder.memory) ? holder.memory : {};
|
|
116
|
+
const search = isRec(memory.search) ? memory.search : {};
|
|
117
|
+
holder.memory = { ...memory, search: { ...search, rememberAcrossConversations: false } };
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Write `rememberAcrossConversations: false` at the agent's own layer of
|
|
122
|
+
* `cfg` (mutated in place). Returns the dotted path written, or `null` when a
|
|
123
|
+
* roster exists but does not list the agent (nothing written).
|
|
124
|
+
*/
|
|
125
|
+
export function setAgentRememberAcrossConversationsFalse(cfg: Rec, agentId: string): string | null {
|
|
126
|
+
const slot = findAgentSlot(cfg, agentId);
|
|
127
|
+
if (slot.kind === "entries") {
|
|
128
|
+
setFlagFalse(slot.entry);
|
|
129
|
+
return `agents.entries.${slot.key}.memory.search`;
|
|
130
|
+
}
|
|
131
|
+
if (slot.kind === "list") {
|
|
132
|
+
setFlagFalse(slot.entry);
|
|
133
|
+
return `agents.list[${slot.index}].memory.search`;
|
|
134
|
+
}
|
|
135
|
+
if (slot.kind === "no-roster") {
|
|
136
|
+
setFlagFalse(cfg);
|
|
137
|
+
return "memory.search";
|
|
138
|
+
}
|
|
139
|
+
return null;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
type Marker = { agents?: Record<string, { outcome: DmRecallOutcome; at: string }> };
|
|
143
|
+
|
|
144
|
+
function readMarker(file: string): Marker {
|
|
145
|
+
try {
|
|
146
|
+
const parsed = JSON.parse(fs.readFileSync(file, "utf8")) as unknown;
|
|
147
|
+
return isRec(parsed) ? (parsed as Marker) : {};
|
|
148
|
+
} catch {
|
|
149
|
+
return {};
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function writeMarker(file: string, agentId: string, outcome: DmRecallOutcome): void {
|
|
154
|
+
const marker = readMarker(file);
|
|
155
|
+
const agents = isRec(marker.agents) ? marker.agents : {};
|
|
156
|
+
agents[normalizeId(agentId)] = { outcome, at: new Date().toISOString() };
|
|
157
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
158
|
+
fs.writeFileSync(file, `${JSON.stringify({ ...marker, agents }, null, 2)}\n`);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/** Apply the one-time default. Never throws. */
|
|
162
|
+
export async function applyDmRecallDefault(params: {
|
|
163
|
+
agentId: string;
|
|
164
|
+
hostVersion: string | undefined | null;
|
|
165
|
+
stateDir: string;
|
|
166
|
+
readConfig: () => unknown;
|
|
167
|
+
mutateConfigFile: MutateConfigFile;
|
|
168
|
+
log?: Log;
|
|
169
|
+
}): Promise<DmRecallOutcome> {
|
|
170
|
+
const { agentId, log } = params;
|
|
171
|
+
const markerFile = path.join(params.stateDir, DM_RECALL_MARKER_FILENAME);
|
|
172
|
+
try {
|
|
173
|
+
if (readMarker(markerFile).agents?.[normalizeId(agentId)]) return "already-handled";
|
|
174
|
+
if (!hostHasRememberAcrossConversations(params.hostVersion)) return "unsupported-host";
|
|
175
|
+
|
|
176
|
+
const cfg = params.readConfig();
|
|
177
|
+
const current = readRememberAcrossConversations(isRec(cfg) ? cfg : {}, agentId);
|
|
178
|
+
const explicit = current.agent ?? current.global;
|
|
179
|
+
if (explicit === true) {
|
|
180
|
+
log?.warn?.(
|
|
181
|
+
`clawchat-plugin-openclaw: memory.search.rememberAcrossConversations is true for agent '${agentId}', so a ` +
|
|
182
|
+
"friend's ClawChat direct chat can recall what the owner said in theirs. Kept as set. To turn it off: " +
|
|
183
|
+
`openclaw config set memory.search.rememberAcrossConversations false (or set it to false on agent '${agentId}'), then restart the gateway.`,
|
|
184
|
+
);
|
|
185
|
+
writeMarker(markerFile, agentId, "kept-true");
|
|
186
|
+
return "kept-true";
|
|
187
|
+
}
|
|
188
|
+
if (explicit === false) {
|
|
189
|
+
writeMarker(markerFile, agentId, "kept-false");
|
|
190
|
+
return "kept-false";
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
if (findAgentSlot(isRec(cfg) ? cfg : {}, agentId).kind === "missing") {
|
|
194
|
+
log?.info?.(
|
|
195
|
+
`clawchat-plugin-openclaw: agent '${agentId}' is not in the agents roster; rememberAcrossConversations left unset`,
|
|
196
|
+
);
|
|
197
|
+
return "agent-not-in-roster";
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
let written: string | null = null;
|
|
201
|
+
let raced: boolean | undefined;
|
|
202
|
+
await params.mutateConfigFile({
|
|
203
|
+
afterWrite: { mode: "none", reason: "clawchat-plugin-openclaw: no recall across ClawChat direct chats" },
|
|
204
|
+
mutate(draft) {
|
|
205
|
+
// Re-check on the locked, current file: someone may have set it meanwhile.
|
|
206
|
+
const now = readRememberAcrossConversations(draft, agentId);
|
|
207
|
+
raced = now.agent ?? now.global;
|
|
208
|
+
if (raced !== undefined) return;
|
|
209
|
+
written = setAgentRememberAcrossConversationsFalse(draft, agentId);
|
|
210
|
+
},
|
|
211
|
+
});
|
|
212
|
+
if (written === null) {
|
|
213
|
+
if (raced === undefined) return "agent-not-in-roster";
|
|
214
|
+
const outcome: DmRecallOutcome = raced ? "kept-true" : "kept-false";
|
|
215
|
+
writeMarker(markerFile, agentId, outcome);
|
|
216
|
+
return outcome;
|
|
217
|
+
}
|
|
218
|
+
writeMarker(markerFile, agentId, "wrote-false");
|
|
219
|
+
log?.info?.(
|
|
220
|
+
`clawchat-plugin-openclaw: set ${written}.rememberAcrossConversations=false for agent '${agentId}' ` +
|
|
221
|
+
"(ClawChat direct chats with different people no longer recall each other)",
|
|
222
|
+
);
|
|
223
|
+
return "wrote-false";
|
|
224
|
+
} catch (err) {
|
|
225
|
+
log?.error?.(
|
|
226
|
+
`clawchat-plugin-openclaw: could not apply the rememberAcrossConversations default for agent '${agentId}': ${err instanceof Error ? err.message : String(err)}`,
|
|
227
|
+
);
|
|
228
|
+
return "write-failed";
|
|
229
|
+
}
|
|
230
|
+
}
|
package/src/login.runtime.ts
CHANGED
|
@@ -16,6 +16,7 @@ import {
|
|
|
16
16
|
import { getClawChatStore, type ClawChatStore } from "./storage.ts";
|
|
17
17
|
import { buildOnboardingContext, RECONNECT_GUIDE_URL } from "./onboarding-context.ts";
|
|
18
18
|
import { boundCodeNewIdentityMessage, preCheckConnectCode, unpairableMessage } from "./connect-check.ts";
|
|
19
|
+
import { connectFailedError, readConnectFailureGuidance, waitRetryDelayMs } from "./connect-failure.ts";
|
|
19
20
|
// Re-exported: `src/login.runtime.test.ts` and `src/commands.ts` import these
|
|
20
21
|
// from here, so the historical import path keeps working after the move.
|
|
21
22
|
import {
|
|
@@ -68,6 +69,15 @@ export interface LoginParams {
|
|
|
68
69
|
* `promptActivationIntentFromStdin`; overridden by tests.
|
|
69
70
|
*/
|
|
70
71
|
readActivationIntent?: () => Promise<ActivationIntent | null>;
|
|
72
|
+
/**
|
|
73
|
+
* Wait used before the single `next_action: wait_retry` retry of
|
|
74
|
+
* `/v1/agents/connect`. Defaults to a `setTimeout`; overridden by tests.
|
|
75
|
+
*/
|
|
76
|
+
sleep?: (ms: number) => Promise<void>;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function defaultSleep(ms: number): Promise<void> {
|
|
80
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
71
81
|
}
|
|
72
82
|
|
|
73
83
|
/** The two outcomes available once an identity is already stored. */
|
|
@@ -396,8 +406,30 @@ export async function runOpenclawClawlingLogin(params: LoginParams): Promise<voi
|
|
|
396
406
|
// A brand-new identity is precisely "do not replay" — the replay is the only
|
|
397
407
|
// thing that would bind this code to the incumbent agent.
|
|
398
408
|
const existingUserId = newAccount ? "" : storedUserId;
|
|
409
|
+
// Rate-limit (`next_action: wait_retry`) budget: one wait-and-retry per
|
|
410
|
+
// activation, shared across both requests below.
|
|
411
|
+
let waitRetryUsed = false;
|
|
412
|
+
const sleep = params.sleep ?? defaultSleep;
|
|
413
|
+
const redeem = async (body: Parameters<typeof apiClient.agentsConnect>[0]) => {
|
|
414
|
+
try {
|
|
415
|
+
return await apiClient.agentsConnect(body);
|
|
416
|
+
} catch (err) {
|
|
417
|
+
if (!(err instanceof ClawlingApiError) || waitRetryUsed) throw err;
|
|
418
|
+
const guidance = readConnectFailureGuidance(err.meta?.data);
|
|
419
|
+
// Only wait_retry is retried: a rate-limited request did not consume the
|
|
420
|
+
// code. Every other next_action (and any unknown one) is final.
|
|
421
|
+
if (guidance.nextAction !== "wait_retry") throw err;
|
|
422
|
+
waitRetryUsed = true;
|
|
423
|
+
const delayMs = waitRetryDelayMs(guidance.retryAfterSeconds);
|
|
424
|
+
runtime.log(
|
|
425
|
+
`Connect code redeem rate limited — retrying once in ${Math.round(delayMs / 1000)}s …`,
|
|
426
|
+
);
|
|
427
|
+
await sleep(delayMs);
|
|
428
|
+
return await apiClient.agentsConnect(body);
|
|
429
|
+
}
|
|
430
|
+
};
|
|
399
431
|
try {
|
|
400
|
-
result = await
|
|
432
|
+
result = await redeem({
|
|
401
433
|
code: inviteCode,
|
|
402
434
|
platform: AGENTS_CONNECT_PLATFORM,
|
|
403
435
|
type: AGENTS_CONNECT_TYPE,
|
|
@@ -419,7 +451,7 @@ export async function runOpenclawClawlingLogin(params: LoginParams): Promise<voi
|
|
|
419
451
|
`Stored userId ${existingUserId} no longer exists on this server — retrying as a fresh pairing …`,
|
|
420
452
|
);
|
|
421
453
|
try {
|
|
422
|
-
result = await
|
|
454
|
+
result = await redeem({
|
|
423
455
|
code: inviteCode,
|
|
424
456
|
platform: AGENTS_CONNECT_PLATFORM,
|
|
425
457
|
type: AGENTS_CONNECT_TYPE,
|
|
@@ -427,12 +459,12 @@ export async function runOpenclawClawlingLogin(params: LoginParams): Promise<voi
|
|
|
427
459
|
});
|
|
428
460
|
} catch (retryErr) {
|
|
429
461
|
if (retryErr instanceof ClawlingApiError) {
|
|
430
|
-
throw
|
|
462
|
+
throw connectFailedError(retryErr);
|
|
431
463
|
}
|
|
432
464
|
throw retryErr;
|
|
433
465
|
}
|
|
434
466
|
} else {
|
|
435
|
-
throw
|
|
467
|
+
throw connectFailedError(err);
|
|
436
468
|
}
|
|
437
469
|
} else {
|
|
438
470
|
throw err;
|
package/src/runtime.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { applyDmRecallDefault } from "./dm-recall-default.ts";
|
|
1
2
|
import {
|
|
2
3
|
AckTimeoutError,
|
|
3
4
|
AuthError,
|
|
@@ -1293,6 +1294,47 @@ export async function startOpenclawClawlingGateway(params: StartGatewayParams):
|
|
|
1293
1294
|
: undefined;
|
|
1294
1295
|
};
|
|
1295
1296
|
|
|
1297
|
+
// OpenClaw >= 2026.8.1 recalls across a single agent's DMs unless DM isolation
|
|
1298
|
+
// is visible in the gateway config; ours is in-memory only. Write the agent's
|
|
1299
|
+
// `rememberAcrossConversations: false` once, unless the owner set it
|
|
1300
|
+
// (dm-recall-default.ts). Fire-and-forget: never blocks or fails startup.
|
|
1301
|
+
void (async () => {
|
|
1302
|
+
const stateDir = runtime.state?.resolveStateDir?.();
|
|
1303
|
+
const mutateConfigFile = resolveMutateConfigFile();
|
|
1304
|
+
const readConfig = (runtime.config as { current?: () => unknown } | undefined)?.current;
|
|
1305
|
+
if (!stateDir || !mutateConfigFile || typeof readConfig !== "function") return;
|
|
1306
|
+
let agentId: string;
|
|
1307
|
+
try {
|
|
1308
|
+
agentId = runtime.channel.routing.resolveAgentRoute({
|
|
1309
|
+
cfg: withClawChatSessionScope(cfg),
|
|
1310
|
+
channel: CHANNEL_ID,
|
|
1311
|
+
accountId,
|
|
1312
|
+
peer: { kind: "direct", id: account.ownerUserId || "owner" },
|
|
1313
|
+
}).agentId;
|
|
1314
|
+
} catch (err) {
|
|
1315
|
+
log?.info?.(
|
|
1316
|
+
`[${accountId}] clawchat-plugin-openclaw rememberAcrossConversations default skipped: agent route unavailable (${err instanceof Error ? err.message : String(err)})`,
|
|
1317
|
+
);
|
|
1318
|
+
return;
|
|
1319
|
+
}
|
|
1320
|
+
await applyDmRecallDefault({
|
|
1321
|
+
agentId,
|
|
1322
|
+
hostVersion: runtime.version,
|
|
1323
|
+
stateDir,
|
|
1324
|
+
readConfig: () => readConfig.call(runtime.config),
|
|
1325
|
+
mutateConfigFile: mutateConfigFile as never,
|
|
1326
|
+
log: {
|
|
1327
|
+
info: (m) => log?.info?.(`[${accountId}] ${m}`),
|
|
1328
|
+
warn: (m) => {
|
|
1329
|
+
const warn = (log as { warn?: (msg: string) => void } | undefined)?.warn;
|
|
1330
|
+
if (warn) warn(`[${accountId}] ${m}`);
|
|
1331
|
+
else log?.info?.(`[${accountId}] WARNING ${m}`);
|
|
1332
|
+
},
|
|
1333
|
+
error: (m) => log?.error?.(`[${accountId}] ${m}`),
|
|
1334
|
+
},
|
|
1335
|
+
});
|
|
1336
|
+
})();
|
|
1337
|
+
|
|
1296
1338
|
// §0/§C.1 — write the account slot's `token`/`refreshToken` keys. `tokens=null`
|
|
1297
1339
|
// blanks them (auto-logout); otherwise persists the rotated pair.
|
|
1298
1340
|
const persistConfigTokens = async (
|