@questionbase/deskfree 0.3.0-alpha.2 → 0.3.0-alpha.21
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 +24 -14
- package/dist/index.d.ts +745 -6
- package/dist/index.js +9192 -18
- package/dist/index.js.map +1 -1
- package/package.json +8 -9
- package/skills/deskfree/SKILL.md +510 -221
- package/skills/deskfree/references/tools.md +144 -0
- package/dist/channel.d.ts +0 -3
- package/dist/channel.d.ts.map +0 -1
- package/dist/channel.js +0 -505
- package/dist/channel.js.map +0 -1
- package/dist/client.d.ts +0 -143
- package/dist/client.d.ts.map +0 -1
- package/dist/client.js +0 -246
- package/dist/client.js.map +0 -1
- package/dist/deliver.d.ts +0 -22
- package/dist/deliver.d.ts.map +0 -1
- package/dist/deliver.js +0 -350
- package/dist/deliver.js.map +0 -1
- package/dist/gateway.d.ts +0 -13
- package/dist/gateway.d.ts.map +0 -1
- package/dist/gateway.js +0 -836
- package/dist/gateway.js.map +0 -1
- package/dist/index.d.ts.map +0 -1
- package/dist/llm-definitions.d.ts +0 -117
- package/dist/llm-definitions.d.ts.map +0 -1
- package/dist/llm-definitions.js +0 -121
- package/dist/llm-definitions.js.map +0 -1
- package/dist/offline-queue.d.ts +0 -45
- package/dist/offline-queue.d.ts.map +0 -1
- package/dist/offline-queue.js +0 -109
- package/dist/offline-queue.js.map +0 -1
- package/dist/paths.d.ts +0 -10
- package/dist/paths.d.ts.map +0 -1
- package/dist/paths.js +0 -29
- package/dist/paths.js.map +0 -1
- package/dist/runtime.d.ts +0 -17
- package/dist/runtime.d.ts.map +0 -1
- package/dist/runtime.js +0 -24
- package/dist/runtime.js.map +0 -1
- package/dist/tools.d.ts +0 -23
- package/dist/tools.d.ts.map +0 -1
- package/dist/tools.js +0 -437
- package/dist/tools.js.map +0 -1
- package/dist/types.d.ts +0 -438
- package/dist/types.d.ts.map +0 -1
- package/dist/types.js +0 -2
- package/dist/types.js.map +0 -1
- package/dist/workspace.d.ts +0 -18
- package/dist/workspace.d.ts.map +0 -1
- package/dist/workspace.js +0 -83
- package/dist/workspace.js.map +0 -1
package/dist/gateway.js
DELETED
|
@@ -1,836 +0,0 @@
|
|
|
1
|
-
// Use ESM imports for Node.js built-in modules
|
|
2
|
-
import { DeskFreeClient } from './client';
|
|
3
|
-
import { deliverMessageToAgent } from './deliver';
|
|
4
|
-
import { resolvePluginStorePath } from './paths';
|
|
5
|
-
import { getDeskFreeRuntime } from './runtime';
|
|
6
|
-
import { resolveWorkspacePath } from './workspace';
|
|
7
|
-
import { spawn } from 'node:child_process';
|
|
8
|
-
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
9
|
-
import { dirname, join, resolve } from 'node:path';
|
|
10
|
-
import { fileURLToPath } from 'node:url';
|
|
11
|
-
import WebSocket from 'ws';
|
|
12
|
-
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
13
|
-
// ── Active Task Tracking ──────────────────────────────────────────────
|
|
14
|
-
// Tracks the currently active task so outbound messages can be
|
|
15
|
-
// automatically threaded into it without explicit taskId parameters.
|
|
16
|
-
let activeTaskId = null;
|
|
17
|
-
export function setActiveTaskId(taskId) {
|
|
18
|
-
activeTaskId = taskId;
|
|
19
|
-
}
|
|
20
|
-
export function getActiveTaskId() {
|
|
21
|
-
return activeTaskId;
|
|
22
|
-
}
|
|
23
|
-
// ── Workspace S3 Sync Helpers ─────────────────────────────────────────
|
|
24
|
-
/**
|
|
25
|
-
* Helper function to get AWS credentials from DeskFree and run an S3 command.
|
|
26
|
-
* Replaces the previous API-based workspace sync approach with direct AWS CLI usage.
|
|
27
|
-
*/
|
|
28
|
-
async function runS3CommandWithCredentials(client, buildCommand, workspacePath, log) {
|
|
29
|
-
try {
|
|
30
|
-
// Get short-lived credentials + S3 location from DeskFree
|
|
31
|
-
const creds = await client.workspaceCredentials();
|
|
32
|
-
const command = buildCommand(creds.s3Uri);
|
|
33
|
-
// Set up environment with temporary credentials
|
|
34
|
-
const env = {
|
|
35
|
-
...process.env,
|
|
36
|
-
AWS_ACCESS_KEY_ID: creds.accessKeyId,
|
|
37
|
-
AWS_SECRET_ACCESS_KEY: creds.secretAccessKey,
|
|
38
|
-
AWS_SESSION_TOKEN: creds.sessionToken,
|
|
39
|
-
AWS_DEFAULT_REGION: creds.region,
|
|
40
|
-
};
|
|
41
|
-
return new Promise((resolve, reject) => {
|
|
42
|
-
const child = spawn('aws', command, {
|
|
43
|
-
env,
|
|
44
|
-
cwd: workspacePath,
|
|
45
|
-
stdio: 'pipe',
|
|
46
|
-
});
|
|
47
|
-
let stdout = '';
|
|
48
|
-
let stderr = '';
|
|
49
|
-
child.stdout?.on('data', (data) => {
|
|
50
|
-
stdout += data.toString();
|
|
51
|
-
});
|
|
52
|
-
child.stderr?.on('data', (data) => {
|
|
53
|
-
stderr += data.toString();
|
|
54
|
-
});
|
|
55
|
-
child.on('close', (code) => {
|
|
56
|
-
if (code === 0) {
|
|
57
|
-
log.info(`S3 command succeeded: ${command.join(' ')}`);
|
|
58
|
-
if (stdout.trim()) {
|
|
59
|
-
log.debug(`S3 stdout: ${stdout.trim()}`);
|
|
60
|
-
}
|
|
61
|
-
resolve(true);
|
|
62
|
-
}
|
|
63
|
-
else {
|
|
64
|
-
log.warn(`S3 command failed (exit ${code}): ${command.join(' ')}`);
|
|
65
|
-
if (stderr.trim()) {
|
|
66
|
-
log.warn(`S3 stderr: ${stderr.trim()}`);
|
|
67
|
-
}
|
|
68
|
-
reject(new Error(`AWS CLI command failed with exit code ${code}: ${stderr}`));
|
|
69
|
-
}
|
|
70
|
-
});
|
|
71
|
-
child.on('error', (err) => {
|
|
72
|
-
log.warn(`S3 command spawn error: ${err.message}`);
|
|
73
|
-
reject(err);
|
|
74
|
-
});
|
|
75
|
-
});
|
|
76
|
-
}
|
|
77
|
-
catch (err) {
|
|
78
|
-
const message = err instanceof Error ? err.message : String(err);
|
|
79
|
-
log.warn(`Failed to get S3 credentials or run command: ${message}`);
|
|
80
|
-
return false;
|
|
81
|
-
}
|
|
82
|
-
}
|
|
83
|
-
const PLUGIN_VERSION = JSON.parse(readFileSync(resolve(__dirname, '..', 'package.json'), 'utf-8')).version;
|
|
84
|
-
const PING_INTERVAL_MS = 5 * 60 * 1000; // 5 minutes (API GW idle timeout = 10 min)
|
|
85
|
-
const POLL_FALLBACK_INTERVAL_MS = 30 * 1000; // 30s fallback when WS is down
|
|
86
|
-
const WS_CONNECTION_TIMEOUT_MS = 30 * 1000; // 30s timeout for initial connection
|
|
87
|
-
const WS_PONG_TIMEOUT_MS = 10 * 1000; // 10s timeout waiting for pong response
|
|
88
|
-
const BACKOFF_INITIAL_MS = 2000;
|
|
89
|
-
const BACKOFF_MAX_MS = 30000;
|
|
90
|
-
const BACKOFF_FACTOR = 1.8;
|
|
91
|
-
const HEALTH_LOG_INTERVAL_MS = 30 * 60 * 1000; // 30 minutes
|
|
92
|
-
const MAX_CONSECUTIVE_POLL_FAILURES = 5; // Switch to longer backoff after this many
|
|
93
|
-
// Dedup set to prevent re-delivering messages due to cursor precision issues
|
|
94
|
-
// (Postgres microsecond timestamps vs JS millisecond Date precision)
|
|
95
|
-
const deliveredMessageIds = new Set();
|
|
96
|
-
// Module-level health state persists across reconnects
|
|
97
|
-
const healthState = new Map();
|
|
98
|
-
function initializeHealth(accountId) {
|
|
99
|
-
if (!healthState.has(accountId)) {
|
|
100
|
-
healthState.set(accountId, {
|
|
101
|
-
connectionStartTime: Date.now(),
|
|
102
|
-
totalReconnects: 0,
|
|
103
|
-
lastReconnectAt: null,
|
|
104
|
-
avgReconnectInterval: 0,
|
|
105
|
-
totalMessagesDelivered: 0,
|
|
106
|
-
lastMessageAt: null,
|
|
107
|
-
currentMode: 'websocket',
|
|
108
|
-
});
|
|
109
|
-
}
|
|
110
|
-
}
|
|
111
|
-
function updateHealthMode(accountId, mode) {
|
|
112
|
-
const health = healthState.get(accountId);
|
|
113
|
-
if (health) {
|
|
114
|
-
health.currentMode = mode;
|
|
115
|
-
}
|
|
116
|
-
}
|
|
117
|
-
function recordReconnect(accountId) {
|
|
118
|
-
const health = healthState.get(accountId);
|
|
119
|
-
if (health) {
|
|
120
|
-
const now = Date.now();
|
|
121
|
-
if (health.lastReconnectAt) {
|
|
122
|
-
const interval = now - health.lastReconnectAt;
|
|
123
|
-
health.avgReconnectInterval =
|
|
124
|
-
(health.avgReconnectInterval * health.totalReconnects + interval) /
|
|
125
|
-
(health.totalReconnects + 1);
|
|
126
|
-
}
|
|
127
|
-
health.totalReconnects++;
|
|
128
|
-
health.lastReconnectAt = now;
|
|
129
|
-
}
|
|
130
|
-
}
|
|
131
|
-
function recordMessageDelivery(accountId, count) {
|
|
132
|
-
const health = healthState.get(accountId);
|
|
133
|
-
if (health) {
|
|
134
|
-
health.totalMessagesDelivered += count;
|
|
135
|
-
health.lastMessageAt = Date.now();
|
|
136
|
-
}
|
|
137
|
-
}
|
|
138
|
-
function formatDuration(ms) {
|
|
139
|
-
const seconds = Math.floor(ms / 1000);
|
|
140
|
-
const minutes = Math.floor(seconds / 60);
|
|
141
|
-
const hours = Math.floor(minutes / 60);
|
|
142
|
-
if (hours > 0) {
|
|
143
|
-
return `${hours}h${minutes % 60}m`;
|
|
144
|
-
}
|
|
145
|
-
else if (minutes > 0) {
|
|
146
|
-
return `${minutes}m${seconds % 60}s`;
|
|
147
|
-
}
|
|
148
|
-
else {
|
|
149
|
-
return `${seconds}s`;
|
|
150
|
-
}
|
|
151
|
-
}
|
|
152
|
-
function logHealthSummary(accountId, log) {
|
|
153
|
-
const health = healthState.get(accountId);
|
|
154
|
-
if (!health)
|
|
155
|
-
return;
|
|
156
|
-
const uptime = Date.now() - health.connectionStartTime;
|
|
157
|
-
log.info(`DeskFree health: uptime=${formatDuration(uptime)}, ` +
|
|
158
|
-
`reconnects=${health.totalReconnects}, ` +
|
|
159
|
-
`messages=${health.totalMessagesDelivered}, ` +
|
|
160
|
-
`mode=${health.currentMode}`);
|
|
161
|
-
}
|
|
162
|
-
// Serialize poll operations per account to prevent duplicate message delivery.
|
|
163
|
-
// Each account gets its own promise-chain mutex so polls don't block across accounts.
|
|
164
|
-
const pollChains = new Map();
|
|
165
|
-
function enqueuePoll(client, ctx, getCursor, setCursor, log, account) {
|
|
166
|
-
const accountId = ctx.accountId;
|
|
167
|
-
const prev = pollChains.get(accountId) ?? Promise.resolve();
|
|
168
|
-
const next = prev
|
|
169
|
-
.then(async () => {
|
|
170
|
-
const newCursor = await pollAndDeliver(client, ctx, getCursor(), log, account);
|
|
171
|
-
if (newCursor)
|
|
172
|
-
setCursor(newCursor);
|
|
173
|
-
})
|
|
174
|
-
.catch((err) => {
|
|
175
|
-
const message = err instanceof Error ? err.message : String(err);
|
|
176
|
-
log.error(`Poll error: ${message}`);
|
|
177
|
-
});
|
|
178
|
-
pollChains.set(accountId, next);
|
|
179
|
-
}
|
|
180
|
-
function nextBackoff(state) {
|
|
181
|
-
const delay = Math.min(BACKOFF_INITIAL_MS * Math.pow(BACKOFF_FACTOR, state.attempt), BACKOFF_MAX_MS);
|
|
182
|
-
const jitter = delay * 0.25 * Math.random();
|
|
183
|
-
state.attempt++;
|
|
184
|
-
return delay + jitter;
|
|
185
|
-
}
|
|
186
|
-
function resetBackoff(state) {
|
|
187
|
-
state.attempt = 0;
|
|
188
|
-
}
|
|
189
|
-
// Clean up abort listener to prevent memory leak
|
|
190
|
-
function sleepWithAbort(ms, signal) {
|
|
191
|
-
return new Promise((resolve) => {
|
|
192
|
-
const onAbort = () => {
|
|
193
|
-
clearTimeout(timer);
|
|
194
|
-
resolve();
|
|
195
|
-
};
|
|
196
|
-
const timer = setTimeout(() => {
|
|
197
|
-
signal.removeEventListener('abort', onAbort);
|
|
198
|
-
resolve();
|
|
199
|
-
}, ms);
|
|
200
|
-
signal.addEventListener('abort', onAbort, { once: true });
|
|
201
|
-
});
|
|
202
|
-
}
|
|
203
|
-
/**
|
|
204
|
-
* Persistent cursor management.
|
|
205
|
-
* Stores cursor on disk so we can resume from the right position after restart.
|
|
206
|
-
*/
|
|
207
|
-
function loadCursor(ctx) {
|
|
208
|
-
try {
|
|
209
|
-
const cursorPath = resolvePluginStorePath(`cursors/${ctx.accountId}/cursor`);
|
|
210
|
-
return readFileSync(cursorPath, 'utf-8').trim() || null;
|
|
211
|
-
}
|
|
212
|
-
catch {
|
|
213
|
-
return null;
|
|
214
|
-
}
|
|
215
|
-
}
|
|
216
|
-
function saveCursor(ctx, cursor, log) {
|
|
217
|
-
try {
|
|
218
|
-
const filePath = resolvePluginStorePath(`cursors/${ctx.accountId}/cursor`);
|
|
219
|
-
const dir = dirname(filePath);
|
|
220
|
-
mkdirSync(dir, { recursive: true });
|
|
221
|
-
writeFileSync(filePath, cursor, 'utf-8');
|
|
222
|
-
}
|
|
223
|
-
catch (err) {
|
|
224
|
-
// Non-fatal — we'll just re-process some messages on restart
|
|
225
|
-
const message = err instanceof Error ? err.message : String(err);
|
|
226
|
-
log?.warn(`Failed to persist cursor: ${message}`);
|
|
227
|
-
}
|
|
228
|
-
}
|
|
229
|
-
/**
|
|
230
|
-
* Main entry point for the DeskFree channel gateway.
|
|
231
|
-
* Called by OpenClaw's channel manager via gateway.startAccount().
|
|
232
|
-
*
|
|
233
|
-
* Maintains a WebSocket connection for real-time notifications
|
|
234
|
-
* and polls the messages endpoint for actual data.
|
|
235
|
-
* Falls back to interval-based polling if WebSocket is unavailable.
|
|
236
|
-
*/
|
|
237
|
-
export async function startDeskFreeConnection(ctx) {
|
|
238
|
-
const account = ctx.account;
|
|
239
|
-
const client = new DeskFreeClient(account.botToken, account.apiUrl);
|
|
240
|
-
const log = ctx.log ?? getDeskFreeRuntime().logging.createLogger('deskfree');
|
|
241
|
-
let cursor = loadCursor(ctx);
|
|
242
|
-
const backoff = { attempt: 0 };
|
|
243
|
-
let totalReconnects = 0;
|
|
244
|
-
// Initialize health tracking for this account
|
|
245
|
-
initializeHealth(ctx.accountId);
|
|
246
|
-
log.info(`Starting DeskFree connection for account ${ctx.accountId}` +
|
|
247
|
-
(cursor ? ` (resuming from cursor ${cursor})` : ' (fresh start)'));
|
|
248
|
-
// Start health logging timer
|
|
249
|
-
const healthInterval = setInterval(() => {
|
|
250
|
-
if (!ctx.abortSignal.aborted) {
|
|
251
|
-
logHealthSummary(ctx.accountId, log);
|
|
252
|
-
}
|
|
253
|
-
}, HEALTH_LOG_INTERVAL_MS);
|
|
254
|
-
// Clean up health interval on shutdown
|
|
255
|
-
ctx.abortSignal.addEventListener('abort', () => {
|
|
256
|
-
clearInterval(healthInterval);
|
|
257
|
-
}, { once: true });
|
|
258
|
-
// Outer reconnection loop — runs until OpenClaw shuts down
|
|
259
|
-
while (!ctx.abortSignal.aborted) {
|
|
260
|
-
try {
|
|
261
|
-
const { ticket, wsUrl } = await client.getWsTicket();
|
|
262
|
-
resetBackoff(backoff);
|
|
263
|
-
if (totalReconnects > 0) {
|
|
264
|
-
log.info(`Got WS ticket, reconnecting to ${wsUrl}... (reconnect #${totalReconnects})`);
|
|
265
|
-
recordReconnect(ctx.accountId);
|
|
266
|
-
}
|
|
267
|
-
else {
|
|
268
|
-
log.info(`Got WS ticket, connecting to ${wsUrl}...`);
|
|
269
|
-
}
|
|
270
|
-
updateHealthMode(ctx.accountId, 'websocket');
|
|
271
|
-
cursor = await runWebSocketConnection({
|
|
272
|
-
ticket,
|
|
273
|
-
wsUrl,
|
|
274
|
-
client,
|
|
275
|
-
ctx,
|
|
276
|
-
cursor,
|
|
277
|
-
log,
|
|
278
|
-
account,
|
|
279
|
-
});
|
|
280
|
-
// Connection closed cleanly — will reconnect
|
|
281
|
-
totalReconnects++;
|
|
282
|
-
}
|
|
283
|
-
catch (err) {
|
|
284
|
-
totalReconnects++;
|
|
285
|
-
const message = err instanceof Error ? err.message : String(err);
|
|
286
|
-
// If ticket fetch fails, fall back to polling
|
|
287
|
-
if (message.includes('API error') ||
|
|
288
|
-
message.includes('authentication failed') ||
|
|
289
|
-
message.includes('server error')) {
|
|
290
|
-
log.warn(`Ticket fetch failed (attempt #${totalReconnects}): ${message}. Falling back to polling.`);
|
|
291
|
-
recordReconnect(ctx.accountId);
|
|
292
|
-
updateHealthMode(ctx.accountId, 'polling');
|
|
293
|
-
cursor = await runPollingFallback({ client, ctx, cursor, log });
|
|
294
|
-
}
|
|
295
|
-
else {
|
|
296
|
-
log.warn(`Connection error (attempt #${totalReconnects}): ${message}`);
|
|
297
|
-
recordReconnect(ctx.accountId);
|
|
298
|
-
}
|
|
299
|
-
if (ctx.abortSignal.aborted)
|
|
300
|
-
break;
|
|
301
|
-
const delay = nextBackoff(backoff);
|
|
302
|
-
log.info(`Reconnecting in ${Math.round(delay)}ms (attempt #${totalReconnects + 1})...`);
|
|
303
|
-
await sleepWithAbort(delay, ctx.abortSignal);
|
|
304
|
-
}
|
|
305
|
-
}
|
|
306
|
-
log.info(`DeskFree connection loop exited after ${totalReconnects} reconnect(s).`);
|
|
307
|
-
}
|
|
308
|
-
/**
|
|
309
|
-
* Runs a single WebSocket connection session.
|
|
310
|
-
* Returns the latest cursor when the connection closes.
|
|
311
|
-
*/
|
|
312
|
-
async function runWebSocketConnection(opts) {
|
|
313
|
-
const { ticket, wsUrl, client, ctx, log, account } = opts;
|
|
314
|
-
let cursor = opts.cursor;
|
|
315
|
-
return new Promise((resolve, reject) => {
|
|
316
|
-
const ws = new WebSocket(`${wsUrl}?ticket=${ticket}`);
|
|
317
|
-
let pingInterval;
|
|
318
|
-
let connectionTimer;
|
|
319
|
-
let pongTimer;
|
|
320
|
-
let isConnected = false;
|
|
321
|
-
// Centralized cleanup to prevent timer leaks
|
|
322
|
-
const cleanup = () => {
|
|
323
|
-
if (pingInterval !== undefined) {
|
|
324
|
-
clearInterval(pingInterval);
|
|
325
|
-
pingInterval = undefined;
|
|
326
|
-
}
|
|
327
|
-
if (connectionTimer !== undefined) {
|
|
328
|
-
clearTimeout(connectionTimer);
|
|
329
|
-
connectionTimer = undefined;
|
|
330
|
-
}
|
|
331
|
-
if (pongTimer !== undefined) {
|
|
332
|
-
clearTimeout(pongTimer);
|
|
333
|
-
pongTimer = undefined;
|
|
334
|
-
}
|
|
335
|
-
};
|
|
336
|
-
// Connection timeout - if no 'open' event within timeout, fail
|
|
337
|
-
connectionTimer = setTimeout(() => {
|
|
338
|
-
if (!isConnected) {
|
|
339
|
-
cleanup();
|
|
340
|
-
try {
|
|
341
|
-
ws.close();
|
|
342
|
-
}
|
|
343
|
-
catch {
|
|
344
|
-
// Ignore close errors during timeout cleanup
|
|
345
|
-
}
|
|
346
|
-
reject(new Error(`WebSocket connection timeout after ${WS_CONNECTION_TIMEOUT_MS}ms`));
|
|
347
|
-
}
|
|
348
|
-
}, WS_CONNECTION_TIMEOUT_MS);
|
|
349
|
-
ws.on('open', async () => {
|
|
350
|
-
isConnected = true;
|
|
351
|
-
if (connectionTimer !== undefined) {
|
|
352
|
-
clearTimeout(connectionTimer);
|
|
353
|
-
connectionTimer = undefined;
|
|
354
|
-
}
|
|
355
|
-
ctx.setStatus({ running: true, lastStartAt: Date.now() });
|
|
356
|
-
log.info('WebSocket connected.');
|
|
357
|
-
// Keepalive ping every 5 minutes with pong timeout handling
|
|
358
|
-
pingInterval = setInterval(() => {
|
|
359
|
-
if (ws.readyState === WebSocket.OPEN) {
|
|
360
|
-
try {
|
|
361
|
-
ws.send(JSON.stringify({ action: 'ping' }));
|
|
362
|
-
// Start pong timeout
|
|
363
|
-
pongTimer = setTimeout(() => {
|
|
364
|
-
log.warn('Pong timeout - closing WebSocket connection');
|
|
365
|
-
try {
|
|
366
|
-
ws.close(1002, 'pong timeout');
|
|
367
|
-
}
|
|
368
|
-
catch (closeErr) {
|
|
369
|
-
const closeMsg = closeErr instanceof Error
|
|
370
|
-
? closeErr.message
|
|
371
|
-
: String(closeErr);
|
|
372
|
-
log.warn(`Error closing WebSocket on pong timeout: ${closeMsg}`);
|
|
373
|
-
}
|
|
374
|
-
}, WS_PONG_TIMEOUT_MS);
|
|
375
|
-
}
|
|
376
|
-
catch (err) {
|
|
377
|
-
const message = err instanceof Error ? err.message : String(err);
|
|
378
|
-
log.warn(`Failed to send ping: ${message}`);
|
|
379
|
-
}
|
|
380
|
-
}
|
|
381
|
-
}, PING_INTERVAL_MS);
|
|
382
|
-
// Report plugin + OpenClaw versions on connect
|
|
383
|
-
const openclawVersion = getDeskFreeRuntime().version;
|
|
384
|
-
client
|
|
385
|
-
.statusUpdate({
|
|
386
|
-
status: 'idle',
|
|
387
|
-
activeSubAgents: [],
|
|
388
|
-
pluginVersion: PLUGIN_VERSION,
|
|
389
|
-
openclawVersion,
|
|
390
|
-
})
|
|
391
|
-
.catch((err) => {
|
|
392
|
-
const msg = err instanceof Error ? err.message : String(err);
|
|
393
|
-
log.warn(`Failed to send statusUpdate: ${msg}`);
|
|
394
|
-
});
|
|
395
|
-
// Upload local workspace files to S3 on startup (backup local → S3)
|
|
396
|
-
try {
|
|
397
|
-
const cfg = getDeskFreeRuntime().config.loadConfig();
|
|
398
|
-
const workspacePath = resolveWorkspacePath(cfg);
|
|
399
|
-
if (workspacePath) {
|
|
400
|
-
const success = await runS3CommandWithCredentials(client, (s3Uri) => ['s3', 'sync', '.', s3Uri, '--only-show-errors'], workspacePath, log);
|
|
401
|
-
if (success) {
|
|
402
|
-
log.info(`Workspace sync: uploaded files from ${workspacePath} to S3`);
|
|
403
|
-
}
|
|
404
|
-
else {
|
|
405
|
-
log.warn('Workspace sync: failed to upload files to S3');
|
|
406
|
-
}
|
|
407
|
-
}
|
|
408
|
-
else {
|
|
409
|
-
log.debug('Workspace sync: no workspace path configured (agents.defaults.workspace)');
|
|
410
|
-
}
|
|
411
|
-
}
|
|
412
|
-
catch (err) {
|
|
413
|
-
const msg = err instanceof Error ? err.message : String(err);
|
|
414
|
-
log.warn(`Workspace sync failed: ${msg}`);
|
|
415
|
-
}
|
|
416
|
-
// Auto-manage versioned DeskFree directive in AGENTS.md
|
|
417
|
-
try {
|
|
418
|
-
const cfg = getDeskFreeRuntime().config.loadConfig();
|
|
419
|
-
const workspacePath = resolveWorkspacePath(cfg);
|
|
420
|
-
if (workspacePath) {
|
|
421
|
-
const agentsPath = join(workspacePath, 'AGENTS.md');
|
|
422
|
-
const DIRECTIVE_START = '<!-- deskfree-directive-start -->';
|
|
423
|
-
const DIRECTIVE_END = '<!-- deskfree-directive-end -->';
|
|
424
|
-
const directiveBlock = [
|
|
425
|
-
DIRECTIVE_START,
|
|
426
|
-
`<!-- deskfree-directive-version:${PLUGIN_VERSION} -->`,
|
|
427
|
-
'## DeskFree',
|
|
428
|
-
'Always read the deskfree skill (SKILL.md) at startup. Follow its workflow for task tracking and messaging.',
|
|
429
|
-
'1. ALL work requires a task — use `deskfree_create_task` then `deskfree_start_task`.',
|
|
430
|
-
'2. Goals before tasks — use `deskfree_create_goal` for multi-step objectives.',
|
|
431
|
-
'3. Build deliverables incrementally — use `deskfree_update_deliverable` from the start.',
|
|
432
|
-
'4. Always complete tasks — use `deskfree_complete_task` with outcome "done" or "blocked".',
|
|
433
|
-
'5. Auto-threading works — messages sent while a task is active are threaded automatically.',
|
|
434
|
-
'6. One task per sub-agent — sub-agents only get 3 tools: update_deliverable, complete_task, send_message.',
|
|
435
|
-
DIRECTIVE_END,
|
|
436
|
-
].join('\n');
|
|
437
|
-
if (existsSync(agentsPath)) {
|
|
438
|
-
let content = readFileSync(agentsPath, 'utf-8');
|
|
439
|
-
const versionTag = `deskfree-directive-version:${PLUGIN_VERSION}`;
|
|
440
|
-
if (content.includes(versionTag)) {
|
|
441
|
-
// Current version already present — nothing to do
|
|
442
|
-
}
|
|
443
|
-
else if (content.includes(DIRECTIVE_START) &&
|
|
444
|
-
content.includes(DIRECTIVE_END)) {
|
|
445
|
-
// Replace old version block
|
|
446
|
-
const startIdx = content.indexOf(DIRECTIVE_START);
|
|
447
|
-
const endIdx = content.indexOf(DIRECTIVE_END) + DIRECTIVE_END.length;
|
|
448
|
-
content =
|
|
449
|
-
content.slice(0, startIdx) +
|
|
450
|
-
directiveBlock +
|
|
451
|
-
content.slice(endIdx);
|
|
452
|
-
writeFileSync(agentsPath, content, 'utf-8');
|
|
453
|
-
log.info(`Updated DeskFree directive in AGENTS.md to v${PLUGIN_VERSION}`);
|
|
454
|
-
}
|
|
455
|
-
else {
|
|
456
|
-
// No directive block yet — also remove legacy marker if present
|
|
457
|
-
content = content.replace(/\n*## DeskFree\n<!-- deskfree-directive -->\n[^\n]*\n*/, '');
|
|
458
|
-
content = content.trimEnd() + '\n\n' + directiveBlock + '\n';
|
|
459
|
-
writeFileSync(agentsPath, content, 'utf-8');
|
|
460
|
-
log.info(`Appended DeskFree directive v${PLUGIN_VERSION} to AGENTS.md`);
|
|
461
|
-
}
|
|
462
|
-
}
|
|
463
|
-
else {
|
|
464
|
-
log.debug('AGENTS.md not found — OpenClaw may not have scaffolded yet');
|
|
465
|
-
}
|
|
466
|
-
}
|
|
467
|
-
}
|
|
468
|
-
catch (err) {
|
|
469
|
-
const msg = err instanceof Error ? err.message : String(err);
|
|
470
|
-
log.warn(`Failed to check/update DeskFree directive: ${msg}`);
|
|
471
|
-
}
|
|
472
|
-
// Auto-manage versioned DeskFree heartbeat in HEARTBEAT.md
|
|
473
|
-
try {
|
|
474
|
-
const cfg = getDeskFreeRuntime().config.loadConfig();
|
|
475
|
-
const workspacePath = resolveWorkspacePath(cfg);
|
|
476
|
-
if (workspacePath) {
|
|
477
|
-
const heartbeatPath = join(workspacePath, 'HEARTBEAT.md');
|
|
478
|
-
const HEARTBEAT_START = '<!-- deskfree-heartbeat-start -->';
|
|
479
|
-
const HEARTBEAT_END = '<!-- deskfree-heartbeat-end -->';
|
|
480
|
-
const heartbeatBlock = [
|
|
481
|
-
HEARTBEAT_START,
|
|
482
|
-
`<!-- deskfree-heartbeat-version:${PLUGIN_VERSION} -->`,
|
|
483
|
-
'## DeskFree Heartbeat',
|
|
484
|
-
'On each heartbeat, run through this checklist:',
|
|
485
|
-
'1. Run `deskfree_state` to get full workspace snapshot.',
|
|
486
|
-
'2. Any active goals without pending tasks? Create the next task.',
|
|
487
|
-
'3. Any `ready_for_bot` tasks? Check capacity, then `deskfree_start_task` and spawn sub-agents.',
|
|
488
|
-
'4. Any goals with no activity in 48h? Create a task or nudge the human.',
|
|
489
|
-
'5. Any `working_on_it` tasks with no active sub-agent? Complete as blocked or resume.',
|
|
490
|
-
HEARTBEAT_END,
|
|
491
|
-
].join('\n');
|
|
492
|
-
if (existsSync(heartbeatPath)) {
|
|
493
|
-
let content = readFileSync(heartbeatPath, 'utf-8');
|
|
494
|
-
const versionTag = `deskfree-heartbeat-version:${PLUGIN_VERSION}`;
|
|
495
|
-
if (content.includes(versionTag)) {
|
|
496
|
-
// Current version already present — nothing to do
|
|
497
|
-
}
|
|
498
|
-
else if (content.includes(HEARTBEAT_START) &&
|
|
499
|
-
content.includes(HEARTBEAT_END)) {
|
|
500
|
-
// Replace old version block
|
|
501
|
-
const startIdx = content.indexOf(HEARTBEAT_START);
|
|
502
|
-
const endIdx = content.indexOf(HEARTBEAT_END) + HEARTBEAT_END.length;
|
|
503
|
-
content =
|
|
504
|
-
content.slice(0, startIdx) +
|
|
505
|
-
heartbeatBlock +
|
|
506
|
-
content.slice(endIdx);
|
|
507
|
-
writeFileSync(heartbeatPath, content, 'utf-8');
|
|
508
|
-
log.info(`Updated DeskFree heartbeat in HEARTBEAT.md to v${PLUGIN_VERSION}`);
|
|
509
|
-
}
|
|
510
|
-
else {
|
|
511
|
-
content = content.trimEnd() + '\n\n' + heartbeatBlock + '\n';
|
|
512
|
-
writeFileSync(heartbeatPath, content, 'utf-8');
|
|
513
|
-
log.info(`Appended DeskFree heartbeat v${PLUGIN_VERSION} to HEARTBEAT.md`);
|
|
514
|
-
}
|
|
515
|
-
}
|
|
516
|
-
else {
|
|
517
|
-
log.debug('HEARTBEAT.md not found — OpenClaw may not have scaffolded yet');
|
|
518
|
-
}
|
|
519
|
-
}
|
|
520
|
-
}
|
|
521
|
-
catch (err) {
|
|
522
|
-
const msg = err instanceof Error ? err.message : String(err);
|
|
523
|
-
log.warn(`Failed to check/update DeskFree heartbeat: ${msg}`);
|
|
524
|
-
}
|
|
525
|
-
// Enqueue initial catch-up poll through mutex
|
|
526
|
-
enqueuePoll(client, ctx, () => cursor, (c) => {
|
|
527
|
-
cursor = c ?? cursor;
|
|
528
|
-
}, log, account);
|
|
529
|
-
});
|
|
530
|
-
ws.on('message', async (data) => {
|
|
531
|
-
try {
|
|
532
|
-
const raw = data.toString();
|
|
533
|
-
if (!raw || raw.length > 65536) {
|
|
534
|
-
log.warn(`Ignoring oversized or empty WS message (${raw?.length ?? 0} bytes)`);
|
|
535
|
-
return;
|
|
536
|
-
}
|
|
537
|
-
const msg = JSON.parse(raw);
|
|
538
|
-
if (!msg || typeof msg.action !== 'string') {
|
|
539
|
-
log.warn('Ignoring WS message with missing or invalid action field');
|
|
540
|
-
return;
|
|
541
|
-
}
|
|
542
|
-
if (msg.action === 'notify') {
|
|
543
|
-
const notifyMsg = msg;
|
|
544
|
-
// Handle workspace file changes from human edits
|
|
545
|
-
if (notifyMsg.hint === 'workspace.fileChanged') {
|
|
546
|
-
const paths = notifyMsg.paths ?? [];
|
|
547
|
-
log.info(`Workspace file(s) changed by human: ${paths.join(', ') || '(all)'}`);
|
|
548
|
-
// Download changed files using aws s3 cp
|
|
549
|
-
try {
|
|
550
|
-
const cfg = getDeskFreeRuntime().config.loadConfig();
|
|
551
|
-
const workspacePath = resolveWorkspacePath(cfg);
|
|
552
|
-
if (workspacePath && paths.length > 0) {
|
|
553
|
-
for (const filePath of paths) {
|
|
554
|
-
try {
|
|
555
|
-
// Use aws s3 cp to download the specific changed file
|
|
556
|
-
const success = await runS3CommandWithCredentials(client, (s3Uri) => [
|
|
557
|
-
's3',
|
|
558
|
-
'cp',
|
|
559
|
-
`${s3Uri}/${filePath}`,
|
|
560
|
-
filePath,
|
|
561
|
-
'--only-show-errors',
|
|
562
|
-
], workspacePath, log);
|
|
563
|
-
if (success) {
|
|
564
|
-
log.info(`Updated local file: ${filePath}`);
|
|
565
|
-
}
|
|
566
|
-
else {
|
|
567
|
-
log.warn(`Failed to download workspace file: ${filePath}`);
|
|
568
|
-
}
|
|
569
|
-
}
|
|
570
|
-
catch (err) {
|
|
571
|
-
const errMsg = err instanceof Error ? err.message : String(err);
|
|
572
|
-
log.warn(`Failed to download workspace file ${filePath}: ${errMsg}`);
|
|
573
|
-
}
|
|
574
|
-
}
|
|
575
|
-
}
|
|
576
|
-
else if (!workspacePath) {
|
|
577
|
-
log.warn('Cannot sync workspace files: workspace path not configured');
|
|
578
|
-
}
|
|
579
|
-
}
|
|
580
|
-
catch (err) {
|
|
581
|
-
const errMsg = err instanceof Error ? err.message : String(err);
|
|
582
|
-
log.warn(`Workspace file change handling failed: ${errMsg}`);
|
|
583
|
-
}
|
|
584
|
-
}
|
|
585
|
-
// Handle workspace upload requests (upload local → S3)
|
|
586
|
-
if (notifyMsg.hint === 'workspace.uploadRequested') {
|
|
587
|
-
log.info('Workspace upload requested');
|
|
588
|
-
try {
|
|
589
|
-
const cfg = getDeskFreeRuntime().config.loadConfig();
|
|
590
|
-
const workspacePath = resolveWorkspacePath(cfg);
|
|
591
|
-
if (workspacePath) {
|
|
592
|
-
const success = await runS3CommandWithCredentials(client, (s3Uri) => ['s3', 'sync', '.', s3Uri, '--only-show-errors'], workspacePath, log);
|
|
593
|
-
if (success) {
|
|
594
|
-
log.info(`Workspace upload: synced ${workspacePath} to S3`);
|
|
595
|
-
}
|
|
596
|
-
else {
|
|
597
|
-
log.warn('Workspace upload: failed to sync to S3');
|
|
598
|
-
}
|
|
599
|
-
}
|
|
600
|
-
else {
|
|
601
|
-
log.warn('Cannot upload workspace: workspace path not configured');
|
|
602
|
-
}
|
|
603
|
-
}
|
|
604
|
-
catch (err) {
|
|
605
|
-
const errMsg = err instanceof Error ? err.message : String(err);
|
|
606
|
-
log.warn(`Workspace upload failed: ${errMsg}`);
|
|
607
|
-
}
|
|
608
|
-
}
|
|
609
|
-
// Enqueue notification-triggered poll through mutex
|
|
610
|
-
enqueuePoll(client, ctx, () => cursor, (c) => {
|
|
611
|
-
cursor = c ?? cursor;
|
|
612
|
-
}, log);
|
|
613
|
-
}
|
|
614
|
-
else if (msg.action === 'pong') {
|
|
615
|
-
// Clear pong timeout - connection is healthy
|
|
616
|
-
if (pongTimer !== undefined) {
|
|
617
|
-
clearTimeout(pongTimer);
|
|
618
|
-
pongTimer = undefined;
|
|
619
|
-
}
|
|
620
|
-
log.debug('Received pong - connection healthy');
|
|
621
|
-
}
|
|
622
|
-
// Ignore other message types
|
|
623
|
-
}
|
|
624
|
-
catch (err) {
|
|
625
|
-
const message = err instanceof Error ? err.message : String(err);
|
|
626
|
-
log.warn(`Error processing WS message: ${message}`);
|
|
627
|
-
}
|
|
628
|
-
});
|
|
629
|
-
ws.on('close', (code, reason) => {
|
|
630
|
-
cleanup();
|
|
631
|
-
isConnected = false;
|
|
632
|
-
ctx.setStatus({ running: false, lastStopAt: Date.now() });
|
|
633
|
-
// Classify close codes for better logging and error handling
|
|
634
|
-
if (code === 1000) {
|
|
635
|
-
log.info(`WebSocket closed normally: ${code} ${reason.toString()}`);
|
|
636
|
-
}
|
|
637
|
-
else if (code === 1001) {
|
|
638
|
-
log.info(`WebSocket closed - endpoint going away: ${code} ${reason.toString()}`);
|
|
639
|
-
}
|
|
640
|
-
else if (code >= 1002 && code <= 1011) {
|
|
641
|
-
log.warn(`WebSocket closed with error: ${code} ${reason.toString()}`);
|
|
642
|
-
ctx.setStatus({
|
|
643
|
-
running: false,
|
|
644
|
-
lastStopAt: Date.now(),
|
|
645
|
-
lastError: `WebSocket closed with code ${code}: ${reason.toString()}`,
|
|
646
|
-
});
|
|
647
|
-
}
|
|
648
|
-
else if (code >= 4000) {
|
|
649
|
-
log.warn(`WebSocket closed with application error: ${code} ${reason.toString()}`);
|
|
650
|
-
ctx.setStatus({
|
|
651
|
-
running: false,
|
|
652
|
-
lastStopAt: Date.now(),
|
|
653
|
-
lastError: `Application error ${code}: ${reason.toString()}`,
|
|
654
|
-
});
|
|
655
|
-
}
|
|
656
|
-
else {
|
|
657
|
-
log.info(`WebSocket closed: ${code} ${reason.toString()}`);
|
|
658
|
-
}
|
|
659
|
-
resolve(cursor);
|
|
660
|
-
});
|
|
661
|
-
ws.on('error', (err) => {
|
|
662
|
-
cleanup();
|
|
663
|
-
isConnected = false;
|
|
664
|
-
const errorMessage = err instanceof Error ? err.message : String(err);
|
|
665
|
-
log.error(`WebSocket error: ${errorMessage}`);
|
|
666
|
-
ctx.setStatus({
|
|
667
|
-
running: false,
|
|
668
|
-
lastError: errorMessage,
|
|
669
|
-
lastStopAt: Date.now(),
|
|
670
|
-
});
|
|
671
|
-
reject(err);
|
|
672
|
-
});
|
|
673
|
-
// Clean shutdown - register abort handler before potential errors
|
|
674
|
-
ctx.abortSignal.addEventListener('abort', () => {
|
|
675
|
-
log.info('Shutdown requested - closing WebSocket');
|
|
676
|
-
cleanup();
|
|
677
|
-
try {
|
|
678
|
-
if (ws.readyState === WebSocket.OPEN ||
|
|
679
|
-
ws.readyState === WebSocket.CONNECTING) {
|
|
680
|
-
ws.close(1000, 'shutdown');
|
|
681
|
-
}
|
|
682
|
-
}
|
|
683
|
-
catch (err) {
|
|
684
|
-
const message = err instanceof Error ? err.message : String(err);
|
|
685
|
-
log.warn(`Error closing WebSocket during shutdown: ${message}`);
|
|
686
|
-
}
|
|
687
|
-
}, { once: true });
|
|
688
|
-
});
|
|
689
|
-
}
|
|
690
|
-
/**
|
|
691
|
-
* Fallback polling loop when WebSocket is unavailable.
|
|
692
|
-
* Polls every 30s until aborted or until we should retry WebSocket.
|
|
693
|
-
*/
|
|
694
|
-
async function runPollingFallback(opts) {
|
|
695
|
-
const { client, ctx, log } = opts;
|
|
696
|
-
let cursor = opts.cursor;
|
|
697
|
-
let iterations = 0;
|
|
698
|
-
const maxIterations = 10; // After 10 polls (~5 min), try WebSocket again
|
|
699
|
-
ctx.setStatus({ running: true, lastStartAt: Date.now() });
|
|
700
|
-
log.info('Running in polling fallback mode.');
|
|
701
|
-
let consecutiveFailures = 0;
|
|
702
|
-
while (!ctx.abortSignal.aborted && iterations < maxIterations) {
|
|
703
|
-
const newCursor = await pollAndDeliver(client, ctx, cursor, log);
|
|
704
|
-
if (newCursor) {
|
|
705
|
-
cursor = newCursor;
|
|
706
|
-
consecutiveFailures = 0;
|
|
707
|
-
}
|
|
708
|
-
else if (newCursor === null && cursor) {
|
|
709
|
-
// null return with existing cursor means poll succeeded but no new messages - fine
|
|
710
|
-
consecutiveFailures = 0;
|
|
711
|
-
}
|
|
712
|
-
else {
|
|
713
|
-
consecutiveFailures++;
|
|
714
|
-
if (consecutiveFailures >= MAX_CONSECUTIVE_POLL_FAILURES) {
|
|
715
|
-
log.warn(`${consecutiveFailures} consecutive poll failures, breaking out to retry WebSocket`);
|
|
716
|
-
break;
|
|
717
|
-
}
|
|
718
|
-
}
|
|
719
|
-
iterations++;
|
|
720
|
-
// Add jitter to poll interval to avoid thundering herd
|
|
721
|
-
const jitter = Math.random() * POLL_FALLBACK_INTERVAL_MS * 0.2;
|
|
722
|
-
await sleepWithAbort(POLL_FALLBACK_INTERVAL_MS + jitter, ctx.abortSignal);
|
|
723
|
-
}
|
|
724
|
-
ctx.setStatus({ running: false, lastStopAt: Date.now() });
|
|
725
|
-
return cursor;
|
|
726
|
-
}
|
|
727
|
-
/**
|
|
728
|
-
* Poll the messages endpoint and deliver new messages to the OpenClaw agent.
|
|
729
|
-
* Returns the new cursor, or null if no messages.
|
|
730
|
-
*
|
|
731
|
-
* On first run (cursor is null), we skip delivering old messages and just
|
|
732
|
-
* seed the cursor so we only receive messages from this point forward.
|
|
733
|
-
*/
|
|
734
|
-
async function pollAndDeliver(client, ctx, cursor, log, account) {
|
|
735
|
-
try {
|
|
736
|
-
// SEED is a sentinel value indicating we've completed the first-run
|
|
737
|
-
// flow but had no real cursor (empty inbox). Don't send it to the API
|
|
738
|
-
// since it's not a valid datetime cursor.
|
|
739
|
-
const SEED = 'SEED';
|
|
740
|
-
const isFirstRun = !cursor || cursor === SEED;
|
|
741
|
-
const apiCursor = cursor && cursor !== SEED ? cursor : undefined;
|
|
742
|
-
const response = await client.listMessages({
|
|
743
|
-
...(apiCursor ? { cursor: apiCursor } : {}),
|
|
744
|
-
});
|
|
745
|
-
// On first run, don't deliver old messages — just seed the cursor
|
|
746
|
-
// and send a welcome message so the user knows the connection is live.
|
|
747
|
-
if (isFirstRun) {
|
|
748
|
-
if (response.cursor) {
|
|
749
|
-
log.info(`First run: skipping ${response.items.length} existing message(s), seeding cursor.`);
|
|
750
|
-
saveCursor(ctx, response.cursor, log);
|
|
751
|
-
}
|
|
752
|
-
else {
|
|
753
|
-
log.info('First run: no messages yet (empty inbox).');
|
|
754
|
-
}
|
|
755
|
-
log.info('Connected to DeskFree. Ready to receive messages and tasks.');
|
|
756
|
-
// Send welcome message to trigger the first agent session.
|
|
757
|
-
// This ensures workspace files get scaffolded on fresh instances.
|
|
758
|
-
try {
|
|
759
|
-
const botName = account?.botName;
|
|
760
|
-
const humanName = account?.humanName;
|
|
761
|
-
const welcomeContent = botName && humanName
|
|
762
|
-
? `Hi! I'm ${humanName}. Your name is ${botName}. Welcome to DeskFree — read your BOOTSTRAP.md to get started, then check for any tasks.`
|
|
763
|
-
: "DeskFree plugin installed! Read your BOOTSTRAP.md if you haven't already, then check for any tasks.";
|
|
764
|
-
const welcomeMessage = {
|
|
765
|
-
messageId: `welcome-${Date.now()}`,
|
|
766
|
-
botId: '',
|
|
767
|
-
humanId: 'system',
|
|
768
|
-
authorType: 'user',
|
|
769
|
-
content: welcomeContent,
|
|
770
|
-
createdAt: new Date().toISOString(),
|
|
771
|
-
userName: humanName ?? 'System',
|
|
772
|
-
};
|
|
773
|
-
await deliverMessageToAgent(ctx, welcomeMessage, client);
|
|
774
|
-
log.info('Sent welcome message to agent.');
|
|
775
|
-
}
|
|
776
|
-
catch (err) {
|
|
777
|
-
const msg = err instanceof Error ? err.message : String(err);
|
|
778
|
-
log.warn(`Failed to send welcome message: ${msg}`);
|
|
779
|
-
}
|
|
780
|
-
// Return the API cursor, or SEED as sentinel when API returns null
|
|
781
|
-
// (empty inbox) so subsequent polls don't re-trigger first-run flow.
|
|
782
|
-
return response.cursor ?? SEED;
|
|
783
|
-
}
|
|
784
|
-
if (response.items.length === 0)
|
|
785
|
-
return null;
|
|
786
|
-
// Filter out already-delivered messages (handles cursor precision issues
|
|
787
|
-
// where Postgres microsecond timestamps round-trip through JS millisecond Date)
|
|
788
|
-
const newItems = response.items.filter((m) => !deliveredMessageIds.has(m.messageId));
|
|
789
|
-
if (newItems.length === 0) {
|
|
790
|
-
log.debug(`Poll returned ${response.items.length} item(s), all already delivered.`);
|
|
791
|
-
// Still advance cursor even if all items were duplicates
|
|
792
|
-
if (response.cursor) {
|
|
793
|
-
saveCursor(ctx, response.cursor, log);
|
|
794
|
-
}
|
|
795
|
-
return response.cursor;
|
|
796
|
-
}
|
|
797
|
-
log.info(`Received ${newItems.length} new message(s) (${response.items.length - newItems.length} duplicate(s) skipped).`);
|
|
798
|
-
// Save cursor after each successful delivery so a mid-batch
|
|
799
|
-
// failure doesn't cause the entire batch to be re-delivered on retry.
|
|
800
|
-
let deliveredCount = 0;
|
|
801
|
-
for (const message of newItems) {
|
|
802
|
-
// Skip bot's own messages to prevent echo loops
|
|
803
|
-
if (message.authorType === 'bot') {
|
|
804
|
-
log.debug(`Skipping bot message ${message.messageId}`);
|
|
805
|
-
deliveredMessageIds.add(message.messageId);
|
|
806
|
-
continue;
|
|
807
|
-
}
|
|
808
|
-
await deliverMessageToAgent(ctx, message, client);
|
|
809
|
-
deliveredMessageIds.add(message.messageId);
|
|
810
|
-
deliveredCount++;
|
|
811
|
-
}
|
|
812
|
-
// Cap the dedup set to prevent unbounded memory growth
|
|
813
|
-
if (deliveredMessageIds.size > 1000) {
|
|
814
|
-
const entries = Array.from(deliveredMessageIds);
|
|
815
|
-
deliveredMessageIds.clear();
|
|
816
|
-
for (const id of entries.slice(-500)) {
|
|
817
|
-
deliveredMessageIds.add(id);
|
|
818
|
-
}
|
|
819
|
-
}
|
|
820
|
-
// Record message delivery in health stats
|
|
821
|
-
if (deliveredCount > 0) {
|
|
822
|
-
recordMessageDelivery(ctx.accountId, deliveredCount);
|
|
823
|
-
}
|
|
824
|
-
// Persist cursor
|
|
825
|
-
if (response.cursor) {
|
|
826
|
-
saveCursor(ctx, response.cursor, log);
|
|
827
|
-
}
|
|
828
|
-
return response.cursor;
|
|
829
|
-
}
|
|
830
|
-
catch (err) {
|
|
831
|
-
const message = err instanceof Error ? err.message : String(err);
|
|
832
|
-
log.warn(`Poll failed: ${message}`);
|
|
833
|
-
return null;
|
|
834
|
-
}
|
|
835
|
-
}
|
|
836
|
-
//# sourceMappingURL=gateway.js.map
|