@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
|
|
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/
|
|
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
|
-
/**
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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 (!
|
|
32
|
+
if (!result)
|
|
31
33
|
return;
|
|
32
|
-
if (typeof
|
|
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=${
|
|
36
|
+
params.logger.debug?.(`thread-handoff receipt rejected: surface=${surface} session=${source.sessionKey} thread=${threadId ?? "-"} reason=${result}`);
|
|
35
37
|
return;
|
|
36
38
|
}
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
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
|
-
|
|
82
|
+
pruneEntries(contexts, now);
|
|
72
83
|
return contexts.get(contextKey(sessionKey, sessionId))?.source;
|
|
73
84
|
}
|
|
74
|
-
function
|
|
75
|
-
for (const [key, value] of
|
|
85
|
+
function pruneEntries(entries, now) {
|
|
86
|
+
for (const [key, value] of entries) {
|
|
76
87
|
if (value.capturedAt + RECEIPT_TTL_MS <= now)
|
|
77
|
-
|
|
88
|
+
entries.delete(key);
|
|
78
89
|
}
|
|
79
|
-
while (
|
|
80
|
-
const oldest =
|
|
90
|
+
while (entries.size > CONTEXT_LIMIT) {
|
|
91
|
+
const oldest = entries.keys().next().value;
|
|
81
92
|
if (typeof oldest !== "string")
|
|
82
93
|
return;
|
|
83
|
-
|
|
94
|
+
entries.delete(oldest);
|
|
84
95
|
}
|
|
85
96
|
}
|
|
86
|
-
function
|
|
87
|
-
|
|
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
|
-
|
|
91
|
-
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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",
|