libp2p-mesh 2026.6.20 → 2026.6.22

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
@@ -161,7 +161,7 @@ openclaw libp2p-mesh prompt install
161
161
  openclaw libp2p-mesh profile
162
162
  ```
163
163
 
164
- `USER.md` 中的公开 tag 会在 gateway 启动时只读提取,不会写回 `USER.md`。用户手动新增的结构化属性会保存到:
164
+ `USER.md` 中的公开 tag gateway 使用 OpenClaw 已配置的 agent/API 模型异步提取,不会写回 `USER.md`。初始 announce 或日志可能先不携带属性;等待后续完整 `instance-announce` 快照发出后,再按属性发送或排查匹配结果。用户手动新增的结构化属性会保存到:
165
165
 
166
166
  ```text
167
167
  ~/.openclaw/libp2p/user-profile.json
@@ -205,9 +205,9 @@ openclaw libp2p-mesh labels
205
205
 
206
206
  Labels are private local notes for remote instances you have already discovered. Use them when you want your own grouping to drive sends without asking the remote user to publish that attribute.
207
207
 
208
- ### 7. 重启 gateway
208
+ ### 7. 重启或启动 gateway
209
209
 
210
- 完成 `setup`、`prompt install` 或 `profile` 后,重启 gateway 让配置和提示词生效:
210
+ 完成 `setup` 或 `prompt install` 后,重启 gateway 让配置和提示词生效:
211
211
 
212
212
  ```bash
213
213
  openclaw gateway restart
@@ -219,6 +219,8 @@ openclaw gateway restart
219
219
  openclaw gateway
220
220
  ```
221
221
 
222
+ `profile` 保存后,如果 gateway 正在运行,会自动刷新并重新广播公开属性;只有 gateway 没有运行时,才需要启动或重启后生效。
223
+
222
224
  启动后可以观察日志:
223
225
 
224
226
  ```text
@@ -626,9 +628,11 @@ Tools are not configured in `openclaw.json`; they are registered automatically b
626
628
 
627
629
  There are two public sources:
628
630
 
629
- - `USER.md` tags are extracted read-only at gateway startup. The plugin never edits `USER.md`.
631
+ - `USER.md` tags are produced asynchronously by the gateway. The gateway uses the OpenClaw-configured agent/API model to extract tags from `USER.md` without editing the file.
630
632
  - `user-profile.json` stores manually managed structured attributes such as group, project, role, skill, or a custom key.
631
633
 
634
+ The initial base `instance-announce` may omit `userPublicAttributes`. After the gateway extracts `USER.md` tags and merges `user-profile.json`, it rebroadcasts a full `instance-announce` snapshot. If extraction is unavailable, `USER.md` tags are skipped; profile attributes still broadcast.
635
+
632
636
  By default, `USER.md` is read from:
633
637
 
634
638
  ```text
@@ -647,7 +651,7 @@ Run the profile wizard to manage structured attributes:
647
651
  openclaw libp2p-mesh profile
648
652
  ```
649
653
 
650
- 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.
654
+ 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 full instance announce snapshots after asynchronous extraction completes.
651
655
 
652
656
  The default profile path is:
653
657
 
@@ -44,11 +44,13 @@ function sameRecord(record, payload) {
44
44
  return false;
45
45
  const recordAttributes = normalizeUserPublicAttributes(record.userPublicAttributes);
46
46
  const payloadAttributes = normalizeUserPublicAttributes(payload.userPublicAttributes);
47
+ const payloadIncludesAttributes = "userPublicAttributes" in payload;
47
48
  return (record.peerId === payload.peerId &&
48
49
  record.instanceName === payload.instanceName &&
49
50
  record.pubkey === payload.pubkey &&
50
51
  sameStringArray(record.multiaddrs, payload.multiaddrs) &&
51
- sameUserPublicAttributes(recordAttributes, payloadAttributes) &&
52
+ (!payloadIncludesAttributes ||
53
+ sameUserPublicAttributes(recordAttributes, payloadAttributes)) &&
52
54
  record.lastAnnouncedAt === payload.announcedAt);
53
55
  }
54
56
  function normalizeRecord(value) {
@@ -135,7 +137,9 @@ export function createInstancePeerStore(options) {
135
137
  return runMutation(async () => {
136
138
  const table = await load();
137
139
  const existing = table.instances[payload.instanceId];
138
- const userPublicAttributes = normalizeUserPublicAttributes(payload.userPublicAttributes);
140
+ const userPublicAttributes = "userPublicAttributes" in payload
141
+ ? normalizeUserPublicAttributes(payload.userPublicAttributes)
142
+ : normalizeUserPublicAttributes(existing?.userPublicAttributes);
139
143
  const changed = !sameRecord(existing, payload);
140
144
  const record = {
141
145
  instanceId: payload.instanceId,
@@ -120,6 +120,8 @@ export function createInstanceRouter(options) {
120
120
  const pendingAcks = new Map();
121
121
  const deliveryCache = new Map();
122
122
  const unsubs = [];
123
+ const pendingAttributePeers = new Set();
124
+ let attributeRefreshPromise;
123
125
  function localInstanceId() {
124
126
  const identity = mesh.getInstanceIdentity();
125
127
  if (!identity) {
@@ -127,20 +129,24 @@ export function createInstanceRouter(options) {
127
129
  }
128
130
  return identity.id;
129
131
  }
130
- async function loadUserPublicAttributes() {
132
+ async function loadUserPublicAttributes(loadOptions) {
133
+ const userMdPromise = loadOptions?.refreshUserMd && options.userAttributeSource?.refreshTags
134
+ ? options.userAttributeSource.refreshTags()
135
+ : options.userAttributeSource?.loadTags() ?? Promise.resolve([]);
136
+ const profilePromise = options.userProfileStore?.listAttributes().catch((error) => {
137
+ logger?.warn?.(`[libp2p-mesh] Failed to load profile public attributes: ${summarizeError(error)}`);
138
+ return [];
139
+ }) ?? Promise.resolve([]);
131
140
  const [userMdTags, profileAttributes] = await Promise.all([
132
- options.userAttributeSource?.loadTags().catch((error) => {
141
+ userMdPromise.catch((error) => {
133
142
  logger?.warn?.(`[libp2p-mesh] Failed to load USER.md public attributes: ${summarizeError(error)}`);
134
143
  return [];
135
- }) ?? Promise.resolve([]),
136
- options.userProfileStore?.listAttributes().catch((error) => {
137
- logger?.warn?.(`[libp2p-mesh] Failed to load profile public attributes: ${summarizeError(error)}`);
138
- return [];
139
- }) ?? Promise.resolve([]),
144
+ }),
145
+ profilePromise,
140
146
  ]);
141
147
  return mergeUserPublicAttributes(userMdTags, profileAttributes);
142
148
  }
143
- async function buildAnnouncePayload() {
149
+ function buildBaseAnnouncePayload() {
144
150
  const identity = mesh.getInstanceIdentity();
145
151
  if (!identity) {
146
152
  throw new Error("Local instance identity is not initialized");
@@ -151,15 +157,87 @@ export function createInstanceRouter(options) {
151
157
  instanceName: identity.name,
152
158
  multiaddrs: mesh.getMultiaddrs(),
153
159
  pubkey: identity.pubkey,
154
- userPublicAttributes: await loadUserPublicAttributes(),
155
160
  announcedAt: Date.now(),
156
161
  };
157
162
  }
163
+ async function buildAttributeAnnouncePayload() {
164
+ return {
165
+ ...buildBaseAnnouncePayload(),
166
+ userPublicAttributes: await loadUserPublicAttributes({ refreshUserMd: true }),
167
+ };
168
+ }
169
+ function attributeAnnouncePeers(extraPeerId) {
170
+ const peers = new Set();
171
+ for (const peerId of mesh.getConnectedPeers()) {
172
+ if (isNonEmptyString(peerId) && peerId !== mesh.getLocalPeerId()) {
173
+ peers.add(peerId);
174
+ }
175
+ }
176
+ for (const peerId of announcedPeers) {
177
+ if (isNonEmptyString(peerId) && peerId !== mesh.getLocalPeerId()) {
178
+ peers.add(peerId);
179
+ }
180
+ }
181
+ if (extraPeerId && extraPeerId !== mesh.getLocalPeerId()) {
182
+ peers.add(extraPeerId);
183
+ }
184
+ return [...peers];
185
+ }
186
+ async function sendAttributeAnnounceToPeers(peers) {
187
+ if (peers.length === 0) {
188
+ return;
189
+ }
190
+ const payload = await buildAttributeAnnouncePayload();
191
+ for (const peerId of peers) {
192
+ try {
193
+ await mesh.sendStructuredMessage(peerId, {
194
+ id: crypto.randomUUID(),
195
+ type: "instance-announce",
196
+ to: peerId,
197
+ payload: JSON.stringify(payload),
198
+ });
199
+ logAnnounce("Sent", peerId, payload);
200
+ }
201
+ catch (error) {
202
+ logger?.warn?.(`[libp2p-mesh] Failed to send attribute announce to ${peerId}: ${summarizeError(error)}`);
203
+ }
204
+ }
205
+ }
206
+ function queueAttributeRefresh(extraPeerId) {
207
+ const peers = extraPeerId === undefined
208
+ ? attributeAnnouncePeers()
209
+ : isNonEmptyString(extraPeerId) && extraPeerId !== mesh.getLocalPeerId()
210
+ ? [extraPeerId]
211
+ : [];
212
+ for (const peerId of peers) {
213
+ pendingAttributePeers.add(peerId);
214
+ }
215
+ if (pendingAttributePeers.size === 0 && !attributeRefreshPromise) {
216
+ return;
217
+ }
218
+ drainAttributeRefresh();
219
+ }
220
+ function drainAttributeRefresh() {
221
+ if (!attributeRefreshPromise) {
222
+ attributeRefreshPromise = (async () => {
223
+ while (pendingAttributePeers.size > 0) {
224
+ const peers = [...pendingAttributePeers];
225
+ pendingAttributePeers.clear();
226
+ await sendAttributeAnnounceToPeers(peers).catch((error) => {
227
+ logger?.warn?.(`[libp2p-mesh] Failed to refresh public attributes: ${summarizeError(error)}`);
228
+ });
229
+ }
230
+ })().finally(() => {
231
+ attributeRefreshPromise = undefined;
232
+ });
233
+ }
234
+ return attributeRefreshPromise;
235
+ }
158
236
  async function announceToPeer(peerId) {
159
237
  if (!isNonEmptyString(peerId) || peerId === mesh.getLocalPeerId()) {
160
238
  return;
161
239
  }
162
- const payload = await buildAnnouncePayload();
240
+ const payload = buildBaseAnnouncePayload();
163
241
  await mesh.sendStructuredMessage(peerId, {
164
242
  id: crypto.randomUUID(),
165
243
  type: "instance-announce",
@@ -168,6 +246,13 @@ export function createInstanceRouter(options) {
168
246
  });
169
247
  announcedPeers.add(peerId);
170
248
  logAnnounce("Sent", peerId, payload);
249
+ queueAttributeRefresh(peerId);
250
+ }
251
+ async function refreshPublicAttributes() {
252
+ for (const peerId of attributeAnnouncePeers()) {
253
+ pendingAttributePeers.add(peerId);
254
+ }
255
+ await drainAttributeRefresh();
171
256
  }
172
257
  function announceSummary(direction, peerId, payload, changed) {
173
258
  const changedDetail = changed === undefined ? "" : ` changed=${changed}`;
@@ -582,6 +667,7 @@ export function createInstanceRouter(options) {
582
667
  stop,
583
668
  handleMessage,
584
669
  announceToPeer,
670
+ refreshPublicAttributes,
585
671
  listInstances,
586
672
  resolveInstance,
587
673
  sendInstanceMessage,
@@ -5,7 +5,8 @@ import { createInstancePeerStore } from "./instance-peer-store.js";
5
5
  import { createInstanceRouter } from "./instance-router.js";
6
6
  import { createMeshNetwork } from "./mesh.js";
7
7
  import { createPeerLabelStore } from "./peer-label-store.js";
8
- import { createUserMdAttributeSource } from "./user-md-attributes.js";
8
+ import { createUserMdAgentAttributeSource } from "./user-md-agent-attributes.js";
9
+ import { createOpenClawUserMdAttributeExtractor } from "./user-md-openclaw-extractor.js";
9
10
  import { createUserProfileStore } from "./user-profile-store.js";
10
11
  import { buildP2PTools } from "./agent-tools.js";
11
12
  import { registerLibp2pMeshCli } from "./profile-cli.js";
@@ -13,7 +14,6 @@ export function registerLibp2pMesh(api) {
13
14
  registerLibp2pMeshWithDeps(api);
14
15
  }
15
16
  export function registerLibp2pMeshWithDeps(api, deps = {}) {
16
- registerLibp2pMeshCli(api);
17
17
  const config = api.pluginConfig;
18
18
  let unsubscribeInbound;
19
19
  let serviceStarted = false;
@@ -24,7 +24,10 @@ export function registerLibp2pMeshWithDeps(api, deps = {}) {
24
24
  });
25
25
  const store = createInstancePeerStore({ logger: api.logger });
26
26
  const peerLabelStore = createPeerLabelStore({ logger: api.logger });
27
- const userAttributeSource = createUserMdAttributeSource({ logger: api.logger });
27
+ const userAttributeSource = createUserMdAgentAttributeSource({
28
+ logger: api.logger,
29
+ extractor: createOpenClawUserMdAttributeExtractor(api),
30
+ });
28
31
  const userProfileStore = createUserProfileStore({ logger: api.logger });
29
32
  const delivery = createOpenClawRuntimeInboundDelivery({
30
33
  config: api.config,
@@ -48,6 +51,13 @@ export function registerLibp2pMeshWithDeps(api, deps = {}) {
48
51
  userProfileStore,
49
52
  peerLabelStore,
50
53
  });
54
+ registerLibp2pMeshCli(api, {
55
+ profile: {
56
+ async afterProfileSave() {
57
+ await router.refreshPublicAttributes();
58
+ },
59
+ },
60
+ });
51
61
  const channel = createLibp2pMeshChannel(mesh);
52
62
  function attachInboundHandlers() {
53
63
  return mesh.onMessage((msg) => {
@@ -19,6 +19,7 @@ export type ProfileCliDeps = {
19
19
  createUserMdAttributeSource?: (api: OpenClawPluginApi) => {
20
20
  loadTags(): Promise<Awaited<ReturnType<UserProfileStore["listAttributes"]>>>;
21
21
  };
22
+ afterProfileSave?: () => Promise<void>;
22
23
  };
23
24
  export type LabelsCliDeps = {
24
25
  createPrompter?: (ctx: OpenClawPluginCliContext) => SetupPrompter;
@@ -43,6 +43,7 @@ export function registerLibp2pMeshProfileCommand(root, api, ctx, deps = {}) {
43
43
  writer: {
44
44
  async replaceAttributes(attributes) {
45
45
  await profileStore.replaceAttributes(attributes);
46
+ await deps.afterProfileSave?.();
46
47
  },
47
48
  },
48
49
  });
@@ -36,19 +36,26 @@ export const LIBP2P_MESH_AGENT_PROMPT = `
36
36
 
37
37
  1. 必须使用 \`selector\` 参数,不要使用旧的 \`match.kind/key/value\` 参数。
38
38
 
39
- 2. \`openclaw libp2p-mesh labels\` manages local labels for remote instances. These labels are stored in the local \`peer-labels.json\`, stay on this machine, and are used only when the send tool uses \`scope="local"\` or \`scope="all"\`.
39
+ 2. 公开属性来源:
40
40
 
41
- 3. scope 规则:
41
+ - \`source="USER.md"\` 表示 gateway 使用 OpenClaw 已配置的 agent/API 模型,从 USER.md 异步提取并公开广播的 tag。
42
+ - \`source="profile"\` 表示用户通过 \`openclaw libp2p-mesh profile\` 手动配置的公开结构化属性。
43
+ - gateway 的基础 \`instance-announce\` 可能省略 \`userPublicAttributes\`;这表示本次 announce 没有携带属性,不一定表示该用户没有公开属性。按公开属性发送前先用 \`p2p_list_instances\` 查看当前实例记录。
44
+ - 普通对话 agent 不应自己读取 USER.md 来决定公开属性。USER.md 属性提取是 gateway 后台职责。
45
+
46
+ 3. \`openclaw libp2p-mesh labels\` manages local labels for remote instances. These labels are stored in the local \`peer-labels.json\`, stay on this machine, and are used only when the send tool uses \`scope="local"\` or \`scope="all"\`.
47
+
48
+ 4. scope 规则:
42
49
 
43
50
  - 默认是 \`scope="public"\`;default is scope="public";省略 \`scope\` 时只匹配远端公开广播的 \`userPublicAttributes\`。
44
- - \`scope="public"\` 匹配远端实例自己公开的属性,例如 USER.md tags profile 属性。
51
+ - \`scope="public"\` 匹配远端公开广播的 \`userPublicAttributes\`,包括 USER.md 异步提取 tag profile 属性。
45
52
  - \`scope="local"\` 只匹配本机通过 \`openclaw libp2p-mesh labels\` 给远端实例配置的本地标签。
46
- - \`scope="all"\` 同时匹配 public 和 local 两个来源。
53
+ - \`scope="all"\` 同时匹配公开属性和本地标签。
47
54
  - 用户说“我归类”“我标记”或提到 labels/local labels/本地标签时,使用 \`scope="local"\`。
48
55
  - 用户说 public、公开、自己公开、对方公开时,使用 \`scope="public"\`。
49
56
  - 用户说 both、two sources、两个来源、公开和本地都算时,使用 \`scope="all"\`。
50
57
 
51
- 4. selector 规则:
58
+ 5. selector 规则:
52
59
 
53
60
  - \`group=实验室\` 必须原样传入:
54
61
  - \`selector="group=实验室"\`
@@ -67,7 +74,7 @@ export const LIBP2P_MESH_AGENT_PROMPT = `
67
74
 
68
75
  - \`实验室\` 这种裸值是歧义表达,不要自行改成 \`tag=实验室\` 或 \`tag:实验室\`,直接调用工具会返回歧义错误,或提示用户必须写成 \`group=实验室\` 或 \`tag:实验室\`。
69
76
 
70
- 5. 群发前必须先 dry run:
77
+ 6. 群发前必须先 dry run:
71
78
 
72
79
  - 第一次调用:
73
80
  - \`dryRun=true\`
@@ -75,7 +82,7 @@ export const LIBP2P_MESH_AGENT_PROMPT = `
75
82
  - \`scope\` 使用按上面规则判断出的 scope;如果没有明确本地或双来源意图,可省略,默认是 \`scope="public"\`
76
83
  - \`message\` 填用户要发送的原始消息内容
77
84
 
78
- 6. 如果 dry run 匹配到目标,不需要再询问用户确认,立即再次调用同一个工具发送:
85
+ 7. 如果 dry run 匹配到目标,不需要再询问用户确认,立即再次调用同一个工具发送:
79
86
 
80
87
  - 第二次调用:
81
88
  - \`dryRun=false\`
@@ -84,11 +91,11 @@ export const LIBP2P_MESH_AGENT_PROMPT = `
84
91
  - \`message\` 必须和 dry run 时一致
85
92
  - In English: dry run then actual send must use the same selector/scope/message.
86
93
 
87
- 7. 如果 dry run 没有匹配目标,直接输出工具返回结果,不要猜测网络中还有其他未发现实例。
94
+ 8. 如果 dry run 没有匹配目标,直接输出工具返回结果,不要猜测网络中还有其他未发现实例。
88
95
 
89
- 8. 不要手动在 \`message\` 前面拼接发送方 instanceId;插件会在接收侧元数据和展示文本中携带发送方 instanceId。
96
+ 9. 不要手动在 \`message\` 前面拼接发送方 instanceId;插件会在接收侧元数据和展示文本中携带发送方 instanceId。
90
97
 
91
- 9. 按属性发送只匹配本机 \`instance-peer.json\` 中已发现的实例,不代表全网搜索。
98
+ 10. 按属性发送只匹配本机 \`instance-peer.json\` 中已发现的实例,不代表全网搜索。
92
99
 
93
100
  ## 三、查询和排障
94
101
 
@@ -85,6 +85,10 @@ export type UserPublicAttribute = {
85
85
  label: string;
86
86
  source: "profile";
87
87
  };
88
+ export type UserMdAttributeSource = {
89
+ loadTags(): Promise<UserPublicAttribute[]>;
90
+ refreshTags?(): Promise<UserPublicAttribute[]>;
91
+ };
88
92
  export type LocalPeerLabel = {
89
93
  key: string;
90
94
  value: string;
@@ -205,9 +209,7 @@ export type InstanceRouterOptions = {
205
209
  warn?: (message: string) => void;
206
210
  error?: (message: string) => void;
207
211
  };
208
- userAttributeSource?: {
209
- loadTags(): Promise<UserPublicAttribute[]>;
210
- };
212
+ userAttributeSource?: UserMdAttributeSource;
211
213
  userProfileStore?: {
212
214
  listAttributes(): Promise<UserPublicAttribute[]>;
213
215
  };
@@ -222,6 +224,7 @@ export interface InstanceRouter {
222
224
  stop(): Promise<void>;
223
225
  handleMessage(msg: P2PMessage): Promise<void>;
224
226
  announceToPeer(peerId: string): Promise<void>;
227
+ refreshPublicAttributes(): Promise<void>;
225
228
  listInstances(): Promise<InstancePeerRecord[]>;
226
229
  resolveInstance(instanceId: string): Promise<InstancePeerRecord | undefined>;
227
230
  sendInstanceMessage(instanceId: string, message: string): Promise<{
@@ -0,0 +1,24 @@
1
+ import type { UserMdAttributeSource, UserPublicAttribute } from "./types.js";
2
+ type Logger = {
3
+ debug?: (message: string) => void;
4
+ warn?: (message: string) => void;
5
+ };
6
+ export type UserMdAttributeExtractionUnavailable = {
7
+ unavailable: true;
8
+ reason: string;
9
+ };
10
+ export type UserMdAttributeExtractor = {
11
+ extract(request: {
12
+ markdown: string;
13
+ sourcePath: string;
14
+ }): Promise<UserPublicAttribute[] | UserMdAttributeExtractionUnavailable>;
15
+ };
16
+ export declare function resolveUserMdAttributeCachePath(customPath?: string): string;
17
+ export declare function validateExtractedUserMdTags(value: unknown): UserPublicAttribute[];
18
+ export declare function createUserMdAgentAttributeSource(options?: {
19
+ path?: string;
20
+ cachePath?: string;
21
+ extractor?: UserMdAttributeExtractor;
22
+ logger?: Logger;
23
+ }): UserMdAttributeSource;
24
+ export {};
@@ -0,0 +1,180 @@
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
+ import { normalizeAttributeValue } from "./user-attributes.js";
6
+ import { resolveUserMdPath } from "./user-md-attributes.js";
7
+ const CACHE_VERSION = 1;
8
+ const MAX_AGENT_TAGS = 10;
9
+ const MAX_AGENT_TAG_LENGTH = 40;
10
+ const defaultExtractor = {
11
+ async extract() {
12
+ return {
13
+ unavailable: true,
14
+ reason: "USER.md agent extraction is unavailable in this runtime",
15
+ };
16
+ },
17
+ };
18
+ export function resolveUserMdAttributeCachePath(customPath) {
19
+ if (customPath) {
20
+ return customPath;
21
+ }
22
+ const stateDir = process.env.OPENCLAW_STATE_DIR;
23
+ if (stateDir) {
24
+ return path.join(stateDir, "libp2p", "user-md-attributes-cache.json");
25
+ }
26
+ return path.join(homedir(), ".openclaw", "libp2p", "user-md-attributes-cache.json");
27
+ }
28
+ function isRecord(value) {
29
+ return typeof value === "object" && value !== null;
30
+ }
31
+ function normalizeWhitespace(value) {
32
+ return value.trim().replace(/\s+/g, " ");
33
+ }
34
+ function looksLikeSentence(value) {
35
+ return /[。.!?!?]/.test(value) || value.split(/\s+/).filter(Boolean).length > 4;
36
+ }
37
+ function normalizeAgentTag(value) {
38
+ if (!isRecord(value) || value.kind !== "tag" || value.source !== "USER.md") {
39
+ return undefined;
40
+ }
41
+ if (typeof value.value !== "string" || typeof value.label !== "string") {
42
+ return undefined;
43
+ }
44
+ const tagValue = normalizeWhitespace(value.value);
45
+ const label = normalizeWhitespace(value.label);
46
+ if (!tagValue || !label || tagValue.length > MAX_AGENT_TAG_LENGTH || looksLikeSentence(tagValue)) {
47
+ return undefined;
48
+ }
49
+ return {
50
+ kind: "tag",
51
+ value: tagValue,
52
+ label,
53
+ source: "USER.md",
54
+ };
55
+ }
56
+ export function validateExtractedUserMdTags(value) {
57
+ if (!Array.isArray(value)) {
58
+ return [];
59
+ }
60
+ const attributes = [];
61
+ const seen = new Set();
62
+ for (const rawAttribute of value) {
63
+ const attribute = normalizeAgentTag(rawAttribute);
64
+ if (!attribute) {
65
+ continue;
66
+ }
67
+ const key = normalizeAttributeValue(attribute.value);
68
+ if (seen.has(key)) {
69
+ continue;
70
+ }
71
+ seen.add(key);
72
+ attributes.push(attribute);
73
+ if (attributes.length >= MAX_AGENT_TAGS) {
74
+ break;
75
+ }
76
+ }
77
+ return attributes;
78
+ }
79
+ function hashMarkdown(markdown) {
80
+ return createHash("sha256").update(markdown, "utf8").digest("hex");
81
+ }
82
+ function normalizeCacheFile(value) {
83
+ if (!isRecord(value) || value.version !== CACHE_VERSION || typeof value.userMdHash !== "string") {
84
+ return undefined;
85
+ }
86
+ const attributes = validateExtractedUserMdTags(value.attributes);
87
+ if (!Array.isArray(value.attributes) || attributes.length !== value.attributes.length) {
88
+ return undefined;
89
+ }
90
+ return {
91
+ version: CACHE_VERSION,
92
+ updatedAt: typeof value.updatedAt === "number" ? value.updatedAt : Date.now(),
93
+ userMdHash: value.userMdHash,
94
+ attributes,
95
+ };
96
+ }
97
+ async function readCache(cachePath) {
98
+ try {
99
+ return normalizeCacheFile(JSON.parse(await readFile(cachePath, "utf8")));
100
+ }
101
+ catch {
102
+ return undefined;
103
+ }
104
+ }
105
+ async function writeCache(cachePath, userMdHash, attributes) {
106
+ const cache = {
107
+ version: CACHE_VERSION,
108
+ updatedAt: Date.now(),
109
+ userMdHash,
110
+ attributes: validateExtractedUserMdTags(attributes),
111
+ };
112
+ const dir = path.dirname(cachePath);
113
+ const tmpPath = `${cachePath}.tmp-${process.pid}-${Date.now()}-${randomUUID()}`;
114
+ await mkdir(dir, { recursive: true });
115
+ try {
116
+ await writeFile(tmpPath, `${JSON.stringify(cache, null, 2)}\n`, "utf8");
117
+ await rename(tmpPath, cachePath);
118
+ }
119
+ catch (error) {
120
+ await rm(tmpPath, { force: true }).catch(() => undefined);
121
+ throw error;
122
+ }
123
+ }
124
+ function isExtractionUnavailable(value) {
125
+ return isRecord(value) && value.unavailable === true && typeof value.reason === "string";
126
+ }
127
+ export function createUserMdAgentAttributeSource(options) {
128
+ const filePath = resolveUserMdPath(options?.path);
129
+ const cachePath = resolveUserMdAttributeCachePath(options?.cachePath);
130
+ const extractor = options?.extractor ?? defaultExtractor;
131
+ const logger = options?.logger;
132
+ async function readUserMd() {
133
+ try {
134
+ const markdown = await readFile(filePath, "utf8");
135
+ return { markdown, userMdHash: hashMarkdown(markdown) };
136
+ }
137
+ catch (error) {
138
+ if (error.code === "ENOENT") {
139
+ return undefined;
140
+ }
141
+ logger?.warn?.(`[libp2p-mesh] Failed to read USER.md at ${filePath}: ${error.message}`);
142
+ return undefined;
143
+ }
144
+ }
145
+ async function loadCachedTagsForCurrentHash() {
146
+ const userMd = await readUserMd();
147
+ if (!userMd) {
148
+ return [];
149
+ }
150
+ const cache = await readCache(cachePath);
151
+ return cache?.userMdHash === userMd.userMdHash ? [...cache.attributes] : [];
152
+ }
153
+ return {
154
+ async loadTags() {
155
+ return loadCachedTagsForCurrentHash();
156
+ },
157
+ async refreshTags() {
158
+ const userMd = await readUserMd();
159
+ if (!userMd) {
160
+ return [];
161
+ }
162
+ const cache = await readCache(cachePath);
163
+ if (cache?.userMdHash === userMd.userMdHash) {
164
+ return [...cache.attributes];
165
+ }
166
+ const extracted = await extractor.extract({
167
+ markdown: userMd.markdown,
168
+ sourcePath: filePath,
169
+ });
170
+ if (isExtractionUnavailable(extracted)) {
171
+ logger?.warn?.(`[libp2p-mesh] USER.md agent extraction unavailable: ${extracted.reason}`);
172
+ return [];
173
+ }
174
+ const attributes = validateExtractedUserMdTags(extracted);
175
+ await writeCache(cachePath, userMd.userMdHash, attributes);
176
+ logger?.debug?.(`[libp2p-mesh] Saved USER.md agent attribute cache to ${cachePath}`);
177
+ return attributes;
178
+ },
179
+ };
180
+ }
@@ -0,0 +1,14 @@
1
+ import type { OpenClawPluginApi } from "openclaw/plugin-sdk/core";
2
+ import type { UserPublicAttribute } from "./types.js";
3
+ import type { UserMdAttributeExtractor } from "./user-md-agent-attributes.js";
4
+ export declare const USER_MD_ATTRIBUTE_EXTRACTION_PROMPT: string;
5
+ type Logger = {
6
+ warn?: (message: string) => void;
7
+ };
8
+ export declare function extractLatestAssistantText(messages: unknown[]): string | undefined;
9
+ export declare function parseUserMdAttributeResponse(text: string): UserPublicAttribute[];
10
+ export declare function createOpenClawUserMdAttributeExtractor(api: OpenClawPluginApi, options?: {
11
+ timeoutMs?: number;
12
+ logger?: Logger;
13
+ }): UserMdAttributeExtractor;
14
+ export {};