libp2p-mesh 2026.6.20 → 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/README.md +9 -5
- package/dist/src/instance-peer-store.js +6 -2
- package/dist/src/instance-router.js +96 -10
- package/dist/src/plugin.js +9 -3
- package/dist/src/profile-cli.d.ts +1 -0
- package/dist/src/profile-cli.js +1 -0
- package/dist/src/prompt-config.js +17 -10
- package/dist/src/types.d.ts +6 -3
- package/dist/src/user-md-agent-attributes.d.ts +24 -0
- package/dist/src/user-md-agent-attributes.js +180 -0
- package/package.json +1 -1
- package/src/instance-peer-store.ts +7 -2
- package/src/instance-router.ts +117 -11
- package/src/plugin.ts +10 -4
- package/src/profile-cli.ts +2 -0
- package/src/prompt-config.ts +17 -10
- package/src/types.ts +7 -3
- package/src/user-md-agent-attributes.ts +243 -0
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
|
|
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.
|
|
208
|
+
### 7. 重启或启动 gateway
|
|
209
209
|
|
|
210
|
-
完成 `setup
|
|
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
|
|
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
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
141
|
+
userMdPromise.catch((error) => {
|
|
133
142
|
logger?.warn?.(`[libp2p-mesh] Failed to load USER.md public attributes: ${summarizeError(error)}`);
|
|
134
143
|
return [];
|
|
135
|
-
})
|
|
136
|
-
|
|
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
|
-
|
|
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 =
|
|
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,
|
package/dist/src/plugin.js
CHANGED
|
@@ -5,7 +5,7 @@ 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 {
|
|
8
|
+
import { createUserMdAgentAttributeSource } from "./user-md-agent-attributes.js";
|
|
9
9
|
import { createUserProfileStore } from "./user-profile-store.js";
|
|
10
10
|
import { buildP2PTools } from "./agent-tools.js";
|
|
11
11
|
import { registerLibp2pMeshCli } from "./profile-cli.js";
|
|
@@ -13,7 +13,6 @@ export function registerLibp2pMesh(api) {
|
|
|
13
13
|
registerLibp2pMeshWithDeps(api);
|
|
14
14
|
}
|
|
15
15
|
export function registerLibp2pMeshWithDeps(api, deps = {}) {
|
|
16
|
-
registerLibp2pMeshCli(api);
|
|
17
16
|
const config = api.pluginConfig;
|
|
18
17
|
let unsubscribeInbound;
|
|
19
18
|
let serviceStarted = false;
|
|
@@ -24,7 +23,7 @@ export function registerLibp2pMeshWithDeps(api, deps = {}) {
|
|
|
24
23
|
});
|
|
25
24
|
const store = createInstancePeerStore({ logger: api.logger });
|
|
26
25
|
const peerLabelStore = createPeerLabelStore({ logger: api.logger });
|
|
27
|
-
const userAttributeSource =
|
|
26
|
+
const userAttributeSource = createUserMdAgentAttributeSource({ logger: api.logger });
|
|
28
27
|
const userProfileStore = createUserProfileStore({ logger: api.logger });
|
|
29
28
|
const delivery = createOpenClawRuntimeInboundDelivery({
|
|
30
29
|
config: api.config,
|
|
@@ -48,6 +47,13 @@ export function registerLibp2pMeshWithDeps(api, deps = {}) {
|
|
|
48
47
|
userProfileStore,
|
|
49
48
|
peerLabelStore,
|
|
50
49
|
});
|
|
50
|
+
registerLibp2pMeshCli(api, {
|
|
51
|
+
profile: {
|
|
52
|
+
async afterProfileSave() {
|
|
53
|
+
await router.refreshPublicAttributes();
|
|
54
|
+
},
|
|
55
|
+
},
|
|
56
|
+
});
|
|
51
57
|
const channel = createLibp2pMeshChannel(mesh);
|
|
52
58
|
function attachInboundHandlers() {
|
|
53
59
|
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;
|
package/dist/src/profile-cli.js
CHANGED
|
@@ -36,19 +36,26 @@ export const LIBP2P_MESH_AGENT_PROMPT = `
|
|
|
36
36
|
|
|
37
37
|
1. 必须使用 \`selector\` 参数,不要使用旧的 \`match.kind/key/value\` 参数。
|
|
38
38
|
|
|
39
|
-
2.
|
|
39
|
+
2. 公开属性来源:
|
|
40
40
|
|
|
41
|
-
|
|
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"\`
|
|
51
|
+
- \`scope="public"\` 匹配远端公开广播的 \`userPublicAttributes\`,包括 USER.md 异步提取 tag 和 profile 属性。
|
|
45
52
|
- \`scope="local"\` 只匹配本机通过 \`openclaw libp2p-mesh labels\` 给远端实例配置的本地标签。
|
|
46
|
-
- \`scope="all"\`
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
94
|
+
8. 如果 dry run 没有匹配目标,直接输出工具返回结果,不要猜测网络中还有其他未发现实例。
|
|
88
95
|
|
|
89
|
-
|
|
96
|
+
9. 不要手动在 \`message\` 前面拼接发送方 instanceId;插件会在接收侧元数据和展示文本中携带发送方 instanceId。
|
|
90
97
|
|
|
91
|
-
|
|
98
|
+
10. 按属性发送只匹配本机 \`instance-peer.json\` 中已发现的实例,不代表全网搜索。
|
|
92
99
|
|
|
93
100
|
## 三、查询和排障
|
|
94
101
|
|
package/dist/src/types.d.ts
CHANGED
|
@@ -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
|
+
}
|
package/package.json
CHANGED
|
@@ -66,13 +66,15 @@ function sameRecord(
|
|
|
66
66
|
|
|
67
67
|
const recordAttributes = normalizeUserPublicAttributes(record.userPublicAttributes);
|
|
68
68
|
const payloadAttributes = normalizeUserPublicAttributes(payload.userPublicAttributes);
|
|
69
|
+
const payloadIncludesAttributes = "userPublicAttributes" in payload;
|
|
69
70
|
|
|
70
71
|
return (
|
|
71
72
|
record.peerId === payload.peerId &&
|
|
72
73
|
record.instanceName === payload.instanceName &&
|
|
73
74
|
record.pubkey === payload.pubkey &&
|
|
74
75
|
sameStringArray(record.multiaddrs, payload.multiaddrs) &&
|
|
75
|
-
|
|
76
|
+
(!payloadIncludesAttributes ||
|
|
77
|
+
sameUserPublicAttributes(recordAttributes, payloadAttributes)) &&
|
|
76
78
|
record.lastAnnouncedAt === payload.announcedAt
|
|
77
79
|
);
|
|
78
80
|
}
|
|
@@ -187,7 +189,10 @@ export function createInstancePeerStore(options?: {
|
|
|
187
189
|
return runMutation(async () => {
|
|
188
190
|
const table = await load();
|
|
189
191
|
const existing = table.instances[payload.instanceId];
|
|
190
|
-
const userPublicAttributes =
|
|
192
|
+
const userPublicAttributes =
|
|
193
|
+
"userPublicAttributes" in payload
|
|
194
|
+
? normalizeUserPublicAttributes(payload.userPublicAttributes)
|
|
195
|
+
: normalizeUserPublicAttributes(existing?.userPublicAttributes);
|
|
191
196
|
const changed = !sameRecord(existing, payload);
|
|
192
197
|
const record: InstancePeerRecord = {
|
|
193
198
|
instanceId: payload.instanceId,
|
package/src/instance-router.ts
CHANGED
|
@@ -217,6 +217,8 @@ export function createInstanceRouter(options: InstanceRouterOptions): InstanceRo
|
|
|
217
217
|
const pendingAcks = new Map<string, PendingAck>();
|
|
218
218
|
const deliveryCache = new Map<string, DeliveryCacheEntry>();
|
|
219
219
|
const unsubs: Array<() => void> = [];
|
|
220
|
+
const pendingAttributePeers = new Set<string>();
|
|
221
|
+
let attributeRefreshPromise: Promise<void> | undefined;
|
|
220
222
|
|
|
221
223
|
function localInstanceId(): string {
|
|
222
224
|
const identity = mesh.getInstanceIdentity();
|
|
@@ -226,26 +228,35 @@ export function createInstanceRouter(options: InstanceRouterOptions): InstanceRo
|
|
|
226
228
|
return identity.id;
|
|
227
229
|
}
|
|
228
230
|
|
|
229
|
-
async function loadUserPublicAttributes(
|
|
230
|
-
|
|
231
|
-
|
|
231
|
+
async function loadUserPublicAttributes(loadOptions?: {
|
|
232
|
+
refreshUserMd?: boolean;
|
|
233
|
+
}): Promise<UserPublicAttribute[]> {
|
|
234
|
+
const userMdPromise = loadOptions?.refreshUserMd && options.userAttributeSource?.refreshTags
|
|
235
|
+
? options.userAttributeSource.refreshTags()
|
|
236
|
+
: options.userAttributeSource?.loadTags() ?? Promise.resolve([]);
|
|
237
|
+
|
|
238
|
+
const profilePromise =
|
|
239
|
+
options.userProfileStore?.listAttributes().catch((error) => {
|
|
232
240
|
logger?.warn?.(
|
|
233
|
-
`[libp2p-mesh] Failed to load
|
|
241
|
+
`[libp2p-mesh] Failed to load profile public attributes: ${summarizeError(error)}`,
|
|
234
242
|
);
|
|
235
243
|
return [];
|
|
236
|
-
}) ?? Promise.resolve([])
|
|
237
|
-
|
|
244
|
+
}) ?? Promise.resolve([]);
|
|
245
|
+
|
|
246
|
+
const [userMdTags, profileAttributes] = await Promise.all([
|
|
247
|
+
userMdPromise.catch((error) => {
|
|
238
248
|
logger?.warn?.(
|
|
239
|
-
`[libp2p-mesh] Failed to load
|
|
249
|
+
`[libp2p-mesh] Failed to load USER.md public attributes: ${summarizeError(error)}`,
|
|
240
250
|
);
|
|
241
251
|
return [];
|
|
242
|
-
})
|
|
252
|
+
}),
|
|
253
|
+
profilePromise,
|
|
243
254
|
]);
|
|
244
255
|
|
|
245
256
|
return mergeUserPublicAttributes(userMdTags, profileAttributes);
|
|
246
257
|
}
|
|
247
258
|
|
|
248
|
-
|
|
259
|
+
function buildBaseAnnouncePayload(): InstanceAnnouncePayload {
|
|
249
260
|
const identity = mesh.getInstanceIdentity();
|
|
250
261
|
if (!identity) {
|
|
251
262
|
throw new Error("Local instance identity is not initialized");
|
|
@@ -257,17 +268,102 @@ export function createInstanceRouter(options: InstanceRouterOptions): InstanceRo
|
|
|
257
268
|
instanceName: identity.name,
|
|
258
269
|
multiaddrs: mesh.getMultiaddrs(),
|
|
259
270
|
pubkey: identity.pubkey,
|
|
260
|
-
userPublicAttributes: await loadUserPublicAttributes(),
|
|
261
271
|
announcedAt: Date.now(),
|
|
262
272
|
};
|
|
263
273
|
}
|
|
264
274
|
|
|
275
|
+
async function buildAttributeAnnouncePayload(): Promise<InstanceAnnouncePayload> {
|
|
276
|
+
return {
|
|
277
|
+
...buildBaseAnnouncePayload(),
|
|
278
|
+
userPublicAttributes: await loadUserPublicAttributes({ refreshUserMd: true }),
|
|
279
|
+
};
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
function attributeAnnouncePeers(extraPeerId?: string): string[] {
|
|
283
|
+
const peers = new Set<string>();
|
|
284
|
+
for (const peerId of mesh.getConnectedPeers()) {
|
|
285
|
+
if (isNonEmptyString(peerId) && peerId !== mesh.getLocalPeerId()) {
|
|
286
|
+
peers.add(peerId);
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
for (const peerId of announcedPeers) {
|
|
290
|
+
if (isNonEmptyString(peerId) && peerId !== mesh.getLocalPeerId()) {
|
|
291
|
+
peers.add(peerId);
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
if (extraPeerId && extraPeerId !== mesh.getLocalPeerId()) {
|
|
295
|
+
peers.add(extraPeerId);
|
|
296
|
+
}
|
|
297
|
+
return [...peers];
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
async function sendAttributeAnnounceToPeers(peers: string[]): Promise<void> {
|
|
301
|
+
if (peers.length === 0) {
|
|
302
|
+
return;
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
const payload = await buildAttributeAnnouncePayload();
|
|
306
|
+
for (const peerId of peers) {
|
|
307
|
+
try {
|
|
308
|
+
await mesh.sendStructuredMessage(peerId, {
|
|
309
|
+
id: crypto.randomUUID(),
|
|
310
|
+
type: "instance-announce",
|
|
311
|
+
to: peerId,
|
|
312
|
+
payload: JSON.stringify(payload),
|
|
313
|
+
});
|
|
314
|
+
logAnnounce("Sent", peerId, payload);
|
|
315
|
+
} catch (error) {
|
|
316
|
+
logger?.warn?.(
|
|
317
|
+
`[libp2p-mesh] Failed to send attribute announce to ${peerId}: ${summarizeError(error)}`,
|
|
318
|
+
);
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
function queueAttributeRefresh(extraPeerId?: string): void {
|
|
324
|
+
const peers =
|
|
325
|
+
extraPeerId === undefined
|
|
326
|
+
? attributeAnnouncePeers()
|
|
327
|
+
: isNonEmptyString(extraPeerId) && extraPeerId !== mesh.getLocalPeerId()
|
|
328
|
+
? [extraPeerId]
|
|
329
|
+
: [];
|
|
330
|
+
for (const peerId of peers) {
|
|
331
|
+
pendingAttributePeers.add(peerId);
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
if (pendingAttributePeers.size === 0 && !attributeRefreshPromise) {
|
|
335
|
+
return;
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
drainAttributeRefresh();
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
function drainAttributeRefresh(): Promise<void> {
|
|
342
|
+
if (!attributeRefreshPromise) {
|
|
343
|
+
attributeRefreshPromise = (async () => {
|
|
344
|
+
while (pendingAttributePeers.size > 0) {
|
|
345
|
+
const peers = [...pendingAttributePeers];
|
|
346
|
+
pendingAttributePeers.clear();
|
|
347
|
+
await sendAttributeAnnounceToPeers(peers).catch((error) => {
|
|
348
|
+
logger?.warn?.(
|
|
349
|
+
`[libp2p-mesh] Failed to refresh public attributes: ${summarizeError(error)}`,
|
|
350
|
+
);
|
|
351
|
+
});
|
|
352
|
+
}
|
|
353
|
+
})().finally(() => {
|
|
354
|
+
attributeRefreshPromise = undefined;
|
|
355
|
+
});
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
return attributeRefreshPromise;
|
|
359
|
+
}
|
|
360
|
+
|
|
265
361
|
async function announceToPeer(peerId: string): Promise<void> {
|
|
266
362
|
if (!isNonEmptyString(peerId) || peerId === mesh.getLocalPeerId()) {
|
|
267
363
|
return;
|
|
268
364
|
}
|
|
269
365
|
|
|
270
|
-
const payload =
|
|
366
|
+
const payload = buildBaseAnnouncePayload();
|
|
271
367
|
await mesh.sendStructuredMessage(peerId, {
|
|
272
368
|
id: crypto.randomUUID(),
|
|
273
369
|
type: "instance-announce",
|
|
@@ -276,6 +372,15 @@ export function createInstanceRouter(options: InstanceRouterOptions): InstanceRo
|
|
|
276
372
|
});
|
|
277
373
|
announcedPeers.add(peerId);
|
|
278
374
|
logAnnounce("Sent", peerId, payload);
|
|
375
|
+
queueAttributeRefresh(peerId);
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
async function refreshPublicAttributes(): Promise<void> {
|
|
379
|
+
for (const peerId of attributeAnnouncePeers()) {
|
|
380
|
+
pendingAttributePeers.add(peerId);
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
await drainAttributeRefresh();
|
|
279
384
|
}
|
|
280
385
|
|
|
281
386
|
function announceSummary(
|
|
@@ -791,6 +896,7 @@ export function createInstanceRouter(options: InstanceRouterOptions): InstanceRo
|
|
|
791
896
|
stop,
|
|
792
897
|
handleMessage,
|
|
793
898
|
announceToPeer,
|
|
899
|
+
refreshPublicAttributes,
|
|
794
900
|
listInstances,
|
|
795
901
|
resolveInstance,
|
|
796
902
|
sendInstanceMessage,
|
package/src/plugin.ts
CHANGED
|
@@ -6,7 +6,7 @@ import { createInstancePeerStore } from "./instance-peer-store.js";
|
|
|
6
6
|
import { createInstanceRouter } from "./instance-router.js";
|
|
7
7
|
import { createMeshNetwork } from "./mesh.js";
|
|
8
8
|
import { createPeerLabelStore } from "./peer-label-store.js";
|
|
9
|
-
import {
|
|
9
|
+
import { createUserMdAgentAttributeSource } from "./user-md-agent-attributes.js";
|
|
10
10
|
import { createUserProfileStore } from "./user-profile-store.js";
|
|
11
11
|
import { buildP2PTools } from "./agent-tools.js";
|
|
12
12
|
import { registerLibp2pMeshCli } from "./profile-cli.js";
|
|
@@ -26,8 +26,6 @@ export function registerLibp2pMeshWithDeps(
|
|
|
26
26
|
api: OpenClawPluginApi,
|
|
27
27
|
deps: Libp2pMeshPluginDeps = {},
|
|
28
28
|
) {
|
|
29
|
-
registerLibp2pMeshCli(api);
|
|
30
|
-
|
|
31
29
|
const config = api.pluginConfig as MeshConfig | undefined;
|
|
32
30
|
let unsubscribeInbound: (() => void) | undefined;
|
|
33
31
|
let serviceStarted = false;
|
|
@@ -38,7 +36,7 @@ export function registerLibp2pMeshWithDeps(
|
|
|
38
36
|
});
|
|
39
37
|
const store = createInstancePeerStore({ logger: api.logger });
|
|
40
38
|
const peerLabelStore = createPeerLabelStore({ logger: api.logger });
|
|
41
|
-
const userAttributeSource =
|
|
39
|
+
const userAttributeSource = createUserMdAgentAttributeSource({ logger: api.logger });
|
|
42
40
|
const userProfileStore = createUserProfileStore({ logger: api.logger });
|
|
43
41
|
const delivery = createOpenClawRuntimeInboundDelivery({
|
|
44
42
|
config: api.config,
|
|
@@ -65,6 +63,14 @@ export function registerLibp2pMeshWithDeps(
|
|
|
65
63
|
peerLabelStore,
|
|
66
64
|
});
|
|
67
65
|
|
|
66
|
+
registerLibp2pMeshCli(api, {
|
|
67
|
+
profile: {
|
|
68
|
+
async afterProfileSave() {
|
|
69
|
+
await router.refreshPublicAttributes();
|
|
70
|
+
},
|
|
71
|
+
},
|
|
72
|
+
});
|
|
73
|
+
|
|
68
74
|
const channel = createLibp2pMeshChannel(mesh);
|
|
69
75
|
|
|
70
76
|
function attachInboundHandlers(): () => void {
|
package/src/profile-cli.ts
CHANGED
|
@@ -30,6 +30,7 @@ export type ProfileCliDeps = {
|
|
|
30
30
|
createPrompter?: (ctx: OpenClawPluginCliContext) => SetupPrompter;
|
|
31
31
|
createProfileStore?: (api: OpenClawPluginApi) => Pick<UserProfileStore, "listAttributes" | "replaceAttributes">;
|
|
32
32
|
createUserMdAttributeSource?: (api: OpenClawPluginApi) => { loadTags(): Promise<Awaited<ReturnType<UserProfileStore["listAttributes"]>>> };
|
|
33
|
+
afterProfileSave?: () => Promise<void>;
|
|
33
34
|
};
|
|
34
35
|
|
|
35
36
|
export type LabelsCliDeps = {
|
|
@@ -92,6 +93,7 @@ export function registerLibp2pMeshProfileCommand(
|
|
|
92
93
|
writer: {
|
|
93
94
|
async replaceAttributes(attributes) {
|
|
94
95
|
await profileStore.replaceAttributes(attributes);
|
|
96
|
+
await deps.afterProfileSave?.();
|
|
95
97
|
},
|
|
96
98
|
},
|
|
97
99
|
});
|
package/src/prompt-config.ts
CHANGED
|
@@ -38,19 +38,26 @@ export const LIBP2P_MESH_AGENT_PROMPT = `
|
|
|
38
38
|
|
|
39
39
|
1. 必须使用 \`selector\` 参数,不要使用旧的 \`match.kind/key/value\` 参数。
|
|
40
40
|
|
|
41
|
-
2.
|
|
41
|
+
2. 公开属性来源:
|
|
42
42
|
|
|
43
|
-
|
|
43
|
+
- \`source="USER.md"\` 表示 gateway 使用 OpenClaw 已配置的 agent/API 模型,从 USER.md 异步提取并公开广播的 tag。
|
|
44
|
+
- \`source="profile"\` 表示用户通过 \`openclaw libp2p-mesh profile\` 手动配置的公开结构化属性。
|
|
45
|
+
- gateway 的基础 \`instance-announce\` 可能省略 \`userPublicAttributes\`;这表示本次 announce 没有携带属性,不一定表示该用户没有公开属性。按公开属性发送前先用 \`p2p_list_instances\` 查看当前实例记录。
|
|
46
|
+
- 普通对话 agent 不应自己读取 USER.md 来决定公开属性。USER.md 属性提取是 gateway 后台职责。
|
|
47
|
+
|
|
48
|
+
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"\`.
|
|
49
|
+
|
|
50
|
+
4. scope 规则:
|
|
44
51
|
|
|
45
52
|
- 默认是 \`scope="public"\`;default is scope="public";省略 \`scope\` 时只匹配远端公开广播的 \`userPublicAttributes\`。
|
|
46
|
-
- \`scope="public"\`
|
|
53
|
+
- \`scope="public"\` 匹配远端公开广播的 \`userPublicAttributes\`,包括 USER.md 异步提取 tag 和 profile 属性。
|
|
47
54
|
- \`scope="local"\` 只匹配本机通过 \`openclaw libp2p-mesh labels\` 给远端实例配置的本地标签。
|
|
48
|
-
- \`scope="all"\`
|
|
55
|
+
- \`scope="all"\` 同时匹配公开属性和本地标签。
|
|
49
56
|
- 用户说“我归类”“我标记”或提到 labels/local labels/本地标签时,使用 \`scope="local"\`。
|
|
50
57
|
- 用户说 public、公开、自己公开、对方公开时,使用 \`scope="public"\`。
|
|
51
58
|
- 用户说 both、two sources、两个来源、公开和本地都算时,使用 \`scope="all"\`。
|
|
52
59
|
|
|
53
|
-
|
|
60
|
+
5. selector 规则:
|
|
54
61
|
|
|
55
62
|
- \`group=实验室\` 必须原样传入:
|
|
56
63
|
- \`selector="group=实验室"\`
|
|
@@ -69,7 +76,7 @@ export const LIBP2P_MESH_AGENT_PROMPT = `
|
|
|
69
76
|
|
|
70
77
|
- \`实验室\` 这种裸值是歧义表达,不要自行改成 \`tag=实验室\` 或 \`tag:实验室\`,直接调用工具会返回歧义错误,或提示用户必须写成 \`group=实验室\` 或 \`tag:实验室\`。
|
|
71
78
|
|
|
72
|
-
|
|
79
|
+
6. 群发前必须先 dry run:
|
|
73
80
|
|
|
74
81
|
- 第一次调用:
|
|
75
82
|
- \`dryRun=true\`
|
|
@@ -77,7 +84,7 @@ export const LIBP2P_MESH_AGENT_PROMPT = `
|
|
|
77
84
|
- \`scope\` 使用按上面规则判断出的 scope;如果没有明确本地或双来源意图,可省略,默认是 \`scope="public"\`
|
|
78
85
|
- \`message\` 填用户要发送的原始消息内容
|
|
79
86
|
|
|
80
|
-
|
|
87
|
+
7. 如果 dry run 匹配到目标,不需要再询问用户确认,立即再次调用同一个工具发送:
|
|
81
88
|
|
|
82
89
|
- 第二次调用:
|
|
83
90
|
- \`dryRun=false\`
|
|
@@ -86,11 +93,11 @@ export const LIBP2P_MESH_AGENT_PROMPT = `
|
|
|
86
93
|
- \`message\` 必须和 dry run 时一致
|
|
87
94
|
- In English: dry run then actual send must use the same selector/scope/message.
|
|
88
95
|
|
|
89
|
-
|
|
96
|
+
8. 如果 dry run 没有匹配目标,直接输出工具返回结果,不要猜测网络中还有其他未发现实例。
|
|
90
97
|
|
|
91
|
-
|
|
98
|
+
9. 不要手动在 \`message\` 前面拼接发送方 instanceId;插件会在接收侧元数据和展示文本中携带发送方 instanceId。
|
|
92
99
|
|
|
93
|
-
|
|
100
|
+
10. 按属性发送只匹配本机 \`instance-peer.json\` 中已发现的实例,不代表全网搜索。
|
|
94
101
|
|
|
95
102
|
## 三、查询和排障
|
|
96
103
|
|
package/src/types.ts
CHANGED
|
@@ -102,6 +102,11 @@ 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
|
+
|
|
105
110
|
export type LocalPeerLabel = {
|
|
106
111
|
key: string;
|
|
107
112
|
value: string;
|
|
@@ -232,9 +237,7 @@ export type InstanceRouterOptions = {
|
|
|
232
237
|
warn?: (message: string) => void;
|
|
233
238
|
error?: (message: string) => void;
|
|
234
239
|
};
|
|
235
|
-
userAttributeSource?:
|
|
236
|
-
loadTags(): Promise<UserPublicAttribute[]>;
|
|
237
|
-
};
|
|
240
|
+
userAttributeSource?: UserMdAttributeSource;
|
|
238
241
|
userProfileStore?: {
|
|
239
242
|
listAttributes(): Promise<UserPublicAttribute[]>;
|
|
240
243
|
};
|
|
@@ -250,6 +253,7 @@ export interface InstanceRouter {
|
|
|
250
253
|
stop(): Promise<void>;
|
|
251
254
|
handleMessage(msg: P2PMessage): Promise<void>;
|
|
252
255
|
announceToPeer(peerId: string): Promise<void>;
|
|
256
|
+
refreshPublicAttributes(): Promise<void>;
|
|
253
257
|
listInstances(): Promise<InstancePeerRecord[]>;
|
|
254
258
|
resolveInstance(instanceId: string): Promise<InstancePeerRecord | undefined>;
|
|
255
259
|
sendInstanceMessage(instanceId: string, message: string): Promise<{
|
|
@@ -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
|
+
}
|