@unblocklabs/unblock-memory 0.3.8 → 0.3.9

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 CHANGED
@@ -145,11 +145,10 @@ message content. Other channels are ignored.
145
145
 
146
146
  PeopleSQL registers three tools when enabled:
147
147
 
148
- - `memory_people_inspect` reads one exact person, bounded actionable todos, or
149
- the next person's unseen exact-attributed interaction evidence;
150
- - `memory_people_update` replaces or deletes dossiers, consumes evidence,
151
- toggles one person's injection, and manages company, todo, deletion, or
152
- restoration state; and
148
+ - `memory_people_inspect` lists active people, reads one exact person, reads one
149
+ person's dossier change history, or lists bounded actionable todos;
150
+ - `memory_people_update` replaces or deletes dossiers, toggles one person's
151
+ injection, and manages company, todo, deletion, or restoration state; and
153
152
  - the optional `memory_people_sync` enriches one active OpenClaw Slack account;
154
153
  its tool input accepts an account ID, not a token.
155
154
 
@@ -160,12 +159,24 @@ may need to be allowed explicitly. The sync is bounded to
160
159
  keeps only normalized ID, name, handle, and avatar fields. Slack requires the
161
160
  `users:read` scope.
162
161
 
163
- The agent owns dossier generation and refresh. A cron or isolated agent session
164
- can repeatedly inspect `refinement_next`, synthesize a dossier from its current
165
- state and bounded unseen interaction windows, then call `replace_dossier` with
166
- the evidence locators it consumed. Dossier replacement and evidence consumption
167
- commit atomically; a no-change run may consume evidence without replacing the
168
- dossier. The plugin performs no model call for refinement or prompt injection.
162
+ The agent owns dossier generation and refresh. It can list people, inspect one
163
+ person's current dossier, search ordinary memory and sessions with
164
+ `memory_search`/`memory_get`, and replace the dossier when that would improve a
165
+ future conversation. The plugin owns no dossier-maintenance workflow or refresh
166
+ schedule. A dossier's `reviewedAt` value records its last successful write; it
167
+ is not scheduling state. The plugin performs no model call for dossier
168
+ maintenance or prompt injection.
169
+
170
+ Every `replace_dossier` and `delete_dossier` action requires a concise `reason`.
171
+ The plugin transactionally records that reason with its authoritative before and
172
+ after dossier snapshots. List small newest-first summaries with
173
+ `memory_people_inspect({ view: "dossier_changes", personId, limit?, offset? })`,
174
+ then fetch one exact diff with
175
+ `memory_people_inspect({ view: "dossier_change", personId, changeId })`. List
176
+ responses include `nextOffset`, so all history remains reachable without loading
177
+ many dossiers into one tool result. Because the injected snippet is the dossier's
178
+ `blurb`, its changes are included in the same history. A complete new serialized
179
+ dossier is capped at 64 KiB; larger legacy dossiers remain readable and repairable.
169
180
 
170
181
  Set `people.whisperer.enabled` to inject context. For each exact Slack sender,
171
182
  the plugin prepends that person's stored dossier blurb, bounded by `maxChars`,
@@ -175,6 +186,33 @@ Unthreaded DMs use their OpenClaw session as the conversational scope. Unknown,
175
186
  unavailable, disabled, or dossierless people produce no context. Injection
176
187
  remains subject to OpenClaw's `allowPromptInjection` policy.
177
188
 
189
+ The package includes a `$people-whisperer` skill with the canonical agent
190
+ procedure and dossier shape. For a manual refresh, ask:
191
+
192
+ ```text
193
+ Use $people-whisperer to improve your understanding of this person. Search memory
194
+ and recent sessions, inspect their current PeopleSQL dossier, and update it only
195
+ if the result would make future conversations meaningfully better.
196
+ ```
197
+
198
+ For an optional cron or isolated agent session, use this goal:
199
+
200
+ ```text
201
+ Use $people-whisperer to improve your understanding of people you interact with.
202
+
203
+ Search recent sessions and memory for meaningful information about people. Inspect
204
+ their existing PeopleSQL dossiers when useful. Update a dossier only when doing so
205
+ would make future conversations meaningfully better. Ignore routine conversation,
206
+ repetition, and weak inference. You may update several people or nobody.
207
+ ```
208
+
209
+ Choose any cadence appropriate for the agent; the plugin does not require or
210
+ track one. If session transcripts are a source, configure a `sessions` corpus
211
+ (including `direct` when DMs matter) and refresh it with
212
+ `memory_sync_sessions`. Ordinary `memory_search` calls accept targeted queries,
213
+ corpora, session metadata filters, score thresholds, and up to 20 results per
214
+ call; People Whisperer itself imposes no evidence-window limit.
215
+
178
216
  Use `sessionFilter` to restrict session results by metadata while leaving file
179
217
  corpora searchable. Supported fields are `startedFrom` and `startedTo`
180
218
  (inclusive ISO 8601 timestamps), `provider`, `chatType`, `accountId`, and
@@ -114,22 +114,10 @@ function resolvePeople(value) {
114
114
  throw new Error("unblock-memory people must be an object");
115
115
  }
116
116
  const people = value;
117
- assertOnlyKeys(people, ["enabled", "refinement", "whisperer", "todos"], "people");
117
+ assertOnlyKeys(people, ["enabled", "whisperer", "todos"], "people");
118
118
  const enabled = people.enabled ?? false;
119
119
  if (typeof enabled !== "boolean")
120
120
  throw new Error("unblock-memory people.enabled must be a boolean");
121
- // Accepted only so existing installations can upgrade without first rewriting config.
122
- const legacyRefinement = people.refinement;
123
- if (legacyRefinement !== undefined) {
124
- if (!legacyRefinement ||
125
- typeof legacyRefinement !== "object" ||
126
- Array.isArray(legacyRefinement)) {
127
- throw new Error("unblock-memory people.refinement must be an object");
128
- }
129
- const legacy = legacyRefinement;
130
- assertOnlyKeys(legacy, ["maxPeoplePerRun"], "people.refinement");
131
- positiveInteger(legacy.maxPeoplePerRun, 10, "people.refinement.maxPeoplePerRun", 50);
132
- }
133
121
  const whisperer = people.whisperer ?? {};
134
122
  if (!whisperer || typeof whisperer !== "object" || Array.isArray(whisperer)) {
135
123
  throw new Error("unblock-memory people.whisperer must be an object");
@@ -45,11 +45,11 @@ export function registerPeopleHooks(api, stores, config) {
45
45
  if (threadKey) {
46
46
  const identityKey = promptIdentityKey(sessionKey, accountScope, externalId);
47
47
  if (runId) {
48
- pendingThreadByIdentity.delete(identityKey);
49
48
  threadByRun.set(runId, threadKey);
50
49
  }
51
50
  else {
52
- pendingThreadByIdentity.set(identityKey, threadKey);
51
+ const pendingThreadKey = pendingThreadByIdentity.get(identityKey);
52
+ pendingThreadByIdentity.set(identityKey, pendingThreadKey === undefined || pendingThreadKey === threadKey ? threadKey : null);
53
53
  }
54
54
  }
55
55
  }
@@ -94,12 +94,15 @@ export function registerPeopleHooks(api, stores, config) {
94
94
  if (!sessionKey || !parsed || !accountScope || !externalId || !runId)
95
95
  return;
96
96
  const identityKey = promptIdentityKey(sessionKey, accountScope, externalId);
97
- const pendingThreadKey = pendingThreadByIdentity.get(identityKey);
98
- const threadKey = threadByRun.get(runId) ?? pendingThreadKey;
97
+ const mappedThreadKey = threadByRun.get(runId);
98
+ const pendingThreadKey = mappedThreadKey ? undefined : pendingThreadByIdentity.get(identityKey);
99
+ if (!mappedThreadKey && pendingThreadByIdentity.has(identityKey)) {
100
+ pendingThreadByIdentity.delete(identityKey);
101
+ }
102
+ const threadKey = mappedThreadKey ?? pendingThreadKey ?? undefined;
99
103
  if (!threadKey)
100
104
  return;
101
- if (pendingThreadKey) {
102
- pendingThreadByIdentity.delete(identityKey);
105
+ if (typeof pendingThreadKey === "string") {
103
106
  threadByRun.set(runId, pendingThreadKey);
104
107
  }
105
108
  try {
@@ -17,6 +17,19 @@ export declare const PERSON_DOSSIER_SCHEMA: Type.TObject<{
17
17
  }>>;
18
18
  }>;
19
19
  export type PersonDossier = Static<typeof PERSON_DOSSIER_SCHEMA>;
20
+ export type PersonDossierChange = {
21
+ id: string;
22
+ personId: string;
23
+ action: "replace" | "delete";
24
+ beforeDossier: PersonDossier | null;
25
+ afterDossier: PersonDossier | null;
26
+ reason: string;
27
+ changedAt: string;
28
+ };
29
+ export type PersonDossierChangeSummary = Omit<PersonDossierChange, "beforeDossier" | "afterDossier"> & {
30
+ beforeDossierBytes: number | null;
31
+ afterDossierBytes: number | null;
32
+ };
20
33
  export type Person = {
21
34
  id: string;
22
35
  displayName: string;
@@ -97,12 +110,11 @@ export declare class PeopleStore {
97
110
  name: string;
98
111
  primaryDomain?: string;
99
112
  }): Company | undefined;
100
- listActivePeople(): Person[];
113
+ listActivePeople(limit?: number, offset?: number): Person[];
101
114
  findIdentity(provider: string, accountScope: string, externalId: string): PersonIdentity | undefined;
102
115
  setInjection(personId: string, enabled: boolean): Person | undefined;
103
- replaceDossier(personId: string, input: unknown | undefined, consumedEvidenceLocators?: readonly string[]): PersonDossier | undefined;
104
- deleteDossier(personId: string): boolean;
105
- listProcessedEvidenceLocators(personId: string, source: "session"): Set<string>;
116
+ replaceDossier(personId: string, reasonInput: string, input: unknown): PersonDossier;
117
+ deleteDossier(personId: string, reasonInput: string): boolean;
106
118
  getWhisperReceipt(threadKey: string, personId: string): {
107
119
  runId: string;
108
120
  contribution: string;
@@ -122,6 +134,9 @@ export declare class PeopleStore {
122
134
  dossier: PersonDossier;
123
135
  reviewedAt: string;
124
136
  } | undefined;
137
+ getDossierReviewedAt(personId: string): string | undefined;
138
+ listDossierChanges(personId: string, limit?: number, offset?: number): PersonDossierChangeSummary[];
139
+ getDossierChange(personId: string, changeId: string): PersonDossierChange | undefined;
125
140
  getDossierBlurb(personId: string): string | undefined;
126
141
  softDeletePerson(personId: string): Person | undefined;
127
142
  restorePerson(personId: string): Person | undefined;
@@ -1,3 +1,4 @@
1
+ import { Buffer } from "node:buffer";
1
2
  import { randomUUID } from "node:crypto";
2
3
  import { chmodSync, mkdirSync } from "node:fs";
3
4
  import { join, dirname } from "node:path";
@@ -46,6 +47,17 @@ export const PERSON_DOSSIER_SCHEMA = Type.Object({
46
47
  claims: Type.Array(claimSchema, { minItems: 1, maxItems: 100 }),
47
48
  }, { additionalProperties: false }), { maxItems: BASELINE_DOSSIER_CATEGORIES.length }),
48
49
  }, { additionalProperties: false });
50
+ const MAX_DOSSIER_JSON_BYTES = 64 * 1024;
51
+ function serializeDossier(dossier) {
52
+ const json = JSON.stringify(dossier);
53
+ if (Buffer.byteLength(json, "utf8") > MAX_DOSSIER_JSON_BYTES) {
54
+ throw new Error(`dossier must serialize to at most ${MAX_DOSSIER_JSON_BYTES} bytes`);
55
+ }
56
+ return json;
57
+ }
58
+ function parseDossierJson(json) {
59
+ return Value.Parse(PERSON_DOSSIER_SCHEMA, JSON.parse(json));
60
+ }
49
61
  const OVERFLOW_KEY = "__people_todo_overflow__";
50
62
  function required(value, label) {
51
63
  const normalized = value.trim();
@@ -56,6 +68,12 @@ function required(value, label) {
56
68
  function optional(value) {
57
69
  return value?.trim() || null;
58
70
  }
71
+ function dossierReason(value) {
72
+ const reason = required(value, "reason");
73
+ if (reason.length > 1000)
74
+ throw new Error("reason must not exceed 1000 characters");
75
+ return reason;
76
+ }
59
77
  function person(row) {
60
78
  return {
61
79
  id: row.id,
@@ -290,14 +308,15 @@ export class PeopleStore {
290
308
  throw error;
291
309
  }
292
310
  }
293
- listActivePeople() {
311
+ listActivePeople(limit = 50, offset = 0) {
294
312
  return this.#db
295
313
  .prepare(`
296
314
  SELECT * FROM people
297
315
  WHERE status = 'active'
298
316
  ORDER BY last_seen_at DESC, id
317
+ LIMIT ? OFFSET ?
299
318
  `)
300
- .all()
319
+ .all(limit, offset)
301
320
  .map((row) => person(row));
302
321
  }
303
322
  findIdentity(provider, accountScope, externalId) {
@@ -312,20 +331,24 @@ export class PeopleStore {
312
331
  const row = this.#db.prepare("SELECT * FROM people WHERE id = ?").get(personId);
313
332
  return row ? person(row) : undefined;
314
333
  }
315
- replaceDossier(personId, input, consumedEvidenceLocators = []) {
316
- const dossier = input === undefined ? undefined : Value.Parse(PERSON_DOSSIER_SCHEMA, input);
317
- if (dossier)
318
- this.#validateDossier(dossier);
319
- const locators = this.#validateEvidenceLocators(consumedEvidenceLocators);
334
+ replaceDossier(personId, reasonInput, input) {
335
+ const dossier = Value.Parse(PERSON_DOSSIER_SCHEMA, input);
336
+ this.#validateDossier(dossier);
337
+ const dossierJson = serializeDossier(dossier);
338
+ const reason = dossierReason(reasonInput);
320
339
  const reviewedAt = new Date().toISOString();
321
340
  this.#db.exec("BEGIN IMMEDIATE");
322
341
  try {
323
342
  const target = this.#db.prepare("SELECT id FROM people WHERE id = ?").get(personId);
324
343
  if (!target)
325
344
  throw new Error(`person not found: ${personId}`);
326
- if (dossier) {
327
- this.#db
328
- .prepare(`
345
+ const existing = this.#db
346
+ .prepare("SELECT dossier_json FROM person_dossiers WHERE person_id = ?")
347
+ .get(personId);
348
+ if (existing)
349
+ parseDossierJson(existing.dossier_json);
350
+ this.#db
351
+ .prepare(`
329
352
  INSERT INTO person_dossiers (person_id, dossier_json, blurb, reviewed_at)
330
353
  VALUES (?, ?, ?, ?)
331
354
  ON CONFLICT(person_id) DO UPDATE SET
@@ -333,36 +356,50 @@ export class PeopleStore {
333
356
  blurb = excluded.blurb,
334
357
  reviewed_at = excluded.reviewed_at
335
358
  `)
336
- .run(personId, JSON.stringify(dossier), dossier.blurb, reviewedAt);
337
- }
338
- const consume = this.#db.prepare(`
339
- INSERT OR IGNORE INTO person_evidence_receipts
340
- (person_id, source, locator, processed_at)
341
- VALUES (?, 'session', ?, ?)
342
- `);
343
- for (const locator of locators) {
344
- consume.run(personId, locator, reviewedAt);
345
- }
359
+ .run(personId, dossierJson, dossier.blurb, reviewedAt);
360
+ this.#db
361
+ .prepare(`
362
+ INSERT INTO person_dossier_changes
363
+ (id, person_id, action, before_dossier_json, after_dossier_json, reason, changed_at)
364
+ VALUES (?, ?, 'replace', ?, ?, ?, ?)
365
+ `)
366
+ .run(randomUUID(), personId, existing?.dossier_json ?? null, dossierJson, reason, reviewedAt);
346
367
  this.#db.exec("COMMIT");
347
- return dossier ?? this.getDossier(personId)?.dossier;
368
+ return dossier;
348
369
  }
349
370
  catch (error) {
350
371
  this.#db.exec("ROLLBACK");
351
372
  throw error;
352
373
  }
353
374
  }
354
- deleteDossier(personId) {
355
- return this.#db.prepare("DELETE FROM person_dossiers WHERE person_id = ?").run(personId)
356
- .changes === 1;
357
- }
358
- listProcessedEvidenceLocators(personId, source) {
359
- const rows = this.#db
360
- .prepare(`
361
- SELECT locator FROM person_evidence_receipts
362
- WHERE person_id = ? AND source = ?
363
- `)
364
- .all(personId, source);
365
- return new Set(rows.map((row) => row.locator));
375
+ deleteDossier(personId, reasonInput) {
376
+ const reason = dossierReason(reasonInput);
377
+ const changedAt = new Date().toISOString();
378
+ this.#db.exec("BEGIN IMMEDIATE");
379
+ try {
380
+ const existing = this.#db
381
+ .prepare("SELECT dossier_json FROM person_dossiers WHERE person_id = ?")
382
+ .get(personId);
383
+ if (!existing) {
384
+ this.#db.exec("COMMIT");
385
+ return false;
386
+ }
387
+ parseDossierJson(existing.dossier_json);
388
+ this.#db.prepare("DELETE FROM person_dossiers WHERE person_id = ?").run(personId);
389
+ this.#db
390
+ .prepare(`
391
+ INSERT INTO person_dossier_changes
392
+ (id, person_id, action, before_dossier_json, after_dossier_json, reason, changed_at)
393
+ VALUES (?, ?, 'delete', ?, NULL, ?, ?)
394
+ `)
395
+ .run(randomUUID(), personId, existing.dossier_json, reason, changedAt);
396
+ this.#db.exec("COMMIT");
397
+ return true;
398
+ }
399
+ catch (error) {
400
+ this.#db.exec("ROLLBACK");
401
+ throw error;
402
+ }
366
403
  }
367
404
  getWhisperReceipt(threadKey, personId) {
368
405
  const row = this.#db
@@ -395,11 +432,64 @@ export class PeopleStore {
395
432
  .get(personId);
396
433
  return row
397
434
  ? {
398
- dossier: Value.Parse(PERSON_DOSSIER_SCHEMA, JSON.parse(row.dossier_json)),
435
+ dossier: parseDossierJson(row.dossier_json),
399
436
  reviewedAt: row.reviewed_at,
400
437
  }
401
438
  : undefined;
402
439
  }
440
+ getDossierReviewedAt(personId) {
441
+ const row = this.#db
442
+ .prepare("SELECT reviewed_at FROM person_dossiers WHERE person_id = ?")
443
+ .get(personId);
444
+ return row?.reviewed_at;
445
+ }
446
+ listDossierChanges(personId, limit = 20, offset = 0) {
447
+ return this.#db
448
+ .prepare(`
449
+ SELECT id, person_id, action, reason, changed_at,
450
+ CASE WHEN before_dossier_json IS NULL THEN NULL
451
+ ELSE length(CAST(before_dossier_json AS BLOB)) END AS before_dossier_bytes,
452
+ CASE WHEN after_dossier_json IS NULL THEN NULL
453
+ ELSE length(CAST(after_dossier_json AS BLOB)) END AS after_dossier_bytes
454
+ FROM person_dossier_changes
455
+ WHERE person_id = ?
456
+ ORDER BY changed_at DESC, rowid DESC
457
+ LIMIT ? OFFSET ?
458
+ `)
459
+ .all(personId, limit, offset)
460
+ .map((value) => {
461
+ const row = value;
462
+ return {
463
+ id: row.id,
464
+ personId: row.person_id,
465
+ action: row.action,
466
+ beforeDossierBytes: row.before_dossier_bytes,
467
+ afterDossierBytes: row.after_dossier_bytes,
468
+ reason: row.reason,
469
+ changedAt: row.changed_at,
470
+ };
471
+ });
472
+ }
473
+ getDossierChange(personId, changeId) {
474
+ const row = this.#db
475
+ .prepare("SELECT * FROM person_dossier_changes WHERE person_id = ? AND id = ?")
476
+ .get(personId, changeId);
477
+ return row
478
+ ? {
479
+ id: row.id,
480
+ personId: row.person_id,
481
+ action: row.action,
482
+ beforeDossier: row.before_dossier_json === null
483
+ ? null
484
+ : parseDossierJson(row.before_dossier_json),
485
+ afterDossier: row.after_dossier_json === null
486
+ ? null
487
+ : parseDossierJson(row.after_dossier_json),
488
+ reason: row.reason,
489
+ changedAt: row.changed_at,
490
+ }
491
+ : undefined;
492
+ }
403
493
  getDossierBlurb(personId) {
404
494
  const row = this.#db
405
495
  .prepare("SELECT blurb FROM person_dossiers WHERE person_id = ?")
@@ -591,36 +681,29 @@ export class PeopleStore {
591
681
  throw new Error("dossier sections must have unique categories");
592
682
  }
593
683
  }
594
- #validateEvidenceLocators(locators) {
595
- const unique = [...new Set(locators.map((locator) => locator.trim()))];
596
- const invalid = unique.find((locator) => !/^session:.+:event:\d+$/.test(locator));
597
- if (invalid !== undefined)
598
- throw new Error(`invalid session evidence locator: ${invalid}`);
599
- return unique;
600
- }
601
684
  #migrate() {
602
685
  const current = this.#db.prepare("PRAGMA user_version").get();
603
- if (current.user_version === 2)
686
+ if (current.user_version === 4)
604
687
  return;
605
- if (current.user_version !== 0 && current.user_version !== 1) {
688
+ if (current.user_version !== 0 &&
689
+ current.user_version !== 1 &&
690
+ current.user_version !== 2 &&
691
+ current.user_version !== 3) {
606
692
  throw new Error(`unsupported PeopleSQL schema version: ${current.user_version}`);
607
693
  }
608
694
  this.#db.exec("BEGIN IMMEDIATE");
609
695
  try {
610
- if (current.user_version === 1) {
696
+ if (current.user_version === 2) {
697
+ this.#db.exec(`
698
+ DROP TABLE person_evidence_receipts;
699
+ `);
700
+ }
701
+ else if (current.user_version === 1) {
611
702
  this.#db.exec(`
612
703
  DROP INDEX people_policy_seen;
613
704
  ALTER TABLE people DROP COLUMN refinement_enabled;
614
705
  UPDATE people SET injection_enabled = 1 WHERE status = 'active';
615
706
 
616
- CREATE TABLE person_evidence_receipts (
617
- person_id TEXT NOT NULL REFERENCES people(id) ON DELETE CASCADE,
618
- source TEXT NOT NULL,
619
- locator TEXT NOT NULL,
620
- processed_at TEXT NOT NULL,
621
- PRIMARY KEY (person_id, source, locator)
622
- ) STRICT;
623
-
624
707
  CREATE TABLE person_whisper_receipts (
625
708
  thread_key TEXT NOT NULL,
626
709
  person_id TEXT NOT NULL REFERENCES people(id) ON DELETE CASCADE,
@@ -629,13 +712,10 @@ export class PeopleStore {
629
712
  injected_at TEXT NOT NULL,
630
713
  PRIMARY KEY (thread_key, person_id)
631
714
  ) STRICT;
632
-
633
- PRAGMA user_version = 2;
634
715
  `);
635
- this.#db.exec("COMMIT");
636
- return;
637
716
  }
638
- this.#db.exec(`
717
+ else if (current.user_version === 0) {
718
+ this.#db.exec(`
639
719
  CREATE TABLE companies (
640
720
  id TEXT PRIMARY KEY,
641
721
  name TEXT NOT NULL,
@@ -695,14 +775,6 @@ export class PeopleStore {
695
775
  resolution_note TEXT
696
776
  ) STRICT;
697
777
 
698
- CREATE TABLE person_evidence_receipts (
699
- person_id TEXT NOT NULL REFERENCES people(id) ON DELETE CASCADE,
700
- source TEXT NOT NULL,
701
- locator TEXT NOT NULL,
702
- processed_at TEXT NOT NULL,
703
- PRIMARY KEY (person_id, source, locator)
704
- ) STRICT;
705
-
706
778
  CREATE TABLE person_whisper_receipts (
707
779
  thread_key TEXT NOT NULL,
708
780
  person_id TEXT NOT NULL REFERENCES people(id) ON DELETE CASCADE,
@@ -714,7 +786,22 @@ export class PeopleStore {
714
786
 
715
787
  CREATE INDEX people_status_seen ON people(status, last_seen_at);
716
788
  CREATE INDEX people_todos_status_seen ON people_todos(status, last_seen_at);
717
- PRAGMA user_version = 2;
789
+ `);
790
+ }
791
+ this.#db.exec(`
792
+ CREATE TABLE person_dossier_changes (
793
+ id TEXT PRIMARY KEY,
794
+ person_id TEXT NOT NULL REFERENCES people(id) ON DELETE CASCADE,
795
+ action TEXT NOT NULL CHECK (action IN ('replace', 'delete')),
796
+ before_dossier_json TEXT,
797
+ after_dossier_json TEXT,
798
+ reason TEXT NOT NULL,
799
+ changed_at TEXT NOT NULL
800
+ ) STRICT;
801
+
802
+ CREATE INDEX person_dossier_changes_person_changed
803
+ ON person_dossier_changes(person_id, changed_at DESC);
804
+ PRAGMA user_version = 4;
718
805
  `);
719
806
  this.#db.exec("COMMIT");
720
807
  }
@@ -1,9 +1,7 @@
1
1
  import { jsonResult } from "openclaw/plugin-sdk/agent-runtime";
2
- import { resolveAgentDir } from "openclaw/plugin-sdk/memory-core-host-engine-foundation";
3
2
  import { Type } from "typebox";
4
3
  import { Value } from "typebox/value";
5
4
  import { renderPeopleWhisper } from "./people-hooks.js";
6
- import { nextPeopleRefinement } from "./people-refinement.js";
7
5
  import { PERSON_DOSSIER_SCHEMA } from "./people-store.js";
8
6
  import { createOpenClawSlackDirectory, syncSlackDirectory, } from "./slack-directory.js";
9
7
  const nonEmpty = Type.String({ pattern: "\\S", maxLength: 1000 });
@@ -36,16 +34,36 @@ const inspectParameters = Type.Union([
36
34
  }),
37
35
  }, { additionalProperties: false }),
38
36
  Type.Object({
39
- view: Type.Literal("todos"),
37
+ view: Type.Literal("people"),
40
38
  limit: Type.Optional(Type.Integer({
41
39
  minimum: 1,
42
40
  maximum: 100,
43
- description: "Maximum actionable todos to return.",
41
+ description: "Maximum active people to return.",
42
+ })),
43
+ offset: Type.Optional(Type.Integer({ minimum: 0, maximum: Number.MAX_SAFE_INTEGER, description: "People to skip." })),
44
+ }, { additionalProperties: false }),
45
+ Type.Object({
46
+ view: Type.Literal("dossier_changes"),
47
+ personId: nonEmpty,
48
+ limit: Type.Optional(Type.Integer({
49
+ minimum: 1,
50
+ maximum: 100,
51
+ description: "Maximum dossier change summaries to return, newest first.",
44
52
  })),
53
+ offset: Type.Optional(Type.Integer({ minimum: 0, maximum: Number.MAX_SAFE_INTEGER, description: "Changes to skip." })),
54
+ }, { additionalProperties: false }),
55
+ Type.Object({
56
+ view: Type.Literal("dossier_change"),
57
+ personId: nonEmpty,
58
+ changeId: nonEmpty,
45
59
  }, { additionalProperties: false }),
46
60
  Type.Object({
47
- view: Type.Literal("refinement_next"),
48
- evidenceLimit: Type.Optional(Type.Integer({ minimum: 1, maximum: 50 })),
61
+ view: Type.Literal("todos"),
62
+ limit: Type.Optional(Type.Integer({
63
+ minimum: 1,
64
+ maximum: 100,
65
+ description: "Maximum actionable todos to return.",
66
+ })),
49
67
  }, { additionalProperties: false }),
50
68
  ]);
51
69
  const updateParameters = Type.Union([
@@ -57,16 +75,13 @@ const updateParameters = Type.Union([
57
75
  Type.Object({
58
76
  action: Type.Literal("replace_dossier"),
59
77
  personId: nonEmpty,
60
- dossier: Type.Optional(PERSON_DOSSIER_SCHEMA),
61
- consumedEvidenceLocators: Type.Optional(Type.Array(Type.String({ pattern: "^session:.+:event:\\d+$", maxLength: 1000 }), {
62
- minItems: 1,
63
- maxItems: 50,
64
- uniqueItems: true,
65
- })),
78
+ dossier: PERSON_DOSSIER_SCHEMA,
79
+ reason: nonEmpty,
66
80
  }, { additionalProperties: false }),
67
81
  Type.Object({
68
82
  action: Type.Literal("delete_dossier"),
69
83
  personId: nonEmpty,
84
+ reason: nonEmpty,
70
85
  }, { additionalProperties: false }),
71
86
  Type.Object({
72
87
  action: Type.Literal("set_company"),
@@ -94,7 +109,7 @@ const syncParameters = Type.Object({
94
109
  }, { additionalProperties: false });
95
110
  function context(ctx) {
96
111
  const cfg = ctx.getRuntimeConfig?.() ?? ctx.runtimeConfig ?? ctx.config;
97
- return cfg && ctx.agentId ? { agentId: ctx.agentId, cfg } : undefined;
112
+ return cfg && ctx.agentId ? { agentId: ctx.agentId } : undefined;
98
113
  }
99
114
  function personView(stores, agentId, selector, maxChars) {
100
115
  const store = stores.get(agentId);
@@ -124,33 +139,49 @@ function createInspectTool(stores, config, ctx) {
124
139
  return {
125
140
  name: "memory_people_inspect",
126
141
  label: "Inspect People Memory",
127
- description: "Inspect a person, list actionable people todos, or read the next person's unseen interaction evidence for dossier refinement.",
142
+ description: "List active people, inspect one person, read dossier change history, or list actionable people todos.",
128
143
  parameters: inspectParameters,
129
144
  async execute(_toolCallId, raw) {
130
145
  const input = Value.Parse(inspectParameters, raw);
131
146
  if (input.view === "person") {
132
147
  return jsonResult(personView(stores, active.agentId, input, config.whisperer.maxChars));
133
148
  }
134
- if (input.view === "refinement_next") {
135
- try {
136
- const refinement = nextPeopleRefinement({
137
- store: stores.get(active.agentId),
138
- agentId: active.agentId,
139
- agentDatabasePath: `${resolveAgentDir(active.cfg, active.agentId)}/openclaw-agent.sqlite`,
140
- evidenceLimit: input.evidenceLimit,
141
- });
142
- return jsonResult(refinement ? { status: "ok", refinement } : { status: "empty" });
143
- }
144
- catch (error) {
145
- return jsonResult({
146
- status: "unavailable",
147
- error: error instanceof Error ? error.message : String(error),
148
- });
149
- }
149
+ const store = stores.get(active.agentId);
150
+ if (input.view === "people") {
151
+ const limit = input.limit ?? 50;
152
+ const offset = input.offset ?? 0;
153
+ const people = store.listActivePeople(limit, offset).map((person) => {
154
+ const dossierReviewedAt = store.getDossierReviewedAt(person.id);
155
+ return {
156
+ person,
157
+ identities: store.listIdentities(person.id),
158
+ hasDossier: dossierReviewedAt !== undefined,
159
+ dossierReviewedAt: dossierReviewedAt ?? null,
160
+ };
161
+ });
162
+ return jsonResult({
163
+ status: "ok",
164
+ people,
165
+ nextOffset: people.length === limit ? offset + people.length : null,
166
+ });
167
+ }
168
+ if (input.view === "dossier_changes") {
169
+ const limit = input.limit ?? 20;
170
+ const offset = input.offset ?? 0;
171
+ const changes = store.listDossierChanges(input.personId, limit, offset);
172
+ return jsonResult({
173
+ status: "ok",
174
+ changes,
175
+ nextOffset: changes.length === limit ? offset + changes.length : null,
176
+ });
177
+ }
178
+ if (input.view === "dossier_change") {
179
+ const change = store.getDossierChange(input.personId, input.changeId);
180
+ return jsonResult(change ? { status: "ok", change } : { status: "not_found" });
150
181
  }
151
182
  return jsonResult({
152
183
  status: "ok",
153
- todos: stores.get(active.agentId).listTodos(input.limit ?? 20),
184
+ todos: store.listTodos(input.limit ?? 20),
154
185
  });
155
186
  },
156
187
  };
@@ -172,14 +203,8 @@ function createUpdateTool(stores, ctx) {
172
203
  return jsonResult(person ? { status: "ok", person } : { status: "not_found" });
173
204
  }
174
205
  if (input.action === "replace_dossier") {
175
- if (input.dossier === undefined && input.consumedEvidenceLocators === undefined) {
176
- return jsonResult({
177
- status: "invalid",
178
- error: "replace_dossier requires a dossier or consumed evidence locators",
179
- });
180
- }
181
206
  try {
182
- const dossier = store.replaceDossier(input.personId, input.dossier, input.consumedEvidenceLocators);
207
+ const dossier = store.replaceDossier(input.personId, input.reason, input.dossier);
183
208
  return jsonResult({ status: "ok", dossier });
184
209
  }
185
210
  catch (error) {
@@ -190,7 +215,9 @@ function createUpdateTool(stores, ctx) {
190
215
  }
191
216
  }
192
217
  if (input.action === "delete_dossier") {
193
- return jsonResult(store.deleteDossier(input.personId) ? { status: "ok" } : { status: "not_found" });
218
+ return jsonResult(store.deleteDossier(input.personId, input.reason)
219
+ ? { status: "ok" }
220
+ : { status: "not_found" });
194
221
  }
195
222
  if (input.action === "set_company") {
196
223
  const company = store.setCompany(input.personId, {
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "id": "unblock-memory",
3
3
  "name": "Unblock Memory",
4
- "version": "0.3.8",
4
+ "version": "0.3.9",
5
5
  "description": "Indexes, retrieves, and analyzes configured workspace memory with existing QMD vectors.",
6
6
  "kind": "memory",
7
7
  "activation": { "onStartup": false },
@@ -138,14 +138,6 @@
138
138
  "additionalProperties": false,
139
139
  "properties": {
140
140
  "enabled": { "type": "boolean", "default": false },
141
- "refinement": {
142
- "type": "object",
143
- "description": "Deprecated compatibility field; accepted but ignored.",
144
- "additionalProperties": false,
145
- "properties": {
146
- "maxPeoplePerRun": { "type": "integer", "minimum": 1, "maximum": 50 }
147
- }
148
- },
149
141
  "whisperer": {
150
142
  "type": "object",
151
143
  "additionalProperties": false,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@unblocklabs/unblock-memory",
3
- "version": "0.3.8",
3
+ "version": "0.3.9",
4
4
  "description": "Workspace-native memory for OpenClaw, powered by QMD",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -0,0 +1,105 @@
1
+ ---
2
+ name: people-whisperer
3
+ description: Maintain useful PeopleSQL dossiers from ordinary memory and session evidence so future conversations start with accurate person context.
4
+ ---
5
+
6
+ # People Whisperer
7
+
8
+ Improve the agent's durable understanding of people it interacts with. Prefer no
9
+ write over routine, repetitive, weakly inferred, or already captured information.
10
+ The goal is a useful future conversation, not processing every interaction.
11
+
12
+ ## Choose and inspect
13
+
14
+ - For a named or current person, call `memory_people_inspect` with
15
+ `view: "person"` and their `personId` or exact Slack identity.
16
+ - For autonomous maintenance, call `memory_people_inspect` with
17
+ `view: "people"` and an optional `limit`. Use the returned identity,
18
+ `lastSeenAt`, dossier presence, and dossier `reviewedAt` only as context for
19
+ your judgment. `reviewedAt` is the last dossier write, not a due date.
20
+ - Do not assume every listed person needs work. You may update several people or
21
+ nobody.
22
+
23
+ ## Investigate
24
+
25
+ 1. Read the current dossier when one exists.
26
+ 2. Search for meaningful information with `memory_search`. Use targeted queries,
27
+ relevant corpora, and session metadata filters rather than treating a fixed
28
+ recent-message window as the person's history.
29
+ 3. Follow useful `qmd://` results with `memory_get`. If recent OpenClaw sessions
30
+ are not indexed, use `memory_sync_sessions` and check `memory_sync_status`
31
+ before searching again.
32
+ 4. Prefer direct statements, repeated behavior, decisions, feedback, and
33
+ outcomes. Distinguish observation, reported information, inference, and agent
34
+ assessment. Do not promote small talk or one ambiguous exchange into a durable
35
+ claim.
36
+ 5. Preserve still-useful existing claims. Dossier replacement is complete, not
37
+ a patch.
38
+
39
+ Ordinary `memory_search` supports multiple targeted calls and up to 20 results
40
+ per call. People Whisperer does not impose its own result window or require the
41
+ agent to acknowledge what it inspected.
42
+
43
+ ## Write only when useful
44
+
45
+ Call `memory_people_update` with `action: "replace_dossier"`, the `personId`, a
46
+ concise `reason` for the change, and a complete dossier. The plugin records the
47
+ reason and exact before/after snapshots transactionally. Keep the complete dossier
48
+ under the plugin's 64 KiB serialized limit:
49
+
50
+ ```json
51
+ {
52
+ "action": "replace_dossier",
53
+ "personId": "PeopleSQL person ID",
54
+ "reason": "Added a durable preference supported by recent sessions.",
55
+ "dossier": {
56
+ "schemaVersion": 1,
57
+ "blurb": "Concise context worth having before the next conversation.",
58
+ "sections": [
59
+ {
60
+ "category": "preferences",
61
+ "claims": [
62
+ {
63
+ "statement": "A durable, specific claim.",
64
+ "evidence": [
65
+ {
66
+ "source": "session",
67
+ "locator": "qmd://path-returned-by-memory-search",
68
+ "observedAt": "2026-08-31T12:00:00Z"
69
+ }
70
+ ],
71
+ "epistemicType": "observed",
72
+ "confidence": "high"
73
+ }
74
+ ]
75
+ }
76
+ ]
77
+ }
78
+ }
79
+ ```
80
+
81
+ Allowed section categories are `role`, `priorities`, `preferences`,
82
+ `successCriteria`, `workingStyle`, `relationship`, and `openLoops`. Evidence
83
+ sources are `session`, `memory`, `directory`, or `manual`; `observedAt` and
84
+ `confidence` are optional. Epistemic types are `observed`, `reported`,
85
+ `inferred`, or `agent_assessment`.
86
+
87
+ Make the blurb immediately useful, concise, and honest about uncertainty. Do not
88
+ stuff it with biography or raw evidence. Claim evidence references are
89
+ provenance, not work receipts.
90
+
91
+ Use `delete_dossier` when the current dossier is too unreliable to inject and
92
+ cannot be responsibly repaired; deletion also requires a concise `reason`. Use
93
+ `memory_people_inspect` with `view: "dossier_changes"`, the `personId`, and
94
+ optional `limit`/`offset` to list small newest-first history summaries. Follow a
95
+ summary with `view: "dossier_change"`, the `personId`, and its `changeId` only
96
+ when you need the exact before/after dossier and blurb. Follow `nextOffset` to page.
97
+ Use `set_injection` to disable or re-enable
98
+ whispers for one person without deleting their dossier. Company, todo, and
99
+ person-status actions are available for the corresponding data changes.
100
+
101
+ ## Finish
102
+
103
+ Report whom you investigated, which memory or sessions informed any write, what
104
+ changed, and why skipped people did not need an update. Do not manufacture a
105
+ write to show activity.
@@ -1,21 +0,0 @@
1
- export type PersonSessionEvidence = {
2
- source: "session";
3
- locator: string;
4
- observedAt: string;
5
- text: string;
6
- context: Array<{
7
- locator: string;
8
- role: string;
9
- text: string;
10
- senderId?: string;
11
- }>;
12
- };
13
- export declare function readPersonSessionEvidence(params: {
14
- databasePath: string;
15
- agentId: string;
16
- accountScope: string;
17
- externalId: string;
18
- limit?: number;
19
- maxMessageChars?: number;
20
- excludeLocators?: ReadonlySet<string>;
21
- }): PersonSessionEvidence[];
@@ -1,126 +0,0 @@
1
- import { DatabaseSync } from "node:sqlite";
2
- function record(value) {
3
- return value !== null && typeof value === "object" && !Array.isArray(value)
4
- ? value
5
- : undefined;
6
- }
7
- function messageText(value) {
8
- if (typeof value === "string")
9
- return value.trim() || undefined;
10
- if (!Array.isArray(value))
11
- return undefined;
12
- const text = value
13
- .flatMap((part) => {
14
- const block = record(part);
15
- return block?.type === "text" && typeof block.text === "string" ? [block.text] : [];
16
- })
17
- .join("\n")
18
- .trim();
19
- return text || undefined;
20
- }
21
- function evidenceTimestamp(event, fallback) {
22
- const raw = event.timestamp;
23
- const milliseconds = typeof raw === "number" && Number.isFinite(raw)
24
- ? raw
25
- : typeof raw === "string"
26
- ? Date.parse(raw)
27
- : fallback;
28
- return new Date(Number.isFinite(milliseconds) ? milliseconds : fallback).toISOString();
29
- }
30
- export function readPersonSessionEvidence(params) {
31
- const limit = Math.max(1, Math.min(50, Math.floor(params.limit ?? 20)));
32
- const maxMessageChars = Math.max(1, Math.min(4000, Math.floor(params.maxMessageChars ?? 2000)));
33
- const db = new DatabaseSync(params.databasePath, { readOnly: true });
34
- try {
35
- db.exec("PRAGMA query_only = ON; PRAGMA busy_timeout = 5000; BEGIN");
36
- const version = db.prepare("PRAGMA user_version").get();
37
- const meta = db
38
- .prepare("SELECT schema_version, agent_id FROM schema_meta WHERE meta_key = 'primary'")
39
- .get();
40
- if (version?.user_version !== 17 ||
41
- meta?.schema_version !== 17 ||
42
- meta.agent_id !== params.agentId) {
43
- throw new Error("unsupported or mismatched OpenClaw agent database");
44
- }
45
- const rows = db.prepare(`
46
- SELECT events.session_id, active.active_position, active.event_seq,
47
- events.event_json, events.created_at
48
- FROM session_transcript_active_events AS active
49
- JOIN transcript_events AS events
50
- ON events.session_id = active.session_id AND events.seq = active.event_seq
51
- JOIN session_windows AS sessions ON sessions.session_id = active.session_id
52
- LEFT JOIN conversations ON conversations.conversation_id = sessions.primary_conversation_id
53
- WHERE active.message_position IS NOT NULL
54
- AND COALESCE(sessions.channel, conversations.channel) = 'slack'
55
- AND COALESCE(sessions.account_id, conversations.account_id) = ?
56
- AND json_extract(events.event_json, '$.type') = 'message'
57
- AND json_extract(events.event_json, '$.message.role') = 'user'
58
- AND json_extract(events.event_json, '$.message.__openclaw.senderId') = ?
59
- ORDER BY events.created_at DESC, events.session_id, active.active_position DESC
60
- `);
61
- const contextStatement = db.prepare(`
62
- SELECT active.event_seq, events.event_json
63
- FROM session_transcript_active_events AS active
64
- JOIN transcript_events AS events
65
- ON events.session_id = active.session_id AND events.seq = active.event_seq
66
- WHERE active.session_id = ?
67
- AND active.message_position IS NOT NULL
68
- AND active.active_position BETWEEN ? AND ?
69
- AND json_extract(events.event_json, '$.type') = 'message'
70
- ORDER BY active.active_position
71
- `);
72
- const evidence = [];
73
- for (const row of rows.iterate(params.accountScope, params.externalId)) {
74
- const locator = `session:${row.session_id}:event:${row.event_seq}`;
75
- if (params.excludeLocators?.has(locator))
76
- continue;
77
- const event = record(JSON.parse(row.event_json));
78
- const message = record(event?.message);
79
- const text = messageText(message?.content);
80
- if (!event || !message || !text)
81
- continue;
82
- const context = contextStatement
83
- .all(row.session_id, row.active_position - 1, row.active_position + 2)
84
- .flatMap((contextRow) => {
85
- const candidate = contextRow;
86
- const contextEvent = record(JSON.parse(candidate.event_json));
87
- const contextMessage = record(contextEvent?.message);
88
- const contextText = messageText(contextMessage?.content);
89
- if (!contextMessage || !contextText || typeof contextMessage.role !== "string")
90
- return [];
91
- const metadata = record(contextMessage.__openclaw);
92
- return [
93
- {
94
- locator: `session:${row.session_id}:event:${candidate.event_seq}`,
95
- role: contextMessage.role,
96
- text: contextText.slice(0, maxMessageChars),
97
- ...(typeof metadata?.senderId === "string" ? { senderId: metadata.senderId } : {}),
98
- },
99
- ];
100
- });
101
- evidence.push({
102
- source: "session",
103
- locator,
104
- observedAt: evidenceTimestamp(event, row.created_at),
105
- text: text.slice(0, maxMessageChars),
106
- context,
107
- });
108
- if (evidence.length === limit)
109
- break;
110
- }
111
- db.exec("COMMIT");
112
- return evidence;
113
- }
114
- catch (error) {
115
- try {
116
- db.exec("ROLLBACK");
117
- }
118
- catch {
119
- // The read transaction may not have started if opening the schema failed.
120
- }
121
- throw error;
122
- }
123
- finally {
124
- db.close();
125
- }
126
- }
@@ -1,14 +0,0 @@
1
- import { type PersonSessionEvidence } from "./people-evidence.js";
2
- import type { PeopleStore, Person, PersonDossier, PersonIdentity } from "./people-store.js";
3
- export type PeopleRefinementPacket = {
4
- person: Person;
5
- identities: PersonIdentity[];
6
- currentDossier?: PersonDossier;
7
- evidence: PersonSessionEvidence[];
8
- };
9
- export declare function nextPeopleRefinement(params: {
10
- store: PeopleStore;
11
- agentId: string;
12
- agentDatabasePath: string;
13
- evidenceLimit?: number;
14
- }): PeopleRefinementPacket | undefined;
@@ -1,30 +0,0 @@
1
- import { readPersonSessionEvidence } from "./people-evidence.js";
2
- export function nextPeopleRefinement(params) {
3
- const evidenceLimit = Math.max(1, Math.min(50, Math.floor(params.evidenceLimit ?? 20)));
4
- for (const person of params.store.listActivePeople()) {
5
- const identities = params.store.listIdentities(person.id);
6
- const processed = params.store.listProcessedEvidenceLocators(person.id, "session");
7
- const evidence = identities
8
- .filter((identity) => identity.provider === "slack")
9
- .flatMap((identity) => readPersonSessionEvidence({
10
- databasePath: params.agentDatabasePath,
11
- agentId: params.agentId,
12
- accountScope: identity.accountScope,
13
- externalId: identity.externalId,
14
- limit: evidenceLimit,
15
- excludeLocators: processed,
16
- }))
17
- .filter((entry, index, all) => all.findIndex((candidate) => candidate.locator === entry.locator) === index)
18
- .sort((left, right) => right.observedAt.localeCompare(left.observedAt))
19
- .slice(0, evidenceLimit);
20
- if (evidence.length === 0)
21
- continue;
22
- return {
23
- person,
24
- identities,
25
- currentDossier: params.store.getDossier(person.id)?.dossier,
26
- evidence,
27
- };
28
- }
29
- return undefined;
30
- }