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,1293 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import { PeopleDatabase, newPersonId } from "./db.js";
4
+ import { importLegacyJson } from "./legacy.js";
5
+ import { groupKey, normalizePlatform } from "./platform.js";
6
+ import { ensureDir } from "./paths.js";
7
+ import { SCHEMA_VERSION } from "./schema.js";
8
+ import { nowIso } from "./time.js";
9
+ const DEFAULT_LOGGER = {
10
+ info: () => undefined,
11
+ warn: () => undefined,
12
+ debug: () => undefined,
13
+ };
14
+ /** Sender ids that never become people (system/cron/scrollback auto-rows). */
15
+ const DEFAULT_CAPTURE_SKIP_SENDERS = [
16
+ "^bncr-history-system$",
17
+ "^history-system$",
18
+ "^system:",
19
+ "^cron$",
20
+ ];
21
+ const DEFAULT_MULTI_ACCOUNT_CHANNELS = ["bncr"];
22
+ /** Same-sender family pairs: one human on two accesses of the same platform. */
23
+ const DEFAULT_SENDER_FAMILIES = [["bncr/GewePlus", "bncr/GolemPlus"]];
24
+ function truncate(value, maxChars) {
25
+ if (value.length <= maxChars)
26
+ return value;
27
+ return `${value.slice(0, Math.max(0, maxChars - 1)).trimEnd()}…`;
28
+ }
29
+ export class PeopleService {
30
+ db;
31
+ paths;
32
+ config;
33
+ logger;
34
+ constructor(input) {
35
+ this.paths = input.paths;
36
+ this.config = input.config ?? {};
37
+ this.logger = input.logger ?? DEFAULT_LOGGER;
38
+ ensureDir(this.paths.dataDir);
39
+ this.db = new PeopleDatabase(this.paths.dbPath, this.paths.workspaceKey);
40
+ if (this.config.importLegacy !== false) {
41
+ try {
42
+ const report = importLegacyJson(this.db);
43
+ if (report.imported) {
44
+ this.logger.info(`[people] legacy imported: accounts=${report.accounts} persons=${report.persons} groups=${report.groups}`);
45
+ }
46
+ }
47
+ catch (error) {
48
+ this.logger.warn(`[people] legacy import failed: ${error.message}`);
49
+ }
50
+ }
51
+ }
52
+ close() {
53
+ this.db.close();
54
+ }
55
+ importLegacy(force = false) {
56
+ if (this.config.importLegacy === false) {
57
+ throw new Error("当前配置已禁用旧 JSON 导入(importLegacy=false)。");
58
+ }
59
+ return importLegacyJson(this.db, { force });
60
+ }
61
+ // -------------------------------------------------------------------------
62
+ // Identity capture
63
+ // -------------------------------------------------------------------------
64
+ capture(input) {
65
+ const platform = normalizePlatform(input.platform);
66
+ const senderId = input.senderId.trim();
67
+ const skipped = this.isSkippedSender(senderId);
68
+ const target = this.resolveWriteTarget(platform, senderId);
69
+ const writePlatform = target.kind === "direct" ? target.platform : platform;
70
+ let accountId = null;
71
+ let created = false;
72
+ let displayNameChanged = false;
73
+ if (!skipped && target.kind !== "deferred") {
74
+ const accountResult = this.db.upsertAccount({
75
+ platform: writePlatform,
76
+ senderId,
77
+ displayName: input.senderName,
78
+ currentRole: input.role,
79
+ });
80
+ accountId = accountResult.account.account_id;
81
+ created = accountResult.created;
82
+ displayNameChanged = accountResult.displayNameChanged;
83
+ // Same-sender family rows (GewePlus + GolemPlus) collapse to one person.
84
+ try {
85
+ this.syncFamilyBinding(senderId);
86
+ }
87
+ catch (error) {
88
+ this.logger.warn(`[people] family binding failed for ${senderId}: ${error.message}`);
89
+ }
90
+ }
91
+ let groupId;
92
+ let groupCreated;
93
+ if (this.config.captureGroups !== false && input.chatId?.trim()) {
94
+ const chatId = input.chatId.trim();
95
+ const existing = this.db.findGroup(writePlatform, chatId) ??
96
+ // A bare channel label has no backend of its own. When exactly one
97
+ // backend row exists for this chat, its subject is kept in sync; no row
98
+ // is created and no member is added, so backend records stay separate.
99
+ (target.kind === "deferred" ? this.soleBackendGroup(chatId) : null);
100
+ if (existing) {
101
+ groupId = existing.group_id;
102
+ if (input.chatTitle?.trim() && input.chatTitle.trim() !== existing.group_subject) {
103
+ this.db.updateGroupSubject(existing.group_id, input.chatTitle.trim());
104
+ }
105
+ if (!skipped && accountId) {
106
+ this.db.upsertGroupMember({
107
+ groupId: existing.group_id,
108
+ accountId,
109
+ displayName: input.senderName,
110
+ });
111
+ }
112
+ }
113
+ else if (input.isGroup && target.kind !== "deferred") {
114
+ const createdGroup = this.db.upsertGroup({
115
+ groupId: groupKey(writePlatform, chatId),
116
+ platform: writePlatform,
117
+ chatId,
118
+ displayName: input.chatTitle?.trim() || chatId,
119
+ subject: input.chatTitle?.trim() || null,
120
+ });
121
+ groupId = createdGroup.group.group_id;
122
+ groupCreated = createdGroup.created;
123
+ if (!skipped && accountId) {
124
+ this.db.upsertGroupMember({
125
+ groupId,
126
+ accountId,
127
+ displayName: input.senderName,
128
+ });
129
+ }
130
+ }
131
+ }
132
+ return {
133
+ accountId,
134
+ created,
135
+ displayNameChanged,
136
+ groupId,
137
+ groupCreated,
138
+ };
139
+ }
140
+ resolveAccount(ref) {
141
+ const value = ref.trim();
142
+ if (!value)
143
+ return null;
144
+ const direct = this.db.getAccount(value);
145
+ if (direct)
146
+ return direct;
147
+ const matches = this.db.searchAccounts(value, 2);
148
+ if (matches.length === 1)
149
+ return matches[0] ?? null;
150
+ return null;
151
+ }
152
+ searchAccounts(ref) {
153
+ const direct = this.db.searchAccounts(ref, 10);
154
+ if (direct.length > 0)
155
+ return direct;
156
+ // Channel-style refs ("bncr:GewePlus:<sender>") map to canonical ids
157
+ // ("bncr/GewePlus:<sender>").
158
+ const parts = ref.split(":");
159
+ if (parts.length >= 3 && parts[0] && parts[1]) {
160
+ return this.db.searchAccounts(`${parts[0]}/${parts[1]}:${parts.slice(2).join(":")}`, 10);
161
+ }
162
+ return direct;
163
+ }
164
+ snapshot(accountId) {
165
+ const account = this.db.getAccount(accountId);
166
+ if (!account)
167
+ return null;
168
+ const person = account.bound_person_id ? this.db.getPerson(account.bound_person_id) : null;
169
+ const ownerType = person ? "person" : "account";
170
+ const ownerId = person ? person.person_id : account.account_id;
171
+ return {
172
+ ownerType,
173
+ ownerId,
174
+ account,
175
+ person,
176
+ displayName: person?.call_name ||
177
+ account.call_name ||
178
+ person?.name ||
179
+ account.confirmed_name ||
180
+ account.display_name ||
181
+ account.account_id,
182
+ traits: this.db.getTraits(ownerType, ownerId),
183
+ impressions: this.db.listImpressions(ownerType, ownerId, 20),
184
+ };
185
+ }
186
+ personSnapshot(personId) {
187
+ const person = this.db.getPerson(personId);
188
+ if (!person)
189
+ return null;
190
+ return {
191
+ ownerType: "person",
192
+ ownerId: person.person_id,
193
+ account: null,
194
+ person,
195
+ displayName: person.call_name || person.name || person.person_id,
196
+ traits: this.db.getTraits("person", person.person_id),
197
+ impressions: this.db.listImpressions("person", person.person_id, 20),
198
+ };
199
+ }
200
+ profileTarget(input) {
201
+ const personId = input.personId?.trim();
202
+ if (personId) {
203
+ const snapshot = this.personSnapshot(personId);
204
+ if (!snapshot)
205
+ throw new Error(`人物不存在: ${personId}`);
206
+ return snapshot;
207
+ }
208
+ const accountId = input.accountId?.trim();
209
+ if (!accountId)
210
+ throw new Error("需要提供 accountId 或 personId");
211
+ const snapshot = this.snapshot(accountId);
212
+ if (!snapshot)
213
+ throw new Error(`账号不存在: ${accountId}`);
214
+ return snapshot;
215
+ }
216
+ // -------------------------------------------------------------------------
217
+ // Prompt context
218
+ // -------------------------------------------------------------------------
219
+ contextCard(input) {
220
+ const snapshot = this.snapshot(input.accountId);
221
+ if (!snapshot)
222
+ return "";
223
+ const dimensions = new Map(this.db.listDimensions().map((dimension) => [dimension.key, dimension]));
224
+ const traits = snapshot.traits
225
+ .filter((trait) => !dimensions.get(trait.dimension) || dimensions.get(trait.dimension)?.active)
226
+ .slice(0, 7)
227
+ .map((trait) => `${dimensions.get(trait.dimension)?.label ?? trait.dimension}=${trait.value}`);
228
+ const lines = ["<people_context>"];
229
+ lines.push(`当前对象:${snapshot.displayName}(${snapshot.account.platform})`);
230
+ if (traits.length > 0)
231
+ lines.push(`画像:${traits.join(";")}`);
232
+ if (snapshot.person?.notes)
233
+ lines.push(`备注:${snapshot.person.notes}`);
234
+ const impressionCount = this.db.countImpressions(snapshot.ownerType, snapshot.ownerId);
235
+ if (impressionCount > 12) {
236
+ lines.push(`维护待办:本画像有 ${impressionCount} 条零散印象。与当前人物相关时,先用 people_remember { dimension, mode: "replace" } 归并到 traits,再用 people_forget { scope: "impressions" } 删除被替代的原文,避免误删 traits;不要向用户提及。`);
237
+ }
238
+ if (input.includeGroupMemory !== false && input.groupId) {
239
+ const group = this.db.getGroup(input.groupId);
240
+ if (group) {
241
+ lines.push(`群:${group.group_subject || group.display_name || group.chat_id}`);
242
+ const events = this.db.listGroupEvents(group.group_id, 4);
243
+ if (events.length > 0) {
244
+ lines.push(`群记忆:${events
245
+ .map((event) => `${event.occurred_at.slice(0, 10)} ${event.summary}`)
246
+ .join(";")}`);
247
+ }
248
+ }
249
+ }
250
+ lines.push("使用规则:这些是内部记忆,不要原样展示;本人亲口说出的事实用 people_remember 记录。");
251
+ lines.push("</people_context>");
252
+ return truncate(lines.join("\n"), input.maxChars ?? this.config.injectMaxChars ?? 1200);
253
+ }
254
+ // -------------------------------------------------------------------------
255
+ // Profile rendering
256
+ // -------------------------------------------------------------------------
257
+ formatProfileCard(snapshot) {
258
+ const dimensions = this.db.listDimensions().filter((dimension) => dimension.active);
259
+ const values = new Map(snapshot.traits.map((trait) => [trait.dimension, trait.value]));
260
+ const label = (key) => dimensions.find((dimension) => dimension.key === key)?.label ?? key;
261
+ const firstSeen = snapshot.account?.first_seen ?? snapshot.person?.created_at ?? "";
262
+ const lastSeen = snapshot.account?.last_interaction ?? snapshot.person?.updated_at ?? "";
263
+ const lines = [
264
+ `📋 ${snapshot.displayName} 的画像`,
265
+ "",
266
+ `初次见面 · ${firstSeen} | 最近 · ${lastSeen}`,
267
+ "",
268
+ ];
269
+ const callName = snapshot.person?.call_name || snapshot.account?.call_name;
270
+ if (callName)
271
+ lines.push(`称呼 ┆ ${callName}`);
272
+ for (const dimension of dimensions) {
273
+ const value = values.get(dimension.key);
274
+ if (value)
275
+ lines.push(`${label(dimension.key)} ┆ ${value}`);
276
+ }
277
+ if (snapshot.impressions.length > 0) {
278
+ lines.push("", "零散印象");
279
+ for (const impression of snapshot.impressions.slice(0, 3)) {
280
+ lines.push(`· ${impression.content}`);
281
+ }
282
+ }
283
+ return lines.join("\n");
284
+ }
285
+ formatLookup(account, full = false) {
286
+ const snapshot = this.snapshot(account.account_id);
287
+ if (!snapshot)
288
+ return "未找到该账号。";
289
+ return full ? this.formatProfileDetail(snapshot) : this.formatProfileCard(snapshot);
290
+ }
291
+ formatPersonLookup(personId, full = false) {
292
+ const snapshot = this.personSnapshot(personId);
293
+ if (!snapshot)
294
+ return "未找到该人物。";
295
+ return full ? this.formatProfileDetail(snapshot) : this.formatProfileCard(snapshot);
296
+ }
297
+ formatProfileDetail(snapshot, limit = 300) {
298
+ const dimensions = this.db.listDimensions().filter((dimension) => dimension.active);
299
+ const label = (key) => dimensions.find((dimension) => dimension.key === key)?.label ?? key;
300
+ const total = this.db.countImpressions(snapshot.ownerType, snapshot.ownerId);
301
+ const impressions = this.db.listImpressions(snapshot.ownerType, snapshot.ownerId, Math.max(1, Math.min(500, Math.trunc(limit))));
302
+ const lines = [
303
+ `📋 ${snapshot.displayName}(${snapshot.ownerType}:${snapshot.ownerId})`,
304
+ `特征 ${snapshot.traits.length} 个 · 零散印象 ${total} 条`,
305
+ ];
306
+ if (snapshot.traits.length > 0) {
307
+ lines.push("", "特征");
308
+ for (const trait of snapshot.traits)
309
+ lines.push(`${label(trait.dimension)} ┆ ${trait.value}`);
310
+ }
311
+ if (impressions.length > 0) {
312
+ lines.push("", `零散印象(显示 ${impressions.length}/${total} 条)`);
313
+ for (const impression of impressions)
314
+ lines.push(`[${impression.id}] ${impression.content}`);
315
+ }
316
+ return lines.join("\n");
317
+ }
318
+ resolveGroup(ref) {
319
+ const value = ref.trim();
320
+ if (!value)
321
+ return null;
322
+ const direct = this.db.getGroup(value) ?? this.db.findGroupByChat(value);
323
+ if (direct)
324
+ return direct;
325
+ // Accept legacy and channel-prefixed forms such as
326
+ // "GewePlus:123456789", "Bncr:GolemPlus:987654321:0" or a bare chat id.
327
+ const tokens = value.split(":").map((token) => token.trim()).filter(Boolean).reverse();
328
+ for (const token of tokens) {
329
+ if (!/^-?\d+$/.test(token))
330
+ continue;
331
+ const byChat = this.db.findGroupByChat(token);
332
+ if (byChat)
333
+ return byChat;
334
+ }
335
+ const lower = value.toLowerCase();
336
+ return this.db.listAllGroups().find((group) => group.group_id.toLowerCase() === lower) ?? null;
337
+ }
338
+ /**
339
+ * Resolve a group reference strictly: a bare chat id that maps to several
340
+ * backend rows is ambiguous, because those rows are independent records.
341
+ */
342
+ resolveGroupStrict(ref) {
343
+ const value = ref.trim();
344
+ if (!value)
345
+ throw new Error("group 引用不能为空");
346
+ if (!value.includes("/") && !value.includes(":")) {
347
+ const matches = this.db.findGroupsByChatAll(value);
348
+ if (matches.length > 1) {
349
+ throw new Error(`群号 ${value} 在多个后端下各有独立记录(${matches
350
+ .map((group) => group.group_id)
351
+ .join(", ")})。请改用精确群 key 指定;同一件事需要在多个后端分别记录时各写一条。`);
352
+ }
353
+ }
354
+ const group = this.resolveGroup(value);
355
+ if (!group)
356
+ throw new Error(`没有找到群:${ref}。可以只传数字群号。`);
357
+ return group;
358
+ }
359
+ formatGroup(group) {
360
+ const events = this.db.listGroupEvents(group.group_id, 8);
361
+ const members = this.db.listGroupMembers(group.group_id, 8);
362
+ const lines = [
363
+ `📋 ${group.group_subject || group.display_name || group.chat_id}`,
364
+ `平台:${group.platform} 群 ID:${group.chat_id}`,
365
+ `成员记录:${members.length} 最近更新:${group.updated_at}`,
366
+ ];
367
+ if (events.length > 0) {
368
+ lines.push("", "最近记忆");
369
+ for (const event of events)
370
+ lines.push(`· ${event.occurred_at.slice(0, 10)} [${event.category}] ${event.summary}`);
371
+ }
372
+ return lines.join("\n");
373
+ }
374
+ // -------------------------------------------------------------------------
375
+ // Mutations
376
+ // -------------------------------------------------------------------------
377
+ remember(input) {
378
+ const snapshot = this.profileTarget(input);
379
+ const content = input.content.trim();
380
+ if (!content)
381
+ throw new Error("content 不能为空");
382
+ if (input.mode === "replace" && !input.dimension) {
383
+ throw new Error("replace 模式需要同时提供 dimension,否则无法确定替换哪个特征。");
384
+ }
385
+ let traitUpdated = false;
386
+ if (input.dimension) {
387
+ const active = this.db.listDimensions().filter((item) => item.active);
388
+ const requested = input.dimension.trim();
389
+ const dimension = active.find((item) => item.key === requested) ??
390
+ active.find((item) => item.label === requested) ??
391
+ active.find((item) => item.label.toLowerCase() === requested.toLowerCase());
392
+ if (!dimension) {
393
+ const keys = active.map((item) => `${item.key}(${item.label})`).join(", ");
394
+ throw new Error(`未知或未启用的维度: ${input.dimension}。可用: ${keys}`);
395
+ }
396
+ const existing = snapshot.traits.find((trait) => trait.dimension === dimension.key)?.value;
397
+ const mode = input.mode ?? "append";
398
+ const value = mode === "replace" || !existing
399
+ ? content
400
+ : existing.includes(content)
401
+ ? existing
402
+ : `${existing};${content}`;
403
+ this.db.setTrait(snapshot.ownerType, snapshot.ownerId, dimension.key, value);
404
+ traitUpdated = true;
405
+ }
406
+ // A replace writes the distilled trait value. Keeping a second copy as an
407
+ // impression would make compacted profiles dense again on every cycle.
408
+ const impressionStored = input.mode === "replace" && input.dimension
409
+ ? false
410
+ : this.db.addImpression({
411
+ ownerType: snapshot.ownerType,
412
+ ownerId: snapshot.ownerId,
413
+ content,
414
+ agentId: input.agentId,
415
+ source: input.source,
416
+ });
417
+ return {
418
+ stored: impressionStored,
419
+ impressionStored,
420
+ ownerType: snapshot.ownerType,
421
+ ownerId: snapshot.ownerId,
422
+ traitUpdated,
423
+ };
424
+ }
425
+ note(input) {
426
+ const group = this.db.getGroup(input.groupId) ?? this.db.findGroupByChat(input.groupId);
427
+ if (!group)
428
+ throw new Error(`群不存在: ${input.groupId}`);
429
+ const stored = this.db.addGroupEvent({
430
+ groupId: group.group_id,
431
+ summary: input.summary,
432
+ category: input.category,
433
+ tags: input.tags,
434
+ agentId: input.agentId,
435
+ });
436
+ return { stored, groupId: group.group_id };
437
+ }
438
+ bind(input) {
439
+ const account = this.db.getAccount(input.accountId);
440
+ if (!account)
441
+ throw new Error(`账号不存在: ${input.accountId}`);
442
+ let person = input.personId ? this.db.getPerson(input.personId) : null;
443
+ let createdPerson = false;
444
+ if (!person) {
445
+ const byName = input.name ? this.db.searchPersons(input.name, 2) : [];
446
+ person = byName.length === 1 ? byName[0] ?? null : null;
447
+ }
448
+ if (!person) {
449
+ person = this.db.createPerson({
450
+ personId: input.personId || newPersonId(),
451
+ name: input.name || account.confirmed_name || account.display_name,
452
+ });
453
+ createdPerson = true;
454
+ }
455
+ const moved = this.db.bindAccount(account.account_id, person.person_id, {
456
+ method: input.method ?? "manual",
457
+ confidence: input.confidence ?? "confirmed",
458
+ migrate: true,
459
+ });
460
+ return {
461
+ accountId: account.account_id,
462
+ personId: person.person_id,
463
+ createdPerson,
464
+ moved: { impressions: moved.impressionsMoved, traits: moved.traitsMoved },
465
+ };
466
+ }
467
+ setName(input) {
468
+ const account = this.db.getAccount(input.accountId);
469
+ if (!account)
470
+ throw new Error(`账号不存在: ${input.accountId}`);
471
+ const callName = input.callName?.trim() || null;
472
+ const confirmedName = input.confirmedName?.trim() || null;
473
+ const personName = input.personName?.trim() || null;
474
+ if (!callName && !confirmedName && !personName) {
475
+ throw new Error("至少要提供 callName / confirmedName / personName 之一。");
476
+ }
477
+ if (account.bound_person_id) {
478
+ const person = this.db.getPerson(account.bound_person_id);
479
+ if (!person)
480
+ throw new Error(`绑定的人物不存在: ${account.bound_person_id}`);
481
+ this.db.updatePerson(person.person_id, { callName, name: personName });
482
+ return { target: "person", callName: callName ?? person.call_name };
483
+ }
484
+ if (callName)
485
+ this.db.setCallName(account.account_id, callName);
486
+ if (confirmedName)
487
+ this.db.setConfirmedName(account.account_id, confirmedName);
488
+ return { target: "account", callName: callName ?? account.call_name };
489
+ }
490
+ forget(input) {
491
+ const snapshot = this.profileTarget(input);
492
+ const match = input.match.trim();
493
+ if (!match)
494
+ throw new Error("match 不能为空");
495
+ const scope = input.scope ?? "all";
496
+ return {
497
+ impressions: scope === "traits" ? 0 : this.db.removeImpressions(snapshot.ownerType, snapshot.ownerId, match),
498
+ traits: scope === "impressions"
499
+ ? 0
500
+ : this.db.removeTraitsMatching(snapshot.ownerType, snapshot.ownerId, match),
501
+ };
502
+ }
503
+ mergePersons(sourcePersonId, targetPersonId) {
504
+ const result = this.db.mergePersons(sourcePersonId, targetPersonId);
505
+ return { accounts: result.accounts, impressions: result.impressions, traits: result.traits };
506
+ }
507
+ unbind(accountId, copyProfile = true) {
508
+ return this.db.unbindAccount(accountId, copyProfile);
509
+ }
510
+ mergeAccounts(sourceId, targetId) {
511
+ return this.db.mergeAccounts(sourceId.trim(), targetId.trim());
512
+ }
513
+ /** Split an account id into its platform and sender parts. */
514
+ parseAccountId(accountId) {
515
+ const separator = accountId.indexOf(":");
516
+ if (separator <= 0)
517
+ return null;
518
+ const platform = accountId.slice(0, separator);
519
+ const senderId = accountId.slice(separator + 1);
520
+ if (!platform || !senderId)
521
+ return null;
522
+ return { platform, senderId };
523
+ }
524
+ /** Sender part of an account id, used for skip-pattern checks. */
525
+ senderOf(accountId) {
526
+ return this.parseAccountId(accountId)?.senderId ?? accountId;
527
+ }
528
+ /** An existing person that already owns another account of the same sender. */
529
+ personForOwnerSender(senderId) {
530
+ for (const row of this.db.listAccountsBySender(senderId)) {
531
+ if (row.bound_person_id)
532
+ return row.bound_person_id;
533
+ }
534
+ return null;
535
+ }
536
+ isSkippedSender(senderId) {
537
+ const patterns = this.config.captureSkipSenders ?? DEFAULT_CAPTURE_SKIP_SENDERS;
538
+ const value = senderId.trim();
539
+ if (!value)
540
+ return false;
541
+ return patterns.some((pattern) => {
542
+ try {
543
+ return new RegExp(pattern, "i").test(value);
544
+ }
545
+ catch {
546
+ return value === pattern;
547
+ }
548
+ });
549
+ }
550
+ findBotAccounts() {
551
+ const accounts = this.db.listAllAccounts();
552
+ const botNames = new Set();
553
+ for (const group of this.db.listAllGroups()) {
554
+ for (const name of group.bot_names) {
555
+ const value = name.trim().toLowerCase();
556
+ if (value)
557
+ botNames.add(value);
558
+ }
559
+ }
560
+ const botMembers = new Set();
561
+ for (const member of this.db.listAllGroupMembers()) {
562
+ if (member.classification === "likely_bot" || (member.role_in_group ?? "").toLowerCase() === "bot") {
563
+ botMembers.add(member.account_id);
564
+ }
565
+ }
566
+ const result = [];
567
+ for (const account of accounts) {
568
+ if (botMembers.has(account.account_id)) {
569
+ result.push(account);
570
+ continue;
571
+ }
572
+ const identifiers = [account.display_name, account.confirmed_name, account.call_name, account.sender_id]
573
+ .filter((value) => Boolean(value))
574
+ .map((value) => value.trim().toLowerCase());
575
+ if (identifiers.some((value) => botNames.has(value)))
576
+ result.push(account);
577
+ }
578
+ return result;
579
+ }
580
+ resolveWriteTarget(platform, senderId) {
581
+ const incoming = normalizePlatform(platform);
582
+ const sender = senderId.trim();
583
+ const exact = this.db.getAccountByPlatformSender(incoming, sender);
584
+ if (exact)
585
+ return { kind: "direct", platform: incoming };
586
+ if (this.isBareMultiAccount(incoming))
587
+ return { kind: "deferred", platform: null };
588
+ return { kind: "direct", platform: incoming };
589
+ }
590
+ /** The only backend group row for a chat id, when the choice is unambiguous. */
591
+ soleBackendGroup(chatId) {
592
+ const rows = this.db.findGroupsByChatAll(chatId);
593
+ return rows.length === 1 ? rows[0] : null;
594
+ }
595
+ resolveAccountForLookup(platform, senderId) {
596
+ const target = this.resolveWriteTarget(platform, senderId);
597
+ if (target.kind === "direct") {
598
+ return this.db.getAccountByPlatformSender(target.platform, senderId.trim());
599
+ }
600
+ // A bare channel label has no unique backend. Reads fall back to the most
601
+ // recently active backend row of this channel for the same sender.
602
+ const rows = this.db.listAccountsBySender(senderId.trim()).filter((row) => row.platform.includes("/") && this.channelOf(row.platform) === this.channelOf(platform));
603
+ if (rows.length === 0)
604
+ return null;
605
+ return [...rows].sort((a, b) => b.last_interaction.localeCompare(a.last_interaction))[0];
606
+ }
607
+ /** Platform labels that share a sender-id space (one same-sender family pair). */
608
+ familyPairs() {
609
+ const families = this.config.senderFamilies ?? DEFAULT_SENDER_FAMILIES;
610
+ return families.map((family) => [normalizePlatform(family[0]), normalizePlatform(family[1])]);
611
+ }
612
+ familyKeyOf(platform) {
613
+ const normalized = normalizePlatform(platform).toLowerCase();
614
+ for (const [left, right] of this.familyPairs()) {
615
+ if (normalized === left.toLowerCase() || normalized === right.toLowerCase()) {
616
+ return [left, right].map((value) => value.toLowerCase()).sort().join("+");
617
+ }
618
+ }
619
+ return null;
620
+ }
621
+ /** Rows of one sender that belong to the same same-sender family, e.g. GewePlus + GolemPlus. */
622
+ familyRows(senderId) {
623
+ const rows = this.db.listAccountsBySender(senderId);
624
+ if (rows.length < 2)
625
+ return [];
626
+ const byFamily = new Map();
627
+ for (const row of rows) {
628
+ const key = this.familyKeyOf(row.platform);
629
+ if (!key)
630
+ continue;
631
+ byFamily.set(key, [...(byFamily.get(key) ?? []), row]);
632
+ }
633
+ const result = [];
634
+ for (const list of byFamily.values()) {
635
+ if (list.length >= 2)
636
+ result.push(...list);
637
+ }
638
+ return result;
639
+ }
640
+ /**
641
+ * Same-sender family binding state machine. Rows that share a sender id inside one
642
+ * family (GewePlus + GolemPlus) are one human on two accesses: bind them to one person.
643
+ * Conflicting manual bindings are left untouched and reported instead.
644
+ */
645
+ syncFamilyBinding(senderId) {
646
+ const rows = this.familyRows(senderId);
647
+ if (rows.length < 2)
648
+ return null;
649
+ if (rows.some((row) => row.current_role === "bot"))
650
+ return null;
651
+ const personIds = new Set(rows.map((row) => row.bound_person_id).filter((value) => Boolean(value)));
652
+ if (personIds.size >= 2)
653
+ return null;
654
+ const bound = personIds.size === 1 ? rows.filter((row) => row.bound_person_id) : [];
655
+ const loose = rows.filter((row) => !row.bound_person_id);
656
+ if (bound.length === 0 && loose.length < 2)
657
+ return null;
658
+ let personId = bound[0]?.bound_person_id ?? null;
659
+ if (!personId) {
660
+ const anchor = this.pickFamilyAnchor(rows);
661
+ const person = this.db.createPerson({
662
+ personId: newPersonId(),
663
+ name: this.familyPersonName(anchor),
664
+ });
665
+ personId = person.person_id;
666
+ for (const row of rows) {
667
+ this.db.bindAccount(row.account_id, personId, {
668
+ method: "sender_family",
669
+ confidence: "confirmed",
670
+ migrate: true,
671
+ });
672
+ }
673
+ return `同族归并:${rows.map((row) => row.account_id).join(" + ")} → 新人物 ${personId}`;
674
+ }
675
+ for (const row of loose) {
676
+ this.db.bindAccount(row.account_id, personId, {
677
+ method: "sender_family",
678
+ confidence: "confirmed",
679
+ migrate: true,
680
+ });
681
+ }
682
+ // Rows already bound to the same person may still hold account-level
683
+ // leftovers written before the binding existed; pull them up as well.
684
+ for (const row of rows) {
685
+ this.db.bindAccount(row.account_id, personId, {
686
+ method: "sender_family",
687
+ confidence: "confirmed",
688
+ migrate: true,
689
+ });
690
+ }
691
+ return `同族归并:${rows.map((row) => row.account_id).join(" + ")} → ${personId}`;
692
+ }
693
+ pickFamilyAnchor(rows) {
694
+ const rank = (row) => [
695
+ row.call_name ? 1 : 0,
696
+ row.confirmed_name ? 1 : 0,
697
+ row.display_name ? 1 : 0,
698
+ ];
699
+ return [...rows].sort((a, b) => {
700
+ const ra = rank(a);
701
+ const rb = rank(b);
702
+ for (let i = 0; i < 3; i += 1) {
703
+ if (ra[i] !== rb[i])
704
+ return rb[i] - ra[i];
705
+ }
706
+ return b.last_interaction.localeCompare(a.last_interaction);
707
+ })[0] ?? rows[0];
708
+ }
709
+ familyPersonName(anchor) {
710
+ return anchor.call_name || anchor.confirmed_name || anchor.display_name || anchor.sender_id;
711
+ }
712
+ /** Person binding conflicts created by manual owner bindings inside one family. */
713
+ familyBindingConflicts() {
714
+ const bySender = new Map();
715
+ for (const account of this.db.listAllAccounts()) {
716
+ if (!this.familyKeyOf(account.platform))
717
+ continue;
718
+ bySender.set(account.sender_id, [...(bySender.get(account.sender_id) ?? []), account]);
719
+ }
720
+ const conflicts = [];
721
+ for (const [sender, rows] of bySender) {
722
+ if (rows.length < 2)
723
+ continue;
724
+ if (rows.some((row) => row.current_role === "bot"))
725
+ continue;
726
+ const persons = [...new Set(rows.map((row) => row.bound_person_id).filter((value) => Boolean(value)))];
727
+ if (persons.length >= 2)
728
+ conflicts.push({ sender, rows, persons });
729
+ }
730
+ return conflicts;
731
+ }
732
+ multiAccountChannelSet() {
733
+ const channels = this.config.multiAccountChannels ?? DEFAULT_MULTI_ACCOUNT_CHANNELS;
734
+ return new Set(channels.map((channel) => channel.trim().toLowerCase()).filter((channel) => channel));
735
+ }
736
+ channelOf(platform) {
737
+ return normalizePlatform(platform).split("/")[0]?.trim().toLowerCase() ?? "";
738
+ }
739
+ isBareMultiAccount(platform) {
740
+ const normalized = normalizePlatform(platform);
741
+ if (normalized.includes("/"))
742
+ return false;
743
+ return this.multiAccountChannelSet().has(normalized.toLowerCase());
744
+ }
745
+ isBareMultiAccountRow(account) {
746
+ return this.isBareMultiAccount(account.platform);
747
+ }
748
+ hasBackendTwin(account) {
749
+ return this.db.listAccountsBySender(account.sender_id).some((row) => row.account_id !== account.account_id &&
750
+ row.platform.includes("/") &&
751
+ this.channelOf(row.platform) === this.channelOf(account.platform));
752
+ }
753
+ upsertDimension(key, label, active = true, sort = 999) {
754
+ const normalizedKey = key.trim();
755
+ const normalizedLabel = label.trim() || normalizedKey;
756
+ if (!/^[a-z][a-z0-9_]{1,47}$/.test(normalizedKey)) {
757
+ throw new Error("维度 key 只能使用小写字母、数字和下划线,并以字母开头。");
758
+ }
759
+ this.db.upsertDimension(normalizedKey, normalizedLabel, active, sort);
760
+ return this.db.getDimension(normalizedKey);
761
+ }
762
+ search(query, limit = 10) {
763
+ const value = query.trim();
764
+ if (!value)
765
+ throw new Error("query 不能为空。");
766
+ const capped = Math.max(1, Math.min(50, Math.trunc(limit)));
767
+ return {
768
+ accounts: this.db.searchAccounts(value, capped),
769
+ persons: this.db.searchPersons(value, capped),
770
+ impressions: this.db.searchImpressions(value, capped),
771
+ groupEvents: this.db.searchGroupEvents(value, capped),
772
+ };
773
+ }
774
+ backup(keep = 14) {
775
+ const keepCount = Math.max(2, Math.min(100, Math.trunc(keep)));
776
+ const backupDir = path.join(this.paths.dataDir, "backups");
777
+ ensureDir(backupDir);
778
+ const stamp = new Date().toISOString().replace(/[-:.]/g, "");
779
+ const base = `people-${stamp}`;
780
+ const dbBackup = path.join(backupDir, `${base}.db`);
781
+ const jsonBackup = path.join(backupDir, `${base}.json`);
782
+ this.db.backupTo(dbBackup);
783
+ fs.writeFileSync(jsonBackup, `${JSON.stringify(this.db.dump(), null, 2)}\n`, { mode: 0o600 });
784
+ const bases = new Set();
785
+ for (const name of fs.readdirSync(backupDir)) {
786
+ const match = /^(people-[^.]+)\.(db|json)$/.exec(name);
787
+ if (match?.[1])
788
+ bases.add(match[1]);
789
+ }
790
+ const ordered = [...bases].sort().reverse();
791
+ let removedOld = 0;
792
+ for (const oldBase of ordered.slice(keepCount)) {
793
+ for (const suffix of ["db", "json"]) {
794
+ const file = path.join(backupDir, `${oldBase}.${suffix}`);
795
+ if (!fs.existsSync(file))
796
+ continue;
797
+ fs.unlinkSync(file);
798
+ removedOld += 1;
799
+ }
800
+ }
801
+ return {
802
+ backupDir,
803
+ dbBackup,
804
+ jsonBackup,
805
+ dbBytes: fs.statSync(dbBackup).size,
806
+ jsonBytes: fs.statSync(jsonBackup).size,
807
+ removedOld,
808
+ kept: Math.min(keepCount, ordered.length),
809
+ };
810
+ }
811
+ health() {
812
+ const validation = this.validate();
813
+ const issues = validation.issues.filter((issue) => issue.level !== "info");
814
+ const bindings = this.db.listAllPersonAccounts();
815
+ const linkedPersons = new Set(bindings.map((binding) => binding.person_id));
816
+ const persons = this.db.listAllPersons();
817
+ for (const person of persons) {
818
+ if (!linkedPersons.has(person.person_id)) {
819
+ issues.push({
820
+ level: "warning",
821
+ code: "unlinked_person",
822
+ message: `人物 ${person.person_id}(${person.call_name || person.name || "未命名"})没有绑定账号`,
823
+ });
824
+ }
825
+ }
826
+ const byName = new Map();
827
+ for (const person of persons) {
828
+ const name = (person.call_name || person.name || "").trim().toLowerCase();
829
+ if (!name)
830
+ continue;
831
+ byName.set(name, [...(byName.get(name) ?? []), person.person_id]);
832
+ }
833
+ for (const [name, ids] of byName) {
834
+ if (ids.length > 1) {
835
+ issues.push({
836
+ level: "warning",
837
+ code: "duplicate_person_name",
838
+ message: `人物名「${name}」对应多个记录:${ids.join(", ")}`,
839
+ });
840
+ }
841
+ }
842
+ for (const conflict of this.familyBindingConflicts()) {
843
+ issues.push({
844
+ level: "warning",
845
+ code: "person_binding_conflict",
846
+ message: "同族两条接入绑到不同人物: " +
847
+ conflict.rows.map((row) => row.account_id).join(" 与 ") +
848
+ ",人物 " +
849
+ conflict.persons.join(" / ") +
850
+ "。由 owner 判断保留哪侧,再 people_unbind 另一侧。",
851
+ });
852
+ }
853
+ for (const account of this.db.listAllAccounts()) {
854
+ if (!this.isBareMultiAccountRow(account))
855
+ continue;
856
+ if (this.hasBackendTwin(account))
857
+ continue;
858
+ issues.push({
859
+ level: "warning",
860
+ code: "bare_platform_account",
861
+ message: account.account_id +
862
+ " 是缺后端的裸 " +
863
+ this.channelOf(account.platform) +
864
+ " 行,无法自动确定后端,等待带后端的入站消息或人工指定",
865
+ });
866
+ }
867
+ for (const account of this.findBotAccounts()) {
868
+ if (account.current_role === "bot")
869
+ continue;
870
+ const hasData = account.bound_person_id !== null ||
871
+ this.db.countImpressions("account", account.account_id) > 0 ||
872
+ this.db.getTraits("account", account.account_id).length > 0;
873
+ if (!hasData)
874
+ continue;
875
+ issues.push({
876
+ level: "warning",
877
+ code: "bot_profile_unmarked",
878
+ message: "命中群 botNames 或成员 bot 分类但未标记 bot: " + account.account_id,
879
+ });
880
+ }
881
+ for (const profile of this.db.profileStats(50)) {
882
+ if (profile.impressions > 12) {
883
+ issues.push({
884
+ level: "warning",
885
+ code: "profile_dense",
886
+ message: `${profile.owner_type}:${profile.owner_id} 有 ${profile.impressions} 条印象,建议归并到 traits`,
887
+ });
888
+ }
889
+ }
890
+ const backupDir = path.join(this.paths.dataDir, "backups");
891
+ let lastBackupAt = null;
892
+ if (fs.existsSync(backupDir)) {
893
+ const backups = fs
894
+ .readdirSync(backupDir)
895
+ .filter((name) => /^people-.*\.db$/.test(name))
896
+ .map((name) => path.join(backupDir, name))
897
+ .sort((a, b) => fs.statSync(b).mtimeMs - fs.statSync(a).mtimeMs);
898
+ if (backups[0])
899
+ lastBackupAt = fs.statSync(backups[0]).mtime.toISOString();
900
+ }
901
+ if (!lastBackupAt) {
902
+ issues.push({ level: "warning", code: "no_backup", message: "尚未创建识人库备份" });
903
+ }
904
+ return {
905
+ ok: !issues.some((issue) => issue.level === "error"),
906
+ dataDir: this.paths.dataDir,
907
+ dbPath: this.paths.dbPath,
908
+ counts: validation.counts,
909
+ issues,
910
+ lastBackupAt,
911
+ };
912
+ }
913
+ cycle(input = {}) {
914
+ const before = this.health();
915
+ const intervalHours = Math.max(1, Math.min(168, input.backupIntervalHours ?? 20));
916
+ const backupAgeMs = before.lastBackupAt ? Date.now() - Date.parse(before.lastBackupAt) : Number.POSITIVE_INFINITY;
917
+ const backupDue = input.forceBackup === true || !before.lastBackupAt || backupAgeMs >= intervalHours * 3_600_000;
918
+ // Take a fresh backup before repair whenever the periodic backup is due OR
919
+ // there are issues that repair might touch, so every automatic change has a
920
+ // rollback point.
921
+ const shouldBackup = backupDue || before.issues.length > 0;
922
+ const backup = shouldBackup ? this.backup(input.backupKeep ?? 14) : null;
923
+ const repairActions = this.repair();
924
+ const health = this.health();
925
+ const actions = [];
926
+ for (const issue of health.issues) {
927
+ if (issue.code === "profile_dense") {
928
+ const match = /^(account|person):(.+) 有 (\d+) 条印象/.exec(issue.message);
929
+ if (match?.[1] && match[2]) {
930
+ actions.push({
931
+ kind: "compact",
932
+ target: match[2],
933
+ ownerType: match[1],
934
+ message: `画像有 ${match[3] ?? "?"} 条零散印象,归并到 traits 后删除重复原文。`,
935
+ });
936
+ }
937
+ }
938
+ else if (issue.code === "duplicate_person_name") {
939
+ const match = /人物名「(.+)」对应多个记录:(.+)$/.exec(issue.message);
940
+ if (match?.[2]) {
941
+ actions.push({
942
+ kind: "merge_review",
943
+ target: match[2].trim(),
944
+ message: `人物名「${match[1]}」可能重复。核对账号和一手事实后再合并,证据不足则保留。`,
945
+ });
946
+ }
947
+ }
948
+ else if (issue.code === "person_binding_conflict") {
949
+ const match = /^同族两条接入绑到不同人物: (.+?),人物 (.+)\./u.exec(issue.message);
950
+ actions.push({
951
+ kind: "merge_review",
952
+ target: match?.[2] ?? "",
953
+ ownerType: "person",
954
+ message: `同族接入绑到不同人物(${match?.[1] ?? "见 health 详情"})。不自动改绑:向 owner 报告并给出建议(保留哪侧 + people_unbind 另一侧)。`,
955
+ });
956
+ }
957
+ else if (issue.code === "unlinked_person") {
958
+ const match = /^人物 (\S+)/.exec(issue.message);
959
+ if (match?.[1]) {
960
+ actions.push({
961
+ kind: "merge_review",
962
+ target: match[1],
963
+ ownerType: "person",
964
+ message: "人物没有绑定账号。确认是废弃记录且有明确目标时合并,否则保留。",
965
+ });
966
+ }
967
+ }
968
+ else if (issue.code === "bare_platform_account") {
969
+ const match = /^(\S+) 是/.exec(issue.message);
970
+ if (match?.[1]) {
971
+ actions.push({
972
+ kind: "observe",
973
+ target: match[1],
974
+ ownerType: "account",
975
+ message: "裸行缺后端:等待该发送者带后端的入站消息自动建立规范化行,或人工指定后端。",
976
+ });
977
+ }
978
+ }
979
+ }
980
+ const cycledAt = nowIso();
981
+ const previousCycle = this.db.metaGet("last_cycle_at");
982
+ this.db.metaSet("last_cycle_at", cycledAt);
983
+ this.db.metaSet("last_cycle_report", JSON.stringify({
984
+ cycledAt,
985
+ backup: backup ? { dbBackup: backup.dbBackup, jsonBackup: backup.jsonBackup } : null,
986
+ repairActions,
987
+ actions,
988
+ }));
989
+ return {
990
+ cycledAt,
991
+ backup,
992
+ backupSkipped: !shouldBackup,
993
+ repairActions,
994
+ health,
995
+ actions,
996
+ lastCycleAt: previousCycle,
997
+ };
998
+ }
999
+ // -------------------------------------------------------------------------
1000
+ // Status and validation
1001
+ // -------------------------------------------------------------------------
1002
+ status() {
1003
+ return {
1004
+ workspaceDir: this.paths.workspaceDir,
1005
+ dataDir: this.paths.dataDir,
1006
+ dbPath: this.paths.dbPath,
1007
+ schemaVersion: this.db.schemaVersion(),
1008
+ counts: this.db.counts(),
1009
+ dimensions: this.db.listDimensions(),
1010
+ lastFullScanAt: this.db.getScanMeta().lastFullScanAt,
1011
+ legacyImportedAt: this.db.metaGet("legacy_imported_at"),
1012
+ };
1013
+ }
1014
+ validate() {
1015
+ const issues = [];
1016
+ const counts = this.db.counts();
1017
+ const dimensions = new Map(this.db.listDimensions().map((dimension) => [dimension.key, dimension]));
1018
+ const accounts = this.db.listAllAccounts();
1019
+ const accountIds = new Set(accounts.map((account) => account.account_id));
1020
+ const groups = this.db.listAllGroups();
1021
+ const bindings = this.db.listAllPersonAccounts();
1022
+ if (this.db.schemaVersion() !== SCHEMA_VERSION) {
1023
+ issues.push({
1024
+ level: "error",
1025
+ code: "schema_version",
1026
+ message: `数据库 schema_version=${this.db.schemaVersion()},期望 ${SCHEMA_VERSION}`,
1027
+ });
1028
+ }
1029
+ for (const account of accounts) {
1030
+ if (!account.platform || !account.sender_id) {
1031
+ issues.push({ level: "error", code: "account_identity", message: `${account.account_id} 缺少 platform/sender_id` });
1032
+ }
1033
+ if (account.bound_person_id && !this.db.getPerson(account.bound_person_id)) {
1034
+ issues.push({
1035
+ level: "error",
1036
+ code: "missing_person",
1037
+ message: `${account.account_id} 绑定了不存在的 ${account.bound_person_id}`,
1038
+ });
1039
+ }
1040
+ if (!account.display_name && !account.confirmed_name) {
1041
+ issues.push({ level: "warning", code: "missing_name", message: `${account.account_id} 没有显示名` });
1042
+ }
1043
+ }
1044
+ for (const binding of bindings) {
1045
+ if (!accountIds.has(binding.account_id)) {
1046
+ issues.push({ level: "error", code: "binding_account", message: `绑定引用了不存在的账号 ${binding.account_id}` });
1047
+ }
1048
+ if (!this.db.getPerson(binding.person_id)) {
1049
+ issues.push({ level: "error", code: "binding_person", message: `绑定引用了不存在的人物 ${binding.person_id}` });
1050
+ }
1051
+ }
1052
+ for (const group of groups) {
1053
+ if (!group.chat_id) {
1054
+ issues.push({ level: "error", code: "group_chat", message: `${group.group_id} 缺少 chat_id` });
1055
+ }
1056
+ }
1057
+ for (const member of this.db.listAllGroupMembers()) {
1058
+ if (!accountIds.has(member.account_id)) {
1059
+ issues.push({
1060
+ level: "warning",
1061
+ code: "orphan_member",
1062
+ message: `群 ${member.group_id} 的成员 ${member.account_id} 不在账号索引中`,
1063
+ });
1064
+ }
1065
+ }
1066
+ for (const trait of this.db.listAllTraits()) {
1067
+ if (!dimensions.has(trait.dimension)) {
1068
+ issues.push({
1069
+ level: "warning",
1070
+ code: "unknown_dimension",
1071
+ message: `${trait.owner_type}:${trait.owner_id} 使用了未定义维度 ${trait.dimension}`,
1072
+ });
1073
+ }
1074
+ }
1075
+ if (!this.db.getScanMeta().lastFullScanAt) {
1076
+ issues.push({ level: "info", code: "scan_meta", message: "尚无全量扫描记录" });
1077
+ }
1078
+ if (this.db.hasLegacyJson() && !this.db.metaGet("legacy_imported_at")) {
1079
+ issues.push({ level: "warning", code: "legacy_not_imported", message: "存在旧 JSON 但尚未导入" });
1080
+ }
1081
+ return {
1082
+ ok: !issues.some((issue) => issue.level === "error"),
1083
+ dataDir: this.paths.dataDir,
1084
+ dbPath: this.paths.dbPath,
1085
+ counts,
1086
+ issues,
1087
+ };
1088
+ }
1089
+ repair() {
1090
+ const actions = [];
1091
+ this.db.transaction(() => {
1092
+ for (const binding of this.db.listAllPersonAccounts()) {
1093
+ if (!this.db.getPerson(binding.person_id)) {
1094
+ this.db.createPerson({ personId: binding.person_id });
1095
+ actions.push(`创建缺失人物 ${binding.person_id}`);
1096
+ }
1097
+ if (!this.db.getAccount(binding.account_id)) {
1098
+ const separator = binding.account_id.indexOf(":");
1099
+ if (separator > 0) {
1100
+ this.db.upsertAccount({
1101
+ platform: binding.account_id.slice(0, separator),
1102
+ senderId: binding.account_id.slice(separator + 1),
1103
+ displayName: binding.account_id.slice(separator + 1),
1104
+ });
1105
+ actions.push(`补建账号 ${binding.account_id}`);
1106
+ }
1107
+ }
1108
+ }
1109
+ for (const account of this.db.listAllAccounts()) {
1110
+ if (account.bound_person_id && !this.db.getPerson(account.bound_person_id)) {
1111
+ this.db.createPerson({ personId: account.bound_person_id, name: account.display_name });
1112
+ actions.push(`补建人物 ${account.bound_person_id}`);
1113
+ }
1114
+ }
1115
+ for (const binding of this.db.listAllPersonAccounts()) {
1116
+ const account = this.db.getAccount(binding.account_id);
1117
+ if (account && account.bound_person_id !== binding.person_id) {
1118
+ this.db.bindAccount(account.account_id, binding.person_id, {
1119
+ method: binding.bind_method,
1120
+ confidence: binding.confidence,
1121
+ migrate: true,
1122
+ });
1123
+ actions.push(`同步绑定 ${account.account_id} → ${binding.person_id}`);
1124
+ }
1125
+ }
1126
+ // Drop system/cron auto-rows that carry no data and no binding.
1127
+ for (const account of this.db.listAllAccounts()) {
1128
+ if (!this.isSkippedSender(account.sender_id))
1129
+ continue;
1130
+ if (account.bound_person_id)
1131
+ continue;
1132
+ const impressions = this.db.countImpressions("account", account.account_id);
1133
+ const traits = this.db.getTraits("account", account.account_id).length;
1134
+ if (impressions > 0 || traits > 0)
1135
+ continue;
1136
+ this.db.deleteAccount(account.account_id);
1137
+ actions.push(`移除系统账号 ${account.account_id}`);
1138
+ }
1139
+ // Backfill account rows for orphan group members. A member row without an
1140
+ // account row means the index lost the sender; rebuild it from the member
1141
+ // evidence instead of leaving a dangling reference.
1142
+ for (const member of this.db.listAllGroupMembers()) {
1143
+ if (this.db.getAccount(member.account_id))
1144
+ continue;
1145
+ if (this.isSkippedSender(this.senderOf(member.account_id))) {
1146
+ this.db.removeGroupMember(member.group_id, member.account_id);
1147
+ actions.push(`移除系统成员 ${member.account_id}(${member.group_id})`);
1148
+ continue;
1149
+ }
1150
+ // A member id without a platform segment still carries the sender id;
1151
+ // take the platform from the group row the member was seen in.
1152
+ const parsed = this.parseAccountId(member.account_id);
1153
+ const groupPlatform = this.db.getGroup(member.group_id)?.platform;
1154
+ const platform = parsed?.platform ?? groupPlatform;
1155
+ const senderId = parsed?.senderId ?? member.account_id;
1156
+ if (!platform || !senderId)
1157
+ continue;
1158
+ const created = this.db.upsertAccount({
1159
+ platform,
1160
+ senderId,
1161
+ displayName: member.display_name ?? senderId,
1162
+ });
1163
+ const role = member.classification === "likely_bot"
1164
+ ? "bot"
1165
+ : member.role_in_group === "owner"
1166
+ ? "owner"
1167
+ : undefined;
1168
+ if (role)
1169
+ this.db.setAccountRole(created.account.account_id, role);
1170
+ const ownerPerson = role === "owner" ? this.personForOwnerSender(senderId) : null;
1171
+ if (ownerPerson) {
1172
+ this.db.bindAccount(created.account.account_id, ownerPerson, {
1173
+ method: "member_backfill",
1174
+ confidence: "tentative",
1175
+ migrate: true,
1176
+ });
1177
+ }
1178
+ // The account id is normalized (platform:sender), so the member row that
1179
+ // referenced a platform-less id must be rewritten to the new key.
1180
+ if (created.account.account_id !== member.account_id) {
1181
+ this.db.removeGroupMember(member.group_id, member.account_id);
1182
+ this.db.upsertGroupMember({
1183
+ groupId: member.group_id,
1184
+ accountId: created.account.account_id,
1185
+ displayName: member.display_name ?? undefined,
1186
+ classification: member.classification,
1187
+ roleInGroup: member.role_in_group ?? undefined,
1188
+ });
1189
+ }
1190
+ actions.push(`补建成员账号 ${member.account_id} → ${created.account.account_id}(${member.group_id}${role ? `,标记 ${role}` : ""}${ownerPerson ? `,绑定 ${ownerPerson}` : ""})`);
1191
+ }
1192
+ // A member row we cannot turn into an account at all is a placeholder.
1193
+ for (const member of this.db.listAllGroupMembers()) {
1194
+ if (this.db.getAccount(member.account_id))
1195
+ continue;
1196
+ this.db.removeGroupMember(member.group_id, member.account_id);
1197
+ actions.push(`移除无法定位的成员 ${member.account_id}(${member.group_id})`);
1198
+ }
1199
+ // Accounts without any display name fall back to the sender id so cards
1200
+ // and lookups stay readable. Real names arrive with the next message.
1201
+ for (const account of this.db.listAllAccounts()) {
1202
+ if (account.display_name || account.confirmed_name || account.call_name)
1203
+ continue;
1204
+ this.db.upsertAccount({
1205
+ platform: account.platform,
1206
+ senderId: account.sender_id,
1207
+ displayName: account.sender_id,
1208
+ });
1209
+ actions.push(`补默认显示名 ${account.account_id}`);
1210
+ }
1211
+ // Persons with no binding and no profile carry no memory; drop them.
1212
+ for (const person of this.db.listAllPersons()) {
1213
+ if (this.db.listPersonAccounts(person.person_id).length > 0)
1214
+ continue;
1215
+ if (this.db.countImpressions("person", person.person_id) > 0)
1216
+ continue;
1217
+ if (this.db.getTraits("person", person.person_id).length > 0)
1218
+ continue;
1219
+ // A person with notes may be a deliberate placeholder awaiting a binding.
1220
+ if ((person.notes ?? "").trim())
1221
+ continue;
1222
+ if (this.db.deletePerson(person.person_id)) {
1223
+ actions.push(`移除空人物 ${person.person_id}(${person.call_name || person.name || "未命名"})`);
1224
+ }
1225
+ }
1226
+ // Same-sender family rows collapse to one person (GewePlus + GolemPlus).
1227
+ const familySenders = new Set();
1228
+ for (const account of this.db.listAllAccounts()) {
1229
+ if (this.familyKeyOf(account.platform))
1230
+ familySenders.add(account.sender_id);
1231
+ }
1232
+ for (const sender of familySenders) {
1233
+ try {
1234
+ const action = this.syncFamilyBinding(sender);
1235
+ if (action)
1236
+ actions.push(action);
1237
+ }
1238
+ catch (error) {
1239
+ this.logger.warn(`[people] family sweep failed for ${sender}: ${error.message}`);
1240
+ }
1241
+ }
1242
+ for (const account of this.findBotAccounts()) {
1243
+ if (account.current_role === "bot")
1244
+ continue;
1245
+ this.db.setAccountRole(account.account_id, "bot");
1246
+ actions.push("标记 " + account.account_id + " 为 bot");
1247
+ }
1248
+ for (const account of this.db.listAllAccounts()) {
1249
+ if (!this.isBareMultiAccountRow(account))
1250
+ continue;
1251
+ const siblings = this.db
1252
+ .listAccountsBySender(account.sender_id)
1253
+ .filter((row) => row.account_id !== account.account_id &&
1254
+ row.platform.includes("/") &&
1255
+ this.channelOf(row.platform) === this.channelOf(account.platform));
1256
+ if (siblings.length > 0) {
1257
+ // Backend-qualified rows exist: move any leftover data onto the newest one.
1258
+ const target = [...siblings].sort((a, b) => b.last_interaction.localeCompare(a.last_interaction))[0];
1259
+ const moved = this.db.mergeAccounts(account.account_id, target.account_id);
1260
+ if (moved.impressions > 0 || moved.traits > 0 || moved.groupMembers > 0) {
1261
+ actions.push(`裸行数据迁移 ${account.account_id} → ${target.account_id}(印象 ${moved.impressions} / 特征 ${moved.traits} / 群成员 ${moved.groupMembers})`);
1262
+ }
1263
+ continue;
1264
+ }
1265
+ const impressions = this.db.countImpressions("account", account.account_id);
1266
+ const traits = this.db.getTraits("account", account.account_id).length;
1267
+ if (impressions > 0 || traits > 0)
1268
+ continue;
1269
+ this.db.deleteAccount(account.account_id);
1270
+ actions.push("移除空裸账号 " + account.account_id);
1271
+ }
1272
+ });
1273
+ return actions;
1274
+ }
1275
+ }
1276
+ export function describeCounts(counts) {
1277
+ return Object.entries(counts)
1278
+ .map(([key, value]) => `${key}=${value}`)
1279
+ .join(" ");
1280
+ }
1281
+ export function describeDimensions(dimensions) {
1282
+ return dimensions
1283
+ .map((dimension) => `${dimension.key}(${dimension.label})${dimension.active ? "" : "[off]"}`)
1284
+ .join(", ");
1285
+ }
1286
+ export function resolvePersonName(person, account) {
1287
+ return (person?.call_name ||
1288
+ account.call_name ||
1289
+ person?.name ||
1290
+ account.confirmed_name ||
1291
+ account.display_name ||
1292
+ account.account_id);
1293
+ }