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

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,967 @@
1
+ import crypto from "node:crypto";
2
+ import fs from "node:fs";
3
+ import path from "node:path";
4
+ import { DatabaseSync } from "node:sqlite";
5
+ import { ensureDir } from "./paths.js";
6
+ import { platformLookupKey } from "./platform.js";
7
+ import { SCHEMA_SQL, SCHEMA_VERSION, DEFAULT_DIMENSIONS } from "./schema.js";
8
+ import { nowIso } from "./time.js";
9
+ function text(value) {
10
+ return typeof value === "string" && value.trim() ? value.trim() : null;
11
+ }
12
+ function number(value) {
13
+ return typeof value === "bigint" ? Number(value) : Number(value ?? 0);
14
+ }
15
+ export function normalizeContent(value) {
16
+ return value.trim().replace(/\s+/g, " ").toLowerCase();
17
+ }
18
+ export function contentKey(value) {
19
+ return crypto.createHash("sha256").update(normalizeContent(value)).digest("hex").slice(0, 32);
20
+ }
21
+ export function newPersonId() {
22
+ return `p_${crypto.randomBytes(4).toString("hex")}`;
23
+ }
24
+ export class PeopleDatabase {
25
+ dbPath;
26
+ workspaceKey;
27
+ db;
28
+ transactionDepth = 0;
29
+ constructor(dbPath, workspaceKey = path.dirname(dbPath)) {
30
+ this.dbPath = dbPath;
31
+ this.workspaceKey = workspaceKey;
32
+ ensureDir(path.dirname(dbPath));
33
+ this.db = new DatabaseSync(dbPath);
34
+ this.db.exec("PRAGMA journal_mode=WAL;");
35
+ this.db.exec("PRAGMA busy_timeout=5000;");
36
+ this.db.exec("PRAGMA foreign_keys=ON;");
37
+ this.db.exec("PRAGMA synchronous=NORMAL;");
38
+ this.db.exec(SCHEMA_SQL);
39
+ this.migrate();
40
+ this.ensureDefaultDimensions();
41
+ }
42
+ close() {
43
+ this.db.close();
44
+ }
45
+ migrate() {
46
+ const current = Number(this.metaGet("schema_version") ?? 0);
47
+ this.ensureColumn("accounts", "call_name", "TEXT");
48
+ this.ensureColumn("persons", "call_name", "TEXT");
49
+ if (current < SCHEMA_VERSION) {
50
+ this.metaSet("schema_version", String(SCHEMA_VERSION));
51
+ }
52
+ }
53
+ ensureColumn(table, column, type) {
54
+ const columns = this.db.prepare(`PRAGMA table_info(${table})`).all();
55
+ if (columns.some((entry) => entry.name === column))
56
+ return;
57
+ this.db.exec(`ALTER TABLE ${table} ADD COLUMN ${column} ${type}`);
58
+ }
59
+ transaction(fn) {
60
+ if (this.transactionDepth > 0) {
61
+ const savepoint = `people_sp_${this.transactionDepth}`;
62
+ this.db.exec(`SAVEPOINT ${savepoint}`);
63
+ this.transactionDepth += 1;
64
+ try {
65
+ const result = fn();
66
+ this.db.exec(`RELEASE ${savepoint}`);
67
+ this.transactionDepth -= 1;
68
+ return result;
69
+ }
70
+ catch (error) {
71
+ try {
72
+ this.db.exec(`ROLLBACK TO ${savepoint}`);
73
+ this.db.exec(`RELEASE ${savepoint}`);
74
+ }
75
+ catch {
76
+ // ignore savepoint cleanup failures
77
+ }
78
+ this.transactionDepth -= 1;
79
+ throw error;
80
+ }
81
+ }
82
+ this.db.exec("BEGIN IMMEDIATE");
83
+ this.transactionDepth = 1;
84
+ try {
85
+ const result = fn();
86
+ this.db.exec("COMMIT");
87
+ this.transactionDepth = 0;
88
+ return result;
89
+ }
90
+ catch (error) {
91
+ try {
92
+ this.db.exec("ROLLBACK");
93
+ }
94
+ catch {
95
+ // ignore rollback failures
96
+ }
97
+ this.transactionDepth = 0;
98
+ throw error;
99
+ }
100
+ }
101
+ metaGet(key) {
102
+ const row = this.db.prepare("SELECT value FROM meta WHERE key = ?").get(key);
103
+ return row ? text(row.value) : null;
104
+ }
105
+ metaSet(key, value) {
106
+ this.db
107
+ .prepare("INSERT INTO meta(key, value) VALUES(?, ?) ON CONFLICT(key) DO UPDATE SET value=excluded.value")
108
+ .run(key, value);
109
+ }
110
+ schemaVersion() {
111
+ return Number(this.metaGet("schema_version") ?? 0);
112
+ }
113
+ // -------------------------------------------------------------------------
114
+ // Accounts
115
+ // -------------------------------------------------------------------------
116
+ upsertAccount(input) {
117
+ const platform = input.platform.trim();
118
+ const senderId = input.senderId.trim();
119
+ const platformKey = platformLookupKey(platform);
120
+ const existing = this.getAccountByPlatformSender(platform, senderId);
121
+ const at = input.lastInteraction?.trim() || nowIso();
122
+ const displayName = text(input.displayName);
123
+ if (!existing) {
124
+ const accountId = `${platform}:${senderId}`;
125
+ const firstSeen = input.firstSeen?.trim() || at;
126
+ this.db
127
+ .prepare(`INSERT INTO accounts(account_id, platform, platform_key, sender_id, display_name, current_role, first_seen, last_interaction, updated_at)
128
+ VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?)`)
129
+ .run(accountId, platform, platformKey, senderId, displayName, text(input.currentRole), firstSeen, at, at);
130
+ return {
131
+ account: this.getAccount(accountId),
132
+ created: true,
133
+ displayNameChanged: Boolean(displayName),
134
+ };
135
+ }
136
+ const changed = Boolean(displayName && displayName !== existing.display_name);
137
+ this.db
138
+ .prepare(`UPDATE accounts SET
139
+ platform = ?,
140
+ platform_key = ?,
141
+ sender_id = ?,
142
+ display_name = COALESCE(?, display_name),
143
+ current_role = COALESCE(?, current_role),
144
+ last_interaction = ?,
145
+ updated_at = ?
146
+ WHERE account_id = ?`)
147
+ .run(platform, platformKey, senderId, displayName, text(input.currentRole), at, at, existing.account_id);
148
+ return {
149
+ account: this.getAccount(existing.account_id),
150
+ created: false,
151
+ displayNameChanged: changed,
152
+ };
153
+ }
154
+ getAccount(accountId) {
155
+ const row = this.db.prepare("SELECT * FROM accounts WHERE account_id = ?").get(accountId);
156
+ return row ? this.mapAccount(row) : null;
157
+ }
158
+ getAccountByPlatformSender(platform, senderId) {
159
+ const row = this.db
160
+ .prepare("SELECT * FROM accounts WHERE platform_key = ? AND sender_id = ?")
161
+ .get(platformLookupKey(platform), senderId);
162
+ return row ? this.mapAccount(row) : null;
163
+ }
164
+ /** Drop one group membership row (a member that is not a tracked account). */
165
+ removeGroupMember(groupId, accountId) {
166
+ const result = this.db
167
+ .prepare("DELETE FROM group_members WHERE group_id=? AND account_id=?")
168
+ .run(groupId, accountId);
169
+ return number(result.changes) > 0;
170
+ }
171
+ /** Delete a person and its bindings; refuses when the person still has data. */
172
+ deletePerson(personId) {
173
+ const impressions = this.countImpressions("person", personId);
174
+ const traits = this.getTraits("person", personId).length;
175
+ const bindingRow = this.db
176
+ .prepare("SELECT COUNT(*) AS c FROM person_accounts WHERE person_id=?")
177
+ .get(personId);
178
+ const bindings = number(bindingRow?.c);
179
+ if (impressions > 0 || traits > 0 || bindings > 0)
180
+ return false;
181
+ return this.transaction(() => {
182
+ this.db.prepare("DELETE FROM persons WHERE person_id=?").run(personId);
183
+ return true;
184
+ });
185
+ }
186
+ deleteAccount(accountId) {
187
+ this.db.prepare("DELETE FROM accounts WHERE account_id = ?").run(accountId);
188
+ }
189
+ setAccountRole(accountId, role) {
190
+ this.db.prepare("UPDATE accounts SET current_role=?, updated_at=? WHERE account_id=?").run(role.trim(), nowIso(), accountId);
191
+ }
192
+ /** Move account-level data (impressions, traits, group memberships) from
193
+ * `sourceId` to `targetId`, then drop the source row. If the source was
194
+ * bound to a person and the target was not, the target inherits that person.
195
+ * Runs in one transaction.
196
+ */
197
+ mergeAccounts(sourceId, targetId) {
198
+ const source = this.getAccount(sourceId);
199
+ const target = this.getAccount(targetId);
200
+ if (!source || !target)
201
+ throw new Error(`账号不存在: ${sourceId} 或 ${targetId}`);
202
+ if (source.account_id === target.account_id)
203
+ throw new Error("源账号和目标账号不能相同");
204
+ return this.transaction(() => {
205
+ const impressions = number(this.db
206
+ .prepare(`INSERT OR IGNORE INTO impressions(owner_type, owner_id, content, dedupe_key, source, agent_id, created_at)
207
+ SELECT 'account', ?, content, dedupe_key, source, agent_id, created_at
208
+ FROM impressions WHERE owner_type='account' AND owner_id=?`)
209
+ .run(targetId, sourceId).changes);
210
+ this.db.prepare("DELETE FROM impressions WHERE owner_type='account' AND owner_id=?").run(sourceId);
211
+ // Preserve conflicting source traits as impressions so a merge never
212
+ // silently drops a value the target already holds for the same dimension.
213
+ const sourceTraits = this.getTraits("account", sourceId);
214
+ const targetDims = new Set(this.getTraits("account", targetId).map((trait) => trait.dimension));
215
+ for (const trait of sourceTraits) {
216
+ if (targetDims.has(trait.dimension)) {
217
+ this.addImpression({
218
+ ownerType: "account",
219
+ ownerId: targetId,
220
+ content: `合并保留特征 ${trait.dimension}:${trait.value}`,
221
+ source: "merge",
222
+ });
223
+ }
224
+ }
225
+ const traits = number(this.db
226
+ .prepare(`INSERT OR IGNORE INTO traits(owner_type, owner_id, dimension, value, confidence, updated_at)
227
+ SELECT 'account', ?, dimension, value, confidence, updated_at
228
+ FROM traits WHERE owner_type='account' AND owner_id=?`)
229
+ .run(targetId, sourceId).changes);
230
+ this.db.prepare("DELETE FROM traits WHERE owner_type='account' AND owner_id=?").run(sourceId);
231
+ const groupMembers = number(this.db
232
+ .prepare("UPDATE group_members SET account_id=? WHERE account_id=? AND group_id NOT IN (SELECT group_id FROM group_members WHERE account_id=?)")
233
+ .run(targetId, sourceId, targetId).changes);
234
+ this.db.prepare("DELETE FROM group_members WHERE account_id=?").run(sourceId);
235
+ let bindingMoved = false;
236
+ if (source.bound_person_id && !target.bound_person_id) {
237
+ this.bindAccount(targetId, source.bound_person_id, { method: "manual", confidence: "confirmed", migrate: false });
238
+ bindingMoved = true;
239
+ }
240
+ this.deleteAccount(sourceId);
241
+ return { impressions, traits, groupMembers, bindingMoved };
242
+ });
243
+ }
244
+ searchAccounts(query, limit = 10) {
245
+ const value = `%${query.trim()}%`;
246
+ if (!query.trim())
247
+ return [];
248
+ const rows = this.db
249
+ .prepare(`SELECT * FROM accounts
250
+ WHERE account_id LIKE ? COLLATE NOCASE
251
+ OR sender_id LIKE ? COLLATE NOCASE
252
+ OR display_name LIKE ? COLLATE NOCASE
253
+ OR confirmed_name LIKE ? COLLATE NOCASE
254
+ OR call_name LIKE ? COLLATE NOCASE
255
+ ORDER BY last_interaction DESC LIMIT ?`)
256
+ .all(value, value, value, value, value, limit);
257
+ return rows.map((row) => this.mapAccount(row));
258
+ }
259
+ listAllAccounts() {
260
+ const rows = this.db.prepare("SELECT * FROM accounts ORDER BY last_interaction DESC").all();
261
+ return rows.map((row) => this.mapAccount(row));
262
+ }
263
+ listAccountsBySender(senderId) {
264
+ const rows = this.db
265
+ .prepare("SELECT * FROM accounts WHERE sender_id=? ORDER BY last_interaction DESC")
266
+ .all(senderId.trim());
267
+ return rows.map((row) => this.mapAccount(row));
268
+ }
269
+ listAllPersonAccounts() {
270
+ const rows = this.db.prepare("SELECT * FROM person_accounts ORDER BY bound_at").all();
271
+ return rows.map((row) => ({
272
+ account_id: String(row.account_id),
273
+ person_id: String(row.person_id),
274
+ bind_method: String(row.bind_method),
275
+ confidence: String(row.confidence),
276
+ bound_at: String(row.bound_at),
277
+ }));
278
+ }
279
+ setConfirmedName(accountId, name) {
280
+ const at = nowIso();
281
+ this.db
282
+ .prepare("UPDATE accounts SET confirmed_name = ?, updated_at = ? WHERE account_id = ?")
283
+ .run(name.trim(), at, accountId);
284
+ }
285
+ setCallName(accountId, name) {
286
+ const at = nowIso();
287
+ this.db
288
+ .prepare("UPDATE accounts SET call_name = ?, updated_at = ? WHERE account_id = ?")
289
+ .run(name.trim(), at, accountId);
290
+ }
291
+ touchAccount(accountId, at = nowIso()) {
292
+ this.db
293
+ .prepare("UPDATE accounts SET last_interaction = ?, updated_at = ? WHERE account_id = ?")
294
+ .run(at, at, accountId);
295
+ }
296
+ mapAccount(row) {
297
+ return {
298
+ account_id: String(row.account_id),
299
+ platform: String(row.platform),
300
+ sender_id: String(row.sender_id),
301
+ display_name: text(row.display_name),
302
+ confirmed_name: text(row.confirmed_name),
303
+ call_name: text(row.call_name),
304
+ current_role: text(row.current_role),
305
+ first_seen: String(row.first_seen),
306
+ last_interaction: String(row.last_interaction),
307
+ updated_at: String(row.updated_at),
308
+ bound_person_id: text(row.bound_person_id),
309
+ };
310
+ }
311
+ // -------------------------------------------------------------------------
312
+ // Persons and bindings
313
+ // -------------------------------------------------------------------------
314
+ createPerson(input = {}) {
315
+ const personId = input.personId?.trim() || newPersonId();
316
+ const at = input.createdAt?.trim() || nowIso();
317
+ this.db
318
+ .prepare("INSERT OR IGNORE INTO persons(person_id, name, notes, created_at, updated_at) VALUES(?, ?, ?, ?, ?)")
319
+ .run(personId, text(input.name), text(input.notes), at, at);
320
+ return this.getPerson(personId);
321
+ }
322
+ getPerson(personId) {
323
+ const row = this.db.prepare("SELECT * FROM persons WHERE person_id = ?").get(personId);
324
+ return row
325
+ ? {
326
+ person_id: String(row.person_id),
327
+ name: text(row.name),
328
+ call_name: text(row.call_name),
329
+ notes: text(row.notes),
330
+ created_at: String(row.created_at),
331
+ updated_at: String(row.updated_at),
332
+ }
333
+ : null;
334
+ }
335
+ listAllPersons() {
336
+ const rows = this.db.prepare("SELECT * FROM persons ORDER BY updated_at DESC").all();
337
+ return rows.map((row) => ({
338
+ person_id: String(row.person_id),
339
+ name: text(row.name),
340
+ call_name: text(row.call_name),
341
+ notes: text(row.notes),
342
+ created_at: String(row.created_at),
343
+ updated_at: String(row.updated_at),
344
+ }));
345
+ }
346
+ mergePersons(sourcePersonId, targetPersonId) {
347
+ if (sourcePersonId === targetPersonId)
348
+ throw new Error("源人物和目标人物不能相同。");
349
+ const source = this.getPerson(sourcePersonId);
350
+ const target = this.getPerson(targetPersonId);
351
+ if (!source)
352
+ throw new Error(`源人物不存在: ${sourcePersonId}`);
353
+ if (!target)
354
+ throw new Error(`目标人物不存在: ${targetPersonId}`);
355
+ return this.transaction(() => {
356
+ const impressions = number(this.db
357
+ .prepare(`INSERT OR IGNORE INTO impressions(owner_type, owner_id, content, dedupe_key, source, agent_id, created_at)
358
+ SELECT 'person', ?, content, dedupe_key, source, agent_id, created_at
359
+ FROM impressions WHERE owner_type='person' AND owner_id=?`)
360
+ .run(targetPersonId, sourcePersonId).changes);
361
+ this.db
362
+ .prepare("DELETE FROM impressions WHERE owner_type='person' AND owner_id=?")
363
+ .run(sourcePersonId);
364
+ // Preserve conflicting source traits as impressions so a merge never
365
+ // silently drops a value the target already holds for the same dimension.
366
+ const sourceTraits = this.getTraits("person", sourcePersonId);
367
+ const targetDims = new Set(this.getTraits("person", targetPersonId).map((trait) => trait.dimension));
368
+ for (const trait of sourceTraits) {
369
+ if (targetDims.has(trait.dimension)) {
370
+ this.addImpression({
371
+ ownerType: "person",
372
+ ownerId: targetPersonId,
373
+ content: `合并保留特征 ${trait.dimension}:${trait.value}`,
374
+ source: "merge",
375
+ });
376
+ }
377
+ }
378
+ const traits = number(this.db
379
+ .prepare(`INSERT OR IGNORE INTO traits(owner_type, owner_id, dimension, value, confidence, updated_at)
380
+ SELECT 'person', ?, dimension, value, confidence, updated_at
381
+ FROM traits WHERE owner_type='person' AND owner_id=?`)
382
+ .run(targetPersonId, sourcePersonId).changes);
383
+ this.db.prepare("DELETE FROM traits WHERE owner_type='person' AND owner_id=?").run(sourcePersonId);
384
+ const accounts = number(this.db
385
+ .prepare("UPDATE OR REPLACE person_accounts SET person_id=?, bound_at=? WHERE person_id=?")
386
+ .run(targetPersonId, nowIso(), sourcePersonId).changes);
387
+ this.db
388
+ .prepare("UPDATE accounts SET bound_person_id=?, updated_at=? WHERE bound_person_id=?")
389
+ .run(targetPersonId, nowIso(), sourcePersonId);
390
+ const notes = target.notes?.trim() || source.notes?.trim() || null;
391
+ const callName = target.call_name?.trim() || source.call_name?.trim() || null;
392
+ const name = target.name?.trim() || source.name?.trim() || null;
393
+ this.db
394
+ .prepare("UPDATE persons SET name=?, call_name=?, notes=?, updated_at=? WHERE person_id=?")
395
+ .run(name, callName, notes, nowIso(), targetPersonId);
396
+ this.db.prepare("DELETE FROM persons WHERE person_id=?").run(sourcePersonId);
397
+ return { accounts, impressions, traits, deletedSource: true };
398
+ });
399
+ }
400
+ unbindAccount(accountId, copyProfile = true) {
401
+ const account = this.getAccount(accountId);
402
+ if (!account)
403
+ throw new Error(`账号不存在: ${accountId}`);
404
+ const personId = account.bound_person_id;
405
+ if (!personId)
406
+ return { impressionsCopied: 0, traitsCopied: 0 };
407
+ return this.transaction(() => {
408
+ let impressionsCopied = 0;
409
+ let traitsCopied = 0;
410
+ if (copyProfile) {
411
+ impressionsCopied = number(this.db
412
+ .prepare(`INSERT OR IGNORE INTO impressions(owner_type, owner_id, content, dedupe_key, source, agent_id, created_at)
413
+ SELECT 'account', ?, content, dedupe_key, source, agent_id, created_at
414
+ FROM impressions WHERE owner_type='person' AND owner_id=?`)
415
+ .run(accountId, personId).changes);
416
+ traitsCopied = number(this.db
417
+ .prepare(`INSERT OR IGNORE INTO traits(owner_type, owner_id, dimension, value, confidence, updated_at)
418
+ SELECT 'account', ?, dimension, value, confidence, updated_at
419
+ FROM traits WHERE owner_type='person' AND owner_id=?`)
420
+ .run(accountId, personId).changes);
421
+ }
422
+ this.db.prepare("DELETE FROM person_accounts WHERE account_id=?").run(accountId);
423
+ this.db
424
+ .prepare("UPDATE accounts SET bound_person_id=NULL, updated_at=? WHERE account_id=?")
425
+ .run(nowIso(), accountId);
426
+ return { impressionsCopied, traitsCopied };
427
+ });
428
+ }
429
+ updatePerson(personId, input) {
430
+ const at = nowIso();
431
+ this.db
432
+ .prepare("UPDATE persons SET name = COALESCE(?, name), call_name = COALESCE(?, call_name), notes = COALESCE(?, notes), updated_at = ? WHERE person_id = ?")
433
+ .run(text(input.name), text(input.callName), text(input.notes), at, personId);
434
+ }
435
+ searchPersons(query, limit = 10) {
436
+ const value = `%${query.trim()}%`;
437
+ const rows = this.db
438
+ .prepare("SELECT * FROM persons WHERE person_id LIKE ? OR name LIKE ? OR call_name LIKE ? ORDER BY updated_at DESC LIMIT ?")
439
+ .all(value, value, value, limit);
440
+ return rows.map((row) => ({
441
+ person_id: String(row.person_id),
442
+ name: text(row.name),
443
+ call_name: text(row.call_name),
444
+ notes: text(row.notes),
445
+ created_at: String(row.created_at),
446
+ updated_at: String(row.updated_at),
447
+ }));
448
+ }
449
+ bindAccount(accountId, personId, options = {}) {
450
+ const at = nowIso();
451
+ return this.transaction(() => {
452
+ this.db
453
+ .prepare(`INSERT INTO person_accounts(account_id, person_id, bind_method, confidence, bound_at)
454
+ VALUES(?, ?, ?, ?, ?)
455
+ ON CONFLICT(account_id) DO UPDATE SET
456
+ person_id=excluded.person_id,
457
+ bind_method=excluded.bind_method,
458
+ confidence=excluded.confidence,
459
+ bound_at=excluded.bound_at`)
460
+ .run(accountId, personId, options.method ?? "manual", options.confidence ?? "confirmed", at);
461
+ this.db
462
+ .prepare("UPDATE accounts SET bound_person_id = ?, updated_at = ? WHERE account_id = ?")
463
+ .run(personId, at, accountId);
464
+ let impressionsMoved = 0;
465
+ let traitsMoved = 0;
466
+ if (options.migrate !== false) {
467
+ impressionsMoved = number(this.db
468
+ .prepare(`INSERT OR IGNORE INTO impressions(owner_type, owner_id, content, dedupe_key, source, agent_id, created_at)
469
+ SELECT 'person', ?, content, dedupe_key, source, agent_id, created_at
470
+ FROM impressions WHERE owner_type='account' AND owner_id=?`)
471
+ .run(personId, accountId).changes);
472
+ this.db
473
+ .prepare("DELETE FROM impressions WHERE owner_type='account' AND owner_id=?")
474
+ .run(accountId);
475
+ traitsMoved = number(this.db
476
+ .prepare(`INSERT OR IGNORE INTO traits(owner_type, owner_id, dimension, value, confidence, updated_at)
477
+ SELECT 'person', ?, dimension, value, confidence, updated_at
478
+ FROM traits WHERE owner_type='account' AND owner_id=?`)
479
+ .run(personId, accountId).changes);
480
+ this.db.prepare("DELETE FROM traits WHERE owner_type='account' AND owner_id=?").run(accountId);
481
+ }
482
+ this.db.prepare("UPDATE persons SET updated_at=? WHERE person_id=?").run(at, personId);
483
+ return { impressionsMoved, traitsMoved };
484
+ });
485
+ }
486
+ listPersonAccounts(personId) {
487
+ const rows = this.db
488
+ .prepare(`SELECT a.* FROM accounts a JOIN person_accounts pa ON pa.account_id = a.account_id
489
+ WHERE pa.person_id = ? ORDER BY a.last_interaction DESC`)
490
+ .all(personId);
491
+ return rows.map((row) => this.mapAccount(row));
492
+ }
493
+ // -------------------------------------------------------------------------
494
+ // Traits and impressions
495
+ // -------------------------------------------------------------------------
496
+ setTrait(ownerType, ownerId, dimension, value, confidence = null) {
497
+ const at = nowIso();
498
+ this.db
499
+ .prepare(`INSERT INTO traits(owner_type, owner_id, dimension, value, confidence, updated_at)
500
+ VALUES(?, ?, ?, ?, ?, ?)
501
+ ON CONFLICT(owner_type, owner_id, dimension) DO UPDATE SET
502
+ value=excluded.value, confidence=excluded.confidence, updated_at=excluded.updated_at`)
503
+ .run(ownerType, ownerId, dimension, value.trim(), confidence, at);
504
+ if (ownerType === "person") {
505
+ this.db.prepare("UPDATE persons SET updated_at=? WHERE person_id=?").run(at, ownerId);
506
+ }
507
+ }
508
+ getTraits(ownerType, ownerId) {
509
+ const rows = this.db
510
+ .prepare(`SELECT t.dimension, t.value, t.confidence, t.updated_at, d.sort
511
+ FROM traits t LEFT JOIN dimensions d ON d.key = t.dimension
512
+ WHERE t.owner_type=? AND t.owner_id=?
513
+ ORDER BY COALESCE(d.sort, 999), t.dimension`)
514
+ .all(ownerType, ownerId);
515
+ return rows.map((row) => ({
516
+ dimension: String(row.dimension),
517
+ value: String(row.value),
518
+ confidence: text(row.confidence),
519
+ updated_at: String(row.updated_at),
520
+ }));
521
+ }
522
+ addImpression(input) {
523
+ const content = input.content.trim();
524
+ if (!content)
525
+ return false;
526
+ const result = this.db
527
+ .prepare(`INSERT OR IGNORE INTO impressions(owner_type, owner_id, content, dedupe_key, source, agent_id, created_at)
528
+ VALUES(?, ?, ?, ?, ?, ?, ?)`)
529
+ .run(input.ownerType, input.ownerId, content, contentKey(content), text(input.source), text(input.agentId), input.createdAt ?? nowIso());
530
+ return number(result.changes) > 0;
531
+ }
532
+ listImpressions(ownerType, ownerId, limit = 50) {
533
+ const rows = this.db
534
+ .prepare(`SELECT * FROM impressions WHERE owner_type=? AND owner_id=? ORDER BY created_at DESC, id DESC LIMIT ?`)
535
+ .all(ownerType, ownerId, limit);
536
+ return rows.map((row) => ({
537
+ id: number(row.id),
538
+ owner_type: String(row.owner_type),
539
+ owner_id: String(row.owner_id),
540
+ content: String(row.content),
541
+ source: text(row.source),
542
+ agent_id: text(row.agent_id),
543
+ created_at: String(row.created_at),
544
+ }));
545
+ }
546
+ countImpressions(ownerType, ownerId) {
547
+ const row = this.db
548
+ .prepare("SELECT COUNT(*) AS n FROM impressions WHERE owner_type=? AND owner_id=?")
549
+ .get(ownerType, ownerId);
550
+ return number(row?.n);
551
+ }
552
+ removeImpressions(ownerType, ownerId, match) {
553
+ const result = this.db
554
+ .prepare("DELETE FROM impressions WHERE owner_type=? AND owner_id=? AND content LIKE ?")
555
+ .run(ownerType, ownerId, `%${match.trim()}%`);
556
+ return number(result.changes);
557
+ }
558
+ removeTraitsMatching(ownerType, ownerId, match) {
559
+ const result = this.db
560
+ .prepare("DELETE FROM traits WHERE owner_type=? AND owner_id=? AND value LIKE ?")
561
+ .run(ownerType, ownerId, `%${match.trim()}%`);
562
+ return number(result.changes);
563
+ }
564
+ listAllTraits() {
565
+ const rows = this.db.prepare("SELECT * FROM traits ORDER BY owner_type, owner_id, dimension").all();
566
+ return rows.map((row) => ({
567
+ owner_type: String(row.owner_type),
568
+ owner_id: String(row.owner_id),
569
+ dimension: String(row.dimension),
570
+ value: String(row.value),
571
+ confidence: text(row.confidence),
572
+ updated_at: String(row.updated_at),
573
+ }));
574
+ }
575
+ profileStats(limit = 20) {
576
+ const rows = this.db
577
+ .prepare(`SELECT owner_type, owner_id, COUNT(*) AS n, MIN(created_at) AS oldest, MAX(created_at) AS newest
578
+ FROM impressions GROUP BY owner_type, owner_id ORDER BY n DESC LIMIT ?`)
579
+ .all(limit);
580
+ return rows.map((row) => ({
581
+ owner_type: String(row.owner_type),
582
+ owner_id: String(row.owner_id),
583
+ impressions: number(row.n),
584
+ oldest: String(row.oldest),
585
+ newest: String(row.newest),
586
+ }));
587
+ }
588
+ searchImpressions(query, limit = 10) {
589
+ const value = `%${query.trim()}%`;
590
+ if (!query.trim())
591
+ return [];
592
+ const rows = this.db
593
+ .prepare("SELECT * FROM impressions WHERE content LIKE ? COLLATE NOCASE ORDER BY created_at DESC LIMIT ?")
594
+ .all(value, limit);
595
+ return rows.map((row) => ({
596
+ id: number(row.id),
597
+ owner_type: String(row.owner_type),
598
+ owner_id: String(row.owner_id),
599
+ content: String(row.content),
600
+ source: text(row.source),
601
+ agent_id: text(row.agent_id),
602
+ created_at: String(row.created_at),
603
+ }));
604
+ }
605
+ searchGroupEvents(query, limit = 10) {
606
+ const value = `%${query.trim()}%`;
607
+ if (!query.trim())
608
+ return [];
609
+ const rows = this.db
610
+ .prepare("SELECT * FROM group_events WHERE summary LIKE ? COLLATE NOCASE ORDER BY occurred_at DESC LIMIT ?")
611
+ .all(value, limit);
612
+ return rows.map((row) => ({
613
+ id: number(row.id),
614
+ group_id: String(row.group_id),
615
+ occurred_at: String(row.occurred_at),
616
+ category: String(row.category),
617
+ summary: String(row.summary),
618
+ tags: this.parseStringArray(row.tags),
619
+ agent_id: text(row.agent_id),
620
+ }));
621
+ }
622
+ backupTo(file) {
623
+ ensureDir(path.dirname(file));
624
+ if (fs.existsSync(file))
625
+ throw new Error(`备份文件已存在: ${file}`);
626
+ this.db.exec("PRAGMA wal_checkpoint(FULL);");
627
+ const escaped = file.replace(/'/g, "''");
628
+ this.db.exec(`VACUUM INTO '${escaped}'`);
629
+ }
630
+ dump() {
631
+ const tables = [
632
+ "accounts",
633
+ "persons",
634
+ "person_accounts",
635
+ "impressions",
636
+ "traits",
637
+ "groups",
638
+ "group_members",
639
+ "group_events",
640
+ "group_observations",
641
+ "dimensions",
642
+ "scan_channels",
643
+ "scan_meta",
644
+ "meta",
645
+ ];
646
+ const data = {
647
+ exported_at: nowIso(),
648
+ schema_version: this.schemaVersion(),
649
+ };
650
+ for (const table of tables) {
651
+ data[table] = this.db.prepare(`SELECT * FROM ${table}`).all();
652
+ }
653
+ return data;
654
+ }
655
+ // -------------------------------------------------------------------------
656
+ // Groups
657
+ // -------------------------------------------------------------------------
658
+ upsertGroup(input) {
659
+ const platformKey = platformLookupKey(input.platform);
660
+ const existing = this.getGroup(input.groupId) ?? this.findGroup(input.platform, input.chatId);
661
+ const at = nowIso();
662
+ const subject = text(input.subject);
663
+ const displayName = text(input.displayName);
664
+ if (!existing) {
665
+ this.db
666
+ .prepare(`INSERT INTO groups(group_id, platform, platform_key, chat_id, display_name, group_subject, bot_names, first_seen, updated_at)
667
+ VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?)`)
668
+ .run(input.groupId, input.platform, platformKey, input.chatId, displayName, subject, JSON.stringify(input.botNames ?? []), input.firstSeen?.trim() || at, at);
669
+ return { group: this.getGroup(input.groupId), created: true, subjectChanged: Boolean(subject) };
670
+ }
671
+ const changed = Boolean(subject && subject !== existing.group_subject);
672
+ this.db
673
+ .prepare(`UPDATE groups SET
674
+ platform=?, platform_key=?, chat_id=?,
675
+ display_name=COALESCE(?, display_name),
676
+ group_subject=COALESCE(?, group_subject),
677
+ updated_at=?
678
+ WHERE group_id=?`)
679
+ .run(input.platform, platformKey, input.chatId, displayName, subject, at, existing.group_id);
680
+ return {
681
+ group: this.getGroup(existing.group_id),
682
+ created: false,
683
+ subjectChanged: changed,
684
+ };
685
+ }
686
+ getGroup(groupId) {
687
+ const row = this.db.prepare("SELECT * FROM groups WHERE group_id=?").get(groupId);
688
+ return row ? this.mapGroup(row) : null;
689
+ }
690
+ findGroup(platform, chatId) {
691
+ const row = this.db
692
+ .prepare("SELECT * FROM groups WHERE platform_key=? AND chat_id=?")
693
+ .get(platformLookupKey(platform), chatId);
694
+ return row ? this.mapGroup(row) : null;
695
+ }
696
+ findGroupByChat(chatId) {
697
+ const row = this.db
698
+ .prepare("SELECT * FROM groups WHERE chat_id=? ORDER BY updated_at DESC LIMIT 1")
699
+ .get(chatId);
700
+ return row ? this.mapGroup(row) : null;
701
+ }
702
+ /** All group rows sharing the same chat id, newest first (multi-backend independence). */
703
+ findGroupsByChatAll(chatId) {
704
+ const rows = this.db
705
+ .prepare("SELECT * FROM groups WHERE chat_id=? ORDER BY updated_at DESC")
706
+ .all(chatId.trim());
707
+ return rows.map((row) => this.mapGroup(row));
708
+ }
709
+ listAllGroups() {
710
+ const rows = this.db.prepare("SELECT * FROM groups ORDER BY updated_at DESC").all();
711
+ return rows.map((row) => this.mapGroup(row));
712
+ }
713
+ updateGroupSubject(groupId, subject) {
714
+ const at = nowIso();
715
+ this.db
716
+ .prepare("UPDATE groups SET group_subject=?, updated_at=? WHERE group_id=?")
717
+ .run(subject.trim(), at, groupId);
718
+ }
719
+ setGroupBotNames(groupId, names) {
720
+ this.db.prepare("UPDATE groups SET bot_names=? WHERE group_id=?").run(JSON.stringify(names), groupId);
721
+ }
722
+ countGroupEvents(groupId) {
723
+ const row = this.db.prepare("SELECT COUNT(*) AS n FROM group_events WHERE group_id=?").get(groupId);
724
+ return number(row?.n ?? 0);
725
+ }
726
+ countGroupMembers(groupId) {
727
+ const row = this.db.prepare("SELECT COUNT(*) AS n FROM group_members WHERE group_id=?").get(groupId);
728
+ return number(row?.n ?? 0);
729
+ }
730
+ mergeGroups(sourceId, targetId) {
731
+ const source = this.getGroup(sourceId);
732
+ const target = this.getGroup(targetId);
733
+ if (!source || !target)
734
+ throw new Error("群不存在: " + sourceId + " 或 " + targetId);
735
+ if (source.group_id === target.group_id)
736
+ throw new Error("源群和目标群不能相同");
737
+ return this.transaction(() => {
738
+ const events = number(this.db
739
+ .prepare("INSERT OR IGNORE INTO group_events(group_id, occurred_at, category, summary, tags, agent_id, dedupe_key, created_at) " +
740
+ "SELECT ?, occurred_at, category, summary, tags, agent_id, dedupe_key, created_at FROM group_events WHERE group_id=?")
741
+ .run(targetId, sourceId).changes);
742
+ this.db.prepare("DELETE FROM group_events WHERE group_id=?").run(sourceId);
743
+ const observations = number(this.db
744
+ .prepare("INSERT OR IGNORE INTO group_observations(group_id, content, dedupe_key, created_at) " +
745
+ "SELECT ?, content, dedupe_key, created_at FROM group_observations WHERE group_id=?")
746
+ .run(targetId, sourceId).changes);
747
+ this.db.prepare("DELETE FROM group_observations WHERE group_id=?").run(sourceId);
748
+ const members = number(this.db
749
+ .prepare("UPDATE group_members SET group_id=? " +
750
+ "WHERE group_id=? AND account_id NOT IN (SELECT account_id FROM group_members WHERE group_id=?)")
751
+ .run(targetId, sourceId, targetId).changes);
752
+ this.db.prepare("DELETE FROM group_members WHERE group_id=?").run(sourceId);
753
+ const botNames = [...new Set([...target.bot_names, ...source.bot_names])];
754
+ this.db.prepare("UPDATE groups SET bot_names=? WHERE group_id=?").run(JSON.stringify(botNames), targetId);
755
+ let subjectAdopted = false;
756
+ if (!target.group_subject && source.group_subject) {
757
+ this.db
758
+ .prepare("UPDATE groups SET group_subject=?, updated_at=? WHERE group_id=?")
759
+ .run(source.group_subject, nowIso(), targetId);
760
+ subjectAdopted = true;
761
+ }
762
+ this.db.prepare("UPDATE scan_channels SET group_id=? WHERE group_id=?").run(targetId, sourceId);
763
+ this.db.prepare("DELETE FROM groups WHERE group_id=?").run(sourceId);
764
+ return { events, members, observations, botNames, subjectAdopted };
765
+ });
766
+ }
767
+ addGroupEvent(input) {
768
+ const summary = input.summary.trim();
769
+ if (!summary)
770
+ return false;
771
+ const occurredAt = input.occurredAt ?? nowIso();
772
+ const dedupe = contentKey(`${occurredAt.slice(0, 10)}:${summary}`);
773
+ const result = this.db
774
+ .prepare(`INSERT OR IGNORE INTO group_events(group_id, occurred_at, category, summary, tags, agent_id, dedupe_key, created_at)
775
+ VALUES(?, ?, ?, ?, ?, ?, ?, ?)`)
776
+ .run(input.groupId, occurredAt, input.category?.trim() || "observation", summary, JSON.stringify(input.tags ?? []), text(input.agentId), dedupe, nowIso());
777
+ if (number(result.changes) > 0) {
778
+ this.db.prepare("UPDATE groups SET updated_at=? WHERE group_id=?").run(nowIso(), input.groupId);
779
+ return true;
780
+ }
781
+ return false;
782
+ }
783
+ listGroupEvents(groupId, limit = 10) {
784
+ const rows = this.db
785
+ .prepare("SELECT * FROM group_events WHERE group_id=? ORDER BY occurred_at DESC, id DESC LIMIT ?")
786
+ .all(groupId, limit);
787
+ return rows.map((row) => ({
788
+ id: number(row.id),
789
+ group_id: String(row.group_id),
790
+ occurred_at: String(row.occurred_at),
791
+ category: String(row.category),
792
+ summary: String(row.summary),
793
+ tags: this.parseStringArray(row.tags),
794
+ agent_id: text(row.agent_id),
795
+ }));
796
+ }
797
+ addGroupObservation(groupId, content) {
798
+ const value = content.trim();
799
+ if (!value)
800
+ return false;
801
+ const result = this.db
802
+ .prepare(`INSERT OR IGNORE INTO group_observations(group_id, content, dedupe_key, created_at)
803
+ VALUES(?, ?, ?, ?)`)
804
+ .run(groupId, value, contentKey(value), nowIso());
805
+ return number(result.changes) > 0;
806
+ }
807
+ upsertGroupMember(input) {
808
+ const at = input.at ?? nowIso();
809
+ this.db
810
+ .prepare(`INSERT INTO group_members(group_id, account_id, display_name, classification, confidence, role_in_group, first_seen, last_seen)
811
+ VALUES(?, ?, ?, ?, ?, ?, ?, ?)
812
+ ON CONFLICT(group_id, account_id) DO UPDATE SET
813
+ display_name=COALESCE(excluded.display_name, group_members.display_name),
814
+ classification=COALESCE(excluded.classification, group_members.classification),
815
+ confidence=COALESCE(excluded.confidence, group_members.confidence),
816
+ role_in_group=COALESCE(excluded.role_in_group, group_members.role_in_group),
817
+ last_seen=excluded.last_seen`)
818
+ .run(input.groupId, input.accountId, text(input.displayName), input.classification?.trim() || "pending", input.confidence?.trim() || "tentative", text(input.roleInGroup), at, at);
819
+ }
820
+ listGroupMembers(groupId, limit = 50) {
821
+ const rows = this.db
822
+ .prepare("SELECT * FROM group_members WHERE group_id=? ORDER BY last_seen DESC LIMIT ?")
823
+ .all(groupId, limit);
824
+ return rows.map((row) => ({
825
+ group_id: String(row.group_id),
826
+ account_id: String(row.account_id),
827
+ display_name: text(row.display_name),
828
+ classification: String(row.classification),
829
+ confidence: String(row.confidence),
830
+ role_in_group: text(row.role_in_group),
831
+ first_seen: String(row.first_seen),
832
+ last_seen: String(row.last_seen),
833
+ }));
834
+ }
835
+ listAllGroupMembers() {
836
+ const rows = this.db.prepare("SELECT * FROM group_members ORDER BY group_id, last_seen DESC").all();
837
+ return rows.map((row) => ({
838
+ group_id: String(row.group_id),
839
+ account_id: String(row.account_id),
840
+ display_name: text(row.display_name),
841
+ classification: String(row.classification),
842
+ confidence: String(row.confidence),
843
+ role_in_group: text(row.role_in_group),
844
+ first_seen: String(row.first_seen),
845
+ last_seen: String(row.last_seen),
846
+ }));
847
+ }
848
+ mapGroup(row) {
849
+ return {
850
+ group_id: String(row.group_id),
851
+ platform: String(row.platform),
852
+ chat_id: String(row.chat_id),
853
+ display_name: text(row.display_name),
854
+ group_subject: text(row.group_subject),
855
+ bot_names: this.parseStringArray(row.bot_names),
856
+ first_seen: String(row.first_seen),
857
+ updated_at: String(row.updated_at),
858
+ };
859
+ }
860
+ // -------------------------------------------------------------------------
861
+ // Dimensions
862
+ // -------------------------------------------------------------------------
863
+ ensureDefaultDimensions() {
864
+ const statement = this.db.prepare("INSERT OR IGNORE INTO dimensions(key, label, active, sort) VALUES(?, ?, 1, ?)");
865
+ for (const dimension of DEFAULT_DIMENSIONS) {
866
+ statement.run(dimension.key, dimension.label, dimension.sort);
867
+ }
868
+ }
869
+ upsertDimension(key, label, active, sort = 999) {
870
+ this.db
871
+ .prepare(`INSERT INTO dimensions(key, label, active, sort) VALUES(?, ?, ?, ?)
872
+ ON CONFLICT(key) DO UPDATE SET label=excluded.label, active=excluded.active, sort=excluded.sort`)
873
+ .run(key.trim(), label.trim(), active ? 1 : 0, sort);
874
+ }
875
+ listDimensions() {
876
+ const rows = this.db.prepare("SELECT * FROM dimensions ORDER BY sort, key").all();
877
+ return rows.map((row) => ({
878
+ key: String(row.key),
879
+ label: String(row.label),
880
+ active: number(row.active) !== 0,
881
+ sort: number(row.sort),
882
+ }));
883
+ }
884
+ getDimension(key) {
885
+ const row = this.db.prepare("SELECT * FROM dimensions WHERE key=?").get(key);
886
+ if (!row)
887
+ return null;
888
+ return {
889
+ key: String(row.key),
890
+ label: String(row.label),
891
+ active: number(row.active) !== 0,
892
+ sort: number(row.sort),
893
+ };
894
+ }
895
+ // -------------------------------------------------------------------------
896
+ // Scan state
897
+ // -------------------------------------------------------------------------
898
+ upsertScanChannel(input) {
899
+ this.db
900
+ .prepare(`INSERT INTO scan_channels(workspace_key, channel_key, session_key, display_name, kind, platform, group_id, last_scan_at)
901
+ VALUES(?, ?, ?, ?, ?, ?, ?, ?)
902
+ ON CONFLICT(workspace_key, channel_key) DO UPDATE SET
903
+ session_key=COALESCE(excluded.session_key, scan_channels.session_key),
904
+ display_name=COALESCE(excluded.display_name, scan_channels.display_name),
905
+ kind=COALESCE(excluded.kind, scan_channels.kind),
906
+ platform=COALESCE(excluded.platform, scan_channels.platform),
907
+ group_id=COALESCE(excluded.group_id, scan_channels.group_id),
908
+ last_scan_at=COALESCE(excluded.last_scan_at, scan_channels.last_scan_at)`)
909
+ .run(this.workspaceKey, input.channelKey, text(input.sessionKey), text(input.displayName), text(input.kind), text(input.platform), text(input.groupId), text(input.lastScanAt));
910
+ }
911
+ setScanMeta(lastFullScanAt) {
912
+ this.db
913
+ .prepare(`INSERT INTO scan_meta(workspace_key, last_full_scan_at, updated_at) VALUES(?, ?, ?)
914
+ ON CONFLICT(workspace_key) DO UPDATE SET last_full_scan_at=excluded.last_full_scan_at, updated_at=excluded.updated_at`)
915
+ .run(this.workspaceKey, lastFullScanAt, nowIso());
916
+ }
917
+ getScanMeta() {
918
+ const row = this.db
919
+ .prepare("SELECT * FROM scan_meta WHERE workspace_key=?")
920
+ .get(this.workspaceKey);
921
+ return {
922
+ lastFullScanAt: row ? text(row.last_full_scan_at) : null,
923
+ updatedAt: row ? text(row.updated_at) : null,
924
+ };
925
+ }
926
+ // -------------------------------------------------------------------------
927
+ // Counts
928
+ // -------------------------------------------------------------------------
929
+ counts() {
930
+ const tables = [
931
+ "accounts",
932
+ "persons",
933
+ "person_accounts",
934
+ "groups",
935
+ "group_members",
936
+ "group_events",
937
+ "group_observations",
938
+ "impressions",
939
+ "traits",
940
+ "dimensions",
941
+ "scan_channels",
942
+ ];
943
+ const result = {};
944
+ for (const table of tables) {
945
+ const row = this.db.prepare(`SELECT COUNT(*) AS n FROM ${table}`).get();
946
+ result[table] = number(row?.n);
947
+ }
948
+ return result;
949
+ }
950
+ hasLegacyJson() {
951
+ const names = ["accounts.json", "groups.json", "persons.json", "person_accounts.json", "scan-state.json"];
952
+ return names.some((name) => fs.existsSync(path.join(path.dirname(this.dbPath), name)));
953
+ }
954
+ parseStringArray(value) {
955
+ if (Array.isArray(value))
956
+ return value.filter((item) => typeof item === "string");
957
+ if (typeof value !== "string" || !value.trim())
958
+ return [];
959
+ try {
960
+ const parsed = JSON.parse(value);
961
+ return Array.isArray(parsed) ? parsed.filter((item) => typeof item === "string") : [];
962
+ }
963
+ catch {
964
+ return [];
965
+ }
966
+ }
967
+ }