libp2p-mesh 2026.6.11 → 2026.6.13

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -10,6 +10,7 @@ P2P mesh network plugin for OpenClaw. Enables direct peer-to-peer communication
10
10
  - **Bootstrap Mode** — Optional static bootstrap peer list for non-LAN scenarios
11
11
  - **WebSocket Transport** — Optional WebSocket support for NAT/firewall-friendly connections
12
12
  - **NAT Traversal** — Built-in AutoNAT + UPnP + Circuit Relay v2 + DCUtR for peers behind home routers / firewalls
13
+ - **User Public Attributes** — Announce public tags and structured profile attributes so agents can dry-run and send to locally discovered instances by attribute
13
14
 
14
15
  ## Requirements
15
16
 
@@ -443,6 +444,129 @@ The sender reports success only after the remote OpenClaw instance forwards the
443
444
 
444
445
  Tools are not configured in `openclaw.json`; they are registered automatically by the plugin through `api.registerTool()`.
445
446
 
447
+ ### User public attributes
448
+
449
+ `libp2p-mesh` can announce user public attributes with instance route announcements. These attributes help agents find matching OpenClaw instances after those instances have already been discovered through the mesh.
450
+
451
+ There are two sources:
452
+
453
+ - `USER.md` tags are extracted read-only at gateway startup. The plugin never edits `USER.md`.
454
+ - `user-profile.json` stores manually managed structured attributes such as group, project, role, skill, or a custom key.
455
+
456
+ Run the profile wizard to manage structured attributes:
457
+
458
+ ```bash
459
+ openclaw libp2p-mesh profile
460
+ ```
461
+
462
+ The wizard previews read-only `USER.md` tags and lets you add, edit, or remove only structured profile attributes. Tags extracted from `USER.md` are not written to `user-profile.json`; they are merged in memory with profile attributes and broadcast only in instance announce messages.
463
+
464
+ The default profile path is:
465
+
466
+ ```text
467
+ ~/.openclaw/libp2p/user-profile.json
468
+ ```
469
+
470
+ When `OPENCLAW_STATE_DIR` is set:
471
+
472
+ ```text
473
+ $OPENCLAW_STATE_DIR/libp2p/user-profile.json
474
+ ```
475
+
476
+ Example `user-profile.json`:
477
+
478
+ ```json
479
+ {
480
+ "version": 1,
481
+ "updatedAt": 1782180000000,
482
+ "attributes": [
483
+ {
484
+ "kind": "structured",
485
+ "key": "project",
486
+ "value": "openclaw",
487
+ "label": "project: openclaw",
488
+ "source": "profile"
489
+ },
490
+ {
491
+ "kind": "structured",
492
+ "key": "role",
493
+ "value": "maintainer",
494
+ "label": "role: maintainer",
495
+ "source": "profile"
496
+ }
497
+ ]
498
+ }
499
+ ```
500
+
501
+ Remote attributes are cached in plugin-managed instance state under `instance-peer.json.userPublicAttributes`:
502
+
503
+ ```json
504
+ {
505
+ "version": 1,
506
+ "updatedAt": 1782180000000,
507
+ "instances": {
508
+ "alice-mac@AQIDBAUGBweI.7a3f9e2b": {
509
+ "instanceId": "alice-mac@AQIDBAUGBweI.7a3f9e2b",
510
+ "peerId": "12D3KooW...",
511
+ "instanceName": "alice-mac",
512
+ "multiaddrs": ["/ip4/192.168.1.23/tcp/4001"],
513
+ "userPublicAttributes": [
514
+ {
515
+ "kind": "tag",
516
+ "value": "libp2p",
517
+ "label": "libp2p",
518
+ "source": "USER.md"
519
+ },
520
+ {
521
+ "kind": "structured",
522
+ "key": "project",
523
+ "value": "openclaw",
524
+ "label": "project: openclaw",
525
+ "source": "profile"
526
+ }
527
+ ],
528
+ "lastSeenAt": 1782180000000,
529
+ "lastAnnouncedAt": 1782180000000,
530
+ "source": "announce"
531
+ }
532
+ }
533
+ }
534
+ ```
535
+
536
+ Use `p2p_send_user_attribute_message` for attribute-based group messages. Always dry-run first, review the matched instances with the user, then send only after confirmation:
537
+
538
+ ```text
539
+ p2p_send_user_attribute_message({
540
+ "match": { "kind": "structured", "key": "project", "value": "openclaw" },
541
+ "message": "今晚同步一下进展",
542
+ "dryRun": true
543
+ })
544
+ ```
545
+
546
+ After confirming the dry-run targets:
547
+
548
+ ```text
549
+ p2p_send_user_attribute_message({
550
+ "match": { "kind": "structured", "key": "project", "value": "openclaw" },
551
+ "message": "今晚同步一下进展",
552
+ "dryRun": false
553
+ })
554
+ ```
555
+
556
+ Tag matches use only the tag value:
557
+
558
+ ```text
559
+ p2p_send_user_attribute_message({
560
+ "match": { "kind": "tag", "value": "libp2p" },
561
+ "message": "libp2p 方向有个问题想确认",
562
+ "dryRun": true
563
+ })
564
+ ```
565
+
566
+ The first version matches only instances already present in the local `instance-peer.json` discovery cache. It does not search the whole network or ask disconnected peers for more users.
567
+
568
+ Privacy boundary: public attributes are broadcast with instance announce messages to peers your gateway connects to. Do not put private, sensitive, or access-controlled information in `USER.md` tags or `user-profile.json` structured attributes.
569
+
446
570
  ## Troubleshooting
447
571
 
448
572
  ### Peers do not discover each other
@@ -1,4 +1,13 @@
1
- import type { DeliveryTargetResult, InstanceRouter, MeshNetwork } from "./types.js";
1
+ import type { DeliveryTargetResult, InstanceRouter, MeshNetwork, UserPublicAttribute } from "./types.js";
2
+ type SendUserAttributeToolParams = {
3
+ match?: {
4
+ kind?: unknown;
5
+ key?: unknown;
6
+ value?: unknown;
7
+ };
8
+ message?: unknown;
9
+ dryRun?: unknown;
10
+ };
2
11
  export declare function buildP2PTools(mesh: MeshNetwork, router?: InstanceRouter): ({
3
12
  name: string;
4
13
  label: string;
@@ -16,6 +25,8 @@ export declare function buildP2PTools(mesh: MeshNetwork, router?: InstanceRouter
16
25
  };
17
26
  topic?: undefined;
18
27
  instanceId?: undefined;
28
+ match?: undefined;
29
+ dryRun?: undefined;
19
30
  };
20
31
  required: string[];
21
32
  };
@@ -62,6 +73,8 @@ export declare function buildP2PTools(mesh: MeshNetwork, router?: InstanceRouter
62
73
  };
63
74
  peerId?: undefined;
64
75
  instanceId?: undefined;
76
+ match?: undefined;
77
+ dryRun?: undefined;
65
78
  };
66
79
  required: string[];
67
80
  };
@@ -102,6 +115,8 @@ export declare function buildP2PTools(mesh: MeshNetwork, router?: InstanceRouter
102
115
  message?: undefined;
103
116
  topic?: undefined;
104
117
  instanceId?: undefined;
118
+ match?: undefined;
119
+ dryRun?: undefined;
105
120
  };
106
121
  required?: undefined;
107
122
  };
@@ -141,6 +156,8 @@ export declare function buildP2PTools(mesh: MeshNetwork, router?: InstanceRouter
141
156
  message?: undefined;
142
157
  topic?: undefined;
143
158
  instanceId?: undefined;
159
+ match?: undefined;
160
+ dryRun?: undefined;
144
161
  };
145
162
  required?: undefined;
146
163
  };
@@ -189,6 +206,8 @@ export declare function buildP2PTools(mesh: MeshNetwork, router?: InstanceRouter
189
206
  message?: undefined;
190
207
  topic?: undefined;
191
208
  instanceId?: undefined;
209
+ match?: undefined;
210
+ dryRun?: undefined;
192
211
  };
193
212
  required?: undefined;
194
213
  };
@@ -230,6 +249,8 @@ export declare function buildP2PTools(mesh: MeshNetwork, router?: InstanceRouter
230
249
  message?: undefined;
231
250
  topic?: undefined;
232
251
  instanceId?: undefined;
252
+ match?: undefined;
253
+ dryRun?: undefined;
233
254
  };
234
255
  required?: undefined;
235
256
  };
@@ -258,6 +279,7 @@ export declare function buildP2PTools(mesh: MeshNetwork, router?: InstanceRouter
258
279
  instanceName?: string;
259
280
  multiaddrs: string[];
260
281
  pubkey?: string;
282
+ userPublicAttributes?: UserPublicAttribute[];
261
283
  lastSeenAt: number;
262
284
  lastAnnouncedAt: number;
263
285
  source: "announce";
@@ -294,6 +316,8 @@ export declare function buildP2PTools(mesh: MeshNetwork, router?: InstanceRouter
294
316
  peerId?: undefined;
295
317
  message?: undefined;
296
318
  topic?: undefined;
319
+ match?: undefined;
320
+ dryRun?: undefined;
297
321
  };
298
322
  required: string[];
299
323
  };
@@ -369,6 +393,8 @@ export declare function buildP2PTools(mesh: MeshNetwork, router?: InstanceRouter
369
393
  };
370
394
  peerId?: undefined;
371
395
  topic?: undefined;
396
+ match?: undefined;
397
+ dryRun?: undefined;
372
398
  };
373
399
  required: string[];
374
400
  };
@@ -430,4 +456,97 @@ export declare function buildP2PTools(mesh: MeshNetwork, router?: InstanceRouter
430
456
  };
431
457
  isError?: undefined;
432
458
  }>;
459
+ } | {
460
+ name: string;
461
+ label: string;
462
+ description: string;
463
+ parameters: {
464
+ type: "object";
465
+ properties: {
466
+ match: {
467
+ type: "object";
468
+ description: string;
469
+ oneOf: ({
470
+ type: "object";
471
+ additionalProperties: boolean;
472
+ properties: {
473
+ kind: {
474
+ const: "tag";
475
+ };
476
+ value: {
477
+ type: "string";
478
+ description: string;
479
+ };
480
+ key?: undefined;
481
+ };
482
+ required: string[];
483
+ } | {
484
+ type: "object";
485
+ additionalProperties: boolean;
486
+ properties: {
487
+ kind: {
488
+ const: "structured";
489
+ };
490
+ key: {
491
+ type: "string";
492
+ description: string;
493
+ };
494
+ value: {
495
+ type: "string";
496
+ description: string;
497
+ };
498
+ };
499
+ required: string[];
500
+ })[];
501
+ };
502
+ message: {
503
+ type: "string";
504
+ description: string;
505
+ };
506
+ dryRun: {
507
+ type: "boolean";
508
+ description: string;
509
+ };
510
+ peerId?: undefined;
511
+ topic?: undefined;
512
+ instanceId?: undefined;
513
+ };
514
+ required: string[];
515
+ };
516
+ execute(_toolCallId: string, params: SendUserAttributeToolParams): Promise<{
517
+ content: {
518
+ type: "text";
519
+ text: string;
520
+ }[];
521
+ details: {
522
+ initialized: boolean;
523
+ error?: undefined;
524
+ };
525
+ isError: boolean;
526
+ } | {
527
+ content: {
528
+ type: "text";
529
+ text: string;
530
+ }[];
531
+ details: {
532
+ error: string;
533
+ initialized?: undefined;
534
+ };
535
+ isError: boolean;
536
+ } | {
537
+ content: {
538
+ type: "text";
539
+ text: string;
540
+ }[];
541
+ details: import("./types.js").UserAttributeMessageResult;
542
+ isError?: undefined;
543
+ } | {
544
+ content: {
545
+ type: "text";
546
+ text: string;
547
+ }[];
548
+ details: import("./types.js").UserAttributeMessageResult;
549
+ isError: boolean | undefined;
550
+ }>;
433
551
  })[];
552
+ export {};
@@ -12,6 +12,49 @@ function formatDeliveryResults(instanceId, delivered, results) {
12
12
  });
13
13
  return [heading, ...lines].join("\n");
14
14
  }
15
+ function attributeLabel(attribute) {
16
+ if (attribute.kind === "tag") {
17
+ return `tag:${attribute.value}`;
18
+ }
19
+ return `${attribute.key}:${attribute.value}`;
20
+ }
21
+ function instanceTargetLabel(target) {
22
+ const name = target.instanceName ? ` (${target.instanceName})` : "";
23
+ return `${target.instanceId}${name} -> ${target.peerId}`;
24
+ }
25
+ function formatUserAttributeTargets(targets) {
26
+ return targets.map((target) => `${instanceTargetLabel(target)} [${attributeLabel(target.matchedAttribute)}]`);
27
+ }
28
+ function formatUserAttributeResults(results) {
29
+ return results.map((result) => {
30
+ let status = "已送达";
31
+ if (!result.sent) {
32
+ status = `发送失败:${result.error ?? "unknown error"}`;
33
+ }
34
+ else if (!result.delivered) {
35
+ status = `投递失败:${result.error ?? "unknown error"}`;
36
+ }
37
+ return `${instanceTargetLabel(result)}:${status}`;
38
+ });
39
+ }
40
+ function normalizeUserAttributeMatch(params) {
41
+ const match = params.match;
42
+ if (!match || typeof match !== "object") {
43
+ return "match is required.";
44
+ }
45
+ if (match.kind === "tag") {
46
+ const value = typeof match.value === "string" ? match.value.trim() : "";
47
+ return value ? { kind: "tag", value } : "match.value is required for tag matches.";
48
+ }
49
+ if (match.kind === "structured") {
50
+ const key = typeof match.key === "string" ? match.key.trim() : "";
51
+ const value = typeof match.value === "string" ? match.value.trim() : "";
52
+ return key && value
53
+ ? { kind: "structured", key, value }
54
+ : "match.key and match.value are required for structured matches.";
55
+ }
56
+ return 'match.kind must be "tag" or "structured".';
57
+ }
15
58
  export function buildP2PTools(mesh, router) {
16
59
  return [
17
60
  {
@@ -369,5 +412,115 @@ export function buildP2PTools(mesh, router) {
369
412
  };
370
413
  },
371
414
  },
415
+ {
416
+ name: "p2p_send_user_attribute_message",
417
+ label: "P2P Send User Attribute Message",
418
+ description: "Send a user message to discovered OpenClaw instances matching a public user attribute. Always run a dry run with dryRun=true first and ask the user to confirm targets before group sending.",
419
+ parameters: {
420
+ type: "object",
421
+ properties: {
422
+ match: {
423
+ type: "object",
424
+ description: 'User public attribute match. Use { "kind": "tag", "value": "..." } for USER.md tags or { "kind": "structured", "key": "...", "value": "..." } for profile attributes.',
425
+ oneOf: [
426
+ {
427
+ type: "object",
428
+ additionalProperties: false,
429
+ properties: {
430
+ kind: { const: "tag" },
431
+ value: {
432
+ type: "string",
433
+ description: "Tag value to match.",
434
+ },
435
+ },
436
+ required: ["kind", "value"],
437
+ },
438
+ {
439
+ type: "object",
440
+ additionalProperties: false,
441
+ properties: {
442
+ kind: { const: "structured" },
443
+ key: {
444
+ type: "string",
445
+ description: "Structured attribute key to match.",
446
+ },
447
+ value: {
448
+ type: "string",
449
+ description: "Structured attribute value to match.",
450
+ },
451
+ },
452
+ required: ["kind", "key", "value"],
453
+ },
454
+ ],
455
+ },
456
+ message: {
457
+ type: "string",
458
+ description: "Message content to send after dry-run target confirmation.",
459
+ },
460
+ dryRun: {
461
+ type: "boolean",
462
+ description: "Preview matching instances without sending. Run this before group sending.",
463
+ },
464
+ },
465
+ required: ["match", "message"],
466
+ },
467
+ async execute(_toolCallId, params) {
468
+ if (!router) {
469
+ return {
470
+ content: [{ type: "text", text: "Instance router is not initialized." }],
471
+ details: { initialized: false },
472
+ isError: true,
473
+ };
474
+ }
475
+ const match = normalizeUserAttributeMatch(params);
476
+ const message = typeof params.message === "string" ? params.message.trim() : "";
477
+ if (typeof match === "string" || !message) {
478
+ const error = typeof match === "string" ? match : "message is required.";
479
+ return {
480
+ content: [{ type: "text", text: error }],
481
+ details: { error },
482
+ isError: true,
483
+ };
484
+ }
485
+ const dryRun = params.dryRun === true;
486
+ const result = await router.sendUserAttributeMessage(match, message, { dryRun });
487
+ if (result.error) {
488
+ return {
489
+ content: [{ type: "text", text: result.error }],
490
+ details: result,
491
+ isError: true,
492
+ };
493
+ }
494
+ if (dryRun) {
495
+ const targetLines = formatUserAttributeTargets(result.targets ?? []);
496
+ return {
497
+ content: [
498
+ {
499
+ type: "text",
500
+ text: [
501
+ `Dry run matched ${result.matched} instance(s). No message was sent.`,
502
+ ...targetLines,
503
+ ].join("\n"),
504
+ },
505
+ ],
506
+ details: result,
507
+ };
508
+ }
509
+ const resultLines = formatUserAttributeResults(result.results ?? []);
510
+ return {
511
+ content: [
512
+ {
513
+ type: "text",
514
+ text: [
515
+ `Matched ${result.matched} instance(s); sent ${result.sent}; delivered ${result.delivered}; failed ${result.failed}.`,
516
+ ...resultLines,
517
+ ].join("\n"),
518
+ },
519
+ ],
520
+ details: result,
521
+ isError: result.failed > 0 ? true : undefined,
522
+ };
523
+ },
524
+ },
372
525
  ];
373
526
  }
@@ -1,6 +1,7 @@
1
1
  import { mkdir, readFile, rename, writeFile } from "node:fs/promises";
2
2
  import { homedir } from "node:os";
3
3
  import path from "node:path";
4
+ import { normalizeUserPublicAttribute } from "./user-attributes.js";
4
5
  export function resolveInstancePeerPath(customPath) {
5
6
  if (customPath)
6
7
  return customPath;
@@ -22,25 +23,56 @@ function sameStringArray(a = [], b = []) {
22
23
  return false;
23
24
  return a.every((value, index) => value === b[index]);
24
25
  }
26
+ function normalizeUserPublicAttributes(value) {
27
+ if (!Array.isArray(value)) {
28
+ return [];
29
+ }
30
+ return value
31
+ .map((attribute) => normalizeUserPublicAttribute(attribute))
32
+ .filter((attribute) => attribute !== undefined);
33
+ }
34
+ function sameUserPublicAttributes(a = [], b = []) {
35
+ if (a.length !== b.length)
36
+ return false;
37
+ return a.every((attribute, index) => {
38
+ const other = b[index];
39
+ return JSON.stringify(attribute) === JSON.stringify(other);
40
+ });
41
+ }
25
42
  function sameRecord(record, payload) {
26
43
  if (!record)
27
44
  return false;
45
+ const recordAttributes = normalizeUserPublicAttributes(record.userPublicAttributes);
46
+ const payloadAttributes = normalizeUserPublicAttributes(payload.userPublicAttributes);
28
47
  return (record.peerId === payload.peerId &&
29
48
  record.instanceName === payload.instanceName &&
30
49
  record.pubkey === payload.pubkey &&
31
50
  sameStringArray(record.multiaddrs, payload.multiaddrs) &&
51
+ sameUserPublicAttributes(recordAttributes, payloadAttributes) &&
32
52
  record.lastAnnouncedAt === payload.announcedAt);
33
53
  }
54
+ function normalizeRecord(value) {
55
+ return {
56
+ ...value,
57
+ userPublicAttributes: normalizeUserPublicAttributes(value.userPublicAttributes),
58
+ };
59
+ }
34
60
  function normalizeTable(value) {
35
61
  const candidate = value && typeof value === "object" ? value : {};
36
62
  const table = candidate;
37
63
  const instances = table.instances && typeof table.instances === "object" && !Array.isArray(table.instances)
38
64
  ? table.instances
39
65
  : {};
66
+ const normalizedInstances = {};
67
+ for (const [instanceId, record] of Object.entries(instances)) {
68
+ if (record && typeof record === "object") {
69
+ normalizedInstances[instanceId] = normalizeRecord(record);
70
+ }
71
+ }
40
72
  return {
41
73
  version: 1,
42
74
  updatedAt: typeof table.updatedAt === "number" ? table.updatedAt : Date.now(),
43
- instances: instances,
75
+ instances: normalizedInstances,
44
76
  };
45
77
  }
46
78
  export function createInstancePeerStore(options) {
@@ -103,6 +135,7 @@ export function createInstancePeerStore(options) {
103
135
  return runMutation(async () => {
104
136
  const table = await load();
105
137
  const existing = table.instances[payload.instanceId];
138
+ const userPublicAttributes = normalizeUserPublicAttributes(payload.userPublicAttributes);
106
139
  const changed = !sameRecord(existing, payload);
107
140
  const record = {
108
141
  instanceId: payload.instanceId,
@@ -110,6 +143,7 @@ export function createInstancePeerStore(options) {
110
143
  instanceName: payload.instanceName,
111
144
  pubkey: payload.pubkey,
112
145
  multiaddrs: payload.multiaddrs,
146
+ userPublicAttributes,
113
147
  lastAnnouncedAt: payload.announcedAt,
114
148
  lastSeenAt: Date.now(),
115
149
  source: "announce",
@@ -1,14 +1,8 @@
1
- import type { InboundDeliveryAdapter, InstancePeerStore, InstanceRouter, MeshConfig, MeshNetwork } from "./types.js";
1
+ import type { InstanceRouter, InstanceRouterOptions } from "./types.js";
2
2
  export type RouterLogger = {
3
3
  info?: (message: string) => void;
4
4
  debug?: (message: string) => void;
5
5
  warn?: (message: string) => void;
6
6
  error?: (message: string) => void;
7
7
  };
8
- export declare function createInstanceRouter(options: {
9
- mesh: MeshNetwork;
10
- store: InstancePeerStore;
11
- delivery: InboundDeliveryAdapter;
12
- config?: MeshConfig;
13
- logger?: RouterLogger;
14
- }): InstanceRouter;
8
+ export declare function createInstanceRouter(options: InstanceRouterOptions): InstanceRouter;