@relaymessenger/openclaw-plugin 0.3.0 → 0.3.4
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/channel.js +110 -73
- package/dist/src/client.js +13 -219
- package/dist/src/inbound.js +5 -0
- package/dist/src/invocations.js +47 -0
- package/dist/src/outbound.js +2 -0
- package/dist/src/poll-loop.js +12 -0
- package/dist/src/responding.js +36 -0
- package/dist/src/state-files.js +114 -38
- package/dist/src/vendor/relay-sdk/client.js +163 -0
- package/dist/src/vendor/relay-sdk/errors.js +45 -0
- package/dist/src/vendor/relay-sdk/types.js +2 -0
- package/dist/src/vendor/relay-sdk/url.js +39 -0
- package/package.json +1 -1
- package/src/channel.ts +111 -74
- package/src/client.ts +38 -300
- package/src/inbound.ts +11 -0
- package/src/invocations.ts +58 -0
- package/src/outbound.ts +11 -4
- package/src/poll-loop.ts +12 -0
- package/src/responding.ts +52 -0
- package/src/state-files.ts +130 -44
- package/src/types.ts +15 -1
- package/src/vendor/relay-sdk/README.md +28 -0
- package/src/vendor/relay-sdk/client.ts +293 -0
- package/src/vendor/relay-sdk/errors.ts +61 -0
- package/src/vendor/relay-sdk/types.ts +82 -0
- package/src/vendor/relay-sdk/url.ts +43 -0
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Which group invocation an in-flight turn belongs to.
|
|
3
|
+
*
|
|
4
|
+
* Relay mints an invocation when a human invokes an agent in a group, and
|
|
5
|
+
* every call the agent then makes about that message has to carry the id back:
|
|
6
|
+
* `/typing` and `/responding` refuse without it, and so does the reply itself.
|
|
7
|
+
*
|
|
8
|
+
* The reply does not leave through this plugin's own code. It leaves through
|
|
9
|
+
* core's durable message adapter, whose send context carries `to`, `text`, and
|
|
10
|
+
* delivery bookkeeping and nothing about the message being answered
|
|
11
|
+
* (`ChannelMessageSendTextContext`). There is no field to thread the id
|
|
12
|
+
* through, so the turn parks it here for the adapter to find.
|
|
13
|
+
*
|
|
14
|
+
* Keyed by (accountId, conversationId) because `to` and `accountId` are all
|
|
15
|
+
* the adapter knows. Two agents in one group get separate slots. Two
|
|
16
|
+
* overlapping turns for ONE agent in ONE group share a slot and the later one
|
|
17
|
+
* wins — bounded by the server, which spends an invocation exactly once and
|
|
18
|
+
* refuses the loser rather than misattributing it.
|
|
19
|
+
*/
|
|
20
|
+
const pendingInvocations = new Map<string, string>();
|
|
21
|
+
|
|
22
|
+
function slotKey(accountId: string, conversationId: string): string {
|
|
23
|
+
return `${accountId}\0${conversationId}`;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Hold `invocationId` for the life of one turn. Returns the release function;
|
|
28
|
+
* call it in a `finally` so a thrown turn cannot strand the slot.
|
|
29
|
+
*
|
|
30
|
+
* Releasing only clears the slot if this turn still owns it, so a turn that
|
|
31
|
+
* finishes after being superseded cannot delete its successor's id.
|
|
32
|
+
*/
|
|
33
|
+
export function rememberRelayInvocation(params: {
|
|
34
|
+
accountId: string;
|
|
35
|
+
conversationId: string;
|
|
36
|
+
invocationId: string;
|
|
37
|
+
}): () => void {
|
|
38
|
+
const key = slotKey(params.accountId, params.conversationId);
|
|
39
|
+
pendingInvocations.set(key, params.invocationId);
|
|
40
|
+
return () => {
|
|
41
|
+
if (pendingInvocations.get(key) === params.invocationId) {
|
|
42
|
+
pendingInvocations.delete(key);
|
|
43
|
+
}
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** The invocation an outbound send in this conversation belongs to, if any. */
|
|
48
|
+
export function relayInvocationFor(params: {
|
|
49
|
+
accountId: string;
|
|
50
|
+
conversationId: string;
|
|
51
|
+
}): string | undefined {
|
|
52
|
+
return pendingInvocations.get(slotKey(params.accountId, params.conversationId));
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Test seam: drop every slot. */
|
|
56
|
+
export function resetRelayInvocationsForTest(): void {
|
|
57
|
+
pendingInvocations.clear();
|
|
58
|
+
}
|
package/src/outbound.ts
CHANGED
|
@@ -4,8 +4,7 @@
|
|
|
4
4
|
// (server contract: commitMessage.ts idempotent replay).
|
|
5
5
|
import { createHash } from "node:crypto";
|
|
6
6
|
import { RelayApiError } from "./client.js";
|
|
7
|
-
import type { RelayClient } from "./client.js";
|
|
8
|
-
import type { RelayMessage } from "./types.js";
|
|
7
|
+
import type { RelayClient, RelaySentMessage } from "./client.js";
|
|
9
8
|
|
|
10
9
|
/**
|
|
11
10
|
* Per-part text ceiling declared to core's renderer so long agent replies are
|
|
@@ -72,7 +71,7 @@ export type RelayOutboundSendResult = {
|
|
|
72
71
|
* commits exactly one, but the 202 is always an array and the receipt
|
|
73
72
|
* should name everything the server stored.
|
|
74
73
|
*/
|
|
75
|
-
messages:
|
|
74
|
+
messages: RelaySentMessage[];
|
|
76
75
|
};
|
|
77
76
|
|
|
78
77
|
export async function sendRelayText(params: {
|
|
@@ -80,6 +79,11 @@ export async function sendRelayText(params: {
|
|
|
80
79
|
conversationId: string;
|
|
81
80
|
text: string;
|
|
82
81
|
replyToId?: string | null;
|
|
82
|
+
/**
|
|
83
|
+
* Required when replying into a group: the server refuses an agent's group
|
|
84
|
+
* message that does not name the invocation it is answering.
|
|
85
|
+
*/
|
|
86
|
+
invocationId?: string;
|
|
83
87
|
idempotencyKey: string;
|
|
84
88
|
signal?: AbortSignal;
|
|
85
89
|
}): Promise<RelayOutboundSendResult> {
|
|
@@ -90,6 +94,7 @@ export async function sendRelayText(params: {
|
|
|
90
94
|
conversationId: params.conversationId,
|
|
91
95
|
parts: [{ type: "text", text: params.text }],
|
|
92
96
|
...(params.replyToId ? { replyTo: { message_id: params.replyToId } } : {}),
|
|
97
|
+
...(params.invocationId ? { invocationId: params.invocationId } : {}),
|
|
93
98
|
idempotencyKey: params.idempotencyKey,
|
|
94
99
|
...(params.signal ? { signal: params.signal } : {}),
|
|
95
100
|
});
|
|
@@ -109,7 +114,7 @@ export async function sendRelayText(params: {
|
|
|
109
114
|
}
|
|
110
115
|
|
|
111
116
|
export type RelayUnknownSendVerdict =
|
|
112
|
-
| { status: "sent"; messageId: string; messages:
|
|
117
|
+
| { status: "sent"; messageId: string; messages: RelaySentMessage[] }
|
|
113
118
|
| { status: "not_sent" }
|
|
114
119
|
| { status: "unresolved"; error?: string; retryable?: boolean };
|
|
115
120
|
|
|
@@ -125,6 +130,7 @@ export async function reconcileRelayUnknownSend(params: {
|
|
|
125
130
|
conversationId: string;
|
|
126
131
|
text: string;
|
|
127
132
|
replyToId?: string | null;
|
|
133
|
+
invocationId?: string;
|
|
128
134
|
idempotencyKey: string;
|
|
129
135
|
}): Promise<RelayUnknownSendVerdict> {
|
|
130
136
|
try {
|
|
@@ -133,6 +139,7 @@ export async function reconcileRelayUnknownSend(params: {
|
|
|
133
139
|
conversationId: params.conversationId,
|
|
134
140
|
text: params.text,
|
|
135
141
|
replyToId: params.replyToId ?? null,
|
|
142
|
+
...(params.invocationId ? { invocationId: params.invocationId } : {}),
|
|
136
143
|
idempotencyKey: params.idempotencyKey,
|
|
137
144
|
});
|
|
138
145
|
return { status: "sent", messageId: result.messageId, messages: result.messages };
|
package/src/poll-loop.ts
CHANGED
|
@@ -130,6 +130,18 @@ export async function runRelayPollLoop(params: RelayPollLoopParams): Promise<voi
|
|
|
130
130
|
} catch (error) {
|
|
131
131
|
if (!attempted) {
|
|
132
132
|
params.deduper.releaseEvent(event.event_id);
|
|
133
|
+
// A rejection is the server's final answer: replaying the identical
|
|
134
|
+
// request produces the identical refusal. Holding the cursor for it
|
|
135
|
+
// is a livelock, and the cursor is ONE watermark for the whole
|
|
136
|
+
// channel — so a single permanently-refused event would starve every
|
|
137
|
+
// later message, direct ones included (REL-167). Losing one event is
|
|
138
|
+
// strictly better than losing the channel, so log it loudly and let
|
|
139
|
+
// the page cursor move past it.
|
|
140
|
+
if (error instanceof RelayApiError && error.kind === "rejected") {
|
|
141
|
+
log(`[relay] event ${event.event_id} was permanently rejected by the server, ` +
|
|
142
|
+
`skipping it so later messages are not starved: ${String(error)}`);
|
|
143
|
+
continue;
|
|
144
|
+
}
|
|
133
145
|
log(`[relay] event ${event.event_id} safe preflight failed, will replay: ${String(error)}`);
|
|
134
146
|
batchFailed = true;
|
|
135
147
|
break;
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { RelayApiError } from "./client.js";
|
|
2
|
+
import type { RelayClient } from "./client.js";
|
|
3
|
+
import type { RelayInboundFacts } from "./inbound.js";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Record the read/responding receipt, then commit the durable attempt marker.
|
|
7
|
+
*
|
|
8
|
+
* The receipt is a courtesy to the person waiting: it turns their message Read
|
|
9
|
+
* and shows that something is composing. It is NOT permission to answer, and
|
|
10
|
+
* it used to be treated as such — a rejected receipt threw here, before
|
|
11
|
+
* `markAttempt`, which sent the poll loop down its replay branch and froze the
|
|
12
|
+
* channel's single delivery cursor. One group mention whose receipt the server
|
|
13
|
+
* refused therefore starved every later message, direct ones included
|
|
14
|
+
* (REL-167).
|
|
15
|
+
*
|
|
16
|
+
* So a failed receipt is reported and the turn continues. The ordering that
|
|
17
|
+
* mattered is kept: the receipt is still attempted BEFORE the attempt marker,
|
|
18
|
+
* so a receipt that succeeds still precedes any agent or tool work.
|
|
19
|
+
*/
|
|
20
|
+
export async function markRespondingBeforeAttempt(params: {
|
|
21
|
+
client: RelayClient;
|
|
22
|
+
/**
|
|
23
|
+
* The whole fact bundle, not its fields one at a time. The receipt needs the
|
|
24
|
+
* conversation, the message, AND the invocation when there is one, and a
|
|
25
|
+
* caller that copies two of those three out by hand can silently forget the
|
|
26
|
+
* third — which is how the group receipt shipped without its invocation id.
|
|
27
|
+
*/
|
|
28
|
+
facts: Pick<RelayInboundFacts, "conversationId" | "messageId" | "invocationId">;
|
|
29
|
+
label: string;
|
|
30
|
+
markAttempt: () => Promise<void>;
|
|
31
|
+
onReceiptFailure?: (line: string) => void;
|
|
32
|
+
}): Promise<void> {
|
|
33
|
+
const { facts } = params;
|
|
34
|
+
try {
|
|
35
|
+
await params.client.setResponding({
|
|
36
|
+
conversationId: facts.conversationId,
|
|
37
|
+
messageId: facts.messageId,
|
|
38
|
+
label: params.label,
|
|
39
|
+
...(facts.invocationId ? { invocationId: facts.invocationId } : {}),
|
|
40
|
+
});
|
|
41
|
+
} catch (error) {
|
|
42
|
+
// An aborted shutdown is not a receipt failure; let it settle the loop.
|
|
43
|
+
if (error instanceof Error && error.name === "AbortError") {
|
|
44
|
+
throw error;
|
|
45
|
+
}
|
|
46
|
+
const detail = error instanceof RelayApiError ? error.message : String(error);
|
|
47
|
+
params.onReceiptFailure?.(
|
|
48
|
+
`responding receipt for message ${facts.messageId} failed, answering anyway: ${detail}`,
|
|
49
|
+
);
|
|
50
|
+
}
|
|
51
|
+
await params.markAttempt();
|
|
52
|
+
}
|
package/src/state-files.ts
CHANGED
|
@@ -95,6 +95,89 @@ function lockRetryDelayMs(attempt: number, remainingMs: number): number {
|
|
|
95
95
|
return Math.max(1, Math.min(backoff * (0.5 + Math.random() / 2), remainingMs));
|
|
96
96
|
}
|
|
97
97
|
|
|
98
|
+
/**
|
|
99
|
+
* The sidecar lock has no in-process fast path: every waiter polls the lock
|
|
100
|
+
* file, and losing an attempt costs an exclusive create plus a snapshot read.
|
|
101
|
+
* Relay mutates one document from several tasks at once — a poll batch
|
|
102
|
+
* registers one dedupe entry per inbound message — so N in-process writers
|
|
103
|
+
* become N pollers competing with the holder for the same file. Funnelling
|
|
104
|
+
* them through one in-memory queue leaves a single poller per process, which
|
|
105
|
+
* matters most on Windows: same-process losers no longer race the holder's
|
|
106
|
+
* unlink, so contention stops manifesting as delete-pending denials.
|
|
107
|
+
*
|
|
108
|
+
* Keyed by the store's file path. Two paths spelled differently for one file
|
|
109
|
+
* would each get a queue and simply fall back to the sidecar lock for
|
|
110
|
+
* correctness, so a miss costs throughput rather than serialization.
|
|
111
|
+
*/
|
|
112
|
+
const RELAY_STATE_MUTEX_KEY = Symbol.for("relay.stateFileMutexes");
|
|
113
|
+
|
|
114
|
+
function stateFileMutexes(): Map<string, Promise<void>> {
|
|
115
|
+
const container = globalThis as typeof globalThis & {
|
|
116
|
+
[RELAY_STATE_MUTEX_KEY]?: Map<string, Promise<void>>;
|
|
117
|
+
};
|
|
118
|
+
container[RELAY_STATE_MUTEX_KEY] ??= new Map<string, Promise<void>>();
|
|
119
|
+
return container[RELAY_STATE_MUTEX_KEY];
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function fileLockTimeout(filePath: string): Error {
|
|
123
|
+
return Object.assign(new Error(`file lock timeout for ${filePath}`), {
|
|
124
|
+
code: "file_lock_timeout",
|
|
125
|
+
});
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** Waits for our turn, but never past the caller's lock deadline. */
|
|
129
|
+
async function awaitTurn(
|
|
130
|
+
turn: Promise<void>,
|
|
131
|
+
deadline: number,
|
|
132
|
+
filePath: string,
|
|
133
|
+
): Promise<void> {
|
|
134
|
+
const remaining = deadline - Date.now();
|
|
135
|
+
if (remaining <= 0) throw fileLockTimeout(filePath);
|
|
136
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
137
|
+
try {
|
|
138
|
+
await Promise.race([
|
|
139
|
+
turn,
|
|
140
|
+
new Promise<never>((_resolve, reject) => {
|
|
141
|
+
timer = setTimeout(() => reject(fileLockTimeout(filePath)), remaining);
|
|
142
|
+
}),
|
|
143
|
+
]);
|
|
144
|
+
} finally {
|
|
145
|
+
if (timer) clearTimeout(timer);
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
async function withStateFileMutex<R>(
|
|
150
|
+
filePath: string,
|
|
151
|
+
deadline: number,
|
|
152
|
+
run: () => Promise<R>,
|
|
153
|
+
): Promise<R> {
|
|
154
|
+
const mutexes = stateFileMutexes();
|
|
155
|
+
const previous = mutexes.get(filePath);
|
|
156
|
+
let release!: () => void;
|
|
157
|
+
const ours = new Promise<void>((resolve) => {
|
|
158
|
+
release = resolve;
|
|
159
|
+
});
|
|
160
|
+
// Chain even when we abandon our turn on timeout: later waiters still queue
|
|
161
|
+
// behind the holder we were waiting on, so ordering survives a giving-up
|
|
162
|
+
// waiter.
|
|
163
|
+
const tail = previous ? previous.then(() => ours) : ours;
|
|
164
|
+
mutexes.set(filePath, tail);
|
|
165
|
+
let tookTurn = false;
|
|
166
|
+
try {
|
|
167
|
+
if (previous) await awaitTurn(previous, deadline, filePath);
|
|
168
|
+
tookTurn = true;
|
|
169
|
+
return await run();
|
|
170
|
+
} finally {
|
|
171
|
+
release();
|
|
172
|
+
// Forgetting the queue is only safe once it has drained. A waiter that gave
|
|
173
|
+
// up is still queued behind a holder that is running, so dropping the entry
|
|
174
|
+
// there would let the next caller past the holder and back onto the lock
|
|
175
|
+
// file the queue exists to keep it off. Leaving it costs one settled promise
|
|
176
|
+
// until the next caller drains it.
|
|
177
|
+
if (tookTurn && mutexes.get(filePath) === tail) mutexes.delete(filePath);
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
98
181
|
function canRecoverRelayStateLock(value: unknown): boolean {
|
|
99
182
|
return (
|
|
100
183
|
isRelayStateLockOwner(value) &&
|
|
@@ -147,53 +230,56 @@ export function openRelayStateDocument<T>(params: {
|
|
|
147
230
|
});
|
|
148
231
|
const withMutationLock = async <R>(run: () => Promise<R>): Promise<R> => {
|
|
149
232
|
const deadline = Date.now() + lockTimeoutMs;
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
233
|
+
return await withStateFileMutex(store.filePath, deadline, async () => {
|
|
234
|
+
for (let attempt = 0; ; attempt += 1) {
|
|
235
|
+
// Only acquisition is retried. Once the mutation itself has started it
|
|
236
|
+
// has observed state under the lock, so replaying it could double-apply.
|
|
237
|
+
let mutationStarted = false;
|
|
238
|
+
try {
|
|
239
|
+
return await withFileLock(
|
|
240
|
+
store.filePath,
|
|
241
|
+
{
|
|
242
|
+
managerKey: `relay-state:${store.filePath}`,
|
|
243
|
+
staleMs: RELAY_STATE_LOCK_TIMEOUT_MS,
|
|
244
|
+
timeoutMs: Math.max(1, deadline - Date.now()),
|
|
245
|
+
staleRecovery: "remove-if-unchanged",
|
|
246
|
+
retry: {
|
|
247
|
+
retries: 300,
|
|
248
|
+
minTimeout: 25,
|
|
249
|
+
maxTimeout: 250,
|
|
250
|
+
randomize: true,
|
|
251
|
+
},
|
|
252
|
+
payload: (): RelayStateLockOwner => ({
|
|
253
|
+
version: RELAY_STATE_LOCK_VERSION,
|
|
254
|
+
kind: "relay-state",
|
|
255
|
+
pid: process.pid,
|
|
256
|
+
host: hostname(),
|
|
257
|
+
createdAt: new Date().toISOString(),
|
|
258
|
+
}),
|
|
259
|
+
shouldReclaim: ({ payload }) => canRecoverRelayStateLock(payload),
|
|
260
|
+
shouldRemoveStaleLock: ({ payload }) =>
|
|
261
|
+
canRecoverRelayStateLock(payload),
|
|
167
262
|
},
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
}
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
if (
|
|
186
|
-
mutationStarted ||
|
|
187
|
-
remaining <= 0 ||
|
|
188
|
-
!isWindowsLockAcquisitionContention(error)
|
|
189
|
-
) {
|
|
190
|
-
throw error;
|
|
263
|
+
async () => {
|
|
264
|
+
mutationStarted = true;
|
|
265
|
+
return await run();
|
|
266
|
+
},
|
|
267
|
+
);
|
|
268
|
+
} catch (error) {
|
|
269
|
+
const remaining = deadline - Date.now();
|
|
270
|
+
if (
|
|
271
|
+
mutationStarted ||
|
|
272
|
+
remaining <= 0 ||
|
|
273
|
+
!isWindowsLockAcquisitionContention(error)
|
|
274
|
+
) {
|
|
275
|
+
throw error;
|
|
276
|
+
}
|
|
277
|
+
await new Promise((resolve) =>
|
|
278
|
+
setTimeout(resolve, lockRetryDelayMs(attempt, remaining)),
|
|
279
|
+
);
|
|
191
280
|
}
|
|
192
|
-
await new Promise((resolve) =>
|
|
193
|
-
setTimeout(resolve, lockRetryDelayMs(attempt, remaining)),
|
|
194
|
-
);
|
|
195
281
|
}
|
|
196
|
-
}
|
|
282
|
+
});
|
|
197
283
|
};
|
|
198
284
|
|
|
199
285
|
return {
|
package/src/types.ts
CHANGED
|
@@ -3,7 +3,10 @@
|
|
|
3
3
|
// { events, next_cursor }, cursor N acknowledges everything <= N.
|
|
4
4
|
|
|
5
5
|
export type RelaySender = {
|
|
6
|
-
|
|
6
|
+
// `system` is real on the wire: a group's own notices are authored by it
|
|
7
|
+
// (creating a group commits "<name> created <title>"). Only `user` messages
|
|
8
|
+
// start a turn, and `buildRelayInboundFacts` drops everything else.
|
|
9
|
+
kind: "user" | "agent" | "system";
|
|
7
10
|
id: string;
|
|
8
11
|
};
|
|
9
12
|
|
|
@@ -105,6 +108,17 @@ export type RelayEvent = {
|
|
|
105
108
|
created_at: string;
|
|
106
109
|
data: {
|
|
107
110
|
message?: RelayMessage;
|
|
111
|
+
/**
|
|
112
|
+
* Present only when this event belongs to a group invocation: a human
|
|
113
|
+
* mentioned the agent, replied to it, or picked it. In a group the server
|
|
114
|
+
* delivers nothing to an agent that was not invoked, and every call the
|
|
115
|
+
* agent then makes about the message must carry this id back
|
|
116
|
+
* (`/typing`, `/responding`, and the reply itself all refuse without it).
|
|
117
|
+
* A direct message never carries one — the server rejects
|
|
118
|
+
* `invoked_agent_ids` outside a group — so its presence is also how the
|
|
119
|
+
* plugin knows it is in a group.
|
|
120
|
+
*/
|
|
121
|
+
invocation_id?: string;
|
|
108
122
|
[key: string]: unknown;
|
|
109
123
|
};
|
|
110
124
|
};
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
# Vendored `@relaymessenger/sdk` client
|
|
2
|
+
|
|
3
|
+
`client.ts`, `errors.ts`, `types.ts`, and `url.ts` are **verbatim copies** of
|
|
4
|
+
`packages/sdk/src/` in this same repository. Do not edit them here. Fix the SDK
|
|
5
|
+
and re-copy.
|
|
6
|
+
|
|
7
|
+
## Why a copy
|
|
8
|
+
|
|
9
|
+
The plugin used to carry its own hand-rolled Relay client. The two drifted, and
|
|
10
|
+
the drift shipped a defect: the plugin's client had no `invocationId` on
|
|
11
|
+
`sendMessage`, `setTyping`, or `setResponding`, so the first group mention an
|
|
12
|
+
agent received wedged its whole event stream (REL-167). The SDK client has
|
|
13
|
+
always had those parameters.
|
|
14
|
+
|
|
15
|
+
The SDK is not published to npm yet (`packages/cli/CLAUDE.md`), so the plugin
|
|
16
|
+
cannot depend on it. Vendoring adopts the correct client now instead of growing
|
|
17
|
+
a second one.
|
|
18
|
+
|
|
19
|
+
## Removing this directory
|
|
20
|
+
|
|
21
|
+
When `@relaymessenger/sdk` ships:
|
|
22
|
+
|
|
23
|
+
1. Add it to `dependencies` in `integrations/openclaw/package.json`.
|
|
24
|
+
2. Point `src/client.ts` at `@relaymessenger/sdk` instead of `./vendor/relay-sdk/*`.
|
|
25
|
+
3. Delete this directory.
|
|
26
|
+
|
|
27
|
+
`src/client.ts` is the only file that imports from here, so that is the whole
|
|
28
|
+
swap.
|