@unblocklabs/unblock-memory 0.3.7 → 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
@@ -139,57 +139,79 @@ directory.
139
139
 
140
140
  PeopleSQL is an optional agent-local people store. When `people.enabled` is
141
141
  true, incoming Slack messages with a canonical agent session key and exact
142
- account and sender IDs create or refresh a disabled-by-default person record.
142
+ account and sender IDs create or refresh an injection-enabled person record.
143
143
  Incomplete Slack identities create a bounded, deduplicated todo without storing
144
144
  message content. Other channels are ignored.
145
145
 
146
- PeopleSQL registers three optional tools when enabled:
146
+ PeopleSQL registers three tools when enabled:
147
147
 
148
- - `memory_people_inspect` lets an owner read one exact person or bounded actionable todos;
149
- - `memory_people_update` changes explicit policy, company, todo, deletion, or
150
- restoration state; and
151
- - `memory_people_sync` manually enriches one Slack account through OpenClaw's
152
- authenticated directory CLI without accepting or reading a token.
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
152
+ - the optional `memory_people_sync` enriches one active OpenClaw Slack account;
153
+ its tool input accepts an account ID, not a token.
153
154
 
154
- Allow the tools you intend the agent to use through OpenClaw's `tools.allow`
155
- configuration; enabling PeopleSQL alone does not expose optional tools.
156
-
157
- Inspection, administrative updates, and directory sync require OpenClaw's
158
- host-derived owner authorization. Dossier replacement runs only through the plugin-owned
159
- Codex refinement command. Soft-deleted people can be restored explicitly; restoration
160
- leaves both policies disabled. The sync is bounded to
161
- 200 normalized directory entries per call and is safe to rerun. With pinned
162
- OpenClaw `2026.8.1-beta.3`, the directory contract supplies ID, name, and handle;
163
- Unblock Memory ignores raw provider payloads. Slack requires the
155
+ The inspect and update tools are part of the normal agent tool surface; they do
156
+ not depend on sender-owner authorization. Directory sync remains optional and
157
+ may need to be allowed explicitly. The sync is bounded to
158
+ 200 normalized directory entries per call and is safe to rerun. Unblock Memory
159
+ keeps only normalized ID, name, handle, and avatar fields. Slack requires the
164
160
  `users:read` scope.
165
161
 
166
- For weekly dossier maintenance, schedule the plugin-owned CLI with an
167
- operator-authored OpenClaw command automation rather than adding a plugin
168
- scheduler:
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.
180
+
181
+ Set `people.whisperer.enabled` to inject context. For each exact Slack sender,
182
+ the plugin prepends that person's stored dossier blurb, bounded by `maxChars`,
183
+ once per `(Slack thread, person)`. Receipts are durable across retries and
184
+ Gateway restarts, while different people in one thread are handled independently.
185
+ Unthreaded DMs use their OpenClaw session as the conversational scope. Unknown,
186
+ unavailable, disabled, or dossierless people produce no context. Injection
187
+ remains subject to OpenClaw's `allowPromptInjection` policy.
188
+
189
+ The package includes a `$people-whisperer` skill with the canonical agent
190
+ procedure and dossier shape. For a manual refresh, ask:
169
191
 
170
- ```bash
171
- openclaw automations create "0 4 * * 0" \
172
- --name "People Whisperer refinement" \
173
- --command-argv '["openclaw","unblock-memory","people","refine","--agent","main"]' \
174
- --timeout-seconds 1800 \
175
- --no-deliver
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.
176
207
  ```
177
208
 
178
- `openclaw unblock-memory people refine --agent <id>` selects a bounded candidate
179
- batch, reads exact-sender session evidence, and invokes one
180
- ephemeral, read-only `codex exec` with structured output. It validates the
181
- complete result set, person IDs, dossier schema, and evidence locators before
182
- writing each dossier transactionally. Refinement uses exact-attributed session
183
- evidence; additional evidence sources can be added when they are implemented.
184
- The host running the command must already have working Codex CLI authentication.
185
- The plugin performs no model call on the prompt-injection path.
186
-
187
- Set both `people.whisperer.enabled` and the person's injection policy to enable
188
- prompt context. The plugin then prepends only that exact person's stored dossier
189
- blurb, bounded by `maxChars`, once per session. Unknown, unavailable, or
190
- incomplete identities produce no context. This non-bundled prompt hook requires
191
- `plugins.entries.unblock-memory.hooks.allowConversationAccess: true` and remains
192
- subject to OpenClaw's `allowPromptInjection` policy.
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.
193
215
 
194
216
  Use `sessionFilter` to restrict session results by metadata while leaving file
195
217
  corpora searchable. Supported fields are `startedFrom` and `startedTo`
@@ -25,9 +25,6 @@ export type UnblockMemoryConfig = {
25
25
  };
26
26
  people: {
27
27
  enabled: boolean;
28
- refinement: {
29
- maxPeoplePerRun: number;
30
- };
31
28
  whisperer: {
32
29
  enabled: boolean;
33
30
  maxChars: number;
@@ -10,7 +10,6 @@ export const DEFAULT_CORPORA = [
10
10
  ];
11
11
  export const DEFAULT_PEOPLE_CONFIG = {
12
12
  enabled: false,
13
- refinement: { maxPeoplePerRun: 10 },
14
13
  whisperer: { enabled: false, maxChars: 1200 },
15
14
  todos: { maxOpen: 1000 },
16
15
  };
@@ -115,16 +114,10 @@ function resolvePeople(value) {
115
114
  throw new Error("unblock-memory people must be an object");
116
115
  }
117
116
  const people = value;
118
- assertOnlyKeys(people, ["enabled", "refinement", "whisperer", "todos"], "people");
117
+ assertOnlyKeys(people, ["enabled", "whisperer", "todos"], "people");
119
118
  const enabled = people.enabled ?? false;
120
119
  if (typeof enabled !== "boolean")
121
120
  throw new Error("unblock-memory people.enabled must be a boolean");
122
- const refinement = people.refinement ?? {};
123
- if (!refinement || typeof refinement !== "object" || Array.isArray(refinement)) {
124
- throw new Error("unblock-memory people.refinement must be an object");
125
- }
126
- const refinementRecord = refinement;
127
- assertOnlyKeys(refinementRecord, ["maxPeoplePerRun"], "people.refinement");
128
121
  const whisperer = people.whisperer ?? {};
129
122
  if (!whisperer || typeof whisperer !== "object" || Array.isArray(whisperer)) {
130
123
  throw new Error("unblock-memory people.whisperer must be an object");
@@ -143,9 +136,6 @@ function resolvePeople(value) {
143
136
  assertOnlyKeys(todosRecord, ["maxOpen"], "people.todos");
144
137
  return {
145
138
  enabled,
146
- refinement: {
147
- maxPeoplePerRun: positiveInteger(refinementRecord.maxPeoplePerRun, DEFAULT_PEOPLE_CONFIG.refinement.maxPeoplePerRun, "people.refinement.maxPeoplePerRun", 50),
148
- },
149
139
  whisperer: {
150
140
  enabled: whispererEnabled,
151
141
  maxChars: positiveInteger(whispererRecord.maxChars, DEFAULT_PEOPLE_CONFIG.whisperer.maxChars, "people.whisperer.maxChars", 4000),
@@ -2,6 +2,14 @@ import { parseAgentSessionKey } from "openclaw/plugin-sdk/routing";
2
2
  function nonBlank(value) {
3
3
  return typeof value === "string" && value.trim() ? value.trim() : undefined;
4
4
  }
5
+ function identifier(value) {
6
+ if (typeof value === "number" && Number.isFinite(value))
7
+ return String(value);
8
+ return nonBlank(value);
9
+ }
10
+ function promptIdentityKey(sessionKey, accountScope, externalId) {
11
+ return JSON.stringify([sessionKey, accountScope, externalId]);
12
+ }
5
13
  function observedAt(timestamp) {
6
14
  if (timestamp === undefined || !Number.isFinite(timestamp))
7
15
  return undefined;
@@ -13,18 +21,41 @@ export function renderPeopleWhisper(blurb, maxChars) {
13
21
  return normalized ? normalized.slice(0, maxChars) : undefined;
14
22
  }
15
23
  export function registerPeopleHooks(api, stores, config) {
24
+ const threadByRun = new Map();
25
+ const pendingThreadByIdentity = new Map();
16
26
  api.on("message_received", (event, context) => {
17
27
  if (context.channelId !== "slack")
18
28
  return;
19
- const agentId = parseAgentSessionKey(context.sessionKey)?.agentId;
20
- if (!agentId)
29
+ const parsed = parseAgentSessionKey(context.sessionKey);
30
+ if (!parsed)
21
31
  return;
22
32
  const accountScope = nonBlank(context.accountId);
23
33
  const externalId = nonBlank(context.senderId);
34
+ const conversationId = nonBlank(context.conversationId);
35
+ const runId = nonBlank(event.runId) ?? nonBlank(context.runId);
36
+ const sessionKey = nonBlank(context.sessionKey);
37
+ if (config.whisperer.enabled && accountScope && externalId && conversationId && sessionKey) {
38
+ const threadRootId = identifier(event.threadId) ?? nonBlank(event.replyToId);
39
+ const rootMessageId = threadRootId ?? nonBlank(event.messageId);
40
+ const threadKey = conversationId.startsWith("D") && threadRootId === undefined
41
+ ? `slack:${accountScope}:${conversationId}:session:${sessionKey}`
42
+ : rootMessageId
43
+ ? `slack:${accountScope}:${conversationId}:${rootMessageId}`
44
+ : undefined;
45
+ if (threadKey) {
46
+ const identityKey = promptIdentityKey(sessionKey, accountScope, externalId);
47
+ if (runId) {
48
+ threadByRun.set(runId, threadKey);
49
+ }
50
+ else {
51
+ const pendingThreadKey = pendingThreadByIdentity.get(identityKey);
52
+ pendingThreadByIdentity.set(identityKey, pendingThreadKey === undefined || pendingThreadKey === threadKey ? threadKey : null);
53
+ }
54
+ }
55
+ }
24
56
  try {
25
- const store = stores.get(agentId);
57
+ const store = stores.get(parsed.agentId);
26
58
  if (!accountScope || !externalId) {
27
- const conversationId = nonBlank(context.conversationId);
28
59
  store.upsertTodo({
29
60
  deduplicationKey: `incomplete-slack-identity:${accountScope ?? "missing"}:${externalId ?? "missing"}:` +
30
61
  `${conversationId ?? "unknown"}`,
@@ -52,50 +83,59 @@ export function registerPeopleHooks(api, stores, config) {
52
83
  });
53
84
  if (!config.whisperer.enabled)
54
85
  return;
55
- const injectedBySession = new Map();
56
86
  api.on("before_prompt_build", (_event, context) => {
57
87
  if (context.trigger !== "user" || context.messageProvider !== "slack")
58
88
  return;
59
- const parsed = parseAgentSessionKey(context.sessionKey);
89
+ const sessionKey = nonBlank(context.sessionKey);
90
+ const parsed = parseAgentSessionKey(sessionKey);
60
91
  const accountScope = nonBlank(context.accountId);
61
92
  const externalId = nonBlank(context.senderId);
62
- const sessionScope = nonBlank(context.sessionId) ?? nonBlank(context.sessionKey);
63
93
  const runId = nonBlank(context.runId);
64
- if (!parsed || !accountScope || !externalId || !sessionScope || !runId)
94
+ if (!sessionKey || !parsed || !accountScope || !externalId || !runId)
95
+ return;
96
+ const identityKey = promptIdentityKey(sessionKey, accountScope, externalId);
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;
103
+ if (!threadKey)
65
104
  return;
105
+ if (typeof pendingThreadKey === "string") {
106
+ threadByRun.set(runId, pendingThreadKey);
107
+ }
66
108
  try {
67
109
  const store = stores.get(parsed.agentId);
68
110
  const person = store.findPersonByIdentity("slack", accountScope, externalId);
69
111
  if (!person || person.status !== "active" || !person.injectionEnabled)
70
112
  return;
71
- const injected = injectedBySession.get(sessionScope);
72
- const previous = injected?.get(person.id);
73
- if (previous)
74
- return previous.runId === runId ? previous.contribution : undefined;
113
+ const previous = store.getWhisperReceipt(threadKey, person.id);
114
+ if (previous) {
115
+ return previous.runId === runId ? { prependContext: previous.contribution } : undefined;
116
+ }
75
117
  const blurb = store.getDossierBlurb(person.id);
76
118
  const prependContext = blurb
77
119
  ? renderPeopleWhisper(blurb, config.whisperer.maxChars)
78
120
  : undefined;
79
121
  if (!prependContext)
80
122
  return;
81
- const contribution = { prependContext };
82
- const state = { runId, contribution };
83
- if (injected)
84
- injected.set(person.id, state);
85
- else
86
- injectedBySession.set(sessionScope, new Map([[person.id, state]]));
87
- return contribution;
123
+ const receipt = store.recordWhisperReceipt({
124
+ threadKey,
125
+ personId: person.id,
126
+ runId,
127
+ contribution: prependContext,
128
+ });
129
+ return receipt.runId === runId ? { prependContext: receipt.contribution } : undefined;
88
130
  }
89
131
  catch (error) {
90
132
  api.logger.warn(`unblock-memory people whisperer lookup failed: ${String(error)}`);
91
133
  return;
92
134
  }
93
135
  });
94
- api.on("session_end", (event, context) => {
95
- injectedBySession.delete(event.sessionId);
96
- if (event.sessionKey)
97
- injectedBySession.delete(event.sessionKey);
98
- if (context.sessionKey)
99
- injectedBySession.delete(context.sessionKey);
136
+ api.on("agent_end", (event, context) => {
137
+ const runId = nonBlank(event.runId) ?? nonBlank(context.runId);
138
+ if (runId)
139
+ threadByRun.delete(runId);
100
140
  });
101
141
  }
@@ -17,13 +17,25 @@ 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;
23
36
  preferredName: string | null;
24
37
  status: "active" | "unavailable" | "archived";
25
38
  companyId: string | null;
26
- refinementEnabled: boolean;
27
39
  injectionEnabled: boolean;
28
40
  lastSeenAt: string | null;
29
41
  createdAt: string;
@@ -98,19 +110,33 @@ export declare class PeopleStore {
98
110
  name: string;
99
111
  primaryDomain?: string;
100
112
  }): Company | undefined;
101
- listRefinementCandidates(limit: number): Person[];
113
+ listActivePeople(limit?: number, offset?: number): Person[];
102
114
  findIdentity(provider: string, accountScope: string, externalId: string): PersonIdentity | undefined;
103
- setPolicies(personId: string, policies: {
104
- refinementEnabled?: boolean;
105
- injectionEnabled?: boolean;
106
- }): Person | undefined;
107
- replaceDossier(personId: string, input: unknown, reviewedAt?: string, options?: {
108
- requireRefinementEnabled?: boolean;
109
- }): PersonDossier;
115
+ setInjection(personId: string, enabled: boolean): Person | undefined;
116
+ replaceDossier(personId: string, reasonInput: string, input: unknown): PersonDossier;
117
+ deleteDossier(personId: string, reasonInput: string): boolean;
118
+ getWhisperReceipt(threadKey: string, personId: string): {
119
+ runId: string;
120
+ contribution: string;
121
+ injectedAt: string;
122
+ } | undefined;
123
+ recordWhisperReceipt(input: {
124
+ threadKey: string;
125
+ personId: string;
126
+ runId: string;
127
+ contribution: string;
128
+ }): {
129
+ runId: string;
130
+ contribution: string;
131
+ injectedAt: string;
132
+ };
110
133
  getDossier(personId: string): {
111
134
  dossier: PersonDossier;
112
135
  reviewedAt: string;
113
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;
114
140
  getDossierBlurb(personId: string): string | undefined;
115
141
  softDeletePerson(personId: string): Person | undefined;
116
142
  restorePerson(personId: string): Person | undefined;