libp2p-mesh 2026.6.19 → 2026.6.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/src/types.ts CHANGED
@@ -102,10 +102,51 @@ export type UserPublicAttribute =
102
102
  source: "profile";
103
103
  };
104
104
 
105
+ export type UserMdAttributeSource = {
106
+ loadTags(): Promise<UserPublicAttribute[]>;
107
+ refreshTags?(): Promise<UserPublicAttribute[]>;
108
+ };
109
+
110
+ export type LocalPeerLabel = {
111
+ key: string;
112
+ value: string;
113
+ };
114
+
115
+ export type LocalPeerLabelAttribute = {
116
+ kind: "structured";
117
+ key: string;
118
+ value: string;
119
+ label: string;
120
+ source: "local";
121
+ };
122
+
123
+ export type PeerLabelsFile = {
124
+ version: 1;
125
+ updatedAt: number;
126
+ peers: Record<string, { labels: LocalPeerLabel[] }>;
127
+ };
128
+
129
+ export type PeerLabelStore = {
130
+ load(): Promise<PeerLabelsFile>;
131
+ save(file: PeerLabelsFile): Promise<PeerLabelsFile>;
132
+ listRawLabels(instanceId: string): Promise<LocalPeerLabel[]>;
133
+ listLabels(instanceId: string): Promise<LocalPeerLabelAttribute[]>;
134
+ replaceLabels(instanceId: string, labels: LocalPeerLabel[]): Promise<PeerLabelsFile>;
135
+ };
136
+
105
137
  export type UserAttributeMatch =
106
138
  | { kind: "tag"; value: string }
107
139
  | { kind: "structured"; key: string; value: string };
108
140
 
141
+ export type UserAttributeMatchScope = "public" | "local" | "all";
142
+
143
+ export type UserAttributeMatchSource = "public" | "local" | "all";
144
+
145
+ export type UserAttributeMessageOptions = {
146
+ dryRun?: boolean;
147
+ scope?: UserAttributeMatchScope;
148
+ };
149
+
109
150
  export interface InstancePeerRecord {
110
151
  instanceId: string;
111
152
  peerId: string;
@@ -139,7 +180,8 @@ export type UserAttributeMessageTarget = {
139
180
  instanceId: string;
140
181
  instanceName?: string;
141
182
  peerId: string;
142
- matchedAttribute: UserPublicAttribute;
183
+ matchedAttribute: UserPublicAttribute | LocalPeerLabelAttribute;
184
+ matchSource: UserAttributeMatchSource;
143
185
  };
144
186
 
145
187
  export type UserAttributeMessageDeliveryResult = UserAttributeMessageTarget & {
@@ -149,6 +191,7 @@ export type UserAttributeMessageDeliveryResult = UserAttributeMessageTarget & {
149
191
  };
150
192
 
151
193
  export type UserAttributeMessageResult = {
194
+ scope: UserAttributeMatchScope;
152
195
  matched: number;
153
196
  sent: number;
154
197
  delivered: number;
@@ -194,12 +237,13 @@ export type InstanceRouterOptions = {
194
237
  warn?: (message: string) => void;
195
238
  error?: (message: string) => void;
196
239
  };
197
- userAttributeSource?: {
198
- loadTags(): Promise<UserPublicAttribute[]>;
199
- };
240
+ userAttributeSource?: UserMdAttributeSource;
200
241
  userProfileStore?: {
201
242
  listAttributes(): Promise<UserPublicAttribute[]>;
202
243
  };
244
+ peerLabelStore?: {
245
+ listLabels(instanceId: string): Promise<LocalPeerLabelAttribute[]>;
246
+ };
203
247
  };
204
248
 
205
249
  export interface InstanceRouter {
@@ -209,6 +253,7 @@ export interface InstanceRouter {
209
253
  stop(): Promise<void>;
210
254
  handleMessage(msg: P2PMessage): Promise<void>;
211
255
  announceToPeer(peerId: string): Promise<void>;
256
+ refreshPublicAttributes(): Promise<void>;
212
257
  listInstances(): Promise<InstancePeerRecord[]>;
213
258
  resolveInstance(instanceId: string): Promise<InstancePeerRecord | undefined>;
214
259
  sendInstanceMessage(instanceId: string, message: string): Promise<{
@@ -225,7 +270,7 @@ export interface InstanceRouter {
225
270
  sendUserAttributeMessage(
226
271
  match: UserAttributeMatch,
227
272
  message: string,
228
- options?: { dryRun?: boolean },
273
+ options?: UserAttributeMessageOptions,
229
274
  ): Promise<UserAttributeMessageResult>;
230
275
  }
231
276
 
@@ -0,0 +1,243 @@
1
+ import { createHash, randomUUID } from "node:crypto";
2
+ import { mkdir, readFile, rename, rm, writeFile } from "node:fs/promises";
3
+ import { homedir } from "node:os";
4
+ import path from "node:path";
5
+
6
+ import type { UserMdAttributeSource, UserPublicAttribute } from "./types.js";
7
+ import { normalizeAttributeValue } from "./user-attributes.js";
8
+ import { resolveUserMdPath } from "./user-md-attributes.js";
9
+
10
+ const CACHE_VERSION = 1;
11
+ const MAX_AGENT_TAGS = 10;
12
+ const MAX_AGENT_TAG_LENGTH = 40;
13
+
14
+ type Logger = {
15
+ debug?: (message: string) => void;
16
+ warn?: (message: string) => void;
17
+ };
18
+
19
+ type UserMdAttributeCacheFile = {
20
+ version: 1;
21
+ updatedAt: number;
22
+ userMdHash: string;
23
+ attributes: UserPublicAttribute[];
24
+ };
25
+
26
+ export type UserMdAttributeExtractionUnavailable = {
27
+ unavailable: true;
28
+ reason: string;
29
+ };
30
+
31
+ export type UserMdAttributeExtractor = {
32
+ extract(request: {
33
+ markdown: string;
34
+ sourcePath: string;
35
+ }): Promise<UserPublicAttribute[] | UserMdAttributeExtractionUnavailable>;
36
+ };
37
+
38
+ const defaultExtractor: UserMdAttributeExtractor = {
39
+ async extract() {
40
+ return {
41
+ unavailable: true,
42
+ reason: "USER.md agent extraction is unavailable in this runtime",
43
+ };
44
+ },
45
+ };
46
+
47
+ export function resolveUserMdAttributeCachePath(customPath?: string): string {
48
+ if (customPath) {
49
+ return customPath;
50
+ }
51
+
52
+ const stateDir = process.env.OPENCLAW_STATE_DIR;
53
+ if (stateDir) {
54
+ return path.join(stateDir, "libp2p", "user-md-attributes-cache.json");
55
+ }
56
+
57
+ return path.join(homedir(), ".openclaw", "libp2p", "user-md-attributes-cache.json");
58
+ }
59
+
60
+ function isRecord(value: unknown): value is Record<string, unknown> {
61
+ return typeof value === "object" && value !== null;
62
+ }
63
+
64
+ function normalizeWhitespace(value: string): string {
65
+ return value.trim().replace(/\s+/g, " ");
66
+ }
67
+
68
+ function looksLikeSentence(value: string): boolean {
69
+ return /[。.!?!?]/.test(value) || value.split(/\s+/).filter(Boolean).length > 4;
70
+ }
71
+
72
+ function normalizeAgentTag(value: unknown): UserPublicAttribute | undefined {
73
+ if (!isRecord(value) || value.kind !== "tag" || value.source !== "USER.md") {
74
+ return undefined;
75
+ }
76
+
77
+ if (typeof value.value !== "string" || typeof value.label !== "string") {
78
+ return undefined;
79
+ }
80
+
81
+ const tagValue = normalizeWhitespace(value.value);
82
+ const label = normalizeWhitespace(value.label);
83
+ if (!tagValue || !label || tagValue.length > MAX_AGENT_TAG_LENGTH || looksLikeSentence(tagValue)) {
84
+ return undefined;
85
+ }
86
+
87
+ return {
88
+ kind: "tag",
89
+ value: tagValue,
90
+ label,
91
+ source: "USER.md",
92
+ };
93
+ }
94
+
95
+ export function validateExtractedUserMdTags(value: unknown): UserPublicAttribute[] {
96
+ if (!Array.isArray(value)) {
97
+ return [];
98
+ }
99
+
100
+ const attributes: UserPublicAttribute[] = [];
101
+ const seen = new Set<string>();
102
+
103
+ for (const rawAttribute of value) {
104
+ const attribute = normalizeAgentTag(rawAttribute);
105
+ if (!attribute) {
106
+ continue;
107
+ }
108
+
109
+ const key = normalizeAttributeValue(attribute.value);
110
+ if (seen.has(key)) {
111
+ continue;
112
+ }
113
+
114
+ seen.add(key);
115
+ attributes.push(attribute);
116
+ if (attributes.length >= MAX_AGENT_TAGS) {
117
+ break;
118
+ }
119
+ }
120
+
121
+ return attributes;
122
+ }
123
+
124
+ function hashMarkdown(markdown: string): string {
125
+ return createHash("sha256").update(markdown, "utf8").digest("hex");
126
+ }
127
+
128
+ function normalizeCacheFile(value: unknown): UserMdAttributeCacheFile | undefined {
129
+ if (!isRecord(value) || value.version !== CACHE_VERSION || typeof value.userMdHash !== "string") {
130
+ return undefined;
131
+ }
132
+
133
+ const attributes = validateExtractedUserMdTags(value.attributes);
134
+ if (!Array.isArray(value.attributes) || attributes.length !== value.attributes.length) {
135
+ return undefined;
136
+ }
137
+
138
+ return {
139
+ version: CACHE_VERSION,
140
+ updatedAt: typeof value.updatedAt === "number" ? value.updatedAt : Date.now(),
141
+ userMdHash: value.userMdHash,
142
+ attributes,
143
+ };
144
+ }
145
+
146
+ async function readCache(cachePath: string): Promise<UserMdAttributeCacheFile | undefined> {
147
+ try {
148
+ return normalizeCacheFile(JSON.parse(await readFile(cachePath, "utf8")));
149
+ } catch {
150
+ return undefined;
151
+ }
152
+ }
153
+
154
+ async function writeCache(cachePath: string, userMdHash: string, attributes: UserPublicAttribute[]): Promise<void> {
155
+ const cache: UserMdAttributeCacheFile = {
156
+ version: CACHE_VERSION,
157
+ updatedAt: Date.now(),
158
+ userMdHash,
159
+ attributes: validateExtractedUserMdTags(attributes),
160
+ };
161
+ const dir = path.dirname(cachePath);
162
+ const tmpPath = `${cachePath}.tmp-${process.pid}-${Date.now()}-${randomUUID()}`;
163
+
164
+ await mkdir(dir, { recursive: true });
165
+ try {
166
+ await writeFile(tmpPath, `${JSON.stringify(cache, null, 2)}\n`, "utf8");
167
+ await rename(tmpPath, cachePath);
168
+ } catch (error) {
169
+ await rm(tmpPath, { force: true }).catch(() => undefined);
170
+ throw error;
171
+ }
172
+ }
173
+
174
+ function isExtractionUnavailable(value: unknown): value is UserMdAttributeExtractionUnavailable {
175
+ return isRecord(value) && value.unavailable === true && typeof value.reason === "string";
176
+ }
177
+
178
+ export function createUserMdAgentAttributeSource(options?: {
179
+ path?: string;
180
+ cachePath?: string;
181
+ extractor?: UserMdAttributeExtractor;
182
+ logger?: Logger;
183
+ }): UserMdAttributeSource {
184
+ const filePath = resolveUserMdPath(options?.path);
185
+ const cachePath = resolveUserMdAttributeCachePath(options?.cachePath);
186
+ const extractor = options?.extractor ?? defaultExtractor;
187
+ const logger = options?.logger;
188
+
189
+ async function readUserMd(): Promise<{ markdown: string; userMdHash: string } | undefined> {
190
+ try {
191
+ const markdown = await readFile(filePath, "utf8");
192
+ return { markdown, userMdHash: hashMarkdown(markdown) };
193
+ } catch (error) {
194
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") {
195
+ return undefined;
196
+ }
197
+
198
+ logger?.warn?.(`[libp2p-mesh] Failed to read USER.md at ${filePath}: ${(error as Error).message}`);
199
+ return undefined;
200
+ }
201
+ }
202
+
203
+ async function loadCachedTagsForCurrentHash(): Promise<UserPublicAttribute[]> {
204
+ const userMd = await readUserMd();
205
+ if (!userMd) {
206
+ return [];
207
+ }
208
+
209
+ const cache = await readCache(cachePath);
210
+ return cache?.userMdHash === userMd.userMdHash ? [...cache.attributes] : [];
211
+ }
212
+
213
+ return {
214
+ async loadTags(): Promise<UserPublicAttribute[]> {
215
+ return loadCachedTagsForCurrentHash();
216
+ },
217
+ async refreshTags(): Promise<UserPublicAttribute[]> {
218
+ const userMd = await readUserMd();
219
+ if (!userMd) {
220
+ return [];
221
+ }
222
+
223
+ const cache = await readCache(cachePath);
224
+ if (cache?.userMdHash === userMd.userMdHash) {
225
+ return [...cache.attributes];
226
+ }
227
+
228
+ const extracted = await extractor.extract({
229
+ markdown: userMd.markdown,
230
+ sourcePath: filePath,
231
+ });
232
+ if (isExtractionUnavailable(extracted)) {
233
+ logger?.warn?.(`[libp2p-mesh] USER.md agent extraction unavailable: ${extracted.reason}`);
234
+ return [];
235
+ }
236
+
237
+ const attributes = validateExtractedUserMdTags(extracted);
238
+ await writeCache(cachePath, userMd.userMdHash, attributes);
239
+ logger?.debug?.(`[libp2p-mesh] Saved USER.md agent attribute cache to ${cachePath}`);
240
+ return attributes;
241
+ },
242
+ };
243
+ }