@newbase-clawchat/openclaw-clawchat 2026.5.12-2 → 2026.5.12-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 +39 -17
- package/dist/index.js +3 -1
- package/dist/src/api-client.js +71 -12
- package/dist/src/api-types.test-d.js +10 -0
- package/dist/src/channel.js +5 -5
- package/dist/src/channel.setup.js +4 -17
- package/dist/src/clawchat-memory.js +290 -0
- package/dist/src/clawchat-metadata.js +235 -0
- package/dist/src/client.js +31 -93
- package/dist/src/commands.js +3 -3
- package/dist/src/config.js +58 -3
- package/dist/src/group-message-coalescer.js +107 -0
- package/dist/src/inbound.js +24 -28
- package/dist/src/login.runtime.js +82 -19
- package/dist/src/media-runtime.js +2 -3
- package/dist/src/message-mapper.js +1 -1
- package/dist/src/mock-transport.js +31 -0
- package/dist/src/outbound.js +281 -56
- package/dist/src/plugin-prompts.js +76 -0
- package/dist/src/profile-prompt.js +150 -0
- package/dist/src/profile-sync.js +169 -0
- package/dist/src/prompt-injection.js +25 -0
- package/dist/src/protocol-types.js +63 -0
- package/dist/src/protocol-types.typecheck.js +1 -0
- package/dist/src/protocol.js +2 -2
- package/dist/src/reply-dispatcher.js +143 -40
- package/dist/src/runtime.js +813 -109
- package/dist/src/storage.js +636 -0
- package/dist/src/tools-schema.js +70 -10
- package/dist/src/tools.js +600 -112
- package/dist/src/ws-alignment.js +8 -0
- package/dist/src/ws-client.js +588 -0
- package/index.ts +6 -1
- package/openclaw.plugin.json +44 -4
- package/package.json +4 -3
- package/prompts/platform.md +7 -0
- package/skills/clawchat/SKILL.md +90 -0
- package/src/api-client.test.ts +360 -15
- package/src/api-client.ts +127 -25
- package/src/api-types.test-d.ts +12 -0
- package/src/api-types.ts +71 -4
- package/src/buffered-stream.test.ts +1 -1
- package/src/buffered-stream.ts +1 -1
- package/src/channel.outbound.test.ts +270 -60
- package/src/channel.setup.ts +9 -18
- package/src/channel.test.ts +33 -25
- package/src/channel.ts +5 -7
- package/src/clawchat-memory.test.ts +372 -0
- package/src/clawchat-memory.ts +363 -0
- package/src/clawchat-metadata.test.ts +350 -0
- package/src/clawchat-metadata.ts +352 -0
- package/src/client.test.ts +57 -48
- package/src/client.ts +37 -129
- package/src/commands.test.ts +2 -2
- package/src/commands.ts +3 -3
- package/src/config.test.ts +169 -4
- package/src/config.ts +86 -6
- package/src/group-message-coalescer.test.ts +223 -0
- package/src/group-message-coalescer.ts +154 -0
- package/src/inbound.test.ts +106 -19
- package/src/inbound.ts +31 -35
- package/src/login.runtime.test.ts +294 -11
- package/src/login.runtime.ts +90 -21
- package/src/manifest.test.ts +86 -14
- package/src/media-runtime.test.ts +31 -2
- package/src/media-runtime.ts +7 -10
- package/src/message-mapper.test.ts +2 -2
- package/src/message-mapper.ts +2 -2
- package/src/mock-transport.test.ts +35 -0
- package/src/mock-transport.ts +38 -0
- package/src/outbound.test.ts +811 -95
- package/src/outbound.ts +332 -65
- package/src/plugin-entry.test.ts +3 -1
- package/src/plugin-prompts.test.ts +78 -0
- package/src/plugin-prompts.ts +92 -0
- package/src/profile-prompt.test.ts +435 -0
- package/src/profile-prompt.ts +208 -0
- package/src/profile-sync.test.ts +611 -0
- package/src/profile-sync.ts +268 -0
- package/src/prompt-injection.test.ts +39 -0
- package/src/prompt-injection.ts +45 -0
- package/src/protocol-types.test.ts +69 -0
- package/src/protocol-types.ts +296 -0
- package/src/protocol-types.typecheck.ts +89 -0
- package/src/protocol.ts +2 -2
- package/src/reply-dispatcher.test.ts +720 -135
- package/src/reply-dispatcher.ts +174 -42
- package/src/runtime.test.ts +3884 -337
- package/src/runtime.ts +956 -128
- package/src/storage.test.ts +692 -0
- package/src/storage.ts +989 -0
- package/src/streaming.test.ts +1 -1
- package/src/streaming.ts +1 -1
- package/src/tools-schema.ts +115 -13
- package/src/tools.test.ts +501 -10
- package/src/tools.ts +739 -133
- package/src/ws-alignment.ts +9 -0
- package/src/ws-client.test.ts +1218 -0
- package/src/ws-client.ts +662 -0
|
@@ -0,0 +1,350 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import os from "node:os";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
5
|
+
import { readClawChatMemoryFile } from "./clawchat-memory.ts";
|
|
6
|
+
import {
|
|
7
|
+
pullGroupMetadata,
|
|
8
|
+
pullOwnerMetadata,
|
|
9
|
+
pullUserMetadata,
|
|
10
|
+
pushMetadata,
|
|
11
|
+
updateMetadata,
|
|
12
|
+
} from "./clawchat-metadata.ts";
|
|
13
|
+
|
|
14
|
+
let roots: string[] = [];
|
|
15
|
+
|
|
16
|
+
function tempRoot(): string {
|
|
17
|
+
const root = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-clawchat-metadata-"));
|
|
18
|
+
roots.push(root);
|
|
19
|
+
return root;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
afterEach(() => {
|
|
23
|
+
for (const root of roots) fs.rmSync(root, { recursive: true, force: true });
|
|
24
|
+
roots = [];
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
describe("clawchat metadata mapping", () => {
|
|
28
|
+
it("maps agent detail to owner.md metadata fields including behavior", async () => {
|
|
29
|
+
const root = tempRoot();
|
|
30
|
+
const api = {
|
|
31
|
+
getAgentDetail: vi.fn().mockResolvedValue({
|
|
32
|
+
agent: {
|
|
33
|
+
id: "agent_row_1",
|
|
34
|
+
user_id: "usr_self",
|
|
35
|
+
owner_id: "usr_owner",
|
|
36
|
+
type: "agent",
|
|
37
|
+
nickname: "Hermes\nBot",
|
|
38
|
+
avatar_url: "https://example.test/hermes.png",
|
|
39
|
+
bio: "Line 1\r\nLine 2",
|
|
40
|
+
behavior: "Be concise\rBe kind",
|
|
41
|
+
visibility: "public",
|
|
42
|
+
status: "online",
|
|
43
|
+
raw: { ignored: true },
|
|
44
|
+
updated_at: "2026-05-24T10:00:00.000Z",
|
|
45
|
+
},
|
|
46
|
+
}),
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
const result = await pullOwnerMetadata({
|
|
50
|
+
memoryRoot: root,
|
|
51
|
+
agentId: "agent_row_1",
|
|
52
|
+
accountUserId: "usr_self",
|
|
53
|
+
accountOwnerUserId: "usr_owner",
|
|
54
|
+
api,
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
expect(result).toMatchObject({ ok: true, failures: [] });
|
|
58
|
+
expect(api.getAgentDetail).toHaveBeenCalledWith("agent_row_1");
|
|
59
|
+
await expect(readClawChatMemoryFile(root, { targetType: "owner", targetId: "owner" }))
|
|
60
|
+
.resolves.toMatchObject({
|
|
61
|
+
metadata: {
|
|
62
|
+
updated_at: "2026-05-24T10:00:00.000Z",
|
|
63
|
+
agent_id: "usr_self",
|
|
64
|
+
owner_id: "usr_owner",
|
|
65
|
+
nickname: "Hermes Bot",
|
|
66
|
+
avatar_url: "https://example.test/hermes.png",
|
|
67
|
+
bio: "Line 1 Line 2",
|
|
68
|
+
behavior: "Be concise Be kind",
|
|
69
|
+
},
|
|
70
|
+
});
|
|
71
|
+
const file = fs.readFileSync(path.join(root, "owner.md"), "utf8");
|
|
72
|
+
expect(file).not.toContain("visibility");
|
|
73
|
+
expect(file).not.toContain("status");
|
|
74
|
+
expect(file).not.toContain("raw");
|
|
75
|
+
expect(file).not.toContain("type:");
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it("maps user profile to users/<id>.md metadata fields and drops unknown fields", async () => {
|
|
79
|
+
const root = tempRoot();
|
|
80
|
+
const api = {
|
|
81
|
+
getUserProfile: vi.fn().mockResolvedValue({
|
|
82
|
+
id: "usr_peer",
|
|
83
|
+
type: "user",
|
|
84
|
+
nickname: "Peer",
|
|
85
|
+
avatar_url: "https://example.test/peer.png",
|
|
86
|
+
bio: "",
|
|
87
|
+
display_name: "Dropped display name",
|
|
88
|
+
kind: "dropped",
|
|
89
|
+
updated_at: "2026-05-24T10:01:00.000Z",
|
|
90
|
+
}),
|
|
91
|
+
};
|
|
92
|
+
|
|
93
|
+
await pullUserMetadata({ memoryRoot: root, userId: "usr_peer", api });
|
|
94
|
+
|
|
95
|
+
const file = await readClawChatMemoryFile(root, { targetType: "user", targetId: "usr_peer" });
|
|
96
|
+
expect(file.metadata).toEqual({
|
|
97
|
+
updated_at: "2026-05-24T10:01:00.000Z",
|
|
98
|
+
id: "usr_peer",
|
|
99
|
+
nickname: "Peer",
|
|
100
|
+
avatar_url: "https://example.test/peer.png",
|
|
101
|
+
bio: "",
|
|
102
|
+
profile_type: "user",
|
|
103
|
+
});
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
it("maps conversation detail to groups/<id>.md and participant users without writing participants into group metadata", async () => {
|
|
107
|
+
const root = tempRoot();
|
|
108
|
+
const api = {
|
|
109
|
+
getConversation: vi.fn().mockResolvedValue({
|
|
110
|
+
conversation: {
|
|
111
|
+
id: "grp_1",
|
|
112
|
+
type: "group",
|
|
113
|
+
title: "Launch\nRoom",
|
|
114
|
+
description: "Planning\r\nNotes",
|
|
115
|
+
creator_id: "usr_owner",
|
|
116
|
+
created_at: "2026-05-24T09:59:00.000Z",
|
|
117
|
+
updated_at: "2026-05-24T10:02:00.000Z",
|
|
118
|
+
visibility: "private",
|
|
119
|
+
participants: [
|
|
120
|
+
{
|
|
121
|
+
conversation_id: "grp_1",
|
|
122
|
+
user_id: "usr_participant",
|
|
123
|
+
role: "member",
|
|
124
|
+
joined_at: "2026-05-24T10:03:00.000Z",
|
|
125
|
+
},
|
|
126
|
+
],
|
|
127
|
+
},
|
|
128
|
+
}),
|
|
129
|
+
getUserProfile: vi.fn().mockResolvedValue({
|
|
130
|
+
id: "usr_participant",
|
|
131
|
+
nickname: "Participant",
|
|
132
|
+
avatar_url: "https://example.test/usr-participant.png",
|
|
133
|
+
bio: "Product lead",
|
|
134
|
+
type: "user",
|
|
135
|
+
}),
|
|
136
|
+
};
|
|
137
|
+
|
|
138
|
+
const result = await pullGroupMetadata({ memoryRoot: root, groupId: "grp_1", api });
|
|
139
|
+
|
|
140
|
+
expect(result).toMatchObject({ ok: true, failures: [] });
|
|
141
|
+
expect(api.getUserProfile).toHaveBeenCalledWith("usr_participant");
|
|
142
|
+
const groupFile = await readClawChatMemoryFile(root, { targetType: "group", targetId: "grp_1" });
|
|
143
|
+
expect(groupFile.metadata).toEqual({
|
|
144
|
+
updated_at: "2026-05-24T10:02:00.000Z",
|
|
145
|
+
id: "grp_1",
|
|
146
|
+
type: "group",
|
|
147
|
+
title: "Launch Room",
|
|
148
|
+
description: "Planning Notes",
|
|
149
|
+
creator_id: "usr_owner",
|
|
150
|
+
created_at: "2026-05-24T09:59:00.000Z",
|
|
151
|
+
});
|
|
152
|
+
expect(groupFile.raw).not.toContain("participants");
|
|
153
|
+
expect(groupFile.raw).not.toContain("usr_participant");
|
|
154
|
+
|
|
155
|
+
await expect(readClawChatMemoryFile(root, { targetType: "user", targetId: "usr_participant" }))
|
|
156
|
+
.resolves.toMatchObject({
|
|
157
|
+
metadata: {
|
|
158
|
+
id: "usr_participant",
|
|
159
|
+
nickname: "Participant",
|
|
160
|
+
avatar_url: "https://example.test/usr-participant.png",
|
|
161
|
+
bio: "Product lead",
|
|
162
|
+
profile_type: "user",
|
|
163
|
+
},
|
|
164
|
+
});
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
it("writes requested group target even when the response id differs", async () => {
|
|
168
|
+
const root = tempRoot();
|
|
169
|
+
const api = {
|
|
170
|
+
getConversation: vi.fn().mockResolvedValue({
|
|
171
|
+
conversation: {
|
|
172
|
+
id: "grp_response",
|
|
173
|
+
type: "group",
|
|
174
|
+
title: "Launch Room",
|
|
175
|
+
participants: [],
|
|
176
|
+
},
|
|
177
|
+
}),
|
|
178
|
+
};
|
|
179
|
+
|
|
180
|
+
await pullGroupMetadata({ memoryRoot: root, groupId: "grp_requested", api });
|
|
181
|
+
|
|
182
|
+
await expect(readClawChatMemoryFile(root, { targetType: "group", targetId: "grp_requested" }))
|
|
183
|
+
.resolves.toMatchObject({
|
|
184
|
+
exists: true,
|
|
185
|
+
metadata: { id: "grp_requested", type: "group", title: "Launch Room" },
|
|
186
|
+
});
|
|
187
|
+
await expect(readClawChatMemoryFile(root, { targetType: "group", targetId: "grp_response" }))
|
|
188
|
+
.resolves.toMatchObject({ exists: false });
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
it("does not overwrite an existing participant user metadata file during group pull", async () => {
|
|
192
|
+
const root = tempRoot();
|
|
193
|
+
fs.mkdirSync(path.join(root, "users"), { recursive: true });
|
|
194
|
+
fs.writeFileSync(
|
|
195
|
+
path.join(root, "users", "usr_participant.md"),
|
|
196
|
+
[
|
|
197
|
+
"<!-- clawchat:metadata:start -->",
|
|
198
|
+
"id: usr_participant",
|
|
199
|
+
"nickname: Existing",
|
|
200
|
+
"bio: Existing bio",
|
|
201
|
+
"<!-- clawchat:metadata:end -->",
|
|
202
|
+
"",
|
|
203
|
+
"Existing body.",
|
|
204
|
+
].join("\n"),
|
|
205
|
+
);
|
|
206
|
+
const api = {
|
|
207
|
+
getConversation: vi.fn().mockResolvedValue({
|
|
208
|
+
conversation: {
|
|
209
|
+
id: "grp_1",
|
|
210
|
+
type: "group",
|
|
211
|
+
title: "Launch Room",
|
|
212
|
+
participants: [{ user_id: "usr_participant", role: "member" }],
|
|
213
|
+
},
|
|
214
|
+
}),
|
|
215
|
+
getUserProfile: vi.fn(),
|
|
216
|
+
};
|
|
217
|
+
|
|
218
|
+
await pullGroupMetadata({ memoryRoot: root, groupId: "grp_1", api });
|
|
219
|
+
|
|
220
|
+
expect(api.getUserProfile).not.toHaveBeenCalled();
|
|
221
|
+
await expect(readClawChatMemoryFile(root, { targetType: "user", targetId: "usr_participant" }))
|
|
222
|
+
.resolves.toMatchObject({
|
|
223
|
+
metadata: { id: "usr_participant", nickname: "Existing", bio: "Existing bio" },
|
|
224
|
+
body: "Existing body.",
|
|
225
|
+
});
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
it("returns partial participant write failures without rolling back group metadata", async () => {
|
|
229
|
+
const root = tempRoot();
|
|
230
|
+
fs.symlinkSync(os.tmpdir(), path.join(root, "users"));
|
|
231
|
+
const api = {
|
|
232
|
+
getConversation: vi.fn().mockResolvedValue({
|
|
233
|
+
conversation: {
|
|
234
|
+
id: "grp_partial",
|
|
235
|
+
type: "group",
|
|
236
|
+
title: "Partial",
|
|
237
|
+
creator_id: "usr_owner",
|
|
238
|
+
created_at: "2026-05-24T09:59:00.000Z",
|
|
239
|
+
updated_at: "2026-05-24T10:02:00.000Z",
|
|
240
|
+
participants: [{ user_id: "usr_blocked", user: { id: "usr_blocked", nickname: "Blocked" } }],
|
|
241
|
+
},
|
|
242
|
+
}),
|
|
243
|
+
getUserProfile: vi.fn().mockRejectedValue(new Error("user unavailable")),
|
|
244
|
+
};
|
|
245
|
+
|
|
246
|
+
const result = await pullGroupMetadata({ memoryRoot: root, groupId: "grp_partial", api });
|
|
247
|
+
|
|
248
|
+
expect(result.ok).toBe(false);
|
|
249
|
+
expect(result.failures).toEqual([
|
|
250
|
+
expect.objectContaining({ targetType: "user", targetId: "usr_blocked" }),
|
|
251
|
+
]);
|
|
252
|
+
await expect(readClawChatMemoryFile(root, { targetType: "group", targetId: "grp_partial" }))
|
|
253
|
+
.resolves.toMatchObject({
|
|
254
|
+
metadata: expect.objectContaining({ id: "grp_partial", title: "Partial" }),
|
|
255
|
+
});
|
|
256
|
+
});
|
|
257
|
+
});
|
|
258
|
+
|
|
259
|
+
describe("clawchat metadata push and update", () => {
|
|
260
|
+
let root: string;
|
|
261
|
+
|
|
262
|
+
beforeEach(() => {
|
|
263
|
+
root = tempRoot();
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
it("pushes whitelisted owner metadata to the server before rewriting owner.md from the response", async () => {
|
|
267
|
+
fs.writeFileSync(
|
|
268
|
+
path.join(root, "owner.md"),
|
|
269
|
+
[
|
|
270
|
+
"<!-- clawchat:metadata:start -->",
|
|
271
|
+
"nickname: Local",
|
|
272
|
+
"avatar_url: https://example.test/local.png",
|
|
273
|
+
"bio: Local bio",
|
|
274
|
+
"behavior: local-only",
|
|
275
|
+
"<!-- clawchat:metadata:end -->",
|
|
276
|
+
"",
|
|
277
|
+
"Keep body.",
|
|
278
|
+
].join("\n"),
|
|
279
|
+
);
|
|
280
|
+
const api = {
|
|
281
|
+
patchAgent: vi.fn().mockResolvedValue({
|
|
282
|
+
agent: {
|
|
283
|
+
id: "agent_row_1",
|
|
284
|
+
user_id: "usr_self",
|
|
285
|
+
owner_id: "usr_owner",
|
|
286
|
+
nickname: "Server",
|
|
287
|
+
avatar_url: "https://example.test/server.png",
|
|
288
|
+
bio: "Server bio",
|
|
289
|
+
behavior: "Server behavior",
|
|
290
|
+
updated_at: "2026-05-24T10:05:00.000Z",
|
|
291
|
+
},
|
|
292
|
+
}),
|
|
293
|
+
};
|
|
294
|
+
|
|
295
|
+
await pushMetadata({
|
|
296
|
+
memoryRoot: root,
|
|
297
|
+
targetType: "owner",
|
|
298
|
+
targetId: "owner",
|
|
299
|
+
fields: ["nickname", "avatar_url", "bio", "behavior"],
|
|
300
|
+
agentId: "agent_row_1",
|
|
301
|
+
accountUserId: "usr_self",
|
|
302
|
+
api,
|
|
303
|
+
});
|
|
304
|
+
|
|
305
|
+
expect(api.patchAgent).toHaveBeenCalledWith("agent_row_1", {
|
|
306
|
+
nickname: "Local",
|
|
307
|
+
avatar_url: "https://example.test/local.png",
|
|
308
|
+
bio: "Local bio",
|
|
309
|
+
behavior: "local-only",
|
|
310
|
+
});
|
|
311
|
+
await expect(readClawChatMemoryFile(root, { targetType: "owner", targetId: "owner" }))
|
|
312
|
+
.resolves.toMatchObject({
|
|
313
|
+
metadata: expect.objectContaining({ nickname: "Server", behavior: "Server behavior" }),
|
|
314
|
+
body: "Keep body.",
|
|
315
|
+
});
|
|
316
|
+
});
|
|
317
|
+
|
|
318
|
+
it("updates group metadata through the server and leaves the local file unchanged on failure", async () => {
|
|
319
|
+
fs.mkdirSync(path.join(root, "groups"));
|
|
320
|
+
fs.writeFileSync(
|
|
321
|
+
path.join(root, "groups", "grp_1.md"),
|
|
322
|
+
[
|
|
323
|
+
"<!-- clawchat:metadata:start -->",
|
|
324
|
+
"id: grp_1",
|
|
325
|
+
"title: Old",
|
|
326
|
+
"<!-- clawchat:metadata:end -->",
|
|
327
|
+
"",
|
|
328
|
+
"Group body.",
|
|
329
|
+
].join("\n"),
|
|
330
|
+
);
|
|
331
|
+
const before = fs.readFileSync(path.join(root, "groups", "grp_1.md"), "utf8");
|
|
332
|
+
const api = {
|
|
333
|
+
patchConversation: vi.fn().mockRejectedValue(new Error("server refused")),
|
|
334
|
+
};
|
|
335
|
+
|
|
336
|
+
await expect(updateMetadata({
|
|
337
|
+
memoryRoot: root,
|
|
338
|
+
targetType: "group",
|
|
339
|
+
targetId: "grp_1",
|
|
340
|
+
patch: { title: "New", description: "New description", visibility: "dropped" },
|
|
341
|
+
api,
|
|
342
|
+
})).rejects.toThrow("server refused");
|
|
343
|
+
|
|
344
|
+
expect(api.patchConversation).toHaveBeenCalledWith("grp_1", {
|
|
345
|
+
title: "New",
|
|
346
|
+
description: "New description",
|
|
347
|
+
});
|
|
348
|
+
expect(fs.readFileSync(path.join(root, "groups", "grp_1.md"), "utf8")).toBe(before);
|
|
349
|
+
});
|
|
350
|
+
});
|
|
@@ -0,0 +1,352 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
AgentMetadataPatch,
|
|
3
|
+
AgentProfile,
|
|
4
|
+
ConversationDetails,
|
|
5
|
+
ConversationMetadataPatch,
|
|
6
|
+
Profile,
|
|
7
|
+
} from "./api-types.ts";
|
|
8
|
+
import {
|
|
9
|
+
readClawChatMemoryFile,
|
|
10
|
+
writeClawChatMetadata,
|
|
11
|
+
type ClawChatMemoryTargetType,
|
|
12
|
+
} from "./clawchat-memory.ts";
|
|
13
|
+
|
|
14
|
+
export interface MetadataWriteTarget {
|
|
15
|
+
targetType: ClawChatMemoryTargetType;
|
|
16
|
+
targetId: string;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface MetadataFailure extends MetadataWriteTarget {
|
|
20
|
+
error: string;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface MetadataPullResult {
|
|
24
|
+
ok: boolean;
|
|
25
|
+
writes: MetadataWriteTarget[];
|
|
26
|
+
failures: MetadataFailure[];
|
|
27
|
+
conversation?: ConversationDetails;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface MetadataPushResult extends MetadataPullResult {
|
|
31
|
+
metadata: Record<string, string>;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
type MetadataApi = {
|
|
35
|
+
getConversation?: (conversationId: string) => Promise<{ conversation: ConversationDetails }>;
|
|
36
|
+
getUserInfo?: (userId: string) => Promise<Profile>;
|
|
37
|
+
getUserProfile?: (userId: string) => Promise<Profile>;
|
|
38
|
+
getAgentDetail?: (agentId: string) => Promise<{ agent: unknown }>;
|
|
39
|
+
getAgentProfile?: (agentId: string) => Promise<{ agent: unknown }>;
|
|
40
|
+
patchAgent?: (agentId: string, patch: AgentMetadataPatch) => Promise<{ agent: AgentProfile }>;
|
|
41
|
+
patchConversation?: (
|
|
42
|
+
conversationId: string,
|
|
43
|
+
patch: ConversationMetadataPatch,
|
|
44
|
+
) => Promise<{ conversation: ConversationDetails }>;
|
|
45
|
+
updateMyProfile?: (patch: { nickname?: string; avatar_url?: string; bio?: string }) => Promise<Profile>;
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
function asRecord(value: unknown): Record<string, unknown> | null {
|
|
49
|
+
return value && typeof value === "object" ? value as Record<string, unknown> : null;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function hasOwn(record: Record<string, unknown>, key: string): boolean {
|
|
53
|
+
return Object.prototype.hasOwnProperty.call(record, key);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function stringField(record: Record<string, unknown>, key: string): string | undefined {
|
|
57
|
+
if (!hasOwn(record, key)) return undefined;
|
|
58
|
+
const value = record[key];
|
|
59
|
+
return typeof value === "string" ? value : undefined;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function addStringField(
|
|
63
|
+
metadata: Record<string, string>,
|
|
64
|
+
outputKey: string,
|
|
65
|
+
record: Record<string, unknown>,
|
|
66
|
+
inputKey = outputKey,
|
|
67
|
+
): void {
|
|
68
|
+
const value = stringField(record, inputKey);
|
|
69
|
+
if (value !== undefined) metadata[outputKey] = value;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function errorMessage(error: unknown): string {
|
|
73
|
+
return error instanceof Error ? error.message : String(error);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function ownerMetadataFromAgent(
|
|
77
|
+
agent: Record<string, unknown>,
|
|
78
|
+
params: { accountUserId?: string; accountOwnerUserId?: string },
|
|
79
|
+
): Record<string, string> {
|
|
80
|
+
const metadata: Record<string, string> = {};
|
|
81
|
+
addStringField(metadata, "updated_at", agent);
|
|
82
|
+
const agentId = stringField(agent, "user_id") ?? params.accountUserId;
|
|
83
|
+
if (agentId !== undefined) metadata.agent_id = agentId;
|
|
84
|
+
const ownerId = stringField(agent, "owner_id") ?? params.accountOwnerUserId;
|
|
85
|
+
if (ownerId !== undefined) metadata.owner_id = ownerId;
|
|
86
|
+
addStringField(metadata, "nickname", agent);
|
|
87
|
+
addStringField(metadata, "avatar_url", agent);
|
|
88
|
+
addStringField(metadata, "bio", agent);
|
|
89
|
+
addStringField(metadata, "behavior", agent);
|
|
90
|
+
return metadata;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function userMetadataFromRecord(
|
|
94
|
+
record: Record<string, unknown>,
|
|
95
|
+
targetUserId: string,
|
|
96
|
+
): Record<string, string> | null {
|
|
97
|
+
const metadata: Record<string, string> = {};
|
|
98
|
+
addStringField(metadata, "updated_at", record);
|
|
99
|
+
metadata.id = targetUserId;
|
|
100
|
+
addStringField(metadata, "nickname", record);
|
|
101
|
+
addStringField(metadata, "avatar_url", record);
|
|
102
|
+
addStringField(metadata, "bio", record);
|
|
103
|
+
addStringField(metadata, "profile_type", record, "type");
|
|
104
|
+
return metadata;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function userMetadataFromProfile(profile: Profile, userId: string): Record<string, string> {
|
|
108
|
+
const metadata = userMetadataFromRecord(profile as Profile & Record<string, unknown>, userId);
|
|
109
|
+
if (metadata === null) {
|
|
110
|
+
throw new Error("ClawChat user metadata response is missing id");
|
|
111
|
+
}
|
|
112
|
+
return metadata;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function participantUserMetadata(participant: Record<string, unknown>): { userId: string; metadata: Record<string, string> } | null {
|
|
116
|
+
const userRecord = asRecord(participant.user);
|
|
117
|
+
const source = userRecord ?? participant;
|
|
118
|
+
const userId = stringField(source, "id") ?? stringField(participant, "user_id");
|
|
119
|
+
if (!userId) return null;
|
|
120
|
+
const metadata = userMetadataFromRecord(source, userId);
|
|
121
|
+
return metadata ? { userId, metadata } : null;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function groupMetadataFromConversation(conversation: ConversationDetails, groupId: string): Record<string, string> {
|
|
125
|
+
const record = conversation as ConversationDetails & Record<string, unknown>;
|
|
126
|
+
const metadata: Record<string, string> = {};
|
|
127
|
+
addStringField(metadata, "updated_at", record);
|
|
128
|
+
metadata.id = groupId;
|
|
129
|
+
addStringField(metadata, "type", record);
|
|
130
|
+
addStringField(metadata, "title", record);
|
|
131
|
+
addStringField(metadata, "description", record);
|
|
132
|
+
addStringField(metadata, "creator_id", record);
|
|
133
|
+
addStringField(metadata, "created_at", record);
|
|
134
|
+
return metadata;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function pickUpdatePatch<T extends Record<string, string>>(
|
|
138
|
+
patchInput: Record<string, unknown>,
|
|
139
|
+
keys: readonly (keyof T & string)[],
|
|
140
|
+
): T {
|
|
141
|
+
const patch: Record<string, string> = {};
|
|
142
|
+
for (const key of keys) {
|
|
143
|
+
if (hasOwn(patchInput, key) && typeof patchInput[key] === "string") {
|
|
144
|
+
patch[key] = patchInput[key];
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
if (Object.keys(patch).length === 0) {
|
|
148
|
+
throw new Error("ClawChat metadata update must include at least one mutable field");
|
|
149
|
+
}
|
|
150
|
+
return patch as T;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
export async function pullOwnerMetadata(params: {
|
|
154
|
+
memoryRoot: string;
|
|
155
|
+
agentId: string;
|
|
156
|
+
accountUserId?: string;
|
|
157
|
+
accountOwnerUserId?: string;
|
|
158
|
+
api: MetadataApi;
|
|
159
|
+
}): Promise<MetadataPullResult> {
|
|
160
|
+
const getAgent = params.api.getAgentDetail ?? params.api.getAgentProfile;
|
|
161
|
+
if (!getAgent) throw new Error("ClawChat metadata pull requires getAgentDetail");
|
|
162
|
+
if (!params.agentId) throw new Error("ClawChat owner metadata pull requires agentId");
|
|
163
|
+
const data = await getAgent(params.agentId);
|
|
164
|
+
const metadata = ownerMetadataFromAgent(asRecord(data.agent) ?? {}, params);
|
|
165
|
+
await writeClawChatMetadata(params.memoryRoot, { targetType: "owner", targetId: "owner" }, metadata);
|
|
166
|
+
return { ok: true, writes: [{ targetType: "owner", targetId: "owner" }], failures: [] };
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
export async function pullUserMetadata(params: {
|
|
170
|
+
memoryRoot: string;
|
|
171
|
+
userId: string;
|
|
172
|
+
api: MetadataApi;
|
|
173
|
+
}): Promise<MetadataPullResult> {
|
|
174
|
+
const getUser = params.api.getUserProfile ?? params.api.getUserInfo;
|
|
175
|
+
if (!getUser) throw new Error("ClawChat metadata pull requires getUserProfile");
|
|
176
|
+
const profile = await getUser(params.userId);
|
|
177
|
+
const metadata = userMetadataFromProfile(profile, params.userId);
|
|
178
|
+
await writeClawChatMetadata(params.memoryRoot, { targetType: "user", targetId: params.userId }, metadata);
|
|
179
|
+
return { ok: true, writes: [{ targetType: "user", targetId: params.userId }], failures: [] };
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
export async function pullGroupMetadata(params: {
|
|
183
|
+
memoryRoot: string;
|
|
184
|
+
groupId: string;
|
|
185
|
+
api: MetadataApi;
|
|
186
|
+
}): Promise<MetadataPullResult> {
|
|
187
|
+
if (!params.api.getConversation) throw new Error("ClawChat metadata pull requires getConversation");
|
|
188
|
+
const getUser = params.api.getUserProfile ?? params.api.getUserInfo;
|
|
189
|
+
const data = await params.api.getConversation(params.groupId);
|
|
190
|
+
const writes: MetadataWriteTarget[] = [];
|
|
191
|
+
const failures: MetadataFailure[] = [];
|
|
192
|
+
|
|
193
|
+
await writeClawChatMetadata(
|
|
194
|
+
params.memoryRoot,
|
|
195
|
+
{ targetType: "group", targetId: params.groupId },
|
|
196
|
+
groupMetadataFromConversation(data.conversation, params.groupId),
|
|
197
|
+
);
|
|
198
|
+
writes.push({ targetType: "group", targetId: params.groupId });
|
|
199
|
+
|
|
200
|
+
const participants = Array.isArray(data.conversation.participants)
|
|
201
|
+
? data.conversation.participants
|
|
202
|
+
: [];
|
|
203
|
+
for (const participant of participants) {
|
|
204
|
+
const mapped = participantUserMetadata(participant as Record<string, unknown>);
|
|
205
|
+
if (!mapped) continue;
|
|
206
|
+
try {
|
|
207
|
+
const existing = await readClawChatMemoryFile(params.memoryRoot, {
|
|
208
|
+
targetType: "user",
|
|
209
|
+
targetId: mapped.userId,
|
|
210
|
+
});
|
|
211
|
+
if (existing.exists) continue;
|
|
212
|
+
if (!getUser) throw new Error("ClawChat participant metadata pull requires getUserProfile");
|
|
213
|
+
const profile = await getUser(mapped.userId);
|
|
214
|
+
const metadata = userMetadataFromProfile(profile, mapped.userId);
|
|
215
|
+
await writeClawChatMetadata(
|
|
216
|
+
params.memoryRoot,
|
|
217
|
+
{ targetType: "user", targetId: mapped.userId },
|
|
218
|
+
metadata,
|
|
219
|
+
);
|
|
220
|
+
writes.push({ targetType: "user", targetId: mapped.userId });
|
|
221
|
+
} catch (error) {
|
|
222
|
+
failures.push({ targetType: "user", targetId: mapped.userId, error: errorMessage(error) });
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
return { ok: failures.length === 0, writes, failures, conversation: data.conversation };
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
export async function pushMetadata(params: {
|
|
230
|
+
memoryRoot: string;
|
|
231
|
+
targetType: ClawChatMemoryTargetType;
|
|
232
|
+
targetId: string;
|
|
233
|
+
fields: string[];
|
|
234
|
+
agentId?: string;
|
|
235
|
+
accountUserId: string;
|
|
236
|
+
api: MetadataApi;
|
|
237
|
+
}): Promise<MetadataPushResult> {
|
|
238
|
+
const file = await readClawChatMemoryFile(params.memoryRoot, {
|
|
239
|
+
targetType: params.targetType,
|
|
240
|
+
targetId: params.targetId,
|
|
241
|
+
});
|
|
242
|
+
return await updateMetadata({
|
|
243
|
+
memoryRoot: params.memoryRoot,
|
|
244
|
+
targetType: params.targetType,
|
|
245
|
+
targetId: params.targetId,
|
|
246
|
+
agentId: params.agentId,
|
|
247
|
+
accountUserId: params.accountUserId,
|
|
248
|
+
patch: pickPushPatch(params.targetType, params.targetId, file.metadata, {
|
|
249
|
+
fields: params.fields,
|
|
250
|
+
agentId: params.agentId,
|
|
251
|
+
accountUserId: params.accountUserId,
|
|
252
|
+
}),
|
|
253
|
+
api: params.api,
|
|
254
|
+
});
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
function mutableFieldsForTarget(
|
|
258
|
+
targetType: ClawChatMemoryTargetType,
|
|
259
|
+
targetId: string,
|
|
260
|
+
params: { agentId?: string; accountUserId?: string },
|
|
261
|
+
): readonly string[] {
|
|
262
|
+
if (targetType === "owner") {
|
|
263
|
+
if (targetId !== "owner") throw new Error("ClawChat owner metadata targetId must be owner");
|
|
264
|
+
if (!params.agentId) throw new Error("ClawChat owner metadata update requires agentId");
|
|
265
|
+
return ["nickname", "avatar_url", "bio", "behavior"];
|
|
266
|
+
}
|
|
267
|
+
if (targetType === "user") {
|
|
268
|
+
if (!params.accountUserId) throw new Error("ClawChat user metadata update requires accountUserId");
|
|
269
|
+
if (targetId !== params.accountUserId) {
|
|
270
|
+
throw new Error("ClawChat user metadata update is allowed only for the connected user");
|
|
271
|
+
}
|
|
272
|
+
return ["nickname", "avatar_url", "bio"];
|
|
273
|
+
}
|
|
274
|
+
return ["title", "description"];
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
function pickPushPatch(
|
|
278
|
+
targetType: ClawChatMemoryTargetType,
|
|
279
|
+
targetId: string,
|
|
280
|
+
metadata: Record<string, string>,
|
|
281
|
+
params: { fields: string[]; agentId?: string; accountUserId?: string },
|
|
282
|
+
): Record<string, string> {
|
|
283
|
+
if (!Array.isArray(params.fields) || params.fields.length === 0) {
|
|
284
|
+
throw new Error("fields are required for metadata push");
|
|
285
|
+
}
|
|
286
|
+
const allowed = mutableFieldsForTarget(targetType, targetId, params);
|
|
287
|
+
const patch: Record<string, string> = {};
|
|
288
|
+
for (const field of params.fields) {
|
|
289
|
+
if (typeof field !== "string" || field.length === 0) {
|
|
290
|
+
throw new Error("fields must contain non-empty strings");
|
|
291
|
+
}
|
|
292
|
+
if (!allowed.includes(field)) {
|
|
293
|
+
throw new Error(`fields contain non-pushable metadata field: ${field}`);
|
|
294
|
+
}
|
|
295
|
+
if (!hasOwn(metadata, field)) {
|
|
296
|
+
throw new Error(`missing_metadata_field: ${field}`);
|
|
297
|
+
}
|
|
298
|
+
patch[field] = metadata[field]!;
|
|
299
|
+
}
|
|
300
|
+
return patch;
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
export async function updateMetadata(params: {
|
|
304
|
+
memoryRoot: string;
|
|
305
|
+
targetType: ClawChatMemoryTargetType;
|
|
306
|
+
targetId: string;
|
|
307
|
+
agentId?: string;
|
|
308
|
+
accountUserId?: string;
|
|
309
|
+
patch: Record<string, unknown>;
|
|
310
|
+
api: MetadataApi;
|
|
311
|
+
}): Promise<MetadataPushResult> {
|
|
312
|
+
if (params.targetType === "owner") {
|
|
313
|
+
if (!params.agentId) throw new Error("ClawChat owner metadata update requires agentId");
|
|
314
|
+
if (!params.api.patchAgent) throw new Error("ClawChat owner metadata update requires patchAgent");
|
|
315
|
+
const patch = pickUpdatePatch<AgentMetadataPatch & Record<string, string>>(
|
|
316
|
+
params.patch,
|
|
317
|
+
["nickname", "avatar_url", "bio", "behavior"],
|
|
318
|
+
);
|
|
319
|
+
const response = await params.api.patchAgent(params.agentId, patch);
|
|
320
|
+
const metadata = ownerMetadataFromAgent(response.agent as AgentProfile & Record<string, unknown>, {
|
|
321
|
+
accountUserId: params.accountUserId,
|
|
322
|
+
});
|
|
323
|
+
await writeClawChatMetadata(params.memoryRoot, { targetType: "owner", targetId: "owner" }, metadata);
|
|
324
|
+
return { ok: true, writes: [{ targetType: "owner", targetId: "owner" }], failures: [], metadata };
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
if (params.targetType === "user") {
|
|
328
|
+
if (!params.accountUserId) throw new Error("ClawChat user metadata update requires accountUserId");
|
|
329
|
+
if (params.targetId !== params.accountUserId) {
|
|
330
|
+
throw new Error("ClawChat user metadata update is allowed only for the connected user");
|
|
331
|
+
}
|
|
332
|
+
if (!params.api.updateMyProfile) throw new Error("ClawChat user metadata update requires updateMyProfile");
|
|
333
|
+
const patch = pickUpdatePatch<{ nickname?: string; avatar_url?: string; bio?: string } & Record<string, string>>(
|
|
334
|
+
params.patch,
|
|
335
|
+
["nickname", "avatar_url", "bio"],
|
|
336
|
+
);
|
|
337
|
+
const profile = await params.api.updateMyProfile(patch);
|
|
338
|
+
const metadata = userMetadataFromProfile(profile, params.targetId);
|
|
339
|
+
await writeClawChatMetadata(params.memoryRoot, { targetType: "user", targetId: params.targetId }, metadata);
|
|
340
|
+
return { ok: true, writes: [{ targetType: "user", targetId: params.targetId }], failures: [], metadata };
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
if (!params.api.patchConversation) throw new Error("ClawChat group metadata update requires patchConversation");
|
|
344
|
+
const patch = pickUpdatePatch<ConversationMetadataPatch & Record<string, string>>(
|
|
345
|
+
params.patch,
|
|
346
|
+
["title", "description"],
|
|
347
|
+
);
|
|
348
|
+
const response = await params.api.patchConversation(params.targetId, patch);
|
|
349
|
+
const metadata = groupMetadataFromConversation(response.conversation, params.targetId);
|
|
350
|
+
await writeClawChatMetadata(params.memoryRoot, { targetType: "group", targetId: params.targetId }, metadata);
|
|
351
|
+
return { ok: true, writes: [{ targetType: "group", targetId: params.targetId }], failures: [], metadata };
|
|
352
|
+
}
|