@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 +63 -41
- package/dist/src/config.d.ts +0 -3
- package/dist/src/config.js +1 -11
- package/dist/src/people-hooks.js +65 -25
- package/dist/src/people-store.d.ts +35 -9
- package/dist/src/people-store.js +225 -65
- package/dist/src/people-tools.d.ts +1 -1
- package/dist/src/people-tools.js +96 -23
- package/dist/src/plugin.js +0 -2
- package/openclaw.plugin.json +3 -19
- package/package.json +1 -1
- package/skills/people-whisperer/SKILL.md +105 -0
- package/dist/src/people-cli.d.ts +0 -4
- package/dist/src/people-cli.js +0 -47
- package/dist/src/people-evidence.d.ts +0 -14
- package/dist/src/people-evidence.js +0 -93
- package/dist/src/people-refinement.d.ts +0 -72
- package/dist/src/people-refinement.js +0 -269
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
|
|
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
|
|
146
|
+
PeopleSQL registers three tools when enabled:
|
|
147
147
|
|
|
148
|
-
- `memory_people_inspect`
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
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
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
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
|
-
|
|
167
|
-
|
|
168
|
-
|
|
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
|
-
```
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
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
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
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`
|
package/dist/src/config.d.ts
CHANGED
package/dist/src/config.js
CHANGED
|
@@ -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", "
|
|
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),
|
package/dist/src/people-hooks.js
CHANGED
|
@@ -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
|
|
20
|
-
if (!
|
|
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
|
|
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 (!
|
|
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
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
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
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
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("
|
|
95
|
-
|
|
96
|
-
if (
|
|
97
|
-
|
|
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
|
-
|
|
113
|
+
listActivePeople(limit?: number, offset?: number): Person[];
|
|
102
114
|
findIdentity(provider: string, accountScope: string, externalId: string): PersonIdentity | undefined;
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
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;
|