@unblocklabs/unblock-memory 0.3.7 → 0.3.8

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,41 @@ 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
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
150
152
  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.
153
-
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
153
+ - the optional `memory_people_sync` enriches one active OpenClaw Slack account;
154
+ its tool input accepts an account ID, not a token.
155
+
156
+ The inspect and update tools are part of the normal agent tool surface; they do
157
+ not depend on sender-owner authorization. Directory sync remains optional and
158
+ may need to be allowed explicitly. The sync is bounded to
159
+ 200 normalized directory entries per call and is safe to rerun. Unblock Memory
160
+ keeps only normalized ID, name, handle, and avatar fields. Slack requires the
164
161
  `users:read` scope.
165
162
 
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:
169
-
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
176
- ```
177
-
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.
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.
169
+
170
+ Set `people.whisperer.enabled` to inject context. For each exact Slack sender,
171
+ the plugin prepends that person's stored dossier blurb, bounded by `maxChars`,
172
+ once per `(Slack thread, person)`. Receipts are durable across retries and
173
+ Gateway restarts, while different people in one thread are handled independently.
174
+ Unthreaded DMs use their OpenClaw session as the conversational scope. Unknown,
175
+ unavailable, disabled, or dossierless people produce no context. Injection
176
+ remains subject to OpenClaw's `allowPromptInjection` policy.
193
177
 
194
178
  Use `sessionFilter` to restrict session results by metadata while leaving file
195
179
  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
  };
@@ -119,12 +118,18 @@ function resolvePeople(value) {
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");
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);
125
132
  }
126
- const refinementRecord = refinement;
127
- assertOnlyKeys(refinementRecord, ["maxPeoplePerRun"], "people.refinement");
128
133
  const whisperer = people.whisperer ?? {};
129
134
  if (!whisperer || typeof whisperer !== "object" || Array.isArray(whisperer)) {
130
135
  throw new Error("unblock-memory people.whisperer must be an object");
@@ -143,9 +148,6 @@ function resolvePeople(value) {
143
148
  assertOnlyKeys(todosRecord, ["maxOpen"], "people.todos");
144
149
  return {
145
150
  enabled,
146
- refinement: {
147
- maxPeoplePerRun: positiveInteger(refinementRecord.maxPeoplePerRun, DEFAULT_PEOPLE_CONFIG.refinement.maxPeoplePerRun, "people.refinement.maxPeoplePerRun", 50),
148
- },
149
151
  whisperer: {
150
152
  enabled: whispererEnabled,
151
153
  maxChars: positiveInteger(whispererRecord.maxChars, DEFAULT_PEOPLE_CONFIG.whisperer.maxChars, "people.whisperer.maxChars", 4000),
@@ -3,6 +3,12 @@ export type PersonSessionEvidence = {
3
3
  locator: string;
4
4
  observedAt: string;
5
5
  text: string;
6
+ context: Array<{
7
+ locator: string;
8
+ role: string;
9
+ text: string;
10
+ senderId?: string;
11
+ }>;
6
12
  };
7
13
  export declare function readPersonSessionEvidence(params: {
8
14
  databasePath: string;
@@ -11,4 +17,5 @@ export declare function readPersonSessionEvidence(params: {
11
17
  externalId: string;
12
18
  limit?: number;
13
19
  maxMessageChars?: number;
20
+ excludeLocators?: ReadonlySet<string>;
14
21
  }): PersonSessionEvidence[];
@@ -42,9 +42,9 @@ export function readPersonSessionEvidence(params) {
42
42
  meta.agent_id !== params.agentId) {
43
43
  throw new Error("unsupported or mismatched OpenClaw agent database");
44
44
  }
45
- const rows = db
46
- .prepare(`
47
- SELECT events.session_id, active.event_seq, events.event_json, events.created_at
45
+ const rows = db.prepare(`
46
+ SELECT events.session_id, active.active_position, active.event_seq,
47
+ events.event_json, events.created_at
48
48
  FROM session_transcript_active_events AS active
49
49
  JOIN transcript_events AS events
50
50
  ON events.session_id = active.session_id AND events.seq = active.event_seq
@@ -57,24 +57,57 @@ export function readPersonSessionEvidence(params) {
57
57
  AND json_extract(events.event_json, '$.message.role') = 'user'
58
58
  AND json_extract(events.event_json, '$.message.__openclaw.senderId') = ?
59
59
  ORDER BY events.created_at DESC, events.session_id, active.active_position DESC
60
- LIMIT ?
61
- `)
62
- .all(params.accountScope, params.externalId, limit);
63
- const evidence = rows.flatMap((row) => {
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;
64
77
  const event = record(JSON.parse(row.event_json));
65
78
  const message = record(event?.message);
66
79
  const text = messageText(message?.content);
67
80
  if (!event || !message || !text)
68
- return [];
69
- return [
70
- {
71
- source: "session",
72
- locator: `session:${row.session_id}:event:${row.event_seq}`,
73
- observedAt: evidenceTimestamp(event, row.created_at),
74
- text: text.slice(0, maxMessageChars),
75
- },
76
- ];
77
- });
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
+ }
78
111
  db.exec("COMMIT");
79
112
  return evidence;
80
113
  }
@@ -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
+ pendingThreadByIdentity.delete(identityKey);
49
+ threadByRun.set(runId, threadKey);
50
+ }
51
+ else {
52
+ pendingThreadByIdentity.set(identityKey, threadKey);
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,56 @@ 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 pendingThreadKey = pendingThreadByIdentity.get(identityKey);
98
+ const threadKey = threadByRun.get(runId) ?? pendingThreadKey;
99
+ if (!threadKey)
65
100
  return;
101
+ if (pendingThreadKey) {
102
+ pendingThreadByIdentity.delete(identityKey);
103
+ threadByRun.set(runId, pendingThreadKey);
104
+ }
66
105
  try {
67
106
  const store = stores.get(parsed.agentId);
68
107
  const person = store.findPersonByIdentity("slack", accountScope, externalId);
69
108
  if (!person || person.status !== "active" || !person.injectionEnabled)
70
109
  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;
110
+ const previous = store.getWhisperReceipt(threadKey, person.id);
111
+ if (previous) {
112
+ return previous.runId === runId ? { prependContext: previous.contribution } : undefined;
113
+ }
75
114
  const blurb = store.getDossierBlurb(person.id);
76
115
  const prependContext = blurb
77
116
  ? renderPeopleWhisper(blurb, config.whisperer.maxChars)
78
117
  : undefined;
79
118
  if (!prependContext)
80
119
  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;
120
+ const receipt = store.recordWhisperReceipt({
121
+ threadKey,
122
+ personId: person.id,
123
+ runId,
124
+ contribution: prependContext,
125
+ });
126
+ return receipt.runId === runId ? { prependContext: receipt.contribution } : undefined;
88
127
  }
89
128
  catch (error) {
90
129
  api.logger.warn(`unblock-memory people whisperer lookup failed: ${String(error)}`);
91
130
  return;
92
131
  }
93
132
  });
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);
133
+ api.on("agent_end", (event, context) => {
134
+ const runId = nonBlank(event.runId) ?? nonBlank(context.runId);
135
+ if (runId)
136
+ threadByRun.delete(runId);
100
137
  });
101
138
  }
@@ -1,72 +1,14 @@
1
- import { Type } from "typebox";
2
1
  import { type PersonSessionEvidence } from "./people-evidence.js";
3
- import { type PeopleStore, type PersonDossier, type PersonIdentity } from "./people-store.js";
4
- export declare const REFINEMENT_OUTPUT_SCHEMA: Type.TObject<{
5
- results: Type.TArray<Type.TObject<{
6
- personId: Type.TString;
7
- dossier: Type.TObject<{
8
- schemaVersion: Type.TLiteral<1>;
9
- blurb: Type.TString;
10
- sections: Type.TArray<Type.TObject<{
11
- category: Type.TUnion<Type.TLiteral<"role" | "priorities" | "preferences" | "successCriteria" | "workingStyle" | "relationship" | "openLoops">[]>;
12
- claims: Type.TArray<Type.TObject<{
13
- statement: Type.TString;
14
- evidence: Type.TArray<Type.TObject<{
15
- source: Type.TUnion<[Type.TLiteral<"session">, Type.TLiteral<"memory">, Type.TLiteral<"directory">, Type.TLiteral<"manual">]>;
16
- locator: Type.TString;
17
- observedAt: Type.TOptional<Type.TString>;
18
- }>>;
19
- epistemicType: Type.TUnion<[Type.TLiteral<"observed">, Type.TLiteral<"reported">, Type.TLiteral<"inferred">, Type.TLiteral<"agent_assessment">]>;
20
- confidence: Type.TOptional<Type.TUnion<[Type.TLiteral<"low">, Type.TLiteral<"medium">, Type.TLiteral<"high">]>>;
21
- }>>;
22
- }>>;
23
- }>;
24
- }>>;
25
- }>;
26
- type PeopleRefinementInput = {
27
- people: Array<{
28
- personId: string;
29
- displayName: string;
30
- lastSeenAt: string;
31
- identities: PersonIdentity[];
32
- currentDossier?: PersonDossier;
33
- evidence: PersonSessionEvidence[];
34
- }>;
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[];
35
8
  };
36
- export type PeopleRefinementRunner = (params: {
37
- input: PeopleRefinementInput;
38
- outputSchema: typeof REFINEMENT_OUTPUT_SCHEMA;
39
- signal?: AbortSignal;
40
- }) => Promise<unknown>;
41
- export type PeopleRefinementSummary = {
42
- status: "ok";
43
- selected: number;
44
- refined: number;
45
- skippedWithoutEvidence: number;
46
- personIds: string[];
47
- };
48
- export declare function refinePeople(params: {
9
+ export declare function nextPeopleRefinement(params: {
49
10
  store: PeopleStore;
50
11
  agentId: string;
51
12
  agentDatabasePath: string;
52
- maxBlurbChars: number;
53
- runner: PeopleRefinementRunner;
54
- candidateLimit?: number;
55
13
  evidenceLimit?: number;
56
- signal?: AbortSignal;
57
- }): Promise<PeopleRefinementSummary>;
58
- export type CodexCommandRunner = (params: {
59
- executable: string;
60
- args: string[];
61
- cwd: string;
62
- input: string;
63
- env: NodeJS.ProcessEnv;
64
- signal?: AbortSignal;
65
- }) => Promise<void>;
66
- export declare function createCodexPeopleRefinementRunner(runCommand?: CodexCommandRunner, options?: {
67
- environment?: NodeJS.ProcessEnv;
68
- timeoutMs?: number;
69
- maxOutputBytes?: number;
70
- }): PeopleRefinementRunner;
71
- export declare const codexPeopleRefinementRunner: PeopleRefinementRunner;
72
- export {};
14
+ }): PeopleRefinementPacket | undefined;