@unblocklabs/unblock-memory 0.3.3 → 0.3.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +61 -1
- package/dist/src/analysis.js +21 -11
- package/dist/src/config.d.ts +15 -1
- package/dist/src/config.js +97 -13
- package/dist/src/manager.js +47 -40
- package/dist/src/people-cli.d.ts +4 -0
- package/dist/src/people-cli.js +47 -0
- package/dist/src/people-evidence.d.ts +14 -0
- package/dist/src/people-evidence.js +93 -0
- package/dist/src/people-hooks.d.ts +5 -0
- package/dist/src/people-hooks.js +101 -0
- package/dist/src/people-refinement.d.ts +72 -0
- package/dist/src/people-refinement.js +254 -0
- package/dist/src/people-store.d.ts +134 -0
- package/dist/src/people-store.js +679 -0
- package/dist/src/people-tools.d.ts +5 -0
- package/dist/src/people-tools.js +193 -0
- package/dist/src/plugin.js +45 -15
- package/dist/src/skill-whisperer.js +11 -6
- package/dist/src/slack-directory.d.ts +47 -0
- package/dist/src/slack-directory.js +130 -0
- package/dist/src/sources.d.ts +1 -0
- package/dist/src/sources.js +7 -0
- package/openclaw.plugin.json +74 -3
- package/package.json +1 -1
|
@@ -0,0 +1,679 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { chmodSync, mkdirSync } from "node:fs";
|
|
3
|
+
import { join, dirname } from "node:path";
|
|
4
|
+
import { DatabaseSync } from "node:sqlite";
|
|
5
|
+
import { resolveStateDir } from "openclaw/plugin-sdk/memory-core-host-engine-foundation";
|
|
6
|
+
import { normalizeAgentIdStrict } from "openclaw/plugin-sdk/routing";
|
|
7
|
+
import { Type } from "typebox";
|
|
8
|
+
import { Value } from "typebox/value";
|
|
9
|
+
const BASELINE_DOSSIER_CATEGORIES = [
|
|
10
|
+
"role",
|
|
11
|
+
"priorities",
|
|
12
|
+
"preferences",
|
|
13
|
+
"successCriteria",
|
|
14
|
+
"workingStyle",
|
|
15
|
+
"relationship",
|
|
16
|
+
"openLoops",
|
|
17
|
+
];
|
|
18
|
+
const evidenceRefSchema = Type.Object({
|
|
19
|
+
source: Type.Union([
|
|
20
|
+
Type.Literal("session"),
|
|
21
|
+
Type.Literal("memory"),
|
|
22
|
+
Type.Literal("directory"),
|
|
23
|
+
Type.Literal("manual"),
|
|
24
|
+
]),
|
|
25
|
+
locator: Type.String({ minLength: 1, maxLength: 1000 }),
|
|
26
|
+
observedAt: Type.Optional(Type.String({
|
|
27
|
+
pattern: "^\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}(?:\\.\\d{1,9})?(?:Z|[+-]\\d{2}:\\d{2})$",
|
|
28
|
+
})),
|
|
29
|
+
}, { additionalProperties: false });
|
|
30
|
+
const claimSchema = Type.Object({
|
|
31
|
+
statement: Type.String({ minLength: 1, maxLength: 2000 }),
|
|
32
|
+
evidence: Type.Array(evidenceRefSchema, { minItems: 1, maxItems: 50 }),
|
|
33
|
+
epistemicType: Type.Union([
|
|
34
|
+
Type.Literal("observed"),
|
|
35
|
+
Type.Literal("reported"),
|
|
36
|
+
Type.Literal("inferred"),
|
|
37
|
+
Type.Literal("agent_assessment"),
|
|
38
|
+
]),
|
|
39
|
+
confidence: Type.Optional(Type.Union([Type.Literal("low"), Type.Literal("medium"), Type.Literal("high")])),
|
|
40
|
+
}, { additionalProperties: false });
|
|
41
|
+
export const PERSON_DOSSIER_SCHEMA = Type.Object({
|
|
42
|
+
schemaVersion: Type.Literal(1),
|
|
43
|
+
blurb: Type.String({ minLength: 1 }),
|
|
44
|
+
sections: Type.Array(Type.Object({
|
|
45
|
+
category: Type.Union(BASELINE_DOSSIER_CATEGORIES.map((category) => Type.Literal(category))),
|
|
46
|
+
claims: Type.Array(claimSchema, { minItems: 1, maxItems: 100 }),
|
|
47
|
+
}, { additionalProperties: false }), { maxItems: BASELINE_DOSSIER_CATEGORIES.length }),
|
|
48
|
+
}, { additionalProperties: false });
|
|
49
|
+
const OVERFLOW_KEY = "__people_todo_overflow__";
|
|
50
|
+
function required(value, label) {
|
|
51
|
+
const normalized = value.trim();
|
|
52
|
+
if (!normalized)
|
|
53
|
+
throw new Error(`${label} must be a non-empty string`);
|
|
54
|
+
return normalized;
|
|
55
|
+
}
|
|
56
|
+
function optional(value) {
|
|
57
|
+
return value?.trim() || null;
|
|
58
|
+
}
|
|
59
|
+
function person(row) {
|
|
60
|
+
return {
|
|
61
|
+
id: row.id,
|
|
62
|
+
displayName: row.display_name,
|
|
63
|
+
preferredName: row.preferred_name,
|
|
64
|
+
status: row.status,
|
|
65
|
+
companyId: row.company_id,
|
|
66
|
+
refinementEnabled: row.refinement_enabled === 1,
|
|
67
|
+
injectionEnabled: row.injection_enabled === 1,
|
|
68
|
+
lastSeenAt: row.last_seen_at,
|
|
69
|
+
createdAt: row.created_at,
|
|
70
|
+
updatedAt: row.updated_at,
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
function identity(row) {
|
|
74
|
+
return {
|
|
75
|
+
personId: row.person_id,
|
|
76
|
+
provider: row.provider,
|
|
77
|
+
accountScope: row.account_scope,
|
|
78
|
+
externalId: row.external_id,
|
|
79
|
+
displayName: row.display_name,
|
|
80
|
+
realName: row.real_name,
|
|
81
|
+
handle: row.handle,
|
|
82
|
+
avatarUrl: row.avatar_url,
|
|
83
|
+
title: row.title,
|
|
84
|
+
isBot: row.is_bot === null ? null : row.is_bot === 1,
|
|
85
|
+
isDeactivated: row.is_deactivated === 1,
|
|
86
|
+
firstSeenAt: row.first_seen_at,
|
|
87
|
+
lastSeenAt: row.last_seen_at,
|
|
88
|
+
lastSyncedAt: row.last_synced_at,
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
function todo(row) {
|
|
92
|
+
return {
|
|
93
|
+
id: row.id,
|
|
94
|
+
deduplicationKey: row.deduplication_key,
|
|
95
|
+
kind: row.kind,
|
|
96
|
+
context: JSON.parse(row.context_json),
|
|
97
|
+
status: row.status,
|
|
98
|
+
occurrenceCount: row.occurrence_count,
|
|
99
|
+
firstSeenAt: row.first_seen_at,
|
|
100
|
+
lastSeenAt: row.last_seen_at,
|
|
101
|
+
resolvedAt: row.resolved_at,
|
|
102
|
+
resolutionNote: row.resolution_note,
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
function company(row) {
|
|
106
|
+
return {
|
|
107
|
+
id: row.id,
|
|
108
|
+
name: row.name,
|
|
109
|
+
primaryDomain: row.primary_domain,
|
|
110
|
+
status: row.status,
|
|
111
|
+
createdAt: row.created_at,
|
|
112
|
+
updatedAt: row.updated_at,
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
export class PeopleStore {
|
|
116
|
+
#db;
|
|
117
|
+
#maxOpenTodos;
|
|
118
|
+
#maxBlurbChars;
|
|
119
|
+
constructor(path, options) {
|
|
120
|
+
mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
|
|
121
|
+
this.#db = new DatabaseSync(path);
|
|
122
|
+
this.#maxOpenTodos = options.maxOpenTodos;
|
|
123
|
+
this.#maxBlurbChars = options.maxBlurbChars;
|
|
124
|
+
try {
|
|
125
|
+
chmodSync(path, 0o600);
|
|
126
|
+
this.#db.exec("PRAGMA journal_mode = WAL; PRAGMA busy_timeout = 5000; PRAGMA foreign_keys = ON");
|
|
127
|
+
this.#migrate();
|
|
128
|
+
}
|
|
129
|
+
catch (error) {
|
|
130
|
+
this.#db.close();
|
|
131
|
+
throw error;
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
close() {
|
|
135
|
+
this.#db.close();
|
|
136
|
+
}
|
|
137
|
+
upsertIdentity(input) {
|
|
138
|
+
const provider = required(input.provider, "provider");
|
|
139
|
+
const accountScope = required(input.accountScope, "accountScope");
|
|
140
|
+
const externalId = required(input.externalId, "externalId");
|
|
141
|
+
const directorySync = input.syncedAt !== undefined;
|
|
142
|
+
const now = input.seenAt ?? input.syncedAt ?? new Date().toISOString();
|
|
143
|
+
this.#db.exec("BEGIN IMMEDIATE");
|
|
144
|
+
try {
|
|
145
|
+
const existing = this.#identityRow(provider, accountScope, externalId);
|
|
146
|
+
const personId = existing?.person_id ?? randomUUID();
|
|
147
|
+
if (existing) {
|
|
148
|
+
const existingPerson = this.#db
|
|
149
|
+
.prepare("SELECT * FROM people WHERE id = ?")
|
|
150
|
+
.get(personId);
|
|
151
|
+
if (existingPerson.status !== "active") {
|
|
152
|
+
this.#db.exec("COMMIT");
|
|
153
|
+
return { person: person(existingPerson), identity: identity(existing), created: false };
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
if (!existing) {
|
|
157
|
+
const displayName = optional(input.displayName) ?? optional(input.realName) ?? externalId;
|
|
158
|
+
this.#db
|
|
159
|
+
.prepare(`
|
|
160
|
+
INSERT INTO people
|
|
161
|
+
(id, display_name, status, refinement_enabled, injection_enabled,
|
|
162
|
+
last_seen_at, created_at, updated_at)
|
|
163
|
+
VALUES (?, ?, 'active', 0, 0, ?, ?, ?)
|
|
164
|
+
`)
|
|
165
|
+
.run(personId, displayName, directorySync ? null : now, now, now);
|
|
166
|
+
this.#db
|
|
167
|
+
.prepare(`
|
|
168
|
+
INSERT INTO person_identities
|
|
169
|
+
(person_id, provider, account_scope, external_id, display_name, real_name,
|
|
170
|
+
handle, avatar_url, title, is_bot, is_deactivated, first_seen_at,
|
|
171
|
+
last_seen_at, last_synced_at)
|
|
172
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
173
|
+
`)
|
|
174
|
+
.run(personId, provider, accountScope, externalId, optional(input.displayName), optional(input.realName), optional(input.handle), optional(input.avatarUrl), optional(input.title), input.isBot === undefined ? null : Number(input.isBot), Number(input.isDeactivated ?? false), now, now, input.syncedAt ?? null);
|
|
175
|
+
if (provider === "slack" && !directorySync) {
|
|
176
|
+
this.#upsertTodoRow({
|
|
177
|
+
deduplicationKey: `needs-enrichment:slack:${accountScope}:${externalId}`,
|
|
178
|
+
kind: "needs_enrichment",
|
|
179
|
+
context: { personId, provider, accountScope, externalId },
|
|
180
|
+
}, now);
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
else {
|
|
184
|
+
this.#db
|
|
185
|
+
.prepare(`
|
|
186
|
+
UPDATE person_identities SET
|
|
187
|
+
display_name = COALESCE(?, display_name),
|
|
188
|
+
real_name = COALESCE(?, real_name),
|
|
189
|
+
handle = COALESCE(?, handle),
|
|
190
|
+
avatar_url = COALESCE(?, avatar_url),
|
|
191
|
+
title = COALESCE(?, title),
|
|
192
|
+
is_bot = COALESCE(?, is_bot),
|
|
193
|
+
is_deactivated = COALESCE(?, is_deactivated),
|
|
194
|
+
last_seen_at = ?,
|
|
195
|
+
last_synced_at = COALESCE(?, last_synced_at)
|
|
196
|
+
WHERE provider = ? AND account_scope = ? AND external_id = ?
|
|
197
|
+
`)
|
|
198
|
+
.run(optional(input.displayName), optional(input.realName), optional(input.handle), optional(input.avatarUrl), optional(input.title), input.isBot === undefined ? null : Number(input.isBot), input.isDeactivated === undefined ? null : Number(input.isDeactivated), now, input.syncedAt ?? null, provider, accountScope, externalId);
|
|
199
|
+
if (!directorySync) {
|
|
200
|
+
this.#db
|
|
201
|
+
.prepare("UPDATE people SET last_seen_at = ?, updated_at = ? WHERE id = ?")
|
|
202
|
+
.run(now, now, personId);
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
if (input.isDeactivated === true) {
|
|
206
|
+
this.#db
|
|
207
|
+
.prepare(`
|
|
208
|
+
UPDATE people SET status = 'unavailable', refinement_enabled = 0,
|
|
209
|
+
injection_enabled = 0, updated_at = ? WHERE id = ?
|
|
210
|
+
`)
|
|
211
|
+
.run(now, personId);
|
|
212
|
+
}
|
|
213
|
+
const personRow = this.#db
|
|
214
|
+
.prepare("SELECT * FROM people WHERE id = ?")
|
|
215
|
+
.get(personId);
|
|
216
|
+
const identityRow = this.#identityRow(provider, accountScope, externalId);
|
|
217
|
+
this.#db.exec("COMMIT");
|
|
218
|
+
return { person: person(personRow), identity: identity(identityRow), created: !existing };
|
|
219
|
+
}
|
|
220
|
+
catch (error) {
|
|
221
|
+
this.#db.exec("ROLLBACK");
|
|
222
|
+
throw error;
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
findPersonByIdentity(provider, accountScope, externalId) {
|
|
226
|
+
const row = this.#db
|
|
227
|
+
.prepare(`
|
|
228
|
+
SELECT people.* FROM people
|
|
229
|
+
JOIN person_identities ON person_identities.person_id = people.id
|
|
230
|
+
WHERE person_identities.provider = ?
|
|
231
|
+
AND person_identities.account_scope = ?
|
|
232
|
+
AND person_identities.external_id = ?
|
|
233
|
+
`)
|
|
234
|
+
.get(provider, accountScope, externalId);
|
|
235
|
+
return row ? person(row) : undefined;
|
|
236
|
+
}
|
|
237
|
+
getPerson(personId) {
|
|
238
|
+
const row = this.#db.prepare("SELECT * FROM people WHERE id = ?").get(personId);
|
|
239
|
+
return row ? person(row) : undefined;
|
|
240
|
+
}
|
|
241
|
+
listIdentities(personId) {
|
|
242
|
+
return this.#db
|
|
243
|
+
.prepare(`
|
|
244
|
+
SELECT * FROM person_identities WHERE person_id = ?
|
|
245
|
+
ORDER BY provider, account_scope, external_id
|
|
246
|
+
`)
|
|
247
|
+
.all(personId)
|
|
248
|
+
.map((row) => identity(row));
|
|
249
|
+
}
|
|
250
|
+
getCompany(companyId) {
|
|
251
|
+
const row = this.#db.prepare("SELECT * FROM companies WHERE id = ?").get(companyId);
|
|
252
|
+
return row ? company(row) : undefined;
|
|
253
|
+
}
|
|
254
|
+
setCompany(personId, input) {
|
|
255
|
+
const name = required(input.name, "company name");
|
|
256
|
+
const primaryDomain = optional(input.primaryDomain);
|
|
257
|
+
const now = new Date().toISOString();
|
|
258
|
+
this.#db.exec("BEGIN IMMEDIATE");
|
|
259
|
+
try {
|
|
260
|
+
const target = this.#db.prepare("SELECT id FROM people WHERE id = ?").get(personId);
|
|
261
|
+
if (!target) {
|
|
262
|
+
this.#db.exec("COMMIT");
|
|
263
|
+
return undefined;
|
|
264
|
+
}
|
|
265
|
+
let row = this.#db
|
|
266
|
+
.prepare("SELECT * FROM companies WHERE name = ? COLLATE NOCASE")
|
|
267
|
+
.get(name);
|
|
268
|
+
if (!row) {
|
|
269
|
+
const id = randomUUID();
|
|
270
|
+
this.#db
|
|
271
|
+
.prepare(`
|
|
272
|
+
INSERT INTO companies (id, name, primary_domain, status, created_at, updated_at)
|
|
273
|
+
VALUES (?, ?, ?, 'active', ?, ?)
|
|
274
|
+
`)
|
|
275
|
+
.run(id, name, primaryDomain, now, now);
|
|
276
|
+
row = this.#db.prepare("SELECT * FROM companies WHERE id = ?").get(id);
|
|
277
|
+
}
|
|
278
|
+
else if (primaryDomain && primaryDomain !== row.primary_domain) {
|
|
279
|
+
this.#db
|
|
280
|
+
.prepare("UPDATE companies SET primary_domain = ?, updated_at = ? WHERE id = ?")
|
|
281
|
+
.run(primaryDomain, now, row.id);
|
|
282
|
+
row = this.#db.prepare("SELECT * FROM companies WHERE id = ?").get(row.id);
|
|
283
|
+
}
|
|
284
|
+
this.#db
|
|
285
|
+
.prepare("UPDATE people SET company_id = ?, updated_at = ? WHERE id = ?")
|
|
286
|
+
.run(row.id, now, personId);
|
|
287
|
+
this.#db.exec("COMMIT");
|
|
288
|
+
return company(row);
|
|
289
|
+
}
|
|
290
|
+
catch (error) {
|
|
291
|
+
this.#db.exec("ROLLBACK");
|
|
292
|
+
throw error;
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
listRefinementCandidates(limit) {
|
|
296
|
+
const bounded = Math.max(1, Math.min(100, Math.floor(limit)));
|
|
297
|
+
return this.#db
|
|
298
|
+
.prepare(`
|
|
299
|
+
SELECT people.* FROM people
|
|
300
|
+
LEFT JOIN person_dossiers ON person_dossiers.person_id = people.id
|
|
301
|
+
WHERE people.status = 'active'
|
|
302
|
+
AND people.refinement_enabled = 1
|
|
303
|
+
AND people.last_seen_at IS NOT NULL
|
|
304
|
+
AND (person_dossiers.reviewed_at IS NULL OR people.last_seen_at > person_dossiers.reviewed_at)
|
|
305
|
+
ORDER BY people.last_seen_at DESC, people.id
|
|
306
|
+
LIMIT ?
|
|
307
|
+
`)
|
|
308
|
+
.all(bounded)
|
|
309
|
+
.map((row) => person(row));
|
|
310
|
+
}
|
|
311
|
+
findIdentity(provider, accountScope, externalId) {
|
|
312
|
+
const row = this.#identityRow(provider, accountScope, externalId);
|
|
313
|
+
return row ? identity(row) : undefined;
|
|
314
|
+
}
|
|
315
|
+
setPolicies(personId, policies) {
|
|
316
|
+
const now = new Date().toISOString();
|
|
317
|
+
this.#db
|
|
318
|
+
.prepare(`
|
|
319
|
+
UPDATE people SET
|
|
320
|
+
refinement_enabled = COALESCE(?, refinement_enabled),
|
|
321
|
+
injection_enabled = COALESCE(?, injection_enabled),
|
|
322
|
+
updated_at = ?
|
|
323
|
+
WHERE id = ?
|
|
324
|
+
`)
|
|
325
|
+
.run(policies.refinementEnabled === undefined ? null : Number(policies.refinementEnabled), policies.injectionEnabled === undefined ? null : Number(policies.injectionEnabled), now, personId);
|
|
326
|
+
const row = this.#db.prepare("SELECT * FROM people WHERE id = ?").get(personId);
|
|
327
|
+
return row ? person(row) : undefined;
|
|
328
|
+
}
|
|
329
|
+
replaceDossier(personId, input, reviewedAt = new Date().toISOString(), options = {}) {
|
|
330
|
+
const dossier = Value.Parse(PERSON_DOSSIER_SCHEMA, input);
|
|
331
|
+
if (dossier.blurb.length > this.#maxBlurbChars) {
|
|
332
|
+
throw new Error(`dossier blurb must not exceed ${this.#maxBlurbChars} characters`);
|
|
333
|
+
}
|
|
334
|
+
const categories = dossier.sections.map((section) => section.category);
|
|
335
|
+
if (new Set(categories).size !== categories.length) {
|
|
336
|
+
throw new Error("dossier sections must have unique categories");
|
|
337
|
+
}
|
|
338
|
+
this.#db.exec("BEGIN IMMEDIATE");
|
|
339
|
+
try {
|
|
340
|
+
const target = this.#db
|
|
341
|
+
.prepare("SELECT refinement_enabled FROM people WHERE id = ?")
|
|
342
|
+
.get(personId);
|
|
343
|
+
if (!target)
|
|
344
|
+
throw new Error(`person not found: ${personId}`);
|
|
345
|
+
if (options.requireRefinementEnabled && target.refinement_enabled !== 1) {
|
|
346
|
+
throw new Error("person is not enabled for refinement");
|
|
347
|
+
}
|
|
348
|
+
const dossierJson = JSON.stringify(dossier);
|
|
349
|
+
const current = this.#db
|
|
350
|
+
.prepare("SELECT dossier_json FROM person_dossiers WHERE person_id = ?")
|
|
351
|
+
.get(personId);
|
|
352
|
+
if (current?.dossier_json === dossierJson) {
|
|
353
|
+
this.#db
|
|
354
|
+
.prepare("UPDATE person_dossiers SET reviewed_at = ? WHERE person_id = ?")
|
|
355
|
+
.run(reviewedAt, personId);
|
|
356
|
+
}
|
|
357
|
+
else {
|
|
358
|
+
this.#db
|
|
359
|
+
.prepare(`
|
|
360
|
+
INSERT INTO person_dossiers (person_id, dossier_json, blurb, reviewed_at)
|
|
361
|
+
VALUES (?, ?, ?, ?)
|
|
362
|
+
ON CONFLICT(person_id) DO UPDATE SET
|
|
363
|
+
dossier_json = excluded.dossier_json,
|
|
364
|
+
blurb = excluded.blurb,
|
|
365
|
+
reviewed_at = excluded.reviewed_at
|
|
366
|
+
`)
|
|
367
|
+
.run(personId, dossierJson, dossier.blurb, reviewedAt);
|
|
368
|
+
}
|
|
369
|
+
this.#db.exec("COMMIT");
|
|
370
|
+
return dossier;
|
|
371
|
+
}
|
|
372
|
+
catch (error) {
|
|
373
|
+
this.#db.exec("ROLLBACK");
|
|
374
|
+
throw error;
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
getDossier(personId) {
|
|
378
|
+
const row = this.#db
|
|
379
|
+
.prepare("SELECT dossier_json, reviewed_at FROM person_dossiers WHERE person_id = ?")
|
|
380
|
+
.get(personId);
|
|
381
|
+
return row
|
|
382
|
+
? {
|
|
383
|
+
dossier: Value.Parse(PERSON_DOSSIER_SCHEMA, JSON.parse(row.dossier_json)),
|
|
384
|
+
reviewedAt: row.reviewed_at,
|
|
385
|
+
}
|
|
386
|
+
: undefined;
|
|
387
|
+
}
|
|
388
|
+
getDossierBlurb(personId) {
|
|
389
|
+
const row = this.#db
|
|
390
|
+
.prepare("SELECT blurb FROM person_dossiers WHERE person_id = ?")
|
|
391
|
+
.get(personId);
|
|
392
|
+
return row?.blurb;
|
|
393
|
+
}
|
|
394
|
+
softDeletePerson(personId) {
|
|
395
|
+
const now = new Date().toISOString();
|
|
396
|
+
this.#db.exec("BEGIN IMMEDIATE");
|
|
397
|
+
try {
|
|
398
|
+
const changed = this.#db
|
|
399
|
+
.prepare(`
|
|
400
|
+
UPDATE people SET status = 'unavailable', refinement_enabled = 0,
|
|
401
|
+
injection_enabled = 0, updated_at = ? WHERE id = ?
|
|
402
|
+
`)
|
|
403
|
+
.run(now, personId);
|
|
404
|
+
if (changed.changes === 0) {
|
|
405
|
+
this.#db.exec("COMMIT");
|
|
406
|
+
return undefined;
|
|
407
|
+
}
|
|
408
|
+
this.#upsertTodoRow({
|
|
409
|
+
deduplicationKey: `soft-delete-review:${personId}`,
|
|
410
|
+
kind: "soft_delete_review",
|
|
411
|
+
context: { personId },
|
|
412
|
+
}, now);
|
|
413
|
+
const updated = this.getPerson(personId);
|
|
414
|
+
this.#db.exec("COMMIT");
|
|
415
|
+
return updated;
|
|
416
|
+
}
|
|
417
|
+
catch (error) {
|
|
418
|
+
this.#db.exec("ROLLBACK");
|
|
419
|
+
throw error;
|
|
420
|
+
}
|
|
421
|
+
}
|
|
422
|
+
restorePerson(personId) {
|
|
423
|
+
const now = new Date().toISOString();
|
|
424
|
+
this.#db.exec("BEGIN IMMEDIATE");
|
|
425
|
+
try {
|
|
426
|
+
const changed = this.#db
|
|
427
|
+
.prepare(`
|
|
428
|
+
UPDATE people SET status = 'active', refinement_enabled = 0,
|
|
429
|
+
injection_enabled = 0, updated_at = ?
|
|
430
|
+
WHERE id = ? AND status = 'unavailable'
|
|
431
|
+
`)
|
|
432
|
+
.run(now, personId);
|
|
433
|
+
if (changed.changes === 0) {
|
|
434
|
+
this.#db.exec("COMMIT");
|
|
435
|
+
return undefined;
|
|
436
|
+
}
|
|
437
|
+
const restored = this.getPerson(personId);
|
|
438
|
+
this.#db.exec("COMMIT");
|
|
439
|
+
return restored;
|
|
440
|
+
}
|
|
441
|
+
catch (error) {
|
|
442
|
+
this.#db.exec("ROLLBACK");
|
|
443
|
+
throw error;
|
|
444
|
+
}
|
|
445
|
+
}
|
|
446
|
+
resolveTodoByKey(deduplicationKey, note) {
|
|
447
|
+
const now = new Date().toISOString();
|
|
448
|
+
if (!this.#resolveTodoRow(deduplicationKey, note, now))
|
|
449
|
+
return undefined;
|
|
450
|
+
const row = this.#db
|
|
451
|
+
.prepare("SELECT * FROM people_todos WHERE deduplication_key = ?")
|
|
452
|
+
.get(deduplicationKey);
|
|
453
|
+
return row ? todo(row) : undefined;
|
|
454
|
+
}
|
|
455
|
+
upsertTodo(input) {
|
|
456
|
+
const deduplicationKey = required(input.deduplicationKey, "deduplicationKey");
|
|
457
|
+
const kind = required(input.kind, "kind");
|
|
458
|
+
const now = new Date().toISOString();
|
|
459
|
+
this.#db.exec("BEGIN IMMEDIATE");
|
|
460
|
+
try {
|
|
461
|
+
const row = this.#upsertTodoRow({ deduplicationKey, kind, context: input.context }, now);
|
|
462
|
+
this.#db.exec("COMMIT");
|
|
463
|
+
return todo(row);
|
|
464
|
+
}
|
|
465
|
+
catch (error) {
|
|
466
|
+
this.#db.exec("ROLLBACK");
|
|
467
|
+
throw error;
|
|
468
|
+
}
|
|
469
|
+
}
|
|
470
|
+
listTodos(limit = 100) {
|
|
471
|
+
const bounded = Math.max(1, Math.min(100, Math.floor(limit)));
|
|
472
|
+
return this.#db
|
|
473
|
+
.prepare(`
|
|
474
|
+
SELECT * FROM people_todos
|
|
475
|
+
WHERE status IN ('open', 'overflow')
|
|
476
|
+
ORDER BY first_seen_at, id
|
|
477
|
+
LIMIT ?
|
|
478
|
+
`)
|
|
479
|
+
.all(bounded)
|
|
480
|
+
.map((row) => todo(row));
|
|
481
|
+
}
|
|
482
|
+
#upsertTodoRow(input, now) {
|
|
483
|
+
const contextJson = JSON.stringify(input.context ?? {});
|
|
484
|
+
const row = this.#db
|
|
485
|
+
.prepare("SELECT * FROM people_todos WHERE deduplication_key = ?")
|
|
486
|
+
.get(input.deduplicationKey);
|
|
487
|
+
if (row?.status === "resolved") {
|
|
488
|
+
const count = this.#db
|
|
489
|
+
.prepare("SELECT COUNT(*) AS count FROM people_todos WHERE status = 'open'")
|
|
490
|
+
.get();
|
|
491
|
+
if (count.count >= this.#maxOpenTodos)
|
|
492
|
+
return this.#incrementOverflowTodo(now);
|
|
493
|
+
this.#db
|
|
494
|
+
.prepare(`
|
|
495
|
+
UPDATE people_todos
|
|
496
|
+
SET occurrence_count = occurrence_count + 1, context_json = ?, last_seen_at = ?,
|
|
497
|
+
status = 'open', resolved_at = NULL, resolution_note = NULL
|
|
498
|
+
WHERE id = ?
|
|
499
|
+
`)
|
|
500
|
+
.run(contextJson, now, row.id);
|
|
501
|
+
return this.#db.prepare("SELECT * FROM people_todos WHERE id = ?").get(row.id);
|
|
502
|
+
}
|
|
503
|
+
if (row) {
|
|
504
|
+
this.#db
|
|
505
|
+
.prepare(`
|
|
506
|
+
UPDATE people_todos
|
|
507
|
+
SET occurrence_count = occurrence_count + 1, context_json = ?, last_seen_at = ?
|
|
508
|
+
WHERE id = ?
|
|
509
|
+
`)
|
|
510
|
+
.run(contextJson, now, row.id);
|
|
511
|
+
return this.#db.prepare("SELECT * FROM people_todos WHERE id = ?").get(row.id);
|
|
512
|
+
}
|
|
513
|
+
const count = this.#db
|
|
514
|
+
.prepare("SELECT COUNT(*) AS count FROM people_todos WHERE status = 'open'")
|
|
515
|
+
.get();
|
|
516
|
+
if (count.count >= this.#maxOpenTodos)
|
|
517
|
+
return this.#incrementOverflowTodo(now);
|
|
518
|
+
const id = randomUUID();
|
|
519
|
+
this.#db
|
|
520
|
+
.prepare(`
|
|
521
|
+
INSERT INTO people_todos
|
|
522
|
+
(id, deduplication_key, kind, context_json, status, occurrence_count,
|
|
523
|
+
first_seen_at, last_seen_at)
|
|
524
|
+
VALUES (?, ?, ?, ?, 'open', 1, ?, ?)
|
|
525
|
+
`)
|
|
526
|
+
.run(id, input.deduplicationKey, input.kind, contextJson, now, now);
|
|
527
|
+
return this.#db.prepare("SELECT * FROM people_todos WHERE id = ?").get(id);
|
|
528
|
+
}
|
|
529
|
+
#incrementOverflowTodo(now) {
|
|
530
|
+
const row = this.#db
|
|
531
|
+
.prepare("SELECT * FROM people_todos WHERE deduplication_key = ?")
|
|
532
|
+
.get(OVERFLOW_KEY);
|
|
533
|
+
if (row) {
|
|
534
|
+
this.#db
|
|
535
|
+
.prepare(`
|
|
536
|
+
UPDATE people_todos SET occurrence_count = occurrence_count + 1, last_seen_at = ?,
|
|
537
|
+
status = 'overflow', resolved_at = NULL, resolution_note = NULL
|
|
538
|
+
WHERE id = ?
|
|
539
|
+
`)
|
|
540
|
+
.run(now, row.id);
|
|
541
|
+
return this.#db.prepare("SELECT * FROM people_todos WHERE id = ?").get(row.id);
|
|
542
|
+
}
|
|
543
|
+
const id = randomUUID();
|
|
544
|
+
this.#db
|
|
545
|
+
.prepare(`
|
|
546
|
+
INSERT INTO people_todos
|
|
547
|
+
(id, deduplication_key, kind, context_json, status, occurrence_count,
|
|
548
|
+
first_seen_at, last_seen_at)
|
|
549
|
+
VALUES (?, ?, 'overflow', '{}', 'overflow', 1, ?, ?)
|
|
550
|
+
`)
|
|
551
|
+
.run(id, OVERFLOW_KEY, now, now);
|
|
552
|
+
return this.#db.prepare("SELECT * FROM people_todos WHERE id = ?").get(id);
|
|
553
|
+
}
|
|
554
|
+
#resolveTodoRow(deduplicationKey, note, now) {
|
|
555
|
+
const changed = this.#db
|
|
556
|
+
.prepare(`
|
|
557
|
+
UPDATE people_todos SET status = 'resolved', resolved_at = ?, resolution_note = ?, last_seen_at = ?
|
|
558
|
+
WHERE deduplication_key = ? AND status IN ('open', 'overflow')
|
|
559
|
+
`)
|
|
560
|
+
.run(now, note ?? null, now, deduplicationKey);
|
|
561
|
+
return changed.changes === 1;
|
|
562
|
+
}
|
|
563
|
+
#identityRow(provider, accountScope, externalId) {
|
|
564
|
+
return this.#db
|
|
565
|
+
.prepare(`
|
|
566
|
+
SELECT * FROM person_identities
|
|
567
|
+
WHERE provider = ? AND account_scope = ? AND external_id = ?
|
|
568
|
+
`)
|
|
569
|
+
.get(provider, accountScope, externalId);
|
|
570
|
+
}
|
|
571
|
+
#migrate() {
|
|
572
|
+
const current = this.#db.prepare("PRAGMA user_version").get();
|
|
573
|
+
if (current.user_version === 1)
|
|
574
|
+
return;
|
|
575
|
+
if (current.user_version !== 0) {
|
|
576
|
+
throw new Error(`unsupported PeopleSQL schema version: ${current.user_version}`);
|
|
577
|
+
}
|
|
578
|
+
this.#db.exec("BEGIN IMMEDIATE");
|
|
579
|
+
try {
|
|
580
|
+
this.#db.exec(`
|
|
581
|
+
CREATE TABLE companies (
|
|
582
|
+
id TEXT PRIMARY KEY,
|
|
583
|
+
name TEXT NOT NULL,
|
|
584
|
+
primary_domain TEXT,
|
|
585
|
+
status TEXT NOT NULL DEFAULT 'active' CHECK (status IN ('active', 'archived')),
|
|
586
|
+
created_at TEXT NOT NULL,
|
|
587
|
+
updated_at TEXT NOT NULL
|
|
588
|
+
) STRICT;
|
|
589
|
+
|
|
590
|
+
CREATE TABLE people (
|
|
591
|
+
id TEXT PRIMARY KEY,
|
|
592
|
+
display_name TEXT NOT NULL,
|
|
593
|
+
preferred_name TEXT,
|
|
594
|
+
status TEXT NOT NULL DEFAULT 'active' CHECK (status IN ('active', 'unavailable', 'archived')),
|
|
595
|
+
company_id TEXT REFERENCES companies(id),
|
|
596
|
+
refinement_enabled INTEGER NOT NULL DEFAULT 0 CHECK (refinement_enabled IN (0, 1)),
|
|
597
|
+
injection_enabled INTEGER NOT NULL DEFAULT 0 CHECK (injection_enabled IN (0, 1)),
|
|
598
|
+
last_seen_at TEXT,
|
|
599
|
+
created_at TEXT NOT NULL,
|
|
600
|
+
updated_at TEXT NOT NULL
|
|
601
|
+
) STRICT;
|
|
602
|
+
|
|
603
|
+
CREATE TABLE person_identities (
|
|
604
|
+
person_id TEXT NOT NULL REFERENCES people(id) ON DELETE CASCADE,
|
|
605
|
+
provider TEXT NOT NULL,
|
|
606
|
+
account_scope TEXT NOT NULL,
|
|
607
|
+
external_id TEXT NOT NULL,
|
|
608
|
+
display_name TEXT,
|
|
609
|
+
real_name TEXT,
|
|
610
|
+
handle TEXT,
|
|
611
|
+
avatar_url TEXT,
|
|
612
|
+
title TEXT,
|
|
613
|
+
is_bot INTEGER CHECK (is_bot IN (0, 1)),
|
|
614
|
+
is_deactivated INTEGER NOT NULL DEFAULT 0 CHECK (is_deactivated IN (0, 1)),
|
|
615
|
+
first_seen_at TEXT NOT NULL,
|
|
616
|
+
last_seen_at TEXT NOT NULL,
|
|
617
|
+
last_synced_at TEXT,
|
|
618
|
+
PRIMARY KEY (provider, account_scope, external_id)
|
|
619
|
+
) STRICT;
|
|
620
|
+
|
|
621
|
+
CREATE TABLE person_dossiers (
|
|
622
|
+
person_id TEXT PRIMARY KEY REFERENCES people(id) ON DELETE CASCADE,
|
|
623
|
+
dossier_json TEXT NOT NULL,
|
|
624
|
+
blurb TEXT NOT NULL,
|
|
625
|
+
reviewed_at TEXT NOT NULL
|
|
626
|
+
) STRICT;
|
|
627
|
+
|
|
628
|
+
CREATE TABLE people_todos (
|
|
629
|
+
id TEXT PRIMARY KEY,
|
|
630
|
+
deduplication_key TEXT NOT NULL UNIQUE,
|
|
631
|
+
kind TEXT NOT NULL,
|
|
632
|
+
context_json TEXT NOT NULL,
|
|
633
|
+
status TEXT NOT NULL CHECK (status IN ('open', 'resolved', 'overflow')),
|
|
634
|
+
occurrence_count INTEGER NOT NULL DEFAULT 1 CHECK (occurrence_count > 0),
|
|
635
|
+
first_seen_at TEXT NOT NULL,
|
|
636
|
+
last_seen_at TEXT NOT NULL,
|
|
637
|
+
resolved_at TEXT,
|
|
638
|
+
resolution_note TEXT
|
|
639
|
+
) STRICT;
|
|
640
|
+
|
|
641
|
+
CREATE INDEX people_status_seen ON people(status, last_seen_at);
|
|
642
|
+
CREATE INDEX people_policy_seen ON people(refinement_enabled, last_seen_at);
|
|
643
|
+
CREATE INDEX people_todos_status_seen ON people_todos(status, last_seen_at);
|
|
644
|
+
PRAGMA user_version = 1;
|
|
645
|
+
`);
|
|
646
|
+
this.#db.exec("COMMIT");
|
|
647
|
+
}
|
|
648
|
+
catch (error) {
|
|
649
|
+
this.#db.exec("ROLLBACK");
|
|
650
|
+
throw error;
|
|
651
|
+
}
|
|
652
|
+
}
|
|
653
|
+
}
|
|
654
|
+
export class PeopleStores {
|
|
655
|
+
#stateRoot;
|
|
656
|
+
#options;
|
|
657
|
+
#stores = new Map();
|
|
658
|
+
constructor(options) {
|
|
659
|
+
this.#stateRoot = options.stateRoot ?? resolveStateDir();
|
|
660
|
+
this.#options = options;
|
|
661
|
+
}
|
|
662
|
+
get(agentId) {
|
|
663
|
+
const normalized = normalizeAgentIdStrict(agentId);
|
|
664
|
+
if (!normalized.ok || normalized.value !== agentId)
|
|
665
|
+
throw new Error(`invalid agent id: ${agentId}`);
|
|
666
|
+
const canonicalAgentId = normalized.value;
|
|
667
|
+
let store = this.#stores.get(canonicalAgentId);
|
|
668
|
+
if (!store) {
|
|
669
|
+
store = new PeopleStore(join(this.#stateRoot, "agents", canonicalAgentId, "unblock-memory", "people.sqlite"), this.#options);
|
|
670
|
+
this.#stores.set(canonicalAgentId, store);
|
|
671
|
+
}
|
|
672
|
+
return store;
|
|
673
|
+
}
|
|
674
|
+
closeAll() {
|
|
675
|
+
for (const store of this.#stores.values())
|
|
676
|
+
store.close();
|
|
677
|
+
this.#stores.clear();
|
|
678
|
+
}
|
|
679
|
+
}
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
import type { OpenClawPluginApi } from "openclaw/plugin-sdk/plugin-entry";
|
|
2
|
+
import type { UnblockMemoryConfig } from "./config.js";
|
|
3
|
+
import type { PeopleStores } from "./people-store.js";
|
|
4
|
+
import { type SlackDirectoryReader } from "./slack-directory.js";
|
|
5
|
+
export declare function registerPeopleTools(api: OpenClawPluginApi, stores: PeopleStores, config: UnblockMemoryConfig["people"], directoryReader?: SlackDirectoryReader): void;
|