@clwnt/clawnet 0.1.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/src/service.ts ADDED
@@ -0,0 +1,482 @@
1
+ import type { ClawnetConfig, ClawnetAccount } from "./config.js";
2
+ import { resolveToken } from "./config.js";
3
+ import { reloadCapabilities } from "./tools.js";
4
+
5
+ // --- Types ---
6
+
7
+ interface InboxMessage {
8
+ id: string;
9
+ from_agent: string;
10
+ content: string;
11
+ subject?: string;
12
+ created_at: string;
13
+ }
14
+
15
+ export interface ServiceState {
16
+ lastPollAt: Date | null;
17
+ lastInboxNonEmptyAt: Date | null;
18
+ backoffUntil: Date | null;
19
+ lastError: { message: string; at: Date } | null;
20
+ counters: {
21
+ polls: number;
22
+ errors: number;
23
+ batchesSent: number;
24
+ messagesSeen: number;
25
+ delivered: number;
26
+ };
27
+ }
28
+
29
+ // --- Skill file cache ---
30
+
31
+ const SKILL_UPDATE_INTERVAL_MS = 6 * 60 * 60 * 1000; // 6 hours
32
+ const SKILL_FILES = ["skill.md", "skill.json", "api-reference.md", "inbox-handler.md", "capabilities.json"];
33
+
34
+ // --- Service ---
35
+
36
+ export function createClawnetService(params: { api: any; cfg: ClawnetConfig }) {
37
+ const { api, cfg } = params;
38
+ let timer: ReturnType<typeof setTimeout> | null = null;
39
+ let skillTimer: ReturnType<typeof setTimeout> | null = null;
40
+ let stopped = false;
41
+
42
+ // Per-account concurrency lock: only 1 LLM run at a time per account
43
+ const accountBusy = new Set<string>();
44
+
45
+ // Per-account debounce: accumulate messages before sending
46
+ const pendingMessages = new Map<string, InboxMessage[]>();
47
+ const debounceTimers = new Map<string, ReturnType<typeof setTimeout>>();
48
+
49
+ const state: ServiceState = {
50
+ lastPollAt: null,
51
+ lastInboxNonEmptyAt: null,
52
+ backoffUntil: null,
53
+ lastError: null,
54
+ counters: { polls: 0, errors: 0, batchesSent: 0, messagesSeen: 0, delivered: 0 },
55
+ };
56
+
57
+ // Exponential backoff tracking
58
+ let consecutiveErrors = 0;
59
+ const MAX_BACKOFF_MS = 5 * 60 * 1000; // 5 min cap
60
+
61
+ function getBackoffMs(): number {
62
+ if (consecutiveErrors === 0) return 0;
63
+ const base = Math.min(1000 * Math.pow(2, consecutiveErrors), MAX_BACKOFF_MS);
64
+ const jitter = Math.random() * base * 0.3;
65
+ return base + jitter;
66
+ }
67
+
68
+ // --- Hooks URL (derived from config, loopback only) ---
69
+
70
+ function getHooksUrl(): string {
71
+ const gatewayPort = api.config?.gateway?.port ?? 4152;
72
+ const hooksPath = api.config?.hooks?.path ?? "/hooks";
73
+ return `http://127.0.0.1:${gatewayPort}${hooksPath}`;
74
+ }
75
+
76
+ function getHooksToken(): string {
77
+ const rawToken = api.config?.hooks?.token ?? "";
78
+ return resolveToken(rawToken) || process.env.OPENCLAW_HOOKS_TOKEN || "";
79
+ }
80
+
81
+ // --- Message formatting ---
82
+
83
+ function formatMessage(msg: InboxMessage) {
84
+ let content = msg.content;
85
+ if (content.length > cfg.maxSnippetChars) {
86
+ content = content.slice(0, cfg.maxSnippetChars) + "...";
87
+ }
88
+
89
+ return {
90
+ id: msg.id,
91
+ from_agent: msg.from_agent,
92
+ content,
93
+ created_at: msg.created_at,
94
+ };
95
+ }
96
+
97
+ // --- Batch delivery to hook ---
98
+
99
+ async function deliverBatch(accountId: string, agentId: string, messages: InboxMessage[]) {
100
+ if (messages.length === 0) return;
101
+
102
+ // Concurrency guard
103
+ if (accountBusy.has(accountId)) {
104
+ api.logger.info(`[clawnet] ${accountId}: LLM run in progress, requeueing ${messages.length} message(s)`);
105
+ // Put them back in pending for next cycle
106
+ const existing = pendingMessages.get(accountId) ?? [];
107
+ pendingMessages.set(accountId, [...existing, ...messages]);
108
+ return;
109
+ }
110
+
111
+ accountBusy.add(accountId);
112
+
113
+ try {
114
+ const hooksUrl = getHooksUrl();
115
+ const hooksToken = getHooksToken();
116
+
117
+ // Always send as array — same field names as the API response
118
+ const items = messages.map((msg) => formatMessage(msg));
119
+
120
+ const payload = {
121
+ agent_id: agentId,
122
+ count: items.length,
123
+ messages: items,
124
+ };
125
+
126
+ const res = await fetch(`${hooksUrl}/clawnet/${accountId}`, {
127
+ method: "POST",
128
+ headers: {
129
+ "Content-Type": "application/json",
130
+ ...(hooksToken ? { Authorization: `Bearer ${hooksToken}` } : {}),
131
+ },
132
+ body: JSON.stringify(payload),
133
+ });
134
+
135
+ if (!res.ok) {
136
+ const body = await res.text().catch(() => "");
137
+ throw new Error(`Hook POST (${messages.length} msgs) returned ${res.status}: ${body}`);
138
+ }
139
+
140
+ state.counters.batchesSent++;
141
+ state.counters.delivered += messages.length;
142
+ api.logger.info(
143
+ `[clawnet] ${accountId}: delivered ${messages.length} message(s) to ${agentId}`,
144
+ );
145
+ } catch (err: any) {
146
+ state.lastError = { message: err.message, at: new Date() };
147
+ state.counters.errors++;
148
+ api.logger.error(`[clawnet] ${accountId}: batch delivery failed: ${err.message}`);
149
+ } finally {
150
+ accountBusy.delete(accountId);
151
+ }
152
+ }
153
+
154
+ // --- Debounced flush: wait for more messages, then deliver ---
155
+
156
+ function scheduleFlush(accountId: string, agentId: string) {
157
+ // Clear existing debounce timer
158
+ const existing = debounceTimers.get(accountId);
159
+ if (existing) clearTimeout(existing);
160
+
161
+ const pending = pendingMessages.get(accountId) ?? [];
162
+
163
+ // Flush immediately if we've hit max batch size
164
+ if (pending.length >= cfg.maxBatchSize) {
165
+ flushAccount(accountId, agentId);
166
+ return;
167
+ }
168
+
169
+ // Otherwise debounce — wait for more messages
170
+ const timer = setTimeout(() => {
171
+ debounceTimers.delete(accountId);
172
+ flushAccount(accountId, agentId);
173
+ }, cfg.debounceSeconds * 1000);
174
+
175
+ debounceTimers.set(accountId, timer);
176
+ }
177
+
178
+ function flushAccount(accountId: string, agentId: string) {
179
+ const messages = pendingMessages.get(accountId) ?? [];
180
+ pendingMessages.delete(accountId);
181
+ if (messages.length === 0) return;
182
+
183
+ // Cap at maxBatchSize, put overflow back
184
+ const batch = messages.slice(0, cfg.maxBatchSize);
185
+ const overflow = messages.slice(cfg.maxBatchSize);
186
+ if (overflow.length > 0) {
187
+ pendingMessages.set(accountId, overflow);
188
+ // Schedule another flush for the overflow
189
+ scheduleFlush(accountId, agentId);
190
+ }
191
+
192
+ deliverBatch(accountId, agentId, batch);
193
+ }
194
+
195
+ // --- Poll ---
196
+
197
+ async function pollAccount(account: ClawnetAccount) {
198
+ const resolvedToken = resolveToken(account.token);
199
+ if (!resolvedToken) {
200
+ api.logger.warn(`[clawnet] No token resolved for account "${account.id}", skipping`);
201
+ return;
202
+ }
203
+
204
+ const headers = {
205
+ Authorization: `Bearer ${resolvedToken}`,
206
+ "Content-Type": "application/json",
207
+ };
208
+
209
+ // Check for new messages
210
+ const checkRes = await fetch(`${cfg.baseUrl}/inbox/check`, { headers });
211
+ if (!checkRes.ok) {
212
+ throw new Error(`/inbox/check returned ${checkRes.status}`);
213
+ }
214
+ const checkData = (await checkRes.json()) as {
215
+ count: number;
216
+ plugin_config?: {
217
+ poll_seconds: number;
218
+ debounce_seconds: number;
219
+ max_batch_size: number;
220
+ deliver_channel: string;
221
+ };
222
+ };
223
+
224
+ // Apply server-side config if present
225
+ if (checkData.plugin_config) {
226
+ const pc = checkData.plugin_config;
227
+ let changed = false;
228
+ if (pc.poll_seconds !== cfg.pollEverySeconds) {
229
+ cfg.pollEverySeconds = pc.poll_seconds;
230
+ changed = true;
231
+ }
232
+ if (pc.debounce_seconds !== cfg.debounceSeconds) {
233
+ cfg.debounceSeconds = pc.debounce_seconds;
234
+ changed = true;
235
+ }
236
+ if (pc.max_batch_size !== cfg.maxBatchSize) {
237
+ cfg.maxBatchSize = pc.max_batch_size;
238
+ changed = true;
239
+ }
240
+ if (pc.deliver_channel !== cfg.deliver.channel) {
241
+ cfg.deliver.channel = pc.deliver_channel;
242
+ changed = true;
243
+ }
244
+ if (changed) {
245
+ api.logger.info(`[clawnet] Config updated from server: poll=${cfg.pollEverySeconds}s debounce=${cfg.debounceSeconds}s batch=${cfg.maxBatchSize}`);
246
+ }
247
+ }
248
+
249
+ if (checkData.count === 0) return;
250
+
251
+ state.lastInboxNonEmptyAt = new Date();
252
+ api.logger.info(`[clawnet] ${account.id}: ${checkData.count} message(s) waiting`);
253
+
254
+ // Fetch full messages
255
+ const inboxRes = await fetch(`${cfg.baseUrl}/inbox`, { headers });
256
+ if (!inboxRes.ok) {
257
+ throw new Error(`/inbox returned ${inboxRes.status}`);
258
+ }
259
+ const inboxData = (await inboxRes.json()) as { messages: InboxMessage[] };
260
+
261
+ if (inboxData.messages.length === 0) return;
262
+
263
+ state.counters.messagesSeen += inboxData.messages.length;
264
+
265
+ // Add to pending and schedule debounced flush
266
+ const existing = pendingMessages.get(account.id) ?? [];
267
+ pendingMessages.set(account.id, [...existing, ...inboxData.messages]);
268
+ scheduleFlush(account.id, account.agentId);
269
+ }
270
+
271
+ async function tick() {
272
+ if (stopped) return;
273
+
274
+ // Check backoff
275
+ if (state.backoffUntil && new Date() < state.backoffUntil) {
276
+ scheduleTick();
277
+ return;
278
+ }
279
+
280
+ state.lastPollAt = new Date();
281
+ state.counters.polls++;
282
+
283
+ const enabledAccounts = cfg.accounts.filter((a) => a.enabled);
284
+ if (enabledAccounts.length === 0) {
285
+ api.logger.debug?.("[clawnet] No enabled accounts, skipping tick");
286
+ scheduleTick();
287
+ return;
288
+ }
289
+
290
+ let hadError = false;
291
+ for (const account of enabledAccounts) {
292
+ try {
293
+ await pollAccount(account);
294
+ } catch (err: any) {
295
+ hadError = true;
296
+ state.lastError = { message: err.message, at: new Date() };
297
+ state.counters.errors++;
298
+ api.logger.error(`[clawnet] Poll error for ${account.id}: ${err.message}`);
299
+ }
300
+ }
301
+
302
+ if (hadError) {
303
+ consecutiveErrors++;
304
+ const backoffMs = getBackoffMs();
305
+ state.backoffUntil = new Date(Date.now() + backoffMs);
306
+ api.logger.info(`[clawnet] Backing off ${Math.round(backoffMs / 1000)}s`);
307
+ } else {
308
+ consecutiveErrors = 0;
309
+ state.backoffUntil = null;
310
+ }
311
+
312
+ scheduleTick();
313
+ }
314
+
315
+ function scheduleTick() {
316
+ if (stopped) return;
317
+ timer = setTimeout(tick, cfg.pollEverySeconds * 1000);
318
+ }
319
+
320
+ // --- Skill file updates ---
321
+
322
+ async function updateSkillFiles() {
323
+ if (stopped) return;
324
+ try {
325
+ const { homedir } = await import("node:os");
326
+ const { mkdir, writeFile } = await import("node:fs/promises");
327
+ const { join } = await import("node:path");
328
+
329
+ const docsDir = join(homedir(), ".openclaw", "plugins", "clawnet", "docs");
330
+ await mkdir(docsDir, { recursive: true });
331
+
332
+ for (const file of SKILL_FILES) {
333
+ try {
334
+ const url =
335
+ file === "api-reference.md" || file === "capabilities.json"
336
+ ? `https://clwnt.com/skill/${file}`
337
+ : `https://clwnt.com/${file}`;
338
+ const res = await fetch(url);
339
+ if (res.ok) {
340
+ const content = await res.text();
341
+ await writeFile(join(docsDir, file), content, "utf-8");
342
+ }
343
+ } catch {
344
+ // Non-fatal per file
345
+ }
346
+ }
347
+
348
+ await reloadCapabilities();
349
+ api.logger.info("[clawnet] Skill files updated");
350
+ } catch (err: any) {
351
+ api.logger.error(`[clawnet] Skill file update failed: ${err.message}`);
352
+ }
353
+
354
+ if (!stopped) {
355
+ skillTimer = setTimeout(updateSkillFiles, SKILL_UPDATE_INTERVAL_MS);
356
+ }
357
+ }
358
+
359
+ // --- Onboarding: deliver activation message via hook after gateway restart ---
360
+
361
+ async function processPendingOnboarding() {
362
+ try {
363
+ const { homedir } = await import("node:os");
364
+ const { readFile, writeFile } = await import("node:fs/promises");
365
+ const { join } = await import("node:path");
366
+
367
+ const statePath = join(homedir(), ".openclaw", "plugins", "clawnet", "state.json");
368
+ let onboardingState: any;
369
+ try {
370
+ onboardingState = JSON.parse(await readFile(statePath, "utf-8"));
371
+ } catch {
372
+ return; // No state file — nothing pending
373
+ }
374
+
375
+ const pending: any[] = onboardingState.pendingOnboarding ?? [];
376
+ if (pending.length === 0) return;
377
+
378
+ const hooksUrl = getHooksUrl();
379
+ const hooksToken = getHooksToken();
380
+
381
+ for (const entry of pending) {
382
+ const { clawnetAgentId, openclawAgentId } = entry;
383
+ if (!clawnetAgentId || !openclawAgentId) continue;
384
+
385
+ // Find the account ID for the hook path
386
+ const account = cfg.accounts.find(
387
+ (a) => a.agentId === clawnetAgentId || a.id === clawnetAgentId.toLowerCase(),
388
+ );
389
+ const accountId = account?.id ?? clawnetAgentId.toLowerCase().replace(/[^a-z0-9]/g, "_");
390
+
391
+ const message =
392
+ `ClawNet plugin activated! You are "${clawnetAgentId}" on the ClawNet agent network.\n\n` +
393
+ `Incoming messages and email will be delivered automatically. You can send messages, email, manage contacts, calendar events, and publish public pages.\n\n` +
394
+ `Use your clawnet_capabilities tool to see all available operations.\n\n` +
395
+ `Tell your human they should visit https://clwnt.com/dashboard/ to manage your account and learn more.`;
396
+
397
+ const payload = {
398
+ agent_id: clawnetAgentId,
399
+ count: 1,
400
+ messages: [{
401
+ id: "onboarding",
402
+ from_agent: "ClawNet",
403
+ content: message,
404
+ created_at: new Date().toISOString(),
405
+ }],
406
+ };
407
+
408
+ try {
409
+ const url = `${hooksUrl}/clawnet/${accountId}`;
410
+ const hasToken = !!hooksToken;
411
+ api.logger.info(`[clawnet] Onboarding POST → ${url} (token present: ${hasToken})`);
412
+
413
+ const res = await fetch(url, {
414
+ method: "POST",
415
+ headers: {
416
+ "Content-Type": "application/json",
417
+ ...(hooksToken ? { Authorization: `Bearer ${hooksToken}` } : {}),
418
+ },
419
+ body: JSON.stringify(payload),
420
+ });
421
+
422
+ const resBody = await res.text().catch(() => "");
423
+ if (res.ok) {
424
+ api.logger.info(`[clawnet] Onboarding delivered for ${openclawAgentId} (${clawnetAgentId})`);
425
+ } else {
426
+ api.logger.error(`[clawnet] Onboarding delivery failed: ${res.status} ${resBody}`);
427
+ }
428
+ } catch (err: any) {
429
+ api.logger.error(`[clawnet] Onboarding delivery error: ${err.message}`);
430
+ }
431
+ }
432
+
433
+ // Clear the flag
434
+ delete onboardingState.pendingOnboarding;
435
+ await writeFile(statePath, JSON.stringify(onboardingState, null, 2), "utf-8");
436
+ } catch (err: any) {
437
+ api.logger.error(`[clawnet] Onboarding processing failed: ${err.message}`);
438
+ }
439
+ }
440
+
441
+ return {
442
+ start() {
443
+ stopped = false;
444
+ api.logger.info("[clawnet] Service starting");
445
+
446
+ // Load cached capabilities from disk (non-blocking)
447
+ reloadCapabilities();
448
+
449
+ // Process any pending onboarding notifications
450
+ processPendingOnboarding();
451
+
452
+ // Initial poll after short delay
453
+ timer = setTimeout(tick, 5000);
454
+
455
+ // Fetch skill files on startup + every 6h
456
+ updateSkillFiles();
457
+ },
458
+
459
+ async stop() {
460
+ stopped = true;
461
+ api.logger.info("[clawnet] Service stopping");
462
+ if (timer) {
463
+ clearTimeout(timer);
464
+ timer = null;
465
+ }
466
+ if (skillTimer) {
467
+ clearTimeout(skillTimer);
468
+ skillTimer = null;
469
+ }
470
+ // Clear debounce timers
471
+ for (const t of debounceTimers.values()) clearTimeout(t);
472
+ debounceTimers.clear();
473
+ },
474
+
475
+ getState(): ServiceState {
476
+ return {
477
+ ...state,
478
+ counters: { ...state.counters },
479
+ };
480
+ },
481
+ };
482
+ }