@relaymessenger/openclaw-plugin 0.2.0 → 0.3.0

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/README.md CHANGED
@@ -4,10 +4,14 @@ Backs a Relay contact with an OpenClaw agent: install the plugin, point it at
4
4
  an owner-only Agent Token file, and your OpenClaw appears in Relay as a contact
5
5
  you text like a friend.
6
6
 
7
- Requires `openclaw >= 2026.7.2-beta.5`, which today means the OpenClaw beta
8
- channel: stable `2026.7.1-2` is too old, because the plugin derives its
9
- idempotency keys from the per-part delivery context that only the beta line
10
- provides. Install it with `npm install -g openclaw@beta`.
7
+ Requires `openclaw >= 2026.7.1-2`, which the stable channel satisfies today.
8
+
9
+ Cores from `2026.7.2-beta.5` onward tell the channel which part of a delivery
10
+ each chunk is, and the plugin keys its idempotent sends on that. Older cores do
11
+ not, so on those the plugin keys each chunk by a digest of the chunk's own
12
+ text. Both keep a retry replaying the same message instead of posting a second
13
+ one. The one thing an older core cannot do is separate two byte-identical
14
+ chunks of a single reply, which arrive as one message.
11
15
 
12
16
  ## Install
13
17
 
@@ -3,6 +3,17 @@
3
3
  // wiring. Transport logic lives in client/poll-loop/inbound/outbound modules;
4
4
  // this file owns the OpenClaw adapter surfaces.
5
5
  import { createChatChannelPlugin } from "openclaw/plugin-sdk/channel-core";
6
+ /**
7
+ * Read core's part index without requiring it to exist. Cores before
8
+ * 2026.7.2-beta.5 have no `deliveryPartIndex` in their outbound context at all,
9
+ * so naming the field directly would not typecheck against them. Reading it
10
+ * through a widened shape keeps one source compiling on every supported core;
11
+ * `deriveRelayIdempotencyKey` handles the undefined case.
12
+ */
13
+ function deliveryPartIndexOf(ctx) {
14
+ const index = ctx.deliveryPartIndex;
15
+ return typeof index === "number" ? index : undefined;
16
+ }
6
17
  import { resolveStableChannelMessageIngress } from "openclaw/plugin-sdk/channel-ingress-runtime";
7
18
  import { createMessageReceiptFromOutboundResults, defineChannelMessageAdapter, } from "openclaw/plugin-sdk/channel-outbound";
8
19
  import { resolveInboundRouteEnvelopeBuilderWithRuntime } from "openclaw/plugin-sdk/inbound-envelope";
@@ -131,12 +142,13 @@ const relayMessageAdapter = defineChannelMessageAdapter({
131
142
  conversationId: ctx.to,
132
143
  text: ctx.text,
133
144
  replyToId: ctx.replyToId ?? null,
134
- // Stable per (queueId, partIndex): internal retries replay the same
135
- // key, so the server-side idempotent commit makes duplicates
136
- // impossible by contract.
145
+ // Stable per (queueId, part): internal retries replay the same key,
146
+ // so the server-side idempotent commit makes duplicates impossible by
147
+ // contract. On a core with no part index the text names the part.
137
148
  idempotencyKey: deriveRelayIdempotencyKey({
138
149
  deliveryQueueId: ctx.deliveryQueueId,
139
- deliveryPartIndex: ctx.deliveryPartIndex,
150
+ deliveryPartIndex: deliveryPartIndexOf(ctx),
151
+ partText: ctx.text,
140
152
  }),
141
153
  ...(ctx.signal ? { signal: ctx.signal } : {}),
142
154
  });
@@ -596,7 +608,8 @@ export const relayChannelPlugin = createChatChannelPlugin({
596
608
  // this invocation so two intentional identical sends remain two.
597
609
  idempotencyKey: deriveRelayIdempotencyKey({
598
610
  deliveryQueueId: ctx.deliveryQueueId,
599
- deliveryPartIndex: ctx.deliveryPartIndex,
611
+ deliveryPartIndex: deliveryPartIndexOf(ctx),
612
+ partText: text,
600
613
  }),
601
614
  });
602
615
  return { messageId: result.messageId };
@@ -14,14 +14,33 @@ export const RELAY_TEXT_CHUNK_LIMIT = 2_000;
14
14
  const IDEMPOTENCY_KEY_MAX = 255;
15
15
  /**
16
16
  * Idempotency key for one logical send. When core supplies a durable delivery
17
- * queue id, the key is a stable function of (queueId, partIndex) so internal
17
+ * queue id, the key is a stable function of (queueId, part) so internal
18
18
  * retries and reconciliation replay the exact same key. Without a queue id a
19
19
  * fresh key is minted: identical intentional sends must remain distinct.
20
+ *
21
+ * The part term names WHICH piece of one delivery this is. Core supplies
22
+ * `deliveryPartIndex` from 2026.7.2-beta.5 onward and that is authoritative.
23
+ * Older cores do not: they call `enqueueDelivery` once, mint ONE queue id, and
24
+ * then hand the channel each chunk of a long reply under it. Defaulting the
25
+ * missing index to 0 would key every chunk to `...:0`, so the server would
26
+ * replay the first chunk for each of the rest and the person would receive a
27
+ * long answer truncated to its opening chunk with no error anywhere.
28
+ *
29
+ * So when core cannot say which part this is, the part term is a digest of the
30
+ * part's own text. Sibling chunks differ, so each commits; a retry reproduces
31
+ * the same text, so it replays. This is not the content-in-the-key mistake
32
+ * that defeats conflict detection: the digest stands in FOR the position core
33
+ * did not give us, it does not replace it. Two byte-identical chunks in one
34
+ * delivery do collapse to one message, which is the residual cost of an
35
+ * unindexed core and is bounded to a repeat the reader would see twice.
20
36
  */
21
37
  export function deriveRelayIdempotencyKey(params) {
22
38
  const queueId = params.deliveryQueueId?.trim();
39
+ const part = params.deliveryPartIndex ?? (params.partText === undefined
40
+ ? 0
41
+ : `t${createHash("sha256").update(params.partText).digest("hex").slice(0, 16)}`);
23
42
  const key = queueId
24
- ? `relay-send:${queueId}:${params.deliveryPartIndex ?? 0}`
43
+ ? `relay-send:${queueId}:${part}`
25
44
  : `relay-send:${(params.random ?? (() => crypto.randomUUID()))()}`;
26
45
  // Server accepts 8-255 chars; the prefix guarantees the minimum.
27
46
  if (key.length <= IDEMPOTENCY_KEY_MAX) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@relaymessenger/openclaw-plugin",
3
- "version": "0.2.0",
3
+ "version": "0.3.0",
4
4
  "description": "Relay channel plugin for OpenClaw. Text your OpenClaw like a friend.",
5
5
  "license": "MIT",
6
6
  "publishConfig": {
@@ -39,7 +39,7 @@
39
39
  "prepack": "npm run build"
40
40
  },
41
41
  "dependencies": {
42
- "@openclaw/fs-safe": "0.5.5"
42
+ "@openclaw/fs-safe": "0.5.6"
43
43
  },
44
44
  "devDependencies": {
45
45
  "@types/node": "^26.2.0",
@@ -48,7 +48,7 @@
48
48
  "vitest": "^4.1.10"
49
49
  },
50
50
  "peerDependencies": {
51
- "openclaw": ">=2026.7.2-beta.5"
51
+ "openclaw": ">=2026.7.1-2"
52
52
  },
53
53
  "peerDependenciesMeta": {
54
54
  "openclaw": {
@@ -57,12 +57,12 @@
57
57
  },
58
58
  "openclaw": {
59
59
  "compat": {
60
- "pluginApi": ">=2026.7.2-beta.5"
60
+ "pluginApi": ">=2026.7.1-2"
61
61
  },
62
62
  "install": {
63
63
  "localPath": ".",
64
64
  "defaultChoice": "local",
65
- "minHostVersion": ">=2026.7.2-beta.5"
65
+ "minHostVersion": ">=2026.7.1-2"
66
66
  },
67
67
  "build": {
68
68
  "openclawVersion": "2026.7.2-beta.7"
package/src/channel.ts CHANGED
@@ -5,6 +5,18 @@
5
5
  import { createChatChannelPlugin } from "openclaw/plugin-sdk/channel-core";
6
6
  import type { ChannelPlugin, OpenClawConfig } from "openclaw/plugin-sdk/channel-core";
7
7
  import type { ChannelGatewayContext } from "openclaw/plugin-sdk/channel-contract";
8
+
9
+ /**
10
+ * Read core's part index without requiring it to exist. Cores before
11
+ * 2026.7.2-beta.5 have no `deliveryPartIndex` in their outbound context at all,
12
+ * so naming the field directly would not typecheck against them. Reading it
13
+ * through a widened shape keeps one source compiling on every supported core;
14
+ * `deriveRelayIdempotencyKey` handles the undefined case.
15
+ */
16
+ function deliveryPartIndexOf(ctx: unknown): number | undefined {
17
+ const index = (ctx as { deliveryPartIndex?: unknown }).deliveryPartIndex;
18
+ return typeof index === "number" ? index : undefined;
19
+ }
8
20
  import { resolveStableChannelMessageIngress } from "openclaw/plugin-sdk/channel-ingress-runtime";
9
21
  import {
10
22
  createMessageReceiptFromOutboundResults,
@@ -163,12 +175,13 @@ const relayMessageAdapter = defineChannelMessageAdapter({
163
175
  conversationId: ctx.to,
164
176
  text: ctx.text,
165
177
  replyToId: ctx.replyToId ?? null,
166
- // Stable per (queueId, partIndex): internal retries replay the same
167
- // key, so the server-side idempotent commit makes duplicates
168
- // impossible by contract.
178
+ // Stable per (queueId, part): internal retries replay the same key,
179
+ // so the server-side idempotent commit makes duplicates impossible by
180
+ // contract. On a core with no part index the text names the part.
169
181
  idempotencyKey: deriveRelayIdempotencyKey({
170
182
  deliveryQueueId: ctx.deliveryQueueId,
171
- deliveryPartIndex: ctx.deliveryPartIndex,
183
+ deliveryPartIndex: deliveryPartIndexOf(ctx),
184
+ partText: ctx.text,
172
185
  }),
173
186
  ...(ctx.signal ? { signal: ctx.signal } : {}),
174
187
  });
@@ -659,7 +672,8 @@ export const relayChannelPlugin: ChannelPlugin<ResolvedRelayAccount> = createCha
659
672
  // this invocation so two intentional identical sends remain two.
660
673
  idempotencyKey: deriveRelayIdempotencyKey({
661
674
  deliveryQueueId: ctx.deliveryQueueId,
662
- deliveryPartIndex: ctx.deliveryPartIndex,
675
+ deliveryPartIndex: deliveryPartIndexOf(ctx),
676
+ partText: text,
663
677
  }),
664
678
  });
665
679
  return { messageId: result.messageId };
package/src/outbound.ts CHANGED
@@ -19,18 +19,41 @@ const IDEMPOTENCY_KEY_MAX = 255;
19
19
 
20
20
  /**
21
21
  * Idempotency key for one logical send. When core supplies a durable delivery
22
- * queue id, the key is a stable function of (queueId, partIndex) so internal
22
+ * queue id, the key is a stable function of (queueId, part) so internal
23
23
  * retries and reconciliation replay the exact same key. Without a queue id a
24
24
  * fresh key is minted: identical intentional sends must remain distinct.
25
+ *
26
+ * The part term names WHICH piece of one delivery this is. Core supplies
27
+ * `deliveryPartIndex` from 2026.7.2-beta.5 onward and that is authoritative.
28
+ * Older cores do not: they call `enqueueDelivery` once, mint ONE queue id, and
29
+ * then hand the channel each chunk of a long reply under it. Defaulting the
30
+ * missing index to 0 would key every chunk to `...:0`, so the server would
31
+ * replay the first chunk for each of the rest and the person would receive a
32
+ * long answer truncated to its opening chunk with no error anywhere.
33
+ *
34
+ * So when core cannot say which part this is, the part term is a digest of the
35
+ * part's own text. Sibling chunks differ, so each commits; a retry reproduces
36
+ * the same text, so it replays. This is not the content-in-the-key mistake
37
+ * that defeats conflict detection: the digest stands in FOR the position core
38
+ * did not give us, it does not replace it. Two byte-identical chunks in one
39
+ * delivery do collapse to one message, which is the residual cost of an
40
+ * unindexed core and is bounded to a repeat the reader would see twice.
25
41
  */
26
42
  export function deriveRelayIdempotencyKey(params: {
27
43
  deliveryQueueId?: string;
28
44
  deliveryPartIndex?: number;
45
+ /** Part text, used only when core supplies no `deliveryPartIndex`. */
46
+ partText?: string;
29
47
  random?: () => string;
30
48
  }): string {
31
49
  const queueId = params.deliveryQueueId?.trim();
50
+ const part = params.deliveryPartIndex ?? (
51
+ params.partText === undefined
52
+ ? 0
53
+ : `t${createHash("sha256").update(params.partText).digest("hex").slice(0, 16)}`
54
+ );
32
55
  const key = queueId
33
- ? `relay-send:${queueId}:${params.deliveryPartIndex ?? 0}`
56
+ ? `relay-send:${queueId}:${part}`
34
57
  : `relay-send:${(params.random ?? (() => crypto.randomUUID()))()}`;
35
58
  // Server accepts 8-255 chars; the prefix guarantees the minimum.
36
59
  if (key.length <= IDEMPOTENCY_KEY_MAX) {