openclaw-plugin-people-memory 0.0.0-stage → 0.0.2

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.
@@ -0,0 +1,465 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import { parsePlatformFolder } from "./platform.js";
4
+ import { nowIso } from "./time.js";
5
+ function isObject(value) {
6
+ return Boolean(value) && typeof value === "object" && !Array.isArray(value);
7
+ }
8
+ function readJson(file, warnings) {
9
+ try {
10
+ return JSON.parse(fs.readFileSync(file, "utf8"));
11
+ }
12
+ catch (error) {
13
+ warnings.push(`无法读取 ${file}: ${error.message}`);
14
+ return undefined;
15
+ }
16
+ }
17
+ function listJsonFiles(dir) {
18
+ if (!fs.existsSync(dir))
19
+ return [];
20
+ const output = [];
21
+ const walk = (current) => {
22
+ for (const entry of fs.readdirSync(current, { withFileTypes: true })) {
23
+ if (entry.name.startsWith(".") || entry.name.endsWith(".bak"))
24
+ continue;
25
+ const full = path.join(current, entry.name);
26
+ if (entry.isDirectory())
27
+ walk(full);
28
+ else if (entry.isFile() && entry.name.endsWith(".json"))
29
+ output.push(full);
30
+ }
31
+ };
32
+ walk(dir);
33
+ return output;
34
+ }
35
+ function text(value) {
36
+ return typeof value === "string" && value.trim() ? value.trim() : null;
37
+ }
38
+ function entries(value, key) {
39
+ if (!isObject(value))
40
+ return {};
41
+ if (isObject(value[key]))
42
+ return value[key];
43
+ return Object.fromEntries(Object.entries(value).filter(([entryKey]) => !entryKey.startsWith("_")));
44
+ }
45
+ function impressionText(value) {
46
+ if (typeof value === "string")
47
+ return text(value);
48
+ if (!isObject(value))
49
+ return null;
50
+ return text(value.content) ?? text(value.text) ?? text(value.summary);
51
+ }
52
+ function impressionMeta(value) {
53
+ if (!isObject(value))
54
+ return {};
55
+ return {
56
+ createdAt: text(value.created_at) ?? text(value.createdAt) ?? undefined,
57
+ agentId: text(value.agent_id) ?? text(value.agentId) ?? undefined,
58
+ source: text(value.source) ?? undefined,
59
+ };
60
+ }
61
+ function inferAccountFromPath(file) {
62
+ const senderFile = path.basename(file, ".json");
63
+ const folder = path.basename(path.dirname(file));
64
+ if (!folder || folder === "accounts")
65
+ return null;
66
+ return { platform: parsePlatformFolder(folder), senderId: senderFile };
67
+ }
68
+ function inferGroupFromPath(file) {
69
+ const name = path.basename(file, ".json");
70
+ const colon = name.split(":");
71
+ if (colon.length >= 3) {
72
+ const channel = colon[0] ?? "";
73
+ const account = colon[1] ?? "";
74
+ const chatId = colon[2] ?? "";
75
+ if (chatId)
76
+ return { platform: `${channel}/${account}`, chatId };
77
+ }
78
+ const underscore = /^([^_]+)_([^_]+)_(.+)$/.exec(name);
79
+ if (underscore) {
80
+ return { platform: `${underscore[1]}/${underscore[2]}`, chatId: underscore[3] ?? "" };
81
+ }
82
+ return null;
83
+ }
84
+ export function extractChatId(groupId, platform) {
85
+ const value = groupId.trim();
86
+ const prefix = `${platform.replace(/\//g, ":")}:`;
87
+ let chat = value;
88
+ if (value.toLowerCase().startsWith(prefix.toLowerCase())) {
89
+ chat = value.slice(prefix.length);
90
+ }
91
+ else {
92
+ const parts = value.split(":");
93
+ if (parts.length >= 3)
94
+ chat = parts.slice(2).join(":");
95
+ }
96
+ chat = chat.replace(/:0$/, "");
97
+ return chat || value;
98
+ }
99
+ function mergeBotNames(current, incoming) {
100
+ const list = Array.isArray(incoming)
101
+ ? incoming.filter((item) => typeof item === "string")
102
+ : [];
103
+ return [...new Set([...current, ...list].map((item) => item.trim()).filter(Boolean))];
104
+ }
105
+ export function importLegacyJson(db, options = {}) {
106
+ const dataDir = path.dirname(db.dbPath);
107
+ const report = {
108
+ imported: false,
109
+ sourceDir: dataDir,
110
+ accounts: 0,
111
+ persons: 0,
112
+ bindings: 0,
113
+ groups: 0,
114
+ groupEvents: 0,
115
+ groupMembers: 0,
116
+ impressions: 0,
117
+ traits: 0,
118
+ scanChannels: 0,
119
+ warnings: [],
120
+ };
121
+ const already = db.metaGet("legacy_imported_at");
122
+ if (already && !options.force) {
123
+ return { ...report, reason: `already imported at ${already}` };
124
+ }
125
+ if (!db.hasLegacyJson()) {
126
+ return { ...report, reason: "no legacy json found" };
127
+ }
128
+ if (db.counts().accounts > 0 && !options.force) {
129
+ return { ...report, reason: "database already has accounts" };
130
+ }
131
+ const accountsIndexFile = path.join(dataDir, "accounts.json");
132
+ const personsIndexFile = path.join(dataDir, "persons.json");
133
+ const bindingsFile = path.join(dataDir, "person_accounts.json");
134
+ const groupsIndexFile = path.join(dataDir, "groups.json");
135
+ const scanStateFile = path.join(dataDir, "scan-state.json");
136
+ const dimensionsFile = path.join(dataDir, "trait-dimensions.json");
137
+ const accountsIndex = entries(readJson(accountsIndexFile, report.warnings), "accounts");
138
+ const personsIndex = entries(readJson(personsIndexFile, report.warnings), "persons");
139
+ const bindingsIndex = entries(readJson(bindingsFile, report.warnings), "person_accounts");
140
+ const groupsIndex = entries(readJson(groupsIndexFile, report.warnings), "groups");
141
+ const scanState = readJson(scanStateFile, report.warnings);
142
+ const dimensions = readJson(dimensionsFile, report.warnings);
143
+ const accountProfiles = [];
144
+ for (const file of listJsonFiles(path.join(dataDir, "accounts"))) {
145
+ const raw = readJson(file, report.warnings);
146
+ if (!isObject(raw))
147
+ continue;
148
+ const inferred = inferAccountFromPath(file);
149
+ const platform = text(raw.platform) ?? inferred?.platform ?? "";
150
+ const senderId = text(raw.sender_id) ?? inferred?.senderId ?? "";
151
+ if (!platform || !senderId) {
152
+ report.warnings.push(`账号画像缺少 platform/sender_id: ${file}`);
153
+ continue;
154
+ }
155
+ accountProfiles.push({ platform, senderId, raw });
156
+ }
157
+ const personProfiles = new Map();
158
+ for (const file of listJsonFiles(path.join(dataDir, "persons"))) {
159
+ const raw = readJson(file, report.warnings);
160
+ if (!isObject(raw))
161
+ continue;
162
+ const personId = text(raw.person_id) ?? path.basename(file, ".json");
163
+ personProfiles.set(personId, raw);
164
+ }
165
+ const groupProfiles = [];
166
+ for (const file of listJsonFiles(path.join(dataDir, "groups"))) {
167
+ const raw = readJson(file, report.warnings);
168
+ if (isObject(raw))
169
+ groupProfiles.push({ ...raw, __file: file });
170
+ }
171
+ db.transaction(() => {
172
+ if (isObject(dimensions) && Array.isArray(dimensions.dimensions)) {
173
+ let sort = 0;
174
+ for (const item of dimensions.dimensions) {
175
+ if (!isObject(item))
176
+ continue;
177
+ const key = text(item.key);
178
+ const label = text(item.label);
179
+ if (!key || !label)
180
+ continue;
181
+ sort += 10;
182
+ db.upsertDimension(key, label, item.active !== false, sort);
183
+ }
184
+ }
185
+ // Persons first so bindings and account migration have a target.
186
+ for (const [personId, raw] of personProfiles) {
187
+ const person = db.createPerson({ personId, name: text(raw.name), createdAt: text(raw.created_at) ?? undefined });
188
+ const profile = isObject(raw.profile) ? raw.profile : {};
189
+ db.updatePerson(person.person_id, {
190
+ notes: text(profile.notes),
191
+ callName: text(raw.call_name) ?? text(profile.call_name),
192
+ });
193
+ const traits = isObject(profile.traits) ? profile.traits : {};
194
+ for (const [dimension, value] of Object.entries(traits)) {
195
+ const traitValue = text(value);
196
+ if (!traitValue)
197
+ continue;
198
+ db.setTrait("person", person.person_id, dimension, traitValue);
199
+ report.traits += 1;
200
+ }
201
+ const impressions = Array.isArray(profile.impressions) ? profile.impressions : [];
202
+ for (const impression of impressions) {
203
+ const content = impressionText(impression);
204
+ if (!content)
205
+ continue;
206
+ const meta = impressionMeta(impression);
207
+ if (db.addImpression({ ownerType: "person", ownerId: person.person_id, content, createdAt: meta.createdAt, agentId: meta.agentId, source: meta.source })) {
208
+ report.impressions += 1;
209
+ }
210
+ }
211
+ report.persons += 1;
212
+ }
213
+ for (const [personId, raw] of Object.entries(personsIndex)) {
214
+ if (!isObject(raw))
215
+ continue;
216
+ const existing = db.getPerson(personId);
217
+ if (!existing) {
218
+ db.createPerson({ personId, name: text(raw.name), createdAt: text(raw.created_at) ?? undefined });
219
+ if (text(raw.call_name))
220
+ db.updatePerson(personId, { callName: text(raw.call_name) });
221
+ report.persons += 1;
222
+ }
223
+ else if ((text(raw.name) && !existing.name) || text(raw.call_name)) {
224
+ db.updatePerson(personId, {
225
+ name: text(raw.name) && !existing.name ? text(raw.name) : undefined,
226
+ callName: text(raw.call_name),
227
+ });
228
+ }
229
+ }
230
+ // Accounts: index entries first, then profile files (profiles carry richer data).
231
+ for (const [, raw] of Object.entries(accountsIndex)) {
232
+ if (!isObject(raw))
233
+ continue;
234
+ const platform = text(raw.platform);
235
+ const senderId = text(raw.sender_id);
236
+ if (!platform || !senderId) {
237
+ report.warnings.push("accounts.json 存在缺少 platform/sender_id 的条目");
238
+ continue;
239
+ }
240
+ const result = db.upsertAccount({
241
+ platform,
242
+ senderId,
243
+ displayName: text(raw.display_name),
244
+ currentRole: text(raw.current_role),
245
+ });
246
+ if (result.created)
247
+ report.accounts += 1;
248
+ if (text(raw.confirmed_name))
249
+ db.setConfirmedName(result.account.account_id, text(raw.confirmed_name));
250
+ if (text(raw.call_name))
251
+ db.setCallName(result.account.account_id, text(raw.call_name));
252
+ }
253
+ for (const profile of accountProfiles) {
254
+ const { raw } = profile;
255
+ const platform = text(raw.platform) ?? profile.platform;
256
+ const senderId = text(raw.sender_id) ?? profile.senderId;
257
+ if (!platform || !senderId)
258
+ continue;
259
+ const account = db.upsertAccount({
260
+ platform,
261
+ senderId,
262
+ displayName: text(raw.display_name),
263
+ firstSeen: text(raw.first_seen) ?? undefined,
264
+ lastInteraction: text(raw.last_interaction) ?? undefined,
265
+ });
266
+ if (account.created)
267
+ report.accounts += 1;
268
+ if (text(raw.call_name))
269
+ db.setCallName(account.account.account_id, text(raw.call_name));
270
+ const traits = isObject(raw.traits) ? raw.traits : {};
271
+ for (const [dimension, value] of Object.entries(traits)) {
272
+ const traitValue = text(value);
273
+ if (!traitValue)
274
+ continue;
275
+ db.setTrait("account", account.account.account_id, dimension, traitValue);
276
+ report.traits += 1;
277
+ }
278
+ const impressions = Array.isArray(raw.impressions) ? raw.impressions : [];
279
+ for (const impression of impressions) {
280
+ const content = impressionText(impression);
281
+ if (!content)
282
+ continue;
283
+ const meta = impressionMeta(impression);
284
+ if (db.addImpression({ ownerType: "account", ownerId: account.account.account_id, content, createdAt: meta.createdAt, agentId: meta.agentId, source: meta.source })) {
285
+ report.impressions += 1;
286
+ }
287
+ }
288
+ }
289
+ // Bindings from index and bridge table.
290
+ const requestedBindings = new Map();
291
+ for (const [, raw] of Object.entries(accountsIndex)) {
292
+ if (!isObject(raw))
293
+ continue;
294
+ const platform = text(raw.platform);
295
+ const senderId = text(raw.sender_id);
296
+ const personId = text(raw.bound_person_id);
297
+ if (platform && senderId && personId) {
298
+ requestedBindings.set(`${platform}:${senderId}`, { personId, method: "manual", confidence: "confirmed" });
299
+ }
300
+ }
301
+ for (const [accountId, raw] of Object.entries(bindingsIndex)) {
302
+ if (!isObject(raw))
303
+ continue;
304
+ const personId = text(raw.person_id);
305
+ if (!personId)
306
+ continue;
307
+ requestedBindings.set(accountId, {
308
+ personId,
309
+ method: text(raw.bind_method) ?? "manual",
310
+ confidence: text(raw.confidence) ?? "confirmed",
311
+ });
312
+ }
313
+ for (const [accountId, binding] of requestedBindings) {
314
+ if (!db.getAccount(accountId)) {
315
+ const separator = accountId.indexOf(":");
316
+ if (separator > 0) {
317
+ const platform = accountId.slice(0, separator);
318
+ const senderId = accountId.slice(separator + 1);
319
+ db.upsertAccount({ platform, senderId, displayName: senderId });
320
+ report.accounts += 1;
321
+ }
322
+ }
323
+ if (!db.getPerson(binding.personId))
324
+ db.createPerson({ personId: binding.personId });
325
+ if (db.getAccount(accountId)) {
326
+ db.bindAccount(accountId, binding.personId, binding);
327
+ report.bindings += 1;
328
+ }
329
+ else {
330
+ report.warnings.push(`绑定引用了不存在的账号: ${accountId}`);
331
+ }
332
+ }
333
+ // Groups: index first, then richer group files.
334
+ for (const [groupId, raw] of Object.entries(groupsIndex)) {
335
+ if (!isObject(raw))
336
+ continue;
337
+ const platform = text(raw.platform) ?? "unknown";
338
+ const chatId = text(raw.group_id) ?? extractChatId(groupId, platform);
339
+ const result = db.upsertGroup({
340
+ groupId,
341
+ platform,
342
+ chatId,
343
+ displayName: text(raw.displayName) ?? text(raw.display_name),
344
+ subject: text(raw.group_subject),
345
+ botNames: mergeBotNames([], raw.botNames),
346
+ });
347
+ if (result.created)
348
+ report.groups += 1;
349
+ }
350
+ for (const raw of groupProfiles) {
351
+ const platform = text(raw.known_platform) ?? text(raw.platform) ?? inferGroupFromPath(String(raw.__file ?? ""))?.platform ?? "unknown";
352
+ const rawGroupId = text(raw.group_id) ?? text(raw.groupId) ?? "";
353
+ const chatId = text(raw.chat_id) ?? extractChatId(rawGroupId, platform);
354
+ if (!chatId) {
355
+ report.warnings.push("群文件缺少 group_id/chat_id");
356
+ continue;
357
+ }
358
+ const groupId = rawGroupId && rawGroupId.includes(":") ? rawGroupId : `${platform.replace(/\//g, ":")}:${chatId}`;
359
+ const result = db.upsertGroup({
360
+ groupId,
361
+ platform,
362
+ chatId,
363
+ displayName: text(raw.displayName) ?? text(raw.display_name),
364
+ subject: text(raw.group_subject),
365
+ botNames: mergeBotNames(db.getGroup(groupId)?.bot_names ?? [], raw.botNames),
366
+ firstSeen: text(raw.created_at) ?? undefined,
367
+ });
368
+ if (result.created)
369
+ report.groups += 1;
370
+ db.setGroupBotNames(result.group.group_id, mergeBotNames(result.group.bot_names, raw.botNames));
371
+ const members = isObject(raw.members) ? raw.members : {};
372
+ for (const [accountId, memberRaw] of Object.entries(members)) {
373
+ if (!isObject(memberRaw))
374
+ continue;
375
+ db.upsertGroupMember({
376
+ groupId: result.group.group_id,
377
+ accountId,
378
+ displayName: text(memberRaw.display_name) ?? text(memberRaw.displayName),
379
+ classification: text(memberRaw.classification) ?? "pending",
380
+ confidence: text(memberRaw.confidence) ?? "tentative",
381
+ roleInGroup: text(memberRaw.role_in_group) ?? text(memberRaw.role),
382
+ at: text(memberRaw.last_seen) ?? text(memberRaw.lastSeen) ?? undefined,
383
+ });
384
+ report.groupMembers += 1;
385
+ }
386
+ const events = Array.isArray(raw.group_memory) ? raw.group_memory : [];
387
+ for (const eventRaw of events) {
388
+ if (typeof eventRaw === "string") {
389
+ if (db.addGroupEvent({ groupId: result.group.group_id, summary: eventRaw })) {
390
+ report.groupEvents += 1;
391
+ }
392
+ continue;
393
+ }
394
+ if (!isObject(eventRaw))
395
+ continue;
396
+ const headline = text(eventRaw.summary) ??
397
+ text(eventRaw.event) ??
398
+ text(eventRaw.title) ??
399
+ text(eventRaw.content);
400
+ const detail = text(eventRaw.detail) ?? text(eventRaw.description) ?? text(eventRaw.note);
401
+ const summary = headline && detail && !headline.includes(detail) ? `${headline}:${detail}` : headline;
402
+ if (!summary)
403
+ continue;
404
+ const tags = Array.isArray(eventRaw.tags)
405
+ ? eventRaw.tags.filter((tag) => typeof tag === "string")
406
+ : [];
407
+ if (db.addGroupEvent({
408
+ groupId: result.group.group_id,
409
+ summary,
410
+ category: text(eventRaw.category) ?? "observation",
411
+ tags,
412
+ agentId: text(eventRaw.agent_id),
413
+ occurredAt: text(eventRaw.timestamp) ?? text(eventRaw.occurred_at) ?? undefined,
414
+ })) {
415
+ report.groupEvents += 1;
416
+ }
417
+ }
418
+ const observations = Array.isArray(raw.group_observations) ? raw.group_observations : [];
419
+ for (const observation of observations) {
420
+ const content = impressionText(observation);
421
+ if (content && db.addGroupObservation(result.group.group_id, content)) {
422
+ report.groupEvents += 1;
423
+ }
424
+ }
425
+ }
426
+ // Scan state: dict or array.
427
+ if (isObject(scanState)) {
428
+ const channels = scanState.channels;
429
+ const rows = [];
430
+ if (isObject(channels)) {
431
+ for (const [key, value] of Object.entries(channels)) {
432
+ if (isObject(value))
433
+ rows.push([key, value]);
434
+ }
435
+ }
436
+ else if (Array.isArray(channels)) {
437
+ for (const value of channels) {
438
+ if (!isObject(value))
439
+ continue;
440
+ const key = text(value.channel_id) ?? text(value.channelId) ?? text(value.key);
441
+ if (key)
442
+ rows.push([key, value]);
443
+ }
444
+ }
445
+ for (const [key, value] of rows) {
446
+ db.upsertScanChannel({
447
+ channelKey: key,
448
+ sessionKey: text(value.sessionKey) ?? text(value.session_key),
449
+ displayName: text(value.displayName) ?? text(value.display_name),
450
+ kind: text(value.kind),
451
+ platform: text(value.platform),
452
+ groupId: text(value.group_id),
453
+ lastScanAt: text(value.lastScanAt) ?? text(value.last_scan_at),
454
+ });
455
+ report.scanChannels += 1;
456
+ }
457
+ db.setScanMeta(text(scanState.lastScanAt) ?? text(scanState.lastFullScanAt));
458
+ }
459
+ db.metaSet("legacy_imported_at", nowIso());
460
+ db.metaSet("legacy_source", dataDir);
461
+ db.metaSet("legacy_mode", "imported-not-authoritative");
462
+ });
463
+ report.imported = true;
464
+ return report;
465
+ }
@@ -0,0 +1,100 @@
1
+ import fs from "node:fs";
2
+ import os from "node:os";
3
+ import path from "node:path";
4
+ const DEFAULT_STATE_DIR = path.join(os.homedir(), ".openclaw");
5
+ export function expandTemplate(template, values) {
6
+ const home = values.homeDir ?? os.homedir();
7
+ const stateDir = values.stateDir ?? DEFAULT_STATE_DIR;
8
+ const workspaceKey = values.workspaceKey ?? encodeWorkspaceSegment(values.workspace);
9
+ const expanded = template
10
+ .replaceAll("{workspace}", values.workspace)
11
+ .replaceAll("{workspaceDir}", values.workspace)
12
+ .replaceAll("{stateDir}", stateDir)
13
+ .replaceAll("{workspaceKey}", workspaceKey)
14
+ .replaceAll("{agentDir}", values.agentDir ?? "")
15
+ .replaceAll("{agentId}", values.agentId ?? "")
16
+ .replaceAll("{home}", home);
17
+ if (expanded === "~")
18
+ return home;
19
+ if (expanded.startsWith("~/"))
20
+ return path.join(home, expanded.slice(2));
21
+ return expanded;
22
+ }
23
+ export function toAbsolute(value, base) {
24
+ return path.isAbsolute(value) ? path.normalize(value) : path.resolve(base, value);
25
+ }
26
+ export function realpathOrResolve(value) {
27
+ try {
28
+ return fs.realpathSync.native(value);
29
+ }
30
+ catch {
31
+ return path.resolve(value);
32
+ }
33
+ }
34
+ export function ensureDir(dir) {
35
+ fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
36
+ }
37
+ /** Stable, readable directory name for a workspace's state directory. */
38
+ export function encodeWorkspaceSegment(workspaceDir) {
39
+ const real = realpathOrResolve(workspaceDir);
40
+ const trimmed = real.replace(/^[/\\]+/, "");
41
+ // Escape literal underscores before collapsing path separators to "__", so
42
+ // "/a/b" and "/a__b" map to different directories (workspace isolation).
43
+ // Paths without underscores keep their previous directory name.
44
+ const slug = trimmed
45
+ .replace(/_/g, "_5f")
46
+ .replace(/[/\\]+/g, "__")
47
+ .replace(/[^A-Za-z0-9._-]/g, "_");
48
+ return slug || "workspace";
49
+ }
50
+ export function resolveStorePaths(params) {
51
+ const config = params.config ?? {};
52
+ const workspaceDir = realpathOrResolve(params.workspaceDir);
53
+ const stateDir = params.stateDir?.trim() || DEFAULT_STATE_DIR;
54
+ const workspaceKey = encodeWorkspaceSegment(workspaceDir);
55
+ const overrides = config.workspaceDataDirs ?? {};
56
+ const templateValues = {
57
+ workspace: workspaceDir,
58
+ stateDir,
59
+ workspaceKey,
60
+ agentId: params.agentId,
61
+ agentDir: params.agentDir,
62
+ };
63
+ let dataDir;
64
+ for (const [key, value] of Object.entries(overrides)) {
65
+ if (!value)
66
+ continue;
67
+ const candidate = realpathOrResolve(expandTemplate(key, templateValues));
68
+ if (candidate === workspaceDir) {
69
+ dataDir = expandTemplate(value, templateValues);
70
+ break;
71
+ }
72
+ }
73
+ if (!dataDir) {
74
+ const template = config.dataDir?.trim() || "{stateDir}/plugins/people/{workspaceKey}";
75
+ dataDir = expandTemplate(template, templateValues);
76
+ }
77
+ dataDir = toAbsolute(dataDir, workspaceDir);
78
+ const databaseFile = config.databaseFile?.trim() || "people.db";
79
+ const dbPath = path.isAbsolute(databaseFile)
80
+ ? databaseFile
81
+ : path.join(dataDir, databaseFile);
82
+ return {
83
+ workspaceDir,
84
+ dataDir,
85
+ dbPath,
86
+ workspaceKey: realpathOrResolve(dataDir),
87
+ };
88
+ }
89
+ export function resolveWorkspaceRoot(config, agentId) {
90
+ const entry = agentId ? config.agents?.entries?.[agentId] : undefined;
91
+ const explicit = entry?.workspace?.trim();
92
+ if (explicit)
93
+ return explicit;
94
+ const base = config.agents?.defaults?.workspace?.trim() || path.join(os.homedir(), ".openclaw", "workspace");
95
+ // OpenClaw gives the default agent the base workspace and each additional
96
+ // agent its own child directory, unless that agent sets workspace explicitly.
97
+ if (!agentId || agentId === "main")
98
+ return base;
99
+ return path.join(base, agentId);
100
+ }
@@ -0,0 +1,48 @@
1
+ const GENERIC_ACCOUNTS = new Set(["default", "main", "primary", "0", "none", "-"]);
2
+ function clean(value) {
3
+ if (typeof value !== "string")
4
+ return undefined;
5
+ const trimmed = value.trim();
6
+ return trimmed ? trimmed : undefined;
7
+ }
8
+ export function normalizePlatform(value) {
9
+ return value.trim().replace(/\s+/g, "").replace(/\/{2,}/g, "/").replace(/^\/+|\/+$/g, "");
10
+ }
11
+ /** Stable comparison key: `bncr/tgBot` and `bncr:tgBot` compare equal. */
12
+ export function platformLookupKey(value) {
13
+ return normalizePlatform(value).toLowerCase().replace(/[:/]+/g, ":");
14
+ }
15
+ export function parsePlatformFolder(folder) {
16
+ const index = folder.indexOf("_");
17
+ if (index <= 0)
18
+ return folder;
19
+ return `${folder.slice(0, index)}/${folder.slice(index + 1)}`;
20
+ }
21
+ export function resolvePlatform(params) {
22
+ const metadataPlatform = clean(params.metadataPlatform);
23
+ const channel = clean(params.provider) ?? clean(params.channel);
24
+ const account = clean(params.accountId);
25
+ if (metadataPlatform && (!channel || metadataPlatform.toLowerCase().startsWith(channel.toLowerCase()))) {
26
+ return normalizePlatform(metadataPlatform);
27
+ }
28
+ if (!channel)
29
+ return normalizePlatform(metadataPlatform ?? "unknown");
30
+ const overrideKey = Object.keys(params.overrides ?? {}).find((key) => key.toLowerCase() === channel.toLowerCase());
31
+ const template = (overrideKey ? params.overrides?.[overrideKey] : undefined) ?? params.template ?? "{channel}/{account}";
32
+ const usableAccount = account && !GENERIC_ACCOUNTS.has(account.toLowerCase()) ? account : "";
33
+ const rendered = template
34
+ .replaceAll("{channel}", channel)
35
+ .replaceAll("{account}", usableAccount)
36
+ .replaceAll("{accountId}", usableAccount);
37
+ const cleaned = rendered
38
+ .split("/")
39
+ .map((part) => part.trim())
40
+ .filter(Boolean)
41
+ .join("/");
42
+ return cleaned || channel;
43
+ }
44
+ /** Group keys keep the legacy colon form: `bncr/tgBot` + chat -> `bncr:tgBot:<chat>`.
45
+ */
46
+ export function groupKey(platform, chatId) {
47
+ return `${normalizePlatform(platform).replace(/\//g, ":")}:${chatId.trim()}`;
48
+ }