@alignfirst/service-openclaw-plugin 0.4.2 → 0.5.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
@@ -43,8 +43,7 @@ their IDs to the corresponding native contract:
43
43
  The plugin observes successful native `message` actions but never creates a thread itself.
44
44
 
45
45
  - Slack evidence is a `send` to the current parent channel with an explicit `threadId`, nonempty body, `deliveryStatus: "sent"`, and `messageDelivery: { status: "settled", partialDelivery: false }`. The result must include a channel-kind `target` naming the parent and a nonempty `messageId`; `result.receipt.threadId`, when present, must match the requested thread. The plugin-path `{ ok, result }` shape used by team-qualified Slack sends is rejected.
46
- - Discord evidence is a successful anchored `thread-create` in the current parent channel with a
47
- nonempty starter and returned thread ID. A partial result is rejected.
46
+ - Discord evidence is the starter `thread-reply` into a thread the session created. A successful `thread-create` in the current parent channel, anchored on a message and returning a thread ID, records the thread for the session; it is never evidence by itself, even with content. The `thread-reply` into that thread must carry a nonempty starter, and a returned `result.channelId` must name the thread; `result.messageId` becomes the starter message ID. A partial result is rejected at either step. The created thread is kept in gateway memory for one hour.
48
47
  - `thread_handoff { "action": "start", "threadId": "..." }` returns `queued` or
49
48
  `alreadyStarted`, plus the opaque handoff ID and canonical target session key.
50
49
 
@@ -61,7 +60,7 @@ The receiving turn calls `thread_handoff { "action": "claim" }` once before task
61
60
  Inputs are strict. Errors begin with a stable reason code: `unsupportedContext`,
62
61
  `unverifiedThreadDelivery`, `conflictingHandoff`, `invalidTarget`, or
63
62
  `unavailablePersistentState`. A capacity failure preserves `STORE_LIMIT_EXCEEDED` as its cause.
64
- Rejected eligible delivery observations emit one debug line with `notSent`, `partialDelivery`, `channelMismatch`, `threadMismatch`, `missingMessageId`, `missingThread`, `missingStarter`, or `accountMismatch`. The line contains no starter text or result payload.
63
+ Rejected eligible delivery observations emit one debug line with `notSent`, `partialDelivery`, `channelMismatch`, `threadMismatch`, `missingMessageId`, `missingThread`, `missingStarter`, `unknownThread`, or `accountMismatch`. The line contains no starter text or result payload.
65
64
 
66
65
  Starts are limited to distinct regular parent-channel sessions. DMs, group DMs, Slack Agent View,
67
66
  ACP, subagent, cron, global/shared, already-threaded, and ambiguous cross-account routes are not
@@ -21,7 +21,7 @@ export function registerThreadHandoff(api) {
21
21
  configuration,
22
22
  getStore,
23
23
  logger: api.logger,
24
- cache: processShared("receipt-cache/v1", createReceiptCache),
24
+ cache: processShared("receipt-cache/v2", createReceiptCache),
25
25
  });
26
26
  const runIds = processShared("run-ids/v1", () => createRunIdCache());
27
27
  const service = createHandoffService({
@@ -17,15 +17,21 @@ interface HookContext {
17
17
  sessionKey?: string;
18
18
  sessionId?: string;
19
19
  }
20
- /** Captured contexts and observation errors, shared by every registration of the plugin. */
20
+ /**
21
+ * Captured contexts, threads created by a session and observation errors, shared by every
22
+ * registration of the plugin.
23
+ */
21
24
  export interface ReceiptCache {
22
25
  contexts: Map<string, CachedContext>;
26
+ createdThreads: Map<string, CachedEntry>;
23
27
  observationErrors: Map<string, Error>;
24
28
  }
25
- export interface CachedContext {
26
- source: SourceContext;
29
+ export interface CachedEntry {
27
30
  capturedAt: number;
28
31
  }
32
+ export interface CachedContext extends CachedEntry {
33
+ source: SourceContext;
34
+ }
29
35
  export declare function createReceiptCoordinator(params: {
30
36
  configuration: PluginConfiguration;
31
37
  getStore: () => HandoffStore;
@@ -7,43 +7,41 @@ const RECEIPT_WAIT_MS = 1_000;
7
7
  const RECEIPT_POLL_MS = 25;
8
8
  export function createReceiptCoordinator(params) {
9
9
  const now = params.now ?? Date.now;
10
- const { contexts, observationErrors } = params.cache ?? createReceiptCache();
10
+ const { contexts, createdThreads, observationErrors } = params.cache ?? createReceiptCache();
11
11
  return {
12
12
  captureContext(context) {
13
13
  const source = readSourceContext(context, params.configuration);
14
14
  if (!source)
15
15
  return;
16
16
  contexts.set(contextKey(source.sessionKey, source.sessionId), { source, capturedAt: now() });
17
- pruneContexts(contexts, now());
17
+ pruneEntries(contexts, now());
18
18
  },
19
19
  observe(event, context) {
20
20
  const source = readCachedSource(contexts, context, now());
21
21
  if (!source || event.toolName !== "message" || event.error !== undefined)
22
22
  return;
23
23
  const surface = params.configuration.channelSurfaces[source.channelId];
24
- const receipt = parseDeliveryReceipt({
24
+ pruneEntries(createdThreads, now());
25
+ const result = parseObservation({
25
26
  event,
26
27
  source,
27
28
  surface,
29
+ isCreatedThread: (threadId) => createdThreads.has(lookupKey(source.sessionKey, source.sessionId, threadId)),
28
30
  now: now(),
29
31
  });
30
- if (!receipt)
32
+ if (!result)
31
33
  return;
32
- if (typeof receipt === "string") {
34
+ if (typeof result === "string") {
33
35
  const threadId = readObservedThreadId(event, surface);
34
- params.logger.debug?.(`thread-handoff receipt rejected: surface=${surface} session=${source.sessionKey} thread=${threadId ?? "-"} reason=${receipt}`);
36
+ params.logger.debug?.(`thread-handoff receipt rejected: surface=${surface} session=${source.sessionKey} thread=${threadId ?? "-"} reason=${result}`);
35
37
  return;
36
38
  }
37
- const key = lookupKey(receipt.sessionKey, receipt.sessionId, receipt.threadId);
38
- try {
39
- params.getStore().insertReceipt(receipt, now());
40
- observationErrors.delete(key);
41
- }
42
- catch (error) {
43
- const storedError = error instanceof Error ? error : new Error(String(error));
44
- observationErrors.set(key, storedError);
45
- params.logger.error(`thread-handoff receipt persistence failed: ${storedError.message}`);
39
+ if ("createdThreadId" in result) {
40
+ const key = lookupKey(source.sessionKey, source.sessionId, result.createdThreadId);
41
+ createdThreads.set(key, { capturedAt: now() });
42
+ return;
46
43
  }
44
+ persistReceipt({ ...params, receipt: result, observationErrors, now: now() });
47
45
  },
48
46
  async waitForReceipt(identity) {
49
47
  const key = lookupKey(identity.sourceSessionKey, identity.sourceSessionId, identity.threadId);
@@ -60,36 +58,52 @@ export function createReceiptCoordinator(params) {
60
58
  },
61
59
  };
62
60
  }
61
+ function persistReceipt(params) {
62
+ const { receipt, observationErrors } = params;
63
+ const key = lookupKey(receipt.sessionKey, receipt.sessionId, receipt.threadId);
64
+ try {
65
+ params.getStore().insertReceipt(receipt, params.now);
66
+ observationErrors.delete(key);
67
+ }
68
+ catch (error) {
69
+ const storedError = error instanceof Error ? error : new Error(String(error));
70
+ observationErrors.set(key, storedError);
71
+ params.logger.error(`thread-handoff receipt persistence failed: ${storedError.message}`);
72
+ }
73
+ }
63
74
  export function createReceiptCache() {
64
- return { contexts: new Map(), observationErrors: new Map() };
75
+ return { contexts: new Map(), createdThreads: new Map(), observationErrors: new Map() };
65
76
  }
66
77
  function readCachedSource(contexts, context, now) {
67
78
  const sessionKey = nonempty(context.sessionKey);
68
79
  const sessionId = nonempty(context.sessionId);
69
80
  if (!sessionKey || !sessionId)
70
81
  return;
71
- pruneContexts(contexts, now);
82
+ pruneEntries(contexts, now);
72
83
  return contexts.get(contextKey(sessionKey, sessionId))?.source;
73
84
  }
74
- function pruneContexts(contexts, now) {
75
- for (const [key, value] of contexts) {
85
+ function pruneEntries(entries, now) {
86
+ for (const [key, value] of entries) {
76
87
  if (value.capturedAt + RECEIPT_TTL_MS <= now)
77
- contexts.delete(key);
88
+ entries.delete(key);
78
89
  }
79
- while (contexts.size > CONTEXT_LIMIT) {
80
- const oldest = contexts.keys().next().value;
90
+ while (entries.size > CONTEXT_LIMIT) {
91
+ const oldest = entries.keys().next().value;
81
92
  if (typeof oldest !== "string")
82
93
  return;
83
- contexts.delete(oldest);
94
+ entries.delete(oldest);
84
95
  }
85
96
  }
86
- function parseDeliveryReceipt(params) {
87
- if (params.surface === "slack" && params.event.params.action === "send") {
97
+ function parseObservation(params) {
98
+ const action = params.event.params.action;
99
+ if (params.surface === "slack" && action === "send")
88
100
  return parseSlackReceipt(params);
89
- }
90
- if (params.surface === "discord" && params.event.params.action === "thread-create") {
91
- return parseDiscordReceipt(params);
92
- }
101
+ if (params.surface !== "discord")
102
+ return;
103
+ if (action === "thread-create")
104
+ return parseDiscordThreadCreation(params);
105
+ if (action === "thread-reply")
106
+ return parseDiscordStarterReply(params);
93
107
  return;
94
108
  }
95
109
  function parseSlackReceipt(params) {
@@ -138,9 +152,9 @@ function parseSlackReceipt(params) {
138
152
  now: params.now,
139
153
  });
140
154
  }
141
- function parseDiscordReceipt(params) {
155
+ /** A thread this session created in its own channel, anchored on a message. Not a receipt. */
156
+ function parseDiscordThreadCreation(params) {
142
157
  const { event, source } = params;
143
- const starterText = readStarter(event.params);
144
158
  const destination = readDestination(event.params);
145
159
  const anchorMessageId = nonempty(event.params.messageId);
146
160
  const details = readResultDetails(event.result);
@@ -148,8 +162,6 @@ function parseDiscordReceipt(params) {
148
162
  const threadId = nonempty(thread?.id);
149
163
  if (!threadId)
150
164
  return "missingThread";
151
- if (starterText === undefined)
152
- return "missingStarter";
153
165
  if (!anchorMessageId)
154
166
  return "missingMessageId";
155
167
  const returnedParent = nonempty(thread?.parent_id) ?? nonempty(thread?.parentId);
@@ -164,19 +176,50 @@ function parseDiscordReceipt(params) {
164
176
  return "partialDelivery";
165
177
  if (!accountMatches(event.params, source.accountId))
166
178
  return "accountMismatch";
179
+ return { createdThreadId: threadId };
180
+ }
181
+ /** The starter posted by `thread-reply` into a thread this session created. */
182
+ function parseDiscordStarterReply(params) {
183
+ const { event, source } = params;
184
+ const threadId = readReplyThreadId(event.params);
185
+ if (!threadId)
186
+ return "missingThread";
187
+ const starterText = readStarter(event.params);
188
+ if (starterText === undefined)
189
+ return "missingStarter";
190
+ if (!params.isCreatedThread(threadId))
191
+ return "unknownThread";
192
+ const details = readResultDetails(event.result);
193
+ if (details?.ok !== true)
194
+ return "notSent";
195
+ if (details.partial === true)
196
+ return "partialDelivery";
197
+ const result = asRecord(details.result);
198
+ const returnedChannelId = nonempty(result?.channelId);
199
+ if (returnedChannelId !== undefined && returnedChannelId !== threadId)
200
+ return "threadMismatch";
201
+ if (!accountMatches(event.params, source.accountId))
202
+ return "accountMismatch";
167
203
  return createReceipt({
168
204
  source,
169
205
  threadId,
170
206
  starterText,
207
+ starterMessageId: nonempty(result?.messageId),
171
208
  toolCallId: event.toolCallId,
172
209
  now: params.now,
173
210
  });
174
211
  }
212
+ /** Bundled Discord replies into `threadId`, or into the target when it is absent. */
213
+ function readReplyThreadId(params) {
214
+ return nonempty(params.threadId) ?? readDestination(params)?.replace(/^channel:/i, "");
215
+ }
175
216
  function readObservedThreadId(event, surface) {
176
217
  if (surface === "slack")
177
218
  return nonempty(event.params.threadId);
178
219
  if (surface !== "discord")
179
220
  return;
221
+ if (event.params.action === "thread-reply")
222
+ return readReplyThreadId(event.params);
180
223
  const details = readResultDetails(event.result);
181
224
  const thread = asRecord(details?.thread);
182
225
  return nonempty(thread?.id);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@alignfirst/service-openclaw-plugin",
3
- "version": "0.4.2",
3
+ "version": "0.5.0",
4
4
  "description": "OpenClaw capabilities for the AlignFirst Dev Kit.",
5
5
  "keywords": [
6
6
  "alignfirst",
@@ -43,7 +43,7 @@
43
43
  "typebox": "~1.3.23"
44
44
  },
45
45
  "devDependencies": {
46
- "@alignfirst/openclaw-channel-mock-core": "0.10.3",
46
+ "@alignfirst/openclaw-channel-mock-core": "0.11.0",
47
47
  "@types/node": "~26.5.0",
48
48
  "openclaw": "~2026.9.8",
49
49
  "rimraf": "~6.1.3",