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.
- package/DESIGN.md +182 -0
- package/LICENSE +21 -0
- package/README.md +214 -2
- package/assets/icon.png +0 -0
- package/automation/README.md +52 -0
- package/automation/cycle.template.md +51 -0
- package/dist/index.js +17 -0
- package/dist/src/core/db.js +967 -0
- package/dist/src/core/legacy.js +465 -0
- package/dist/src/core/paths.js +100 -0
- package/dist/src/core/platform.js +48 -0
- package/dist/src/core/schema.js +153 -0
- package/dist/src/core/service.js +1293 -0
- package/dist/src/core/time.js +27 -0
- package/dist/src/core/types.js +1 -0
- package/dist/src/plugin/cli.js +177 -0
- package/dist/src/plugin/config.js +64 -0
- package/dist/src/plugin/hooks.js +101 -0
- package/dist/src/plugin/identity.js +77 -0
- package/dist/src/plugin/runtime.js +72 -0
- package/dist/src/plugin/tools.js +649 -0
- package/openclaw.plugin.json +142 -0
- package/package.json +67 -4
- package/skills/people-identifier/SKILL.md +63 -0
|
@@ -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
|
+
}
|