@clwnt/clawnet 0.7.7 → 0.7.8

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@clwnt/clawnet",
3
- "version": "0.7.7",
3
+ "version": "0.7.8",
4
4
  "type": "module",
5
5
  "description": "ClawNet integration for OpenClaw — poll inbox, route messages to hooks",
6
6
  "files": [
package/src/service.ts CHANGED
@@ -11,6 +11,7 @@ interface InboxMessage {
11
11
  content: string;
12
12
  subject?: string;
13
13
  created_at: string;
14
+ type: "email" | "task";
14
15
  }
15
16
 
16
17
  export interface ServiceState {
@@ -72,7 +73,7 @@ async function reloadOnboardingMessage(): Promise<void> {
72
73
 
73
74
  const SKILL_UPDATE_INTERVAL_MS = 6 * 60 * 60 * 1000; // 6 hours
74
75
  const SKILL_FILES = ["skill.json", "api-reference.md", "inbox-handler.md", "capabilities.json", "hook-template.txt", "tool-descriptions.json", "onboarding-message.txt", "inbox-protocol.md"];
75
- export const PLUGIN_VERSION = "0.7.7"; // Reported to server via PATCH /me every 6h
76
+ export const PLUGIN_VERSION = "0.7.8"; // Reported to server via PATCH /me every 6h
76
77
 
77
78
  function loadFreshConfig(api: any): ClawnetConfig {
78
79
  const raw = api.runtime?.config?.loadConfig?.()?.plugins?.entries?.clawnet?.config ?? {};
@@ -134,6 +135,9 @@ export function createClawnetService(params: { api: any; cfg: ClawnetConfig }) {
134
135
 
135
136
  // --- Batch delivery ---
136
137
 
138
+ // Per-account auth context for mark-notified calls from deliverBatch
139
+ const accountAuth = new Map<string, { token: string; baseUrl: string }>();
140
+
137
141
  async function deliverBatch(accountId: string, agentId: string, messages: InboxMessage[]) {
138
142
  if (messages.length === 0) return;
139
143
 
@@ -163,6 +167,50 @@ export function createClawnetService(params: { api: any; cfg: ClawnetConfig }) {
163
167
  api.logger.info(
164
168
  `[clawnet] ${accountId}: delivered ${messages.length} message(s) to ${agentId} via ${freshCfg.deliveryMethod}`,
165
169
  );
170
+
171
+ // Post-delivery: mark items as notified + mark A2A tasks as working
172
+ const auth = accountAuth.get(accountId);
173
+ if (auth) {
174
+ const emailIds = messages.filter((m) => m.type === "email").map((m) => m.id);
175
+ const taskIds = messages.filter((m) => m.type === "task").map((m) => m.id);
176
+
177
+ // Mark notified (non-fatal)
178
+ if (emailIds.length > 0 || taskIds.length > 0) {
179
+ try {
180
+ await fetch(`${auth.baseUrl}/inbox/mark-notified`, {
181
+ method: "POST",
182
+ headers: { Authorization: `Bearer ${auth.token}`, "Content-Type": "application/json" },
183
+ body: JSON.stringify({
184
+ ...(emailIds.length > 0 ? { message_ids: emailIds } : {}),
185
+ ...(taskIds.length > 0 ? { task_ids: taskIds } : {}),
186
+ }),
187
+ });
188
+ api.logger.debug?.(`[clawnet] ${accountId}: marked ${emailIds.length} message(s) + ${taskIds.length} task(s) notified`);
189
+ } catch (err: any) {
190
+ api.logger.warn(`[clawnet] ${accountId}: mark-notified failed (non-fatal): ${err.message}`);
191
+ }
192
+ }
193
+
194
+ // Mark incoming A2A tasks as 'working' (protocol semantics, separate from notification tracking)
195
+ for (const msg of messages) {
196
+ if (msg.type === "task" && msg.content.startsWith("[A2A Task ")) {
197
+ try {
198
+ await fetch(`${auth.baseUrl}/a2a`, {
199
+ method: "POST",
200
+ headers: { Authorization: `Bearer ${auth.token}`, "Content-Type": "application/json" },
201
+ body: JSON.stringify({
202
+ jsonrpc: "2.0",
203
+ id: `ack-${msg.id}`,
204
+ method: "tasks/respond",
205
+ params: { id: msg.id, state: "working" },
206
+ }),
207
+ });
208
+ } catch {
209
+ // Non-fatal — task may get re-delivered next cycle
210
+ }
211
+ }
212
+ }
213
+ }
166
214
  } catch (err: any) {
167
215
  state.lastError = { message: err.message, at: new Date() };
168
216
  state.counters.errors++;
@@ -286,13 +334,16 @@ export function createClawnetService(params: { api: any; cfg: ClawnetConfig }) {
286
334
 
287
335
  // --- Poll ---
288
336
 
289
- async function pollAccount(account: ClawnetAccount): Promise<number> {
337
+ async function pollAccount(account: ClawnetAccount): Promise<{ a2aDmCount: number; sentTaskUpdates: number; notifyCount: number }> {
290
338
  const resolvedToken = resolveToken(account.token);
291
339
  if (!resolvedToken) {
292
340
  api.logger.warn(`[clawnet] No token resolved for account "${account.id}", skipping`);
293
- return 0;
341
+ return { a2aDmCount: 0, sentTaskUpdates: 0, notifyCount: 0 };
294
342
  }
295
343
 
344
+ // Store auth context for deliverBatch to use for mark-notified calls
345
+ accountAuth.set(account.id, { token: resolvedToken, baseUrl: cfg.baseUrl });
346
+
296
347
  const headers = {
297
348
  Authorization: `Bearer ${resolvedToken}`,
298
349
  "Content-Type": "application/json",
@@ -307,11 +358,14 @@ export function createClawnetService(params: { api: any; cfg: ClawnetConfig }) {
307
358
  count: number;
308
359
  task_count?: number;
309
360
  sent_task_updates?: number;
361
+ notify_count?: number;
310
362
  plugin_config?: {
311
363
  poll_seconds: number;
312
364
  debounce_seconds: number;
313
365
  max_batch_size: number;
314
366
  deliver_channel: string;
367
+ notify_on_new?: boolean;
368
+ remind_after_hours?: number | null;
315
369
  };
316
370
  };
317
371
 
@@ -342,23 +396,29 @@ export function createClawnetService(params: { api: any; cfg: ClawnetConfig }) {
342
396
 
343
397
  const a2aDmCount = checkData.task_count ?? 0;
344
398
  const sentTaskUpdates = checkData.sent_task_updates ?? 0;
399
+ const notifyCount = checkData.notify_count ?? (checkData.count + a2aDmCount + sentTaskUpdates);
345
400
 
346
401
  if (checkData.count === 0) {
347
402
  // Email inbox clear — release any delivery lock (agent finished processing)
348
403
  deliveryLock.delete(account.id);
349
- return { a2aDmCount, sentTaskUpdates };
404
+ return { a2aDmCount, sentTaskUpdates, notifyCount };
405
+ }
406
+
407
+ // If nothing needs notification, skip fetch (but don't release lock — inbox still has items)
408
+ if (notifyCount === 0) {
409
+ return { a2aDmCount, sentTaskUpdates, notifyCount };
350
410
  }
351
411
 
352
- // Skip if a recent webhook delivery is still being processed by the LLM.
412
+ // Skip if a recent delivery is still being processed.
353
413
  // TTL-based lock: after successful POST, lock for 10 min to let the agent work.
354
414
  const lockUntil = deliveryLock.get(account.id);
355
415
  if (lockUntil && new Date() < lockUntil) {
356
416
  api.logger.debug?.(`[clawnet] ${account.id}: ${checkData.count} message(s) waiting (delivery lock active, skipping)`);
357
- return { a2aDmCount, sentTaskUpdates };
417
+ return { a2aDmCount, sentTaskUpdates, notifyCount };
358
418
  }
359
419
 
360
420
  state.lastInboxNonEmptyAt = new Date();
361
- api.logger.info(`[clawnet] ${account.id}: ${checkData.count} message(s) waiting`);
421
+ api.logger.info(`[clawnet] ${account.id}: ${checkData.count} message(s) waiting (${notifyCount} to notify)`);
362
422
 
363
423
  // Fetch full messages
364
424
  const inboxRes = await fetch(`${cfg.baseUrl}/inbox`, { headers });
@@ -367,7 +427,7 @@ export function createClawnetService(params: { api: any; cfg: ClawnetConfig }) {
367
427
  }
368
428
  const inboxData = (await inboxRes.json()) as { messages: Array<Record<string, any>> };
369
429
 
370
- if (inboxData.messages.length === 0) return { a2aDmCount, sentTaskUpdates };
430
+ if (inboxData.messages.length === 0) return { a2aDmCount, sentTaskUpdates, notifyCount };
371
431
 
372
432
  // Normalize API field names: API returns "from", plugin uses "from_agent"
373
433
  const normalized: InboxMessage[] = inboxData.messages.map((m) => ({
@@ -376,6 +436,7 @@ export function createClawnetService(params: { api: any; cfg: ClawnetConfig }) {
376
436
  content: m.content,
377
437
  subject: m.email?.subject ?? m.subject,
378
438
  created_at: m.created_at,
439
+ type: "email" as const,
379
440
  }));
380
441
 
381
442
  state.counters.messagesSeen += normalized.length;
@@ -385,7 +446,7 @@ export function createClawnetService(params: { api: any; cfg: ClawnetConfig }) {
385
446
  pendingMessages.set(account.id, [...existing, ...normalized]);
386
447
  scheduleFlush(account.id, account.agentId);
387
448
 
388
- return { a2aDmCount, sentTaskUpdates };
449
+ return { a2aDmCount, sentTaskUpdates, notifyCount };
389
450
  }
390
451
 
391
452
  async function pollAccountA2A(account: ClawnetAccount, a2aDmCount: number) {
@@ -427,7 +488,8 @@ export function createClawnetService(params: { api: any; cfg: ClawnetConfig }) {
427
488
 
428
489
  api.logger.info(`[clawnet] ${account.id}: ${tasks.length} A2A task(s) to deliver`);
429
490
 
430
- // Convert A2A tasks to the message format the hook expects
491
+ // Convert A2A tasks to the message format for delivery
492
+ // Working transition + mark-notified happen post-delivery in deliverBatch
431
493
  const messages: InboxMessage[] = tasks.map((task) => {
432
494
  const history = task.history as Array<{ role: string; parts: Array<{ text?: string }> }> ?? [];
433
495
  const lastMsg = history[history.length - 1];
@@ -438,6 +500,7 @@ export function createClawnetService(params: { api: any; cfg: ClawnetConfig }) {
438
500
  from_agent: task.from,
439
501
  content: `[A2A Task ${task.id}]${contactInfo}\n${text}`,
440
502
  created_at: (task.status as any)?.timestamp ?? new Date().toISOString(),
503
+ type: "task" as const,
441
504
  };
442
505
  });
443
506
 
@@ -445,28 +508,6 @@ export function createClawnetService(params: { api: any; cfg: ClawnetConfig }) {
445
508
  const existing = pendingMessages.get(account.id) ?? [];
446
509
  pendingMessages.set(account.id, [...existing, ...messages]);
447
510
  scheduleFlush(account.id, account.agentId);
448
-
449
- // Mark delivered tasks as 'working' so they don't get re-delivered on next poll.
450
- // This is the equivalent of marking emails 'read' — acknowledges receipt.
451
- for (const task of tasks) {
452
- try {
453
- await fetch(`${cfg.baseUrl}/a2a`, {
454
- method: "POST",
455
- headers: {
456
- Authorization: `Bearer ${resolvedToken}`,
457
- "Content-Type": "application/json",
458
- },
459
- body: JSON.stringify({
460
- jsonrpc: "2.0",
461
- id: `ack-${task.id}`,
462
- method: "tasks/respond",
463
- params: { id: task.id, state: "working" },
464
- }),
465
- });
466
- } catch {
467
- // Non-fatal — task may get re-delivered next cycle
468
- }
469
- }
470
511
  }
471
512
 
472
513
  async function pollSentTaskUpdates(account: ClawnetAccount) {
@@ -477,12 +518,12 @@ export function createClawnetService(params: { api: any; cfg: ClawnetConfig }) {
477
518
  const lockUntil = deliveryLock.get(account.id);
478
519
  if (lockUntil && new Date() < lockUntil) return;
479
520
 
480
- // Fetch tasks I sent that need attention
521
+ // Fetch tasks I sent that need my attention or have finished
481
522
  const body = {
482
523
  jsonrpc: "2.0",
483
524
  id: `sent-poll-${Date.now()}`,
484
525
  method: "tasks/list",
485
- params: { role: "sender", status: "input-required", limit: 50 },
526
+ params: { role: "sender", status: "input-required,completed,failed", limit: 50 },
486
527
  };
487
528
  const res = await fetch(`${cfg.baseUrl}/a2a`, {
488
529
  method: "POST",
@@ -512,6 +553,7 @@ export function createClawnetService(params: { api: any; cfg: ClawnetConfig }) {
512
553
  from_agent: task.to, // the agent that responded
513
554
  content: `[Task update: ${taskState}] Re: "${text.slice(0, 100)}${text.length > 100 ? "…" : ""}"`,
514
555
  created_at: (task.status as any)?.timestamp ?? new Date().toISOString(),
556
+ type: "task" as const,
515
557
  };
516
558
  });
517
559
 
package/src/tools.ts CHANGED
@@ -155,6 +155,8 @@ interface CapabilityOp {
155
155
  description: string;
156
156
  params?: Record<string, { type: string; description: string; required?: boolean }>;
157
157
  rawBodyParam?: string; // If set, send this param as raw text body instead of JSON
158
+ jsonrpc?: boolean; // If true, dispatch via a2aCall() instead of REST
159
+ rpc_method?: string; // JSON-RPC method name (required when jsonrpc is true)
158
160
  }
159
161
 
160
162
  const BUILTIN_OPERATIONS: CapabilityOp[] = [
@@ -268,6 +270,30 @@ const BUILTIN_OPERATIONS: CapabilityOp[] = [
268
270
  { operation: "account.rate_limits", method: "GET", path: "/me/rate-limits", description: "Check your current rate limits" },
269
271
  // Docs
270
272
  { operation: "docs.help", method: "GET", path: "/docs/skill", description: "Get the full ClawNet documentation — features, usage examples, safety rules, setup, troubleshooting, and rate limits" },
273
+ // A2A (JSON-RPC via /a2a)
274
+ { operation: "a2a.card.update", jsonrpc: true, method: "POST", path: "/a2a", rpc_method: "card/update", description: "Update your A2A Agent Card skills", params: {
275
+ skills: { type: "array", description: "Array of {id, name, description} skill objects", required: true },
276
+ }},
277
+ { operation: "a2a.tasks.list", jsonrpc: true, method: "POST", path: "/a2a", rpc_method: "tasks/list", description: "List your A2A tasks", params: {
278
+ status: { type: "string", description: "Filter by status (e.g. 'submitted', 'working', 'completed', 'failed', comma-separated for multiple)" },
279
+ role: { type: "string", description: "'sender' or 'recipient'" },
280
+ limit: { type: "number", description: "Max tasks to return" },
281
+ }},
282
+ { operation: "a2a.tasks.get", jsonrpc: true, method: "POST", path: "/a2a", rpc_method: "tasks/get", description: "Get a specific A2A task by ID", params: {
283
+ id: { type: "string", description: "Task ID", required: true },
284
+ }},
285
+ { operation: "a2a.tasks.respond", jsonrpc: true, method: "POST", path: "/a2a", rpc_method: "tasks/respond", description: "Respond to an A2A task", params: {
286
+ id: { type: "string", description: "Task ID", required: true },
287
+ state: { type: "string", description: "New state: completed, input-required, working, or failed", required: true },
288
+ message: { type: "string", description: "Response message text" },
289
+ artifacts: { type: "array", description: "Array of artifact objects (for completed tasks)" },
290
+ }},
291
+ { operation: "a2a.tasks.cancel", jsonrpc: true, method: "POST", path: "/a2a", rpc_method: "tasks/cancel", description: "Cancel an A2A task", params: {
292
+ id: { type: "string", description: "Task ID", required: true },
293
+ }},
294
+ { operation: "a2a.tasks.count", jsonrpc: true, method: "POST", path: "/a2a", rpc_method: "tasks/count", description: "Count A2A tasks by status", params: {
295
+ status: { type: "string", description: "Filter by status" },
296
+ }},
271
297
  ];
272
298
 
273
299
  // --- Dynamic capabilities ---
@@ -537,11 +563,11 @@ export function registerTools(api: any) {
537
563
 
538
564
  api.registerTool((ctx: { agentId?: string; sessionKey?: string }) => ({
539
565
  name: "clawnet_task_send",
540
- description: toolDesc("clawnet_task_send", "Send a task to another ClawNet agent. Use this when you need something from another agent — a question answered, an action performed, information looked up. Returns a task ID to check for their response later. For fire-and-forget notifications, use email instead."),
566
+ description: toolDesc("clawnet_task_send", "Send a task to another agent. Works with ClawNet agent names (e.g. 'Tom') or external A2A endpoint URLs (e.g. 'https://example.com/a2a/agent'). Use this when you need something from another agent — a question answered, an action performed, information looked up. Returns a task ID to check for their response later. For fire-and-forget notifications, use email instead."),
541
567
  parameters: {
542
568
  type: "object",
543
569
  properties: {
544
- to: { type: "string", description: "Recipient agent name" },
570
+ to: { type: "string", description: "Recipient: agent name (e.g. 'Tom') or external A2A URL (e.g. 'https://example.com/a2a/agent')" },
545
571
  message: { type: "string", description: "Message content" },
546
572
  task_id: { type: "string", description: "If following up on a task (after agent asked for input), provide the task ID" },
547
573
  },
@@ -549,6 +575,26 @@ export function registerTools(api: any) {
549
575
  },
550
576
  async execute(_id: string, params: { to: string; message: string; task_id?: string }) {
551
577
  const cfg = loadFreshConfig(api);
578
+ const isExternalUrl = params.to.startsWith("https://") || params.to.startsWith("http://");
579
+
580
+ if (params.to.startsWith("http://")) {
581
+ return textResult({ error: "Only HTTPS URLs are supported for external A2A endpoints. Use https:// instead of http://." });
582
+ }
583
+
584
+ if (isExternalUrl) {
585
+ // External A2A: route through tasks/send-external on internal endpoint
586
+ const a2aParams: Record<string, unknown> = {
587
+ url: params.to,
588
+ message: { role: "user", parts: [{ kind: "text", text: params.message }] },
589
+ };
590
+ if (params.task_id) {
591
+ a2aParams.taskId = params.task_id;
592
+ }
593
+ const result = await a2aCall(cfg, "/a2a", "tasks/send-external", a2aParams, ctx?.agentId, ctx?.sessionKey);
594
+ return textResult(result.data);
595
+ }
596
+
597
+ // Internal ClawNet agent: existing path
552
598
  const a2aParams: Record<string, unknown> = {
553
599
  message: { role: "user", parts: [{ kind: "text", text: params.message }] },
554
600
  };
@@ -720,6 +766,16 @@ export function registerTools(api: any) {
720
766
  if (query) path += (path.includes('?') ? '&' : '?') + query;
721
767
  }
722
768
 
769
+ // JSON-RPC operations: dispatch via a2aCall()
770
+ if (op.jsonrpc && op.rpc_method) {
771
+ const rpcParams: Record<string, unknown> = {};
772
+ for (const [key, val] of Object.entries(params)) {
773
+ if (val !== undefined) rpcParams[key] = val;
774
+ }
775
+ const result = await a2aCall(cfg, op.path, op.rpc_method, Object.keys(rpcParams).length > 0 ? rpcParams : undefined, ctx?.agentId, ctx?.sessionKey);
776
+ return textResult(result.data);
777
+ }
778
+
723
779
  // Build body for non-GET requests
724
780
  let body: Record<string, unknown> | undefined;
725
781
  let rawBody: string | undefined;