@pnds/pond 1.0.1 → 1.2.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/TOOLS.md +54 -0
- package/package.json +17 -4
- package/src/action-tools.ts +223 -0
- package/src/channel.ts +2 -2
- package/src/fork.ts +213 -0
- package/src/gateway.ts +721 -143
- package/src/hooks.ts +33 -17
- package/src/index.ts +5 -1
- package/src/outbound.ts +4 -1
- package/src/routing.ts +48 -0
- package/src/runtime.ts +84 -1
- package/src/session.ts +5 -0
- package/src/tool-helpers.ts +11 -0
- package/src/tools-md.ts +7 -0
- package/src/wiki-helper.ts +157 -0
- package/src/wiki-tools.ts +370 -0
package/src/gateway.ts
CHANGED
|
@@ -1,14 +1,31 @@
|
|
|
1
|
-
import type {
|
|
2
|
-
import {
|
|
3
|
-
|
|
1
|
+
import type { PluginRuntime } from "openclaw/plugin-sdk";
|
|
2
|
+
import type { ChannelPlugin } from "openclaw/plugin-sdk/core";
|
|
3
|
+
|
|
4
|
+
/** Extract ChannelGatewayAdapter from ChannelPlugin (removed from public SDK exports in 2026.3.24). */
|
|
5
|
+
type ChannelGatewayAdapter<T = unknown> = NonNullable<ChannelPlugin<T>["gateway"]>;
|
|
6
|
+
import { PondClient, PondWs, MENTION_ALL_USER_ID } from "@pnds/sdk";
|
|
7
|
+
import type { Chat, MessageNewData, TextContent, MediaContent, HelloData, ChatUpdateData, AgentConfigUpdateData, TaskAssignedData, Task } from "@pnds/sdk";
|
|
4
8
|
import type { PondChannelConfig } from "./types.js";
|
|
5
9
|
import * as os from "node:os";
|
|
6
10
|
import * as path from "node:path";
|
|
7
11
|
import { resolvePondAccount } from "./accounts.js";
|
|
8
|
-
import {
|
|
9
|
-
|
|
12
|
+
import {
|
|
13
|
+
getPondRuntime,
|
|
14
|
+
setPondAccountState,
|
|
15
|
+
getPondAccountState,
|
|
16
|
+
removePondAccountState,
|
|
17
|
+
setSessionChatId,
|
|
18
|
+
setSessionMessageId,
|
|
19
|
+
setDispatchMessageId,
|
|
20
|
+
setDispatchNoReply,
|
|
21
|
+
} from "./runtime.js";
|
|
22
|
+
import type { PondEvent, DispatchState, ForkResult } from "./runtime.js";
|
|
23
|
+
import { buildOrchestratorSessionKey } from "./session.js";
|
|
10
24
|
import type { ResolvedPondAccount } from "./types.js";
|
|
11
25
|
import { fetchAndApplyPlatformConfig } from "./config-manager.js";
|
|
26
|
+
import { startWikiHelper } from "./wiki-helper.js";
|
|
27
|
+
import { routeTrigger, defaultRoutingStrategy } from "./routing.js";
|
|
28
|
+
import { forkOrchestratorSession, cleanupForkSession, resolveTranscriptFile } from "./fork.js";
|
|
12
29
|
|
|
13
30
|
function resolveWsUrl(account: ResolvedPondAccount): string {
|
|
14
31
|
if (account.config.ws_url) return account.config.ws_url;
|
|
@@ -17,20 +34,22 @@ function resolveWsUrl(account: ResolvedPondAccount): string {
|
|
|
17
34
|
return `${wsBase}/ws`;
|
|
18
35
|
}
|
|
19
36
|
|
|
37
|
+
type ChatInfo = { type: Chat["type"]; name: string | null };
|
|
38
|
+
|
|
20
39
|
/**
|
|
21
|
-
* Fetch all chats with pagination and populate the type
|
|
40
|
+
* Fetch all chats with pagination and populate the info map (type + name).
|
|
22
41
|
*/
|
|
23
42
|
async function fetchAllChats(
|
|
24
43
|
client: PondClient,
|
|
25
44
|
orgId: string,
|
|
26
|
-
|
|
45
|
+
chatInfoMap: Map<string, ChatInfo>,
|
|
27
46
|
): Promise<number> {
|
|
28
47
|
let cursor: string | undefined;
|
|
29
48
|
let total = 0;
|
|
30
49
|
do {
|
|
31
50
|
const res = await client.getChats(orgId, { limit: 100, cursor });
|
|
32
51
|
for (const c of res.data) {
|
|
33
|
-
|
|
52
|
+
chatInfoMap.set(c.id, { type: c.type, name: c.name ?? null });
|
|
34
53
|
}
|
|
35
54
|
total += res.data.length;
|
|
36
55
|
cursor = res.has_more ? res.next_cursor : undefined;
|
|
@@ -38,10 +57,54 @@ async function fetchAllChats(
|
|
|
38
57
|
return total;
|
|
39
58
|
}
|
|
40
59
|
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
60
|
+
// ---------------------------------------------------------------------------
|
|
61
|
+
// Event body formatting
|
|
62
|
+
// ---------------------------------------------------------------------------
|
|
63
|
+
|
|
64
|
+
function buildEventBody(event: PondEvent): string {
|
|
65
|
+
const lines: string[] = [];
|
|
66
|
+
if (event.type === "message") {
|
|
67
|
+
lines.push(`[Event: message.new]`);
|
|
68
|
+
const chatLabel = event.targetName
|
|
69
|
+
? `"${event.targetName}" (${event.targetId})`
|
|
70
|
+
: event.targetId;
|
|
71
|
+
lines.push(`[Chat: ${chatLabel} | type: ${event.targetType ?? "unknown"}]`);
|
|
72
|
+
lines.push(`[From: ${event.senderName} (${event.senderId})]`);
|
|
73
|
+
lines.push(`[Message ID: ${event.messageId}]`);
|
|
74
|
+
if (event.threadRootId) lines.push(`[Thread: ${event.threadRootId}]`);
|
|
75
|
+
if (event.noReply) lines.push(`[Hint: no_reply]`);
|
|
76
|
+
if (event.mediaFields?.MediaUrl) {
|
|
77
|
+
lines.push(`[Attachment: ${event.mediaFields.MediaType ?? "file"} ${event.mediaFields.MediaUrl}]`);
|
|
78
|
+
}
|
|
79
|
+
lines.push("", event.body);
|
|
80
|
+
} else {
|
|
81
|
+
lines.push(`[Event: task.assigned]`);
|
|
82
|
+
const taskLabel = event.targetName
|
|
83
|
+
? `${event.targetName} (${event.targetId})`
|
|
84
|
+
: event.targetId;
|
|
85
|
+
lines.push(`[Task: ${taskLabel} | type: ${event.targetType ?? "task"}]`);
|
|
86
|
+
lines.push(`[Assigned by: ${event.senderName} (${event.senderId})]`);
|
|
87
|
+
lines.push("", event.body);
|
|
88
|
+
}
|
|
89
|
+
return lines.join("\n");
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function buildForkResultPrefix(results: ForkResult[]): string {
|
|
93
|
+
if (!results.length) return "";
|
|
94
|
+
const blocks = results.map((r) => {
|
|
95
|
+
const lines = [`[Fork result]`];
|
|
96
|
+
lines.push(`[Handled: ${r.sourceEvent.type} — ${r.sourceEvent.summary}]`);
|
|
97
|
+
if (r.actions.length) lines.push(`[Actions: ${r.actions.join("; ")}]`);
|
|
98
|
+
if (r.agentRunId) lines.push(`[Agent run: ${r.agentRunId}]`);
|
|
99
|
+
return lines.join("\n");
|
|
100
|
+
});
|
|
101
|
+
return blocks.join("\n\n") + "\n\n---\n\n";
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// ---------------------------------------------------------------------------
|
|
105
|
+
// Dispatch to OpenClaw agent (orchestrator mode — all text suppressed)
|
|
106
|
+
// ---------------------------------------------------------------------------
|
|
107
|
+
|
|
45
108
|
async function dispatchToAgent(opts: {
|
|
46
109
|
core: PluginRuntime;
|
|
47
110
|
cfg: Record<string, unknown>;
|
|
@@ -50,12 +113,19 @@ async function dispatchToAgent(opts: {
|
|
|
50
113
|
agentUserId: string;
|
|
51
114
|
accountId: string;
|
|
52
115
|
sessionKey: string;
|
|
53
|
-
chatId: string;
|
|
54
116
|
messageId: string;
|
|
55
117
|
inboundCtx: Record<string, unknown>;
|
|
118
|
+
triggerType?: string;
|
|
119
|
+
triggerRef?: Record<string, unknown>;
|
|
120
|
+
chatId?: string;
|
|
121
|
+
/** For task dispatches: bind the run to a task target for Redis presence + fallback comments. */
|
|
122
|
+
defaultTargetType?: string;
|
|
123
|
+
defaultTargetId?: string;
|
|
56
124
|
log?: { info: (msg: string) => void; warn: (msg: string) => void; error: (msg: string) => void };
|
|
57
125
|
}) {
|
|
58
|
-
const { core, cfg, client, config, agentUserId, accountId, sessionKey,
|
|
126
|
+
const { core, cfg, client, config, agentUserId, accountId, sessionKey, messageId, inboundCtx, log } = opts;
|
|
127
|
+
const triggerType = opts.triggerType ?? "mention";
|
|
128
|
+
const triggerRef = opts.triggerRef ?? { message_id: messageId };
|
|
59
129
|
const sessionKeyLower = sessionKey.toLowerCase();
|
|
60
130
|
let thinkingSent = false;
|
|
61
131
|
let runIdPromise: Promise<string | undefined> | undefined;
|
|
@@ -66,31 +136,22 @@ async function dispatchToAgent(opts: {
|
|
|
66
136
|
cfg,
|
|
67
137
|
dispatcherOptions: {
|
|
68
138
|
deliver: async (payload: { text?: string }, info: { kind: string }) => {
|
|
139
|
+
// Orchestrator mode: ALL text output is suppressed — agent uses tools to interact.
|
|
140
|
+
// Record as internal step for observability.
|
|
69
141
|
const replyText = payload.text?.trim();
|
|
70
142
|
if (!replyText) return;
|
|
71
|
-
// Wait for run creation to complete before sending
|
|
72
143
|
const runId = runIdPromise ? await runIdPromise : undefined;
|
|
73
|
-
|
|
74
|
-
if (info.kind === "final") {
|
|
75
|
-
// Final reply → chat message
|
|
76
|
-
await client.sendMessage(config.org_id, chatId, {
|
|
77
|
-
message_type: "text",
|
|
78
|
-
content: { text: replyText },
|
|
79
|
-
agent_run_id: runId,
|
|
80
|
-
});
|
|
81
|
-
} else if (info.kind === "block" && runId) {
|
|
82
|
-
// Intermediate text → step with chat_projection hint (server decides)
|
|
144
|
+
if (runId) {
|
|
83
145
|
try {
|
|
84
146
|
await client.createAgentStep(config.org_id, agentUserId, runId, {
|
|
85
147
|
step_type: "text",
|
|
86
|
-
content: { text: replyText },
|
|
87
|
-
chat_projection:
|
|
148
|
+
content: { text: replyText, suppressed: true },
|
|
149
|
+
...(info.kind === "block" ? { chat_projection: false } : {}),
|
|
88
150
|
});
|
|
89
151
|
} catch (err) {
|
|
90
|
-
log?.warn(`pond[${accountId}]: failed to create text step: ${String(err)}`);
|
|
152
|
+
log?.warn(`pond[${accountId}]: failed to create suppressed text step: ${String(err)}`);
|
|
91
153
|
}
|
|
92
154
|
}
|
|
93
|
-
// kind === "tool" → ignore (handled by hooks)
|
|
94
155
|
},
|
|
95
156
|
onReplyStart: () => {
|
|
96
157
|
if (thinkingSent) return;
|
|
@@ -98,9 +159,10 @@ async function dispatchToAgent(opts: {
|
|
|
98
159
|
runIdPromise = (async () => {
|
|
99
160
|
try {
|
|
100
161
|
const run = await client.createAgentRun(config.org_id, agentUserId, {
|
|
101
|
-
trigger_type:
|
|
102
|
-
trigger_ref:
|
|
103
|
-
chat_id: chatId,
|
|
162
|
+
trigger_type: triggerType,
|
|
163
|
+
trigger_ref: triggerRef,
|
|
164
|
+
...(opts.chatId ? { chat_id: opts.chatId } : {}),
|
|
165
|
+
...(opts.defaultTargetType ? { default_target_type: opts.defaultTargetType, default_target_id: opts.defaultTargetId } : {}),
|
|
104
166
|
});
|
|
105
167
|
return run.id;
|
|
106
168
|
} catch (err) {
|
|
@@ -108,7 +170,6 @@ async function dispatchToAgent(opts: {
|
|
|
108
170
|
return undefined;
|
|
109
171
|
}
|
|
110
172
|
})();
|
|
111
|
-
// Store promise immediately so hooks can await it before the run is created
|
|
112
173
|
const state = getPondAccountState(accountId);
|
|
113
174
|
if (state) state.activeRuns.set(sessionKeyLower, runIdPromise);
|
|
114
175
|
},
|
|
@@ -133,11 +194,17 @@ async function dispatchToAgent(opts: {
|
|
|
133
194
|
},
|
|
134
195
|
},
|
|
135
196
|
});
|
|
197
|
+
return true;
|
|
136
198
|
} catch (err) {
|
|
137
199
|
log?.error(`pond[${accountId}]: dispatch failed for ${messageId}: ${String(err)}`);
|
|
200
|
+
return false;
|
|
138
201
|
}
|
|
139
202
|
}
|
|
140
203
|
|
|
204
|
+
// ---------------------------------------------------------------------------
|
|
205
|
+
// Gateway
|
|
206
|
+
// ---------------------------------------------------------------------------
|
|
207
|
+
|
|
141
208
|
export const pondGateway: ChannelGatewayAdapter<ResolvedPondAccount> = {
|
|
142
209
|
startAccount: async (ctx) => {
|
|
143
210
|
const account = resolvePondAccount({ cfg: ctx.cfg, accountId: ctx.accountId });
|
|
@@ -158,16 +225,21 @@ export const pondGateway: ChannelGatewayAdapter<ResolvedPondAccount> = {
|
|
|
158
225
|
const agentUserId = me.id;
|
|
159
226
|
log?.info(`pond[${ctx.accountId}]: authenticated as ${me.display_name} (${agentUserId})`);
|
|
160
227
|
|
|
161
|
-
// Inject env vars so child processes (e.g. @pnds/cli) inherit Pond credentials
|
|
162
|
-
process.env.POND_API_URL = config.pond_url;
|
|
163
|
-
process.env.POND_API_KEY = config.api_key;
|
|
164
|
-
process.env.POND_ORG_ID = config.org_id;
|
|
165
|
-
|
|
166
228
|
// Apply platform config (models, defaults)
|
|
167
|
-
// Use OPENCLAW_STATE_DIR if set, otherwise derive from HOME (systemd sets HOME=/data for hosted agents)
|
|
168
229
|
const stateDir = process.env.OPENCLAW_STATE_DIR
|
|
169
230
|
|| path.join(process.env.HOME || "/data", ".openclaw");
|
|
170
231
|
const openclawConfigPath = path.join(stateDir, "openclaw.json");
|
|
232
|
+
const previousEnv = {
|
|
233
|
+
POND_API_URL: process.env.POND_API_URL,
|
|
234
|
+
POND_API_KEY: process.env.POND_API_KEY,
|
|
235
|
+
POND_ORG_ID: process.env.POND_ORG_ID,
|
|
236
|
+
POND_AGENT_ID: process.env.POND_AGENT_ID,
|
|
237
|
+
};
|
|
238
|
+
process.env.POND_API_URL = config.pond_url;
|
|
239
|
+
process.env.POND_API_KEY = config.api_key;
|
|
240
|
+
process.env.POND_ORG_ID = config.org_id;
|
|
241
|
+
process.env.POND_AGENT_ID = agentUserId;
|
|
242
|
+
|
|
171
243
|
const configManagerOpts = {
|
|
172
244
|
client,
|
|
173
245
|
stateDir,
|
|
@@ -182,6 +254,24 @@ export const pondGateway: ChannelGatewayAdapter<ResolvedPondAccount> = {
|
|
|
182
254
|
log?.warn(`pond[${ctx.accountId}]: platform config fetch failed: ${String(err)}`);
|
|
183
255
|
}
|
|
184
256
|
|
|
257
|
+
let stopWikiHelper: (() => void) | null = null;
|
|
258
|
+
let wikiMountRoot: string | undefined;
|
|
259
|
+
try {
|
|
260
|
+
const wikiHelper = await startWikiHelper({
|
|
261
|
+
accountId: ctx.accountId,
|
|
262
|
+
pondUrl: config.pond_url,
|
|
263
|
+
apiKey: config.api_key,
|
|
264
|
+
orgId: config.org_id,
|
|
265
|
+
agentId: agentUserId,
|
|
266
|
+
stateDir,
|
|
267
|
+
log,
|
|
268
|
+
});
|
|
269
|
+
wikiMountRoot = wikiHelper.mountRoot;
|
|
270
|
+
stopWikiHelper = wikiHelper.stop;
|
|
271
|
+
} catch (err) {
|
|
272
|
+
log?.warn(`pond[${ctx.accountId}]: wiki helper startup failed: ${String(err)}`);
|
|
273
|
+
}
|
|
274
|
+
|
|
185
275
|
// Connect WebSocket via ticket auth (reconnection enabled by default in PondWs)
|
|
186
276
|
const wsUrl = resolveWsUrl(account);
|
|
187
277
|
const ws = new PondWs({
|
|
@@ -189,40 +279,390 @@ export const pondGateway: ChannelGatewayAdapter<ResolvedPondAccount> = {
|
|
|
189
279
|
wsUrl,
|
|
190
280
|
});
|
|
191
281
|
|
|
192
|
-
//
|
|
193
|
-
const
|
|
282
|
+
// Orchestrator session key — single session for all Pond events
|
|
283
|
+
const orchestratorKey = buildOrchestratorSessionKey(ctx.accountId);
|
|
284
|
+
|
|
285
|
+
// Chat info lookup — populated on hello, used for structured event bodies
|
|
286
|
+
const chatInfoMap = new Map<string, ChatInfo>();
|
|
194
287
|
|
|
195
288
|
// Session ID from hello — used for heartbeat
|
|
196
289
|
let sessionId = "";
|
|
197
|
-
// Heartbeat interval handle — created inside hello handler with server-provided interval
|
|
198
290
|
let heartbeatTimer: ReturnType<typeof setInterval> | null = null;
|
|
199
|
-
//
|
|
291
|
+
// Typing indicator management — reference-counted per chat
|
|
200
292
|
const activeDispatches = new Map<string, { count: number; typingTimer: ReturnType<typeof setInterval> }>();
|
|
201
|
-
//
|
|
293
|
+
// Dedupe task dispatches
|
|
294
|
+
const dispatchedTasks = new Set<string>();
|
|
295
|
+
// Dedupe message dispatches — shared by live handler and catch-up.
|
|
296
|
+
// Bounded: evict oldest entries when cap is reached.
|
|
297
|
+
const dispatchedMessages = new Set<string>();
|
|
298
|
+
const DISPATCHED_MESSAGES_CAP = 5000;
|
|
299
|
+
// Tracks whether the agent has had at least one successful hello in this process.
|
|
300
|
+
let hadSuccessfulHello = false;
|
|
301
|
+
const COLD_START_WINDOW_MS = 5 * 60_000;
|
|
302
|
+
// Heartbeat watchdog
|
|
202
303
|
let lastHeartbeatAt = Date.now();
|
|
203
304
|
|
|
204
|
-
|
|
305
|
+
/** Atomically claim a message ID for processing. Returns false if already claimed. */
|
|
306
|
+
function tryClaimMessage(id: string): boolean {
|
|
307
|
+
if (dispatchedMessages.has(id)) return false;
|
|
308
|
+
if (dispatchedMessages.size >= DISPATCHED_MESSAGES_CAP) {
|
|
309
|
+
let toEvict = Math.floor(DISPATCHED_MESSAGES_CAP / 4);
|
|
310
|
+
for (const old of dispatchedMessages) {
|
|
311
|
+
dispatchedMessages.delete(old);
|
|
312
|
+
if (--toEvict <= 0) break;
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
dispatchedMessages.add(id);
|
|
316
|
+
return true;
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
// Sessions dir for fork transcript resolution.
|
|
320
|
+
// Session key "agent:main:pond:..." → agentId is "main" → store at agents/main/sessions/
|
|
321
|
+
const sessionsDir = path.join(stateDir, "agents", "main", "sessions");
|
|
322
|
+
|
|
323
|
+
// ---------------------------------------------------------------------------
|
|
324
|
+
// Dispatch state for fork-on-busy routing
|
|
325
|
+
// ---------------------------------------------------------------------------
|
|
326
|
+
const dispatchState: DispatchState = {
|
|
327
|
+
mainDispatching: false,
|
|
328
|
+
activeForks: new Map(),
|
|
329
|
+
pendingForkResults: [],
|
|
330
|
+
mainBuffer: [],
|
|
331
|
+
};
|
|
332
|
+
|
|
333
|
+
// ---------------------------------------------------------------------------
|
|
334
|
+
// Per-fork queue state (internal to gateway, not exposed via DispatchState)
|
|
335
|
+
// ---------------------------------------------------------------------------
|
|
336
|
+
type ForkQueueItem = {
|
|
337
|
+
event: PondEvent;
|
|
338
|
+
resolve: (dispatched: boolean) => void;
|
|
339
|
+
};
|
|
340
|
+
|
|
341
|
+
type ActiveForkState = {
|
|
342
|
+
sessionKey: string;
|
|
343
|
+
sessionFile: string;
|
|
344
|
+
targetId: string;
|
|
345
|
+
queue: ForkQueueItem[];
|
|
346
|
+
processedEvents: PondEvent[];
|
|
347
|
+
};
|
|
348
|
+
|
|
349
|
+
const forkStates = new Map<string, ActiveForkState>();
|
|
350
|
+
|
|
351
|
+
// ---------------------------------------------------------------------------
|
|
352
|
+
// Typing indicator helpers
|
|
353
|
+
// ---------------------------------------------------------------------------
|
|
354
|
+
function startTyping(chatId: string) {
|
|
355
|
+
const existing = activeDispatches.get(chatId);
|
|
356
|
+
if (existing) {
|
|
357
|
+
existing.count++;
|
|
358
|
+
} else {
|
|
359
|
+
if (ws.state === "connected") ws.sendTyping(chatId, "start");
|
|
360
|
+
const typingRefresh = setInterval(() => {
|
|
361
|
+
if (ws.state === "connected") ws.sendTyping(chatId, "start");
|
|
362
|
+
}, 2000);
|
|
363
|
+
activeDispatches.set(chatId, { count: 1, typingTimer: typingRefresh });
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
function stopTyping(chatId: string) {
|
|
368
|
+
const dispatch = activeDispatches.get(chatId);
|
|
369
|
+
if (!dispatch) return;
|
|
370
|
+
dispatch.count--;
|
|
371
|
+
if (dispatch.count <= 0) {
|
|
372
|
+
clearInterval(dispatch.typingTimer);
|
|
373
|
+
activeDispatches.delete(chatId);
|
|
374
|
+
if (ws.state === "connected") ws.sendTyping(chatId, "stop");
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
// ---------------------------------------------------------------------------
|
|
379
|
+
// Fork drain loop — processes queued events on a forked session
|
|
380
|
+
// ---------------------------------------------------------------------------
|
|
381
|
+
|
|
382
|
+
/**
|
|
383
|
+
* Process all queued events on a fork session, then clean up.
|
|
384
|
+
* Fire-and-forget: callers await per-event promises, not this function.
|
|
385
|
+
*
|
|
386
|
+
* Invariant: between the while-loop empty check and cleanup, there is no
|
|
387
|
+
* await, so JS single-threaded execution guarantees no new events can be
|
|
388
|
+
* pushed to the queue between the check and the cleanup.
|
|
389
|
+
*/
|
|
390
|
+
async function runForkDrainLoop(fork: ActiveForkState) {
|
|
391
|
+
try {
|
|
392
|
+
while (fork.queue.length > 0) {
|
|
393
|
+
const item = fork.queue.shift()!;
|
|
394
|
+
try {
|
|
395
|
+
await runDispatch(item.event, fork.sessionKey, buildEventBody(item.event));
|
|
396
|
+
fork.processedEvents.push(item.event);
|
|
397
|
+
item.resolve(true);
|
|
398
|
+
} catch (err) {
|
|
399
|
+
log?.error(`pond[${ctx.accountId}]: fork dispatch failed for ${item.event.messageId}: ${String(err)}`);
|
|
400
|
+
item.resolve(false);
|
|
401
|
+
break;
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
// Resolve remaining items as not-dispatched (fork stopped after error).
|
|
405
|
+
// These events are NOT acked — inbox retains them for catch-up replay.
|
|
406
|
+
for (const remaining of fork.queue.splice(0)) {
|
|
407
|
+
remaining.resolve(false);
|
|
408
|
+
}
|
|
409
|
+
} finally {
|
|
410
|
+
// Collect fork result for injection into main session's next turn
|
|
411
|
+
if (fork.processedEvents.length > 0) {
|
|
412
|
+
const first = fork.processedEvents[0];
|
|
413
|
+
dispatchState.pendingForkResults.push({
|
|
414
|
+
forkSessionKey: fork.sessionKey,
|
|
415
|
+
sourceEvent: {
|
|
416
|
+
type: first.type,
|
|
417
|
+
targetId: fork.targetId,
|
|
418
|
+
summary: fork.processedEvents.length === 1
|
|
419
|
+
? `${first.type} from ${first.senderName} in ${first.targetName ?? fork.targetId}`
|
|
420
|
+
: `${fork.processedEvents.length} events in ${first.targetName ?? fork.targetId}`,
|
|
421
|
+
},
|
|
422
|
+
actions: [],
|
|
423
|
+
agentRunId: undefined,
|
|
424
|
+
});
|
|
425
|
+
}
|
|
426
|
+
// Remove from tracking maps and clean up session files
|
|
427
|
+
forkStates.delete(fork.targetId);
|
|
428
|
+
dispatchState.activeForks.delete(fork.targetId);
|
|
429
|
+
cleanupForkSession({ sessionFile: fork.sessionFile, sessionKey: fork.sessionKey, sessionsDir });
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
// ---------------------------------------------------------------------------
|
|
434
|
+
// Build OpenClaw inbound context
|
|
435
|
+
// ---------------------------------------------------------------------------
|
|
436
|
+
function buildInboundCtx(event: PondEvent, sessionKey: string, bodyForAgent: string): Record<string, unknown> {
|
|
437
|
+
return core.channel.reply.finalizeInboundContext({
|
|
438
|
+
Body: event.body,
|
|
439
|
+
BodyForAgent: bodyForAgent,
|
|
440
|
+
RawBody: event.body,
|
|
441
|
+
CommandBody: event.body,
|
|
442
|
+
...(event.mediaFields ?? {}),
|
|
443
|
+
From: `pond:${event.senderId}`,
|
|
444
|
+
To: `pond:orchestrator`,
|
|
445
|
+
SessionKey: sessionKey,
|
|
446
|
+
AccountId: ctx.accountId,
|
|
447
|
+
ChatType: event.targetType ?? "unknown",
|
|
448
|
+
SenderName: event.senderName,
|
|
449
|
+
SenderId: event.senderId,
|
|
450
|
+
Provider: "pond" as const,
|
|
451
|
+
Surface: "pond" as const,
|
|
452
|
+
MessageSid: event.messageId,
|
|
453
|
+
Timestamp: Date.now(),
|
|
454
|
+
CommandAuthorized: true,
|
|
455
|
+
OriginatingChannel: "pond" as const,
|
|
456
|
+
OriginatingTo: `pond:orchestrator`,
|
|
457
|
+
});
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
// ---------------------------------------------------------------------------
|
|
461
|
+
// Core dispatch: route event to main or fork session
|
|
462
|
+
// ---------------------------------------------------------------------------
|
|
463
|
+
async function runDispatch(event: PondEvent, sessionKey: string, bodyForAgent: string) {
|
|
464
|
+
// Set provenance maps for wiki tools + no_reply suppression flag
|
|
465
|
+
setSessionChatId(sessionKey, event.targetId);
|
|
466
|
+
setSessionMessageId(sessionKey, event.messageId);
|
|
467
|
+
setDispatchMessageId(sessionKey, event.messageId);
|
|
468
|
+
setDispatchNoReply(sessionKey, event.noReply ?? false);
|
|
469
|
+
|
|
470
|
+
const inboundCtx = buildInboundCtx(event, sessionKey, bodyForAgent);
|
|
471
|
+
const triggerType = event.type === "task" ? "task_assign" : "mention";
|
|
472
|
+
const triggerRef = event.type === "task"
|
|
473
|
+
? { task_id: event.targetId }
|
|
474
|
+
: { message_id: event.messageId };
|
|
475
|
+
const isTask = event.type === "task";
|
|
476
|
+
|
|
477
|
+
const ok = await dispatchToAgent({
|
|
478
|
+
core,
|
|
479
|
+
cfg: ctx.cfg,
|
|
480
|
+
client,
|
|
481
|
+
config,
|
|
482
|
+
agentUserId,
|
|
483
|
+
accountId: ctx.accountId,
|
|
484
|
+
sessionKey,
|
|
485
|
+
messageId: event.messageId,
|
|
486
|
+
inboundCtx,
|
|
487
|
+
triggerType,
|
|
488
|
+
triggerRef,
|
|
489
|
+
chatId: event.targetId.startsWith("cht_") ? event.targetId : undefined,
|
|
490
|
+
defaultTargetType: isTask ? "task" : undefined,
|
|
491
|
+
defaultTargetId: isTask ? event.targetId : undefined,
|
|
492
|
+
log,
|
|
493
|
+
});
|
|
494
|
+
if (!ok) throw new Error(`dispatch failed for ${event.messageId}`);
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
/**
|
|
498
|
+
* Drain buffered events one-by-one into main session.
|
|
499
|
+
* Each event gets its own dispatch (and thus its own AgentRun) to keep provenance correct.
|
|
500
|
+
*
|
|
501
|
+
* IMPORTANT: Caller must hold mainDispatching=true. This function keeps the flag
|
|
502
|
+
* raised throughout the drain to prevent re-entrance from concurrent event handlers.
|
|
503
|
+
* The flag is only lowered when the buffer is fully exhausted.
|
|
504
|
+
*/
|
|
505
|
+
let draining = false;
|
|
506
|
+
async function drainMainBuffer() {
|
|
507
|
+
// Re-entrance guard: if we're already draining (e.g. fork completed during
|
|
508
|
+
// a drain iteration), the outer loop will pick up new items naturally.
|
|
509
|
+
if (draining) return;
|
|
510
|
+
draining = true;
|
|
511
|
+
try {
|
|
512
|
+
while (dispatchState.mainBuffer.length > 0 || dispatchState.pendingForkResults.length > 0) {
|
|
513
|
+
// If we only have fork results but no events, create a synthetic wake-up
|
|
514
|
+
if (dispatchState.mainBuffer.length === 0 && dispatchState.pendingForkResults.length > 0) {
|
|
515
|
+
const forkPrefix = buildForkResultPrefix(dispatchState.pendingForkResults.splice(0));
|
|
516
|
+
const syntheticEvent: PondEvent = {
|
|
517
|
+
type: "message",
|
|
518
|
+
targetId: "_orchestrator",
|
|
519
|
+
targetType: "system",
|
|
520
|
+
senderId: "system",
|
|
521
|
+
senderName: "system",
|
|
522
|
+
messageId: `synthetic-${Date.now()}`,
|
|
523
|
+
body: "[Orchestrator: fork session(s) completed — review results above]",
|
|
524
|
+
};
|
|
525
|
+
const bodyForAgent = forkPrefix + buildEventBody(syntheticEvent);
|
|
526
|
+
dispatchState.mainCurrentTargetId = undefined;
|
|
527
|
+
await runDispatch(syntheticEvent, orchestratorKey, bodyForAgent);
|
|
528
|
+
continue;
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
// Take ONE event at a time for correct provenance
|
|
532
|
+
const event = dispatchState.mainBuffer.shift()!;
|
|
533
|
+
const forkPrefix = buildForkResultPrefix(dispatchState.pendingForkResults.splice(0));
|
|
534
|
+
const bodyForAgent = forkPrefix + buildEventBody(event);
|
|
535
|
+
|
|
536
|
+
dispatchState.mainCurrentTargetId = event.targetId;
|
|
537
|
+
await runDispatch(event, orchestratorKey, bodyForAgent);
|
|
538
|
+
// Ack inbox after successful drain dispatch — these events were buffered
|
|
539
|
+
// without ack, so this is the first time they're confirmed processed.
|
|
540
|
+
const sourceType = event.type === "task" ? "task_activity" : "message";
|
|
541
|
+
client.ackInbox(config.org_id, { source_type: sourceType, source_id: event.messageId }).catch(() => {});
|
|
542
|
+
}
|
|
543
|
+
} finally {
|
|
544
|
+
draining = false;
|
|
545
|
+
dispatchState.mainDispatching = false;
|
|
546
|
+
dispatchState.mainCurrentTargetId = undefined;
|
|
547
|
+
}
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
/**
|
|
551
|
+
* Route and dispatch an inbound event.
|
|
552
|
+
* Returns true if the event was dispatched (main or fork), false if only buffered.
|
|
553
|
+
* Callers should only ack inbox when this returns true — buffered events may be
|
|
554
|
+
* lost on crash and need to be replayed from inbox on the next catch-up.
|
|
555
|
+
*/
|
|
556
|
+
async function handleInboundEvent(event: PondEvent): Promise<boolean> {
|
|
557
|
+
const disposition = routeTrigger(event, dispatchState, defaultRoutingStrategy);
|
|
558
|
+
|
|
559
|
+
switch (disposition.action) {
|
|
560
|
+
case "main": {
|
|
561
|
+
// Main session is idle — dispatch directly.
|
|
562
|
+
// Set mainDispatching BEFORE the await and keep it raised through
|
|
563
|
+
// drainMainBuffer() so no concurrent event can enter this case.
|
|
564
|
+
// drainMainBuffer() lowers the flag when the buffer is fully exhausted.
|
|
565
|
+
const forkPrefix = buildForkResultPrefix(dispatchState.pendingForkResults.splice(0));
|
|
566
|
+
const bodyForAgent = forkPrefix + buildEventBody(event);
|
|
567
|
+
|
|
568
|
+
dispatchState.mainDispatching = true;
|
|
569
|
+
dispatchState.mainCurrentTargetId = event.targetId;
|
|
570
|
+
try {
|
|
571
|
+
await runDispatch(event, orchestratorKey, bodyForAgent);
|
|
572
|
+
} finally {
|
|
573
|
+
// Drain any events that buffered while we were dispatching.
|
|
574
|
+
// mainDispatching stays true — drainMainBuffer owns releasing it.
|
|
575
|
+
await drainMainBuffer();
|
|
576
|
+
}
|
|
577
|
+
return true;
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
case "buffer-main":
|
|
581
|
+
dispatchState.mainBuffer.push(event);
|
|
582
|
+
return false;
|
|
583
|
+
|
|
584
|
+
case "buffer-fork": {
|
|
585
|
+
// Push event into the existing fork's queue. The fork drain loop will
|
|
586
|
+
// process it and resolve the promise when done. If the fork was cleaned
|
|
587
|
+
// up between the routing decision and here (race), fallback to buffer-main.
|
|
588
|
+
const activeFork = forkStates.get(event.targetId);
|
|
589
|
+
if (!activeFork) {
|
|
590
|
+
dispatchState.mainBuffer.push(event);
|
|
591
|
+
return false;
|
|
592
|
+
}
|
|
593
|
+
return new Promise<boolean>((resolve) => {
|
|
594
|
+
activeFork.queue.push({ event, resolve });
|
|
595
|
+
});
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
case "new-fork": {
|
|
599
|
+
// Fork the orchestrator's transcript and dispatch in parallel.
|
|
600
|
+
const transcriptFile = resolveTranscriptFile(sessionsDir, orchestratorKey);
|
|
601
|
+
const fork = transcriptFile
|
|
602
|
+
? forkOrchestratorSession({
|
|
603
|
+
orchestratorSessionKey: orchestratorKey,
|
|
604
|
+
accountId: ctx.accountId,
|
|
605
|
+
transcriptFile,
|
|
606
|
+
sessionsDir,
|
|
607
|
+
})
|
|
608
|
+
: null;
|
|
609
|
+
|
|
610
|
+
if (fork) {
|
|
611
|
+
const activeFork: ActiveForkState = {
|
|
612
|
+
sessionKey: fork.sessionKey,
|
|
613
|
+
sessionFile: fork.sessionFile,
|
|
614
|
+
targetId: event.targetId,
|
|
615
|
+
queue: [],
|
|
616
|
+
processedEvents: [],
|
|
617
|
+
};
|
|
618
|
+
forkStates.set(event.targetId, activeFork);
|
|
619
|
+
dispatchState.activeForks.set(event.targetId, fork.sessionKey);
|
|
620
|
+
|
|
621
|
+
// Create promise for the first event, then start the drain loop
|
|
622
|
+
const firstEventPromise = new Promise<boolean>((resolve) => {
|
|
623
|
+
activeFork.queue.push({ event, resolve });
|
|
624
|
+
});
|
|
625
|
+
// Fire-and-forget — drain loop runs concurrently, callers await per-event promises
|
|
626
|
+
runForkDrainLoop(activeFork).catch((err) => {
|
|
627
|
+
log?.error(`pond[${ctx.accountId}]: fork drain loop error: ${String(err)}`);
|
|
628
|
+
});
|
|
629
|
+
return firstEventPromise;
|
|
630
|
+
} else {
|
|
631
|
+
log?.warn(`pond[${ctx.accountId}]: fork failed, buffering event for main session`);
|
|
632
|
+
dispatchState.mainBuffer.push(event);
|
|
633
|
+
return false;
|
|
634
|
+
}
|
|
635
|
+
}
|
|
636
|
+
}
|
|
637
|
+
return false;
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
// ---------------------------------------------------------------------------
|
|
641
|
+
// WebSocket event handlers
|
|
642
|
+
// ---------------------------------------------------------------------------
|
|
643
|
+
|
|
205
644
|
ws.onStateChange((state) => {
|
|
206
645
|
log?.info(`pond[${ctx.accountId}]: connection state → ${state}`);
|
|
207
646
|
});
|
|
208
647
|
|
|
209
|
-
// On hello, cache chat types and start heartbeat with server-provided interval
|
|
210
648
|
ws.on("hello", async (data: HelloData) => {
|
|
211
649
|
sessionId = data.session_id ?? "";
|
|
212
650
|
const intervalSec = data.heartbeat_interval > 0 ? data.heartbeat_interval : 30;
|
|
213
651
|
try {
|
|
214
|
-
const count = await fetchAllChats(client, config.org_id,
|
|
652
|
+
const count = await fetchAllChats(client, config.org_id, chatInfoMap);
|
|
215
653
|
log?.info(`pond[${ctx.accountId}]: WebSocket connected, ${count} chats cached`);
|
|
216
654
|
|
|
217
|
-
// Cache client state only after successful connection
|
|
218
655
|
setPondAccountState(ctx.accountId, {
|
|
219
656
|
client,
|
|
220
657
|
orgId: config.org_id,
|
|
221
658
|
agentUserId,
|
|
222
659
|
activeRuns: new Map(),
|
|
660
|
+
wikiMountRoot,
|
|
661
|
+
ws,
|
|
662
|
+
orchestratorSessionKey: orchestratorKey,
|
|
223
663
|
});
|
|
224
664
|
|
|
225
|
-
// (Re)start heartbeat
|
|
665
|
+
// (Re)start heartbeat
|
|
226
666
|
if (heartbeatTimer) clearInterval(heartbeatTimer);
|
|
227
667
|
lastHeartbeatAt = Date.now();
|
|
228
668
|
heartbeatTimer = setInterval(() => {
|
|
@@ -242,16 +682,31 @@ export const pondGateway: ChannelGatewayAdapter<ResolvedPondAccount> = {
|
|
|
242
682
|
uptime: os.uptime(),
|
|
243
683
|
});
|
|
244
684
|
}, intervalSec * 1000);
|
|
685
|
+
|
|
686
|
+
// Unified inbox catch-up (replaces separate mention/DM/task catch-up).
|
|
687
|
+
// On cold start, skip items older than COLD_START_WINDOW_MS to avoid
|
|
688
|
+
// replaying historical backlog from a previous process.
|
|
689
|
+
const isFirstHello = !hadSuccessfulHello;
|
|
690
|
+
hadSuccessfulHello = true;
|
|
691
|
+
catchUpFromInbox(isFirstHello).catch((err) => {
|
|
692
|
+
log?.warn(`pond[${ctx.accountId}]: inbox catch-up failed: ${String(err)}`);
|
|
693
|
+
});
|
|
245
694
|
} catch (err) {
|
|
246
695
|
log?.error(`pond[${ctx.accountId}]: failed to fetch chats: ${String(err)}`);
|
|
247
696
|
}
|
|
248
697
|
});
|
|
249
698
|
|
|
250
|
-
// Track chat type changes (new chats, renames, etc.)
|
|
251
699
|
ws.on("chat.update", (data: ChatUpdateData) => {
|
|
252
700
|
const changes = data.changes as Record<string, unknown> | undefined;
|
|
253
|
-
if (changes
|
|
254
|
-
|
|
701
|
+
if (!changes) return;
|
|
702
|
+
const existing = chatInfoMap.get(data.chat_id);
|
|
703
|
+
if (typeof changes.type === "string") {
|
|
704
|
+
chatInfoMap.set(data.chat_id, {
|
|
705
|
+
type: changes.type as Chat["type"],
|
|
706
|
+
name: (typeof changes.name === "string" ? changes.name : existing?.name) ?? null,
|
|
707
|
+
});
|
|
708
|
+
} else if (typeof changes.name === "string" && existing) {
|
|
709
|
+
chatInfoMap.set(data.chat_id, { ...existing, name: changes.name });
|
|
255
710
|
}
|
|
256
711
|
});
|
|
257
712
|
|
|
@@ -259,23 +714,25 @@ export const pondGateway: ChannelGatewayAdapter<ResolvedPondAccount> = {
|
|
|
259
714
|
ws.on("message.new", async (data: MessageNewData) => {
|
|
260
715
|
if (data.sender_id === agentUserId) return;
|
|
261
716
|
if (data.message_type !== "text" && data.message_type !== "file") return;
|
|
717
|
+
// Dedupe: claim this message so catch-up won't re-dispatch it
|
|
718
|
+
if (!tryClaimMessage(data.id)) return;
|
|
262
719
|
|
|
263
720
|
const chatId = data.chat_id;
|
|
264
721
|
|
|
265
|
-
// Resolve chat
|
|
266
|
-
let
|
|
267
|
-
if (!
|
|
722
|
+
// Resolve chat info — query API for unknown chats
|
|
723
|
+
let chatInfo = chatInfoMap.get(chatId);
|
|
724
|
+
if (!chatInfo) {
|
|
268
725
|
try {
|
|
269
726
|
const chat = await client.getChat(config.org_id, chatId);
|
|
270
|
-
|
|
271
|
-
|
|
727
|
+
chatInfo = { type: chat.type, name: chat.name ?? null };
|
|
728
|
+
chatInfoMap.set(chatId, chatInfo);
|
|
272
729
|
} catch (err) {
|
|
273
|
-
log?.warn(`pond[${ctx.accountId}]: failed to resolve chat
|
|
730
|
+
log?.warn(`pond[${ctx.accountId}]: failed to resolve chat for ${chatId}, skipping: ${String(err)}`);
|
|
274
731
|
return;
|
|
275
732
|
}
|
|
276
733
|
}
|
|
277
734
|
|
|
278
|
-
// Build body text and optional media fields
|
|
735
|
+
// Build body text and optional media fields
|
|
279
736
|
let body = "";
|
|
280
737
|
let mediaFields: Record<string, string | undefined> = {};
|
|
281
738
|
|
|
@@ -285,106 +742,64 @@ export const pondGateway: ChannelGatewayAdapter<ResolvedPondAccount> = {
|
|
|
285
742
|
if (!body) return;
|
|
286
743
|
|
|
287
744
|
// In group chats, only respond when @mentioned or @all
|
|
288
|
-
if (
|
|
745
|
+
if (chatInfo.type === "group") {
|
|
289
746
|
const mentions = content.mentions ?? [];
|
|
290
747
|
const isMentioned = mentions.some(
|
|
291
|
-
(m) => m.user_id === agentUserId || m.user_id === MENTION_ALL_USER_ID
|
|
748
|
+
(m) => m.user_id === agentUserId || m.user_id === MENTION_ALL_USER_ID,
|
|
292
749
|
);
|
|
293
750
|
if (!isMentioned) return;
|
|
294
751
|
}
|
|
295
752
|
} else {
|
|
296
|
-
// file message
|
|
297
753
|
const content = data.content as MediaContent;
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
if (chatType === "group") return;
|
|
301
|
-
|
|
302
|
-
body = content.caption?.trim()
|
|
303
|
-
|| `[file: ${content.file_name || "attachment"}]`;
|
|
304
|
-
|
|
305
|
-
// Resolve presigned download URL for the attachment
|
|
754
|
+
if (chatInfo.type === "group") return;
|
|
755
|
+
body = content.caption?.trim() || `[file: ${content.file_name || "attachment"}]`;
|
|
306
756
|
if (content.attachment_id) {
|
|
307
757
|
try {
|
|
308
758
|
const fileRes = await client.getFileUrl(content.attachment_id);
|
|
309
|
-
mediaFields = {
|
|
310
|
-
MediaUrl: fileRes.url,
|
|
311
|
-
MediaType: content.mime_type,
|
|
312
|
-
};
|
|
759
|
+
mediaFields = { MediaUrl: fileRes.url, MediaType: content.mime_type };
|
|
313
760
|
} catch (err) {
|
|
314
761
|
log?.warn(`pond[${ctx.accountId}]: failed to get file URL for ${content.attachment_id}: ${String(err)}`);
|
|
315
|
-
// Degrade gracefully — agent still gets the body text
|
|
316
762
|
}
|
|
317
763
|
}
|
|
318
764
|
}
|
|
319
765
|
|
|
320
|
-
const
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
Timestamp: Date.now(),
|
|
341
|
-
CommandAuthorized: true,
|
|
342
|
-
OriginatingChannel: "pond" as const,
|
|
343
|
-
OriginatingTo: `pond:${chatId}`,
|
|
344
|
-
});
|
|
345
|
-
|
|
346
|
-
// Manage typing indicator so the user sees the agent is working.
|
|
347
|
-
// Reference-counted: concurrent dispatches for the same chat share one timer.
|
|
348
|
-
const existingDispatch = activeDispatches.get(chatId);
|
|
349
|
-
if (existingDispatch) {
|
|
350
|
-
existingDispatch.count++;
|
|
351
|
-
} else {
|
|
352
|
-
if (ws.state === "connected") ws.sendTyping(chatId, "start");
|
|
353
|
-
// Frontend auto-clears typing after 3s, so refresh at 2s to avoid flicker
|
|
354
|
-
const typingRefresh = setInterval(() => {
|
|
355
|
-
if (ws.state === "connected") ws.sendTyping(chatId, "start");
|
|
356
|
-
}, 2000);
|
|
357
|
-
activeDispatches.set(chatId, { count: 1, typingTimer: typingRefresh });
|
|
358
|
-
}
|
|
359
|
-
|
|
766
|
+
const event: PondEvent = {
|
|
767
|
+
type: "message",
|
|
768
|
+
targetId: chatId,
|
|
769
|
+
targetName: chatInfo.name ?? undefined,
|
|
770
|
+
targetType: chatInfo.type,
|
|
771
|
+
senderId: data.sender_id,
|
|
772
|
+
senderName: data.sender?.display_name ?? data.sender_id,
|
|
773
|
+
messageId: data.id,
|
|
774
|
+
body,
|
|
775
|
+
threadRootId: data.thread_root_id ?? undefined,
|
|
776
|
+
noReply: data.hints?.no_reply ?? false,
|
|
777
|
+
mediaFields: Object.keys(mediaFields).length > 0 ? mediaFields : undefined,
|
|
778
|
+
};
|
|
779
|
+
|
|
780
|
+
// Start typing for UX feedback. For buffer-main events (main is busy with
|
|
781
|
+
// same target), typing persists until the buffer drains.
|
|
782
|
+
// For fork events, typing persists until the fork dispatch completes.
|
|
783
|
+
// Both handleInboundEvent paths await their dispatches, so finally works correctly.
|
|
784
|
+
const willDispatch = !dispatchState.mainDispatching || dispatchState.mainCurrentTargetId !== chatId;
|
|
785
|
+
if (willDispatch) startTyping(chatId);
|
|
360
786
|
try {
|
|
361
|
-
await
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
accountId: ctx.accountId,
|
|
368
|
-
sessionKey,
|
|
369
|
-
chatId,
|
|
370
|
-
messageId: data.id,
|
|
371
|
-
inboundCtx,
|
|
372
|
-
log,
|
|
373
|
-
});
|
|
374
|
-
} finally {
|
|
375
|
-
const dispatch = activeDispatches.get(chatId);
|
|
376
|
-
if (dispatch) {
|
|
377
|
-
dispatch.count--;
|
|
378
|
-
if (dispatch.count <= 0) {
|
|
379
|
-
clearInterval(dispatch.typingTimer);
|
|
380
|
-
activeDispatches.delete(chatId);
|
|
381
|
-
if (ws.state === "connected") ws.sendTyping(chatId, "stop");
|
|
382
|
-
}
|
|
787
|
+
const dispatched = await handleInboundEvent(event);
|
|
788
|
+
if (dispatched) {
|
|
789
|
+
client.ackInbox(config.org_id, { source_type: "message", source_id: data.id }).catch(() => {});
|
|
790
|
+
} else {
|
|
791
|
+
// Not dispatched (buffered or fork failed) — release claim so catch-up can retry
|
|
792
|
+
dispatchedMessages.delete(data.id);
|
|
383
793
|
}
|
|
794
|
+
} catch (err) {
|
|
795
|
+
log?.error(`pond[${ctx.accountId}]: event dispatch failed for ${data.id}: ${String(err)}`);
|
|
796
|
+
dispatchedMessages.delete(data.id);
|
|
797
|
+
} finally {
|
|
798
|
+
if (willDispatch) stopTyping(chatId);
|
|
384
799
|
}
|
|
385
800
|
});
|
|
386
801
|
|
|
387
|
-
// Re-apply platform config
|
|
802
|
+
// Re-apply platform config on server push
|
|
388
803
|
ws.on("agent_config.update", async (data: AgentConfigUpdateData) => {
|
|
389
804
|
log?.info(`pond[${ctx.accountId}]: config update notification (version=${data.version})`);
|
|
390
805
|
try {
|
|
@@ -394,23 +809,186 @@ export const pondGateway: ChannelGatewayAdapter<ResolvedPondAccount> = {
|
|
|
394
809
|
}
|
|
395
810
|
});
|
|
396
811
|
|
|
812
|
+
// Task assignment handler
|
|
813
|
+
async function handleTaskAssignment(task: Task): Promise<boolean> {
|
|
814
|
+
const dedupeKey = `${task.id}:${task.updated_at}`;
|
|
815
|
+
if (dispatchedTasks.has(dedupeKey)) {
|
|
816
|
+
log?.info(`pond[${ctx.accountId}]: skipping already-dispatched task ${task.identifier ?? task.id}`);
|
|
817
|
+
return false;
|
|
818
|
+
}
|
|
819
|
+
dispatchedTasks.add(dedupeKey);
|
|
820
|
+
log?.info(`pond[${ctx.accountId}]: task assigned: ${task.identifier ?? task.id} "${task.title}"`);
|
|
821
|
+
|
|
822
|
+
const parts = [`Title: ${task.title}`];
|
|
823
|
+
parts.push(`Status: ${task.status}`, `Priority: ${task.priority}`);
|
|
824
|
+
if (task.description) parts.push("", task.description);
|
|
825
|
+
|
|
826
|
+
const event: PondEvent = {
|
|
827
|
+
type: "task",
|
|
828
|
+
targetId: task.id,
|
|
829
|
+
targetName: task.identifier ?? undefined,
|
|
830
|
+
targetType: "task",
|
|
831
|
+
senderId: task.creator_id,
|
|
832
|
+
senderName: "system",
|
|
833
|
+
messageId: task.id,
|
|
834
|
+
body: parts.join("\n"),
|
|
835
|
+
};
|
|
836
|
+
|
|
837
|
+
const dispatched = await handleInboundEvent(event);
|
|
838
|
+
if (!dispatched) {
|
|
839
|
+
// Buffered, not yet dispatched — release dedupe so catch-up can retry
|
|
840
|
+
dispatchedTasks.delete(dedupeKey);
|
|
841
|
+
}
|
|
842
|
+
return dispatched;
|
|
843
|
+
}
|
|
844
|
+
|
|
845
|
+
// Server event buffer overflowed — events were lost, trigger full catch-up
|
|
846
|
+
ws.on("recovery.overflow", () => {
|
|
847
|
+
log?.warn(`pond[${ctx.accountId}]: recovery.overflow — triggering full catch-up`);
|
|
848
|
+
catchUpFromInbox().catch((err) =>
|
|
849
|
+
log?.warn(`pond[${ctx.accountId}]: overflow catch-up failed: ${String(err)}`));
|
|
850
|
+
});
|
|
851
|
+
|
|
852
|
+
ws.on("task.assigned", async (data: TaskAssignedData) => {
|
|
853
|
+
if (data.assignee_id !== agentUserId) return;
|
|
854
|
+
if (data.status !== "todo" && data.status !== "in_progress") return;
|
|
855
|
+
try {
|
|
856
|
+
const dispatched = await handleTaskAssignment(data);
|
|
857
|
+
if (dispatched) {
|
|
858
|
+
client.ackInbox(config.org_id, { source_type: "task_activity", source_id: data.id }).catch(() => {});
|
|
859
|
+
}
|
|
860
|
+
} catch (err) {
|
|
861
|
+
log?.error(`pond[${ctx.accountId}]: task dispatch failed for ${data.id}: ${String(err)}`);
|
|
862
|
+
}
|
|
863
|
+
});
|
|
864
|
+
|
|
865
|
+
// ── Unified inbox catch-up ──
|
|
866
|
+
// Replays missed mentions, DMs, and task assignments via the inbox API.
|
|
867
|
+
// Adapts each item into a PondEvent and routes through handleInboundEvent.
|
|
868
|
+
async function catchUpFromInbox(coldStart = false) {
|
|
869
|
+
const minAge = coldStart ? Date.now() - COLD_START_WINDOW_MS : 0;
|
|
870
|
+
let cursor: string | undefined;
|
|
871
|
+
let processed = 0;
|
|
872
|
+
let skippedOld = 0;
|
|
873
|
+
|
|
874
|
+
do {
|
|
875
|
+
const page = await client.getInbox(config.org_id, {
|
|
876
|
+
type: "mention,agent_dm,task_assign",
|
|
877
|
+
read: "false",
|
|
878
|
+
limit: 50,
|
|
879
|
+
cursor,
|
|
880
|
+
});
|
|
881
|
+
|
|
882
|
+
for (const item of page.data ?? []) {
|
|
883
|
+
if (minAge > 0 && new Date(item.updated_at).getTime() < minAge) {
|
|
884
|
+
client.markInboxRead(config.org_id, item.id).catch(() => {});
|
|
885
|
+
skippedOld++;
|
|
886
|
+
continue;
|
|
887
|
+
}
|
|
888
|
+
|
|
889
|
+
processed++;
|
|
890
|
+
try {
|
|
891
|
+
let dispatched = false;
|
|
892
|
+
if (item.type === "task_assign" && item.source_id) {
|
|
893
|
+
// Task catch-up: fetch full task and dispatch
|
|
894
|
+
const task = await client.getTask(config.org_id, item.source_id);
|
|
895
|
+
if (task) {
|
|
896
|
+
dispatched = await handleTaskAssignment(task);
|
|
897
|
+
} else {
|
|
898
|
+
dispatched = true; // task deleted — mark read to prevent infinite retry
|
|
899
|
+
}
|
|
900
|
+
} else if (item.source_id && item.chat_id) {
|
|
901
|
+
// Message catch-up (mention or agent_dm): build PondEvent from inbox item
|
|
902
|
+
if (!tryClaimMessage(item.source_id)) continue;
|
|
903
|
+
const msg = await client.getMessage(item.source_id).catch(() => null);
|
|
904
|
+
if (!msg || msg.sender_id === agentUserId) {
|
|
905
|
+
dispatchedMessages.delete(item.source_id);
|
|
906
|
+
client.markInboxRead(config.org_id, item.id).catch(() => {});
|
|
907
|
+
continue;
|
|
908
|
+
}
|
|
909
|
+
|
|
910
|
+
let chatInfo = chatInfoMap.get(item.chat_id);
|
|
911
|
+
if (!chatInfo) {
|
|
912
|
+
try {
|
|
913
|
+
const chat = await client.getChat(config.org_id, item.chat_id);
|
|
914
|
+
chatInfo = { type: chat.type, name: chat.name ?? null };
|
|
915
|
+
chatInfoMap.set(item.chat_id, chatInfo);
|
|
916
|
+
} catch {
|
|
917
|
+
dispatchedMessages.delete(item.source_id);
|
|
918
|
+
continue;
|
|
919
|
+
}
|
|
920
|
+
}
|
|
921
|
+
|
|
922
|
+
let body = "";
|
|
923
|
+
let catchUpMediaFields: Record<string, string | undefined> = {};
|
|
924
|
+
if (msg.message_type === "text") {
|
|
925
|
+
body = (msg.content as TextContent).text?.trim() ?? "";
|
|
926
|
+
} else if (msg.message_type === "file") {
|
|
927
|
+
const fc = msg.content as MediaContent;
|
|
928
|
+
body = fc.caption?.trim() || `[file: ${fc.file_name || "attachment"}]`;
|
|
929
|
+
if (fc.attachment_id) {
|
|
930
|
+
try {
|
|
931
|
+
const fileRes = await client.getFileUrl(fc.attachment_id);
|
|
932
|
+
catchUpMediaFields = { MediaUrl: fileRes.url, MediaType: fc.mime_type };
|
|
933
|
+
} catch { /* degrade gracefully */ }
|
|
934
|
+
}
|
|
935
|
+
}
|
|
936
|
+
if (!body) {
|
|
937
|
+
dispatchedMessages.delete(item.source_id);
|
|
938
|
+
client.markInboxRead(config.org_id, item.id).catch(() => {});
|
|
939
|
+
continue;
|
|
940
|
+
}
|
|
941
|
+
|
|
942
|
+
const event: PondEvent = {
|
|
943
|
+
type: "message",
|
|
944
|
+
targetId: item.chat_id,
|
|
945
|
+
targetName: chatInfo.name ?? undefined,
|
|
946
|
+
targetType: chatInfo.type,
|
|
947
|
+
senderId: msg.sender_id,
|
|
948
|
+
senderName: msg.sender?.display_name ?? msg.sender_id,
|
|
949
|
+
messageId: msg.id,
|
|
950
|
+
body,
|
|
951
|
+
threadRootId: msg.thread_root_id ?? undefined,
|
|
952
|
+
noReply: msg.hints?.no_reply ?? false,
|
|
953
|
+
mediaFields: Object.keys(catchUpMediaFields).length > 0 ? catchUpMediaFields : undefined,
|
|
954
|
+
};
|
|
955
|
+
dispatched = await handleInboundEvent(event);
|
|
956
|
+
}
|
|
957
|
+
if (dispatched) {
|
|
958
|
+
client.markInboxRead(config.org_id, item.id).catch(() => {});
|
|
959
|
+
}
|
|
960
|
+
} catch (err) {
|
|
961
|
+
log?.warn(`pond[${ctx.accountId}]: catch-up item ${item.id} (${item.type}) failed: ${String(err)}`);
|
|
962
|
+
}
|
|
963
|
+
}
|
|
964
|
+
cursor = page.has_more ? page.next_cursor : undefined;
|
|
965
|
+
} while (cursor);
|
|
966
|
+
|
|
967
|
+
if (processed > 0 || skippedOld > 0) {
|
|
968
|
+
log?.info(`pond[${ctx.accountId}]: inbox catch-up: processed ${processed}, skipped ${skippedOld} old item(s)`);
|
|
969
|
+
}
|
|
970
|
+
}
|
|
971
|
+
|
|
397
972
|
log?.info(`pond[${ctx.accountId}]: connecting to ${wsUrl}...`);
|
|
398
973
|
await ws.connect();
|
|
399
974
|
|
|
400
975
|
// Clean up on abort
|
|
401
976
|
ctx.abortSignal.addEventListener("abort", () => {
|
|
402
977
|
if (heartbeatTimer) clearInterval(heartbeatTimer);
|
|
403
|
-
// Clean up all active typing timers to prevent leaks
|
|
404
978
|
for (const [, dispatch] of activeDispatches) {
|
|
405
979
|
clearInterval(dispatch.typingTimer);
|
|
406
980
|
}
|
|
407
981
|
activeDispatches.clear();
|
|
982
|
+
stopWikiHelper?.();
|
|
408
983
|
ws.disconnect();
|
|
409
984
|
removePondAccountState(ctx.accountId);
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
985
|
+
for (const [key, value] of Object.entries(previousEnv)) {
|
|
986
|
+
if (value === undefined) {
|
|
987
|
+
delete process.env[key];
|
|
988
|
+
} else {
|
|
989
|
+
process.env[key] = value;
|
|
990
|
+
}
|
|
991
|
+
}
|
|
414
992
|
log?.info(`pond[${ctx.accountId}]: disconnected`);
|
|
415
993
|
});
|
|
416
994
|
|