@unblocklabs/unblock-memory 0.3.2 → 0.3.4

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
@@ -39,7 +39,7 @@ directories, or globs into named corpora:
39
39
  entries: {
40
40
  "unblock-memory": {
41
41
  hooks: {
42
- // Required only when skillWhisperer.enabled is true.
42
+ // Required when either whisperer is enabled.
43
43
  allowConversationAccess: true,
44
44
  },
45
45
  config: {
@@ -79,6 +79,10 @@ directories, or globs into named corpora:
79
79
  minScore: 0.5,
80
80
  cooldownTurns: 10,
81
81
  },
82
+ people: {
83
+ enabled: false,
84
+ whisperer: { enabled: false, maxChars: 1200 },
85
+ },
82
86
  // Optional: omit unless the local analysis worker is installed.
83
87
  analysis: {
84
88
  executable: "/absolute/path/to/unblock-cluster/bin/unblock-memory-analysis",
@@ -131,6 +135,62 @@ OpenClaw's effective skill inventory from `openclaw.json`. Configured skill
131
135
  globs follow symlinked directories, including OpenClaw's `plugin-skills`
132
136
  directory.
133
137
 
138
+ ### People Whisperer
139
+
140
+ PeopleSQL is an optional agent-local people store. When `people.enabled` is
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.
143
+ Incomplete Slack identities create a bounded, deduplicated todo without storing
144
+ message content. Other channels are ignored.
145
+
146
+ PeopleSQL registers three optional tools when enabled:
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.
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
164
+ `users:read` scope.
165
+
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.
193
+
134
194
  Use `sessionFilter` to restrict session results by metadata while leaving file
135
195
  corpora searchable. Supported fields are `startedFrom` and `startedTo`
136
196
  (inclusive ISO 8601 timestamps), `provider`, `chatType`, `accountId`, and
@@ -123,30 +123,40 @@ export function runAnalysisWorker(params) {
123
123
  stderrBytes += Math.min(chunk.length, remaining);
124
124
  });
125
125
  let settled = false;
126
- let abortError;
127
- const cleanup = () => params.signal?.removeEventListener("abort", onAbort);
128
- const finish = (error) => {
126
+ let aborted = false;
127
+ let abortReason;
128
+ let forceKill;
129
+ const cleanup = () => {
130
+ params.signal?.removeEventListener("abort", onAbort);
131
+ if (forceKill)
132
+ clearTimeout(forceKill);
133
+ };
134
+ const finish = (error, shouldReject = error !== undefined) => {
129
135
  if (settled)
130
136
  return;
131
137
  settled = true;
132
138
  cleanup();
133
- error ? reject(error) : resolve();
139
+ shouldReject ? reject(error) : resolve();
134
140
  };
135
141
  const onAbort = () => {
136
- if (abortError)
142
+ if (aborted)
137
143
  return;
138
- abortError = params.signal?.reason instanceof Error
139
- ? params.signal.reason
140
- : new Error("Memory reclustering aborted");
144
+ aborted = true;
145
+ abortReason = params.signal?.reason;
141
146
  child.kill("SIGTERM");
147
+ forceKill = setTimeout(() => {
148
+ if (!settled)
149
+ child.kill("SIGKILL");
150
+ }, 250);
151
+ forceKill.unref();
142
152
  };
143
153
  params.signal?.addEventListener("abort", onAbort, { once: true });
144
154
  if (params.signal?.aborted)
145
155
  onAbort();
146
- child.on("error", (error) => finish(abortError ?? error));
156
+ child.on("error", (error) => finish(aborted ? abortReason : error, true));
147
157
  child.on("close", (code, signal) => {
148
- if (abortError) {
149
- finish(abortError);
158
+ if (aborted) {
159
+ finish(abortReason, true);
150
160
  return;
151
161
  }
152
162
  if (code === 0) {
@@ -9,7 +9,7 @@ export type SkillCorpusConfig = {
9
9
  paths: readonly string[];
10
10
  };
11
11
  declare const CHAT_TYPES: readonly ["channel", "group", "direct"];
12
- export type ChatType = typeof CHAT_TYPES[number];
12
+ export type ChatType = (typeof CHAT_TYPES)[number];
13
13
  type SessionCorpusConfig = {
14
14
  name: "sessions";
15
15
  kind: "sessions";
@@ -23,6 +23,19 @@ export type UnblockMemoryConfig = {
23
23
  analysis: {
24
24
  executable?: string;
25
25
  };
26
+ people: {
27
+ enabled: boolean;
28
+ refinement: {
29
+ maxPeoplePerRun: number;
30
+ };
31
+ whisperer: {
32
+ enabled: boolean;
33
+ maxChars: number;
34
+ };
35
+ todos: {
36
+ maxOpen: number;
37
+ };
38
+ };
26
39
  skillWhisperer: {
27
40
  enabled: boolean;
28
41
  historyMessages: number;
@@ -30,5 +43,6 @@ export type UnblockMemoryConfig = {
30
43
  cooldownTurns: number;
31
44
  };
32
45
  };
46
+ export declare const DEFAULT_PEOPLE_CONFIG: UnblockMemoryConfig["people"];
33
47
  export declare function resolveConfig(value: unknown): UnblockMemoryConfig;
34
48
  export {};
@@ -1,11 +1,19 @@
1
1
  import { isAbsolute } from "node:path";
2
2
  const DEFAULT_PATHS = ["MEMORY.md", "USER.md", "memory/**/*.md"];
3
3
  const CHAT_TYPES = ["channel", "group", "direct"];
4
- export const DEFAULT_CORPORA = [{
4
+ export const DEFAULT_CORPORA = [
5
+ {
5
6
  name: "memory",
6
7
  kind: "files",
7
8
  paths: DEFAULT_PATHS,
8
- }];
9
+ },
10
+ ];
11
+ export const DEFAULT_PEOPLE_CONFIG = {
12
+ enabled: false,
13
+ refinement: { maxPeoplePerRun: 10 },
14
+ whisperer: { enabled: false, maxChars: 1200 },
15
+ todos: { maxOpen: 1000 },
16
+ };
9
17
  const DEFAULT_SKILL_WHISPERER = {
10
18
  enabled: false,
11
19
  historyMessages: 5,
@@ -44,7 +52,8 @@ function resolveCorpora(value) {
44
52
  if (name !== "skills") {
45
53
  throw new Error('unblock-memory skills corpus must be named "skills"');
46
54
  }
47
- if (!Array.isArray(corpus.paths) || corpus.paths.length === 0 ||
55
+ if (!Array.isArray(corpus.paths) ||
56
+ corpus.paths.length === 0 ||
48
57
  !corpus.paths.every((path) => typeof path === "string" && path.trim())) {
49
58
  throw new Error("unblock-memory corpus skills paths must be a non-empty array of non-empty strings");
50
59
  }
@@ -56,11 +65,16 @@ function resolveCorpora(value) {
56
65
  throw new Error('unblock-memory session corpus must be named "sessions"');
57
66
  }
58
67
  const chatTypes = corpus.chatTypes ?? ["channel", "group"];
59
- if (!Array.isArray(chatTypes) || chatTypes.length === 0 ||
68
+ if (!Array.isArray(chatTypes) ||
69
+ chatTypes.length === 0 ||
60
70
  !chatTypes.every((chatType) => CHAT_TYPES.includes(chatType))) {
61
71
  throw new Error(`unblock-memory corpus sessions chatTypes must contain channel, group, or direct`);
62
72
  }
63
- return { name: "sessions", kind: "sessions", chatTypes: [...new Set(chatTypes)] };
73
+ return {
74
+ name: "sessions",
75
+ kind: "sessions",
76
+ chatTypes: [...new Set(chatTypes)],
77
+ };
64
78
  }
65
79
  assertOnlyKeys(corpus, ["name", "kind", "paths"], `corpora[${index}]`);
66
80
  if (name === "sessions") {
@@ -72,7 +86,8 @@ function resolveCorpora(value) {
72
86
  if (corpus.kind !== "files") {
73
87
  throw new Error(`unblock-memory corpus ${name} must have kind "files", "skills", or "sessions"`);
74
88
  }
75
- if (!Array.isArray(corpus.paths) || corpus.paths.length === 0 ||
89
+ if (!Array.isArray(corpus.paths) ||
90
+ corpus.paths.length === 0 ||
76
91
  !corpus.paths.every((path) => typeof path === "string" && path.trim())) {
77
92
  throw new Error(`unblock-memory corpus ${name} paths must be a non-empty array of non-empty strings`);
78
93
  }
@@ -83,12 +98,70 @@ function resolveCorpora(value) {
83
98
  }
84
99
  return corpora;
85
100
  }
101
+ function positiveInteger(value, fallback, label, maximum) {
102
+ const resolved = value ?? fallback;
103
+ if (typeof resolved !== "number" ||
104
+ !Number.isInteger(resolved) ||
105
+ resolved < 1 ||
106
+ resolved > maximum) {
107
+ throw new Error(`unblock-memory ${label} must be a positive integer no greater than ${maximum}`);
108
+ }
109
+ return resolved;
110
+ }
111
+ function resolvePeople(value) {
112
+ if (value === undefined)
113
+ return DEFAULT_PEOPLE_CONFIG;
114
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
115
+ throw new Error("unblock-memory people must be an object");
116
+ }
117
+ const people = value;
118
+ assertOnlyKeys(people, ["enabled", "refinement", "whisperer", "todos"], "people");
119
+ const enabled = people.enabled ?? false;
120
+ if (typeof enabled !== "boolean")
121
+ 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
+ const whisperer = people.whisperer ?? {};
129
+ if (!whisperer || typeof whisperer !== "object" || Array.isArray(whisperer)) {
130
+ throw new Error("unblock-memory people.whisperer must be an object");
131
+ }
132
+ const whispererRecord = whisperer;
133
+ assertOnlyKeys(whispererRecord, ["enabled", "maxChars"], "people.whisperer");
134
+ const whispererEnabled = whispererRecord.enabled ?? false;
135
+ if (typeof whispererEnabled !== "boolean") {
136
+ throw new Error("unblock-memory people.whisperer.enabled must be a boolean");
137
+ }
138
+ const todos = people.todos ?? {};
139
+ if (!todos || typeof todos !== "object" || Array.isArray(todos)) {
140
+ throw new Error("unblock-memory people.todos must be an object");
141
+ }
142
+ const todosRecord = todos;
143
+ assertOnlyKeys(todosRecord, ["maxOpen"], "people.todos");
144
+ return {
145
+ enabled,
146
+ refinement: {
147
+ maxPeoplePerRun: positiveInteger(refinementRecord.maxPeoplePerRun, DEFAULT_PEOPLE_CONFIG.refinement.maxPeoplePerRun, "people.refinement.maxPeoplePerRun", 50),
148
+ },
149
+ whisperer: {
150
+ enabled: whispererEnabled,
151
+ maxChars: positiveInteger(whispererRecord.maxChars, DEFAULT_PEOPLE_CONFIG.whisperer.maxChars, "people.whisperer.maxChars", 4000),
152
+ },
153
+ todos: {
154
+ maxOpen: positiveInteger(todosRecord.maxOpen, DEFAULT_PEOPLE_CONFIG.todos.maxOpen, "people.todos.maxOpen", 10_000),
155
+ },
156
+ };
157
+ }
86
158
  export function resolveConfig(value) {
87
159
  if (value === undefined || value === null) {
88
160
  return {
89
161
  corpora: DEFAULT_CORPORA,
90
162
  keepEmbeddingModelWarm: true,
91
163
  analysis: {},
164
+ people: DEFAULT_PEOPLE_CONFIG,
92
165
  skillWhisperer: DEFAULT_SKILL_WHISPERER,
93
166
  };
94
167
  }
@@ -96,9 +169,11 @@ export function resolveConfig(value) {
96
169
  throw new Error("unblock-memory config must be an object");
97
170
  }
98
171
  const config = value;
99
- assertOnlyKeys(config, ["corpora", "keepEmbeddingModelWarm", "analysis", "skillWhisperer"], "config");
172
+ assertOnlyKeys(config, ["corpora", "keepEmbeddingModelWarm", "analysis", "people", "skillWhisperer"], "config");
100
173
  const corpora = resolveCorpora(config.corpora);
101
- if (config.keepEmbeddingModelWarm !== undefined && typeof config.keepEmbeddingModelWarm !== "boolean") {
174
+ const people = resolvePeople(config.people);
175
+ if (config.keepEmbeddingModelWarm !== undefined &&
176
+ typeof config.keepEmbeddingModelWarm !== "boolean") {
102
177
  throw new Error("unblock-memory keepEmbeddingModelWarm must be a boolean");
103
178
  }
104
179
  const keepEmbeddingModelWarm = config.keepEmbeddingModelWarm ?? true;
@@ -119,7 +194,9 @@ export function resolveConfig(value) {
119
194
  }
120
195
  let skillWhisperer = DEFAULT_SKILL_WHISPERER;
121
196
  if (config.skillWhisperer !== undefined) {
122
- if (!config.skillWhisperer || typeof config.skillWhisperer !== "object" || Array.isArray(config.skillWhisperer)) {
197
+ if (!config.skillWhisperer ||
198
+ typeof config.skillWhisperer !== "object" ||
199
+ Array.isArray(config.skillWhisperer)) {
123
200
  throw new Error("unblock-memory skillWhisperer must be an object");
124
201
  }
125
202
  const value = config.skillWhisperer;
@@ -130,13 +207,20 @@ export function resolveConfig(value) {
130
207
  const cooldownTurns = value.cooldownTurns ?? 10;
131
208
  if (typeof enabled !== "boolean")
132
209
  throw new Error("unblock-memory skillWhisperer.enabled must be a boolean");
133
- if (typeof historyMessages !== "number" || !Number.isInteger(historyMessages) || historyMessages < 0) {
210
+ if (typeof historyMessages !== "number" ||
211
+ !Number.isInteger(historyMessages) ||
212
+ historyMessages < 0) {
134
213
  throw new Error("unblock-memory skillWhisperer.historyMessages must be a non-negative integer");
135
214
  }
136
- if (typeof minScore !== "number" || !Number.isFinite(minScore) || minScore < 0 || minScore > 1) {
215
+ if (typeof minScore !== "number" ||
216
+ !Number.isFinite(minScore) ||
217
+ minScore < 0 ||
218
+ minScore > 1) {
137
219
  throw new Error("unblock-memory skillWhisperer.minScore must be between 0 and 1");
138
220
  }
139
- if (typeof cooldownTurns !== "number" || !Number.isInteger(cooldownTurns) || cooldownTurns < 0) {
221
+ if (typeof cooldownTurns !== "number" ||
222
+ !Number.isInteger(cooldownTurns) ||
223
+ cooldownTurns < 0) {
140
224
  throw new Error("unblock-memory skillWhisperer.cooldownTurns must be a non-negative integer");
141
225
  }
142
226
  skillWhisperer = { enabled, historyMessages, minScore, cooldownTurns };
@@ -144,5 +228,5 @@ export function resolveConfig(value) {
144
228
  if (skillWhisperer.enabled && !corpora.some((corpus) => corpus.kind === "skills")) {
145
229
  throw new Error('unblock-memory enabled skillWhisperer requires a corpus named "skills" with kind "skills"');
146
230
  }
147
- return { corpora, keepEmbeddingModelWarm, analysis: analysisConfig, skillWhisperer };
231
+ return { corpora, keepEmbeddingModelWarm, analysis: analysisConfig, people, skillWhisperer };
148
232
  }
@@ -1,4 +1,5 @@
1
1
  import type { MemoryPluginCapability } from "openclaw/plugin-sdk/memory-host-core";
2
+ import type { OpenClawPluginToolContext } from "openclaw/plugin-sdk/plugin-entry";
2
3
  import type { SessionMetadata } from "./session-projector.js";
3
4
  import type { ChatType } from "./config.js";
4
5
  export type MemoryPluginRuntimeContract = NonNullable<MemoryPluginCapability["runtime"]>;
@@ -18,9 +19,11 @@ export type SessionSearchFilter = {
18
19
  accountId?: string;
19
20
  conversationId?: string;
20
21
  };
22
+ export type MemoryRequestContext = Pick<OpenClawPluginToolContext, "sessionKey" | "sessionId" | "messageChannel" | "agentAccountId" | "nativeChannelId" | "deliveryContext">;
21
23
  export type CorpusSearchOptions = NonNullable<Parameters<MemorySearchManagerContract["search"]>[1]> & {
22
24
  corpora?: readonly string[];
23
25
  sessionFilter?: SessionSearchFilter;
26
+ requestContext?: MemoryRequestContext;
24
27
  };
25
28
  export type MemoryEmbeddingProbeResult = Awaited<ReturnType<MemorySearchManagerContract["probeEmbeddingAvailability"]>>;
26
29
  export type MemorySyncParams = Parameters<NonNullable<MemorySearchManagerContract["sync"]>>[0];
@@ -1,6 +1,6 @@
1
1
  import type { QMDStore } from "@unblocklabs/qmd";
2
2
  import { type AnalysisRunner, type MemoryAnalysisSummary, type MemoryClusterDetail, type MemoryClusterList, type MemoryClusterSort, type MemoryReclusterOptions } from "./analysis.js";
3
- import type { CorpusMemorySearchResult, CorpusSearchOptions, MemoryEmbeddingProbeResult, MemoryProviderStatus, MemoryReadResult, MemorySearchManagerContract, MemorySyncParams } from "./contracts.js";
3
+ import type { CorpusMemorySearchResult, CorpusSearchOptions, MemoryEmbeddingProbeResult, MemoryProviderStatus, MemoryReadResult, MemoryRequestContext, MemorySearchManagerContract, MemorySyncParams } from "./contracts.js";
4
4
  import type { ChatType } from "./config.js";
5
5
  import { type MaintenanceStatus, type TemporalBasis } from "./curation.js";
6
6
  import { type SessionSyncResult } from "./session-sync.js";
@@ -75,6 +75,7 @@ export declare class QmdMemoryManager implements MemorySearchManagerContract {
75
75
  relPath: string;
76
76
  from?: number;
77
77
  lines?: number;
78
+ requestContext?: MemoryRequestContext;
78
79
  }): Promise<MemoryReadResult>;
79
80
  status(): MemoryProviderStatus;
80
81
  probeEmbeddingAvailability(): Promise<MemoryEmbeddingProbeResult>;
@@ -6,7 +6,7 @@ import picomatch from "picomatch";
6
6
  import { ensureMemoryAnalysisSchema, latestAnalysisCollections, latestAnalysisRunId, markMemoryAnalysisStale, readAnalysisSummary, readCluster, readClusters, runAnalysisWorker, } from "./analysis.js";
7
7
  import { CurationStore, chunkFingerprint, } from "./curation.js";
8
8
  import { readSessionManifest, sessionMetadataByPath, syncSessionProjections, } from "./session-sync.js";
9
- import { parseSafeVirtualPath } from "./sources.js";
9
+ import { parseSafeVirtualPath, sourceMatchesPath } from "./sources.js";
10
10
  const DEFAULT_READ_LINES = 120;
11
11
  const MAX_READ_CHARS = 12_000;
12
12
  const WATCH_DEBOUNCE_MS = 250;
@@ -312,8 +312,11 @@ export class QmdMemoryManager {
312
312
  this.#watcher.on("all", (_event, path) => {
313
313
  if (this.#closed)
314
314
  return;
315
- if (basename(path).toLowerCase() === "skill.md")
315
+ const matchingSources = [...this.#sources.values()].filter((source) => source.kind !== "sessions" && sourceMatchesPath(source, path));
316
+ if (matchingSources.some((source) => source.kind === "skills"))
316
317
  this.#skillIndex = undefined;
318
+ if (!matchingSources.some((source) => source.kind !== "skills"))
319
+ return;
317
320
  this.#dirty = true;
318
321
  if (this.#watchTimer)
319
322
  clearTimeout(this.#watchTimer);
@@ -341,7 +344,7 @@ export class QmdMemoryManager {
341
344
  dbPath: this.#dbPath,
342
345
  keepModelsWarm: this.#keepModelsWarm,
343
346
  config: {
344
- collections: Object.fromEntries([...this.#sources.values()].map((source) => [
347
+ collections: Object.fromEntries(this.#qmdSources().map((source) => [
345
348
  source.collection,
346
349
  { path: source.root, pattern: source.pattern },
347
350
  ])),
@@ -350,7 +353,7 @@ export class QmdMemoryManager {
350
353
  enableSecureDelete(store);
351
354
  ensureMemoryAnalysisSchema(store.internal.db);
352
355
  markStaleForAnalysisCollectionChange(store.internal.db, this.#analysisCollectionNames(), this.#skillCollectionNames().length > 0);
353
- const configuredCollections = new Set(this.#allCollectionNames());
356
+ const configuredCollections = new Set(this.#qmdSources().map((source) => source.collection));
354
357
  const staleCollections = (await store.getStatus()).collections
355
358
  .map((collection) => collection.name)
356
359
  .filter((collection) => !configuredCollections.has(collection));
@@ -383,8 +386,8 @@ export class QmdMemoryManager {
383
386
  this.#store = store;
384
387
  return store;
385
388
  }
386
- #allCollectionNames() {
387
- return [...this.#sources.keys()];
389
+ #qmdSources() {
390
+ return [...this.#sources.values()].filter((source) => source.kind !== "skills");
388
391
  }
389
392
  #analysisCollectionNames() {
390
393
  return [...this.#sources.values()]
@@ -421,7 +424,7 @@ export class QmdMemoryManager {
421
424
  const store = await this.#getStore();
422
425
  this.#dirty = true;
423
426
  const analysisStore = store;
424
- const collections = [...this.#sources.values()].filter((source) => source.kind !== "sessions");
427
+ const collections = this.#qmdSources().filter((source) => source.kind !== "sessions");
425
428
  let analysisMarkedStale = false;
426
429
  const markAnalysisStale = () => {
427
430
  if (analysisMarkedStale || !analysisStore.internal)
@@ -444,16 +447,14 @@ export class QmdMemoryManager {
444
447
  const update = await store.update({ collections: [source.collection] });
445
448
  this.#cleanupRemovedDocuments?.(update.updated + update.removed);
446
449
  const changed = update.indexed + update.updated + update.removed > 0 || update.needsEmbedding > 0;
447
- if (source.kind === "skills" && (changed || params?.force === true))
448
- this.#skillIndex = undefined;
449
- if (source.kind !== "skills" && (changed || params?.force === true))
450
+ if (changed || params?.force === true)
450
451
  markAnalysisStale();
451
452
  const embed = await store.embed({
452
453
  collection: source.collection,
453
454
  force: params?.force,
454
455
  chunkStrategy: "semantic",
455
456
  });
456
- if (source.kind !== "skills" && completedEmbeddingCount(embed) > 0)
457
+ if (completedEmbeddingCount(embed) > 0)
457
458
  markAnalysisStale();
458
459
  }
459
460
  const status = await store.getStatus();
@@ -736,41 +737,47 @@ export class QmdMemoryManager {
736
737
  if (!store.internal?.llm)
737
738
  throw new Error("Skill Whisperer requires the QMD embedding model");
738
739
  const llm = store.internal.llm;
739
- this.#skillIndex ??= (async () => {
740
- const sourceOrder = new Map([...this.#sources.keys()].map((collection, index) => [collection, index]));
741
- const metadata = new Map();
742
- for (const source of this.#sources.values()) {
743
- if (source.kind !== "skills")
744
- continue;
745
- for (const document of readSkillDocuments(source)) {
746
- const name = frontmatterValue(document.body, "name") || basename(dirname(document.path));
747
- const description = frontmatterValue(document.body, "description") ?? "";
748
- const key = name.toLowerCase();
749
- const order = sourceOrder.get(source.collection) ?? Number.MAX_SAFE_INTEGER;
750
- const current = metadata.get(key);
751
- if (!current || order < current.sourceOrder) {
752
- metadata.set(key, {
753
- candidate: { name, path: document.path },
754
- description,
755
- sourceOrder: order,
756
- });
740
+ let skillIndex = this.#skillIndex;
741
+ if (!skillIndex) {
742
+ const pending = (async () => {
743
+ const sourceOrder = new Map([...this.#sources.keys()].map((collection, index) => [collection, index]));
744
+ const metadata = new Map();
745
+ for (const source of this.#sources.values()) {
746
+ if (source.kind !== "skills")
747
+ continue;
748
+ for (const document of readSkillDocuments(source)) {
749
+ const name = frontmatterValue(document.body, "name") || basename(dirname(document.path));
750
+ const description = frontmatterValue(document.body, "description") ?? "";
751
+ const key = name.toLowerCase();
752
+ const order = sourceOrder.get(source.collection) ?? Number.MAX_SAFE_INTEGER;
753
+ const current = metadata.get(key);
754
+ if (!current || order < current.sourceOrder) {
755
+ metadata.set(key, {
756
+ candidate: { name, path: document.path },
757
+ description,
758
+ sourceOrder: order,
759
+ });
760
+ }
757
761
  }
758
762
  }
759
- }
760
- const skills = [...metadata.values()];
761
- const embeddings = await llm.embedBatch(skills.map(({ candidate, description }) => embeddingText(candidate.name, description, llm.embedModelName)));
762
- return skills.flatMap(({ candidate }, index) => {
763
- const embedding = embeddings[index]?.embedding;
764
- return embedding ? [{ ...candidate, score: 0, embedding }] : [];
763
+ const skills = [...metadata.values()];
764
+ const embeddings = await llm.embedBatch(skills.map(({ candidate, description }) => embeddingText(candidate.name, description, llm.embedModelName)));
765
+ return skills.flatMap(({ candidate }, index) => {
766
+ const embedding = embeddings[index]?.embedding;
767
+ return embedding ? [{ ...candidate, score: 0, embedding }] : [];
768
+ });
769
+ })();
770
+ skillIndex = pending.catch((error) => {
771
+ if (this.#skillIndex === skillIndex)
772
+ this.#skillIndex = undefined;
773
+ throw error;
765
774
  });
766
- })().catch((error) => {
767
- this.#skillIndex = undefined;
768
- throw error;
769
- });
775
+ this.#skillIndex = skillIndex;
776
+ }
770
777
  const queryEmbedding = await llm.embed(queryText(query, llm.embedModelName), { isQuery: true });
771
778
  if (!queryEmbedding)
772
779
  return [];
773
- const candidates = await this.#skillIndex;
780
+ const candidates = await skillIndex;
774
781
  return candidates
775
782
  .map(({ embedding, ...candidate }) => ({
776
783
  ...candidate,
@@ -0,0 +1,4 @@
1
+ import type { OpenClawPluginApi } from "openclaw/plugin-sdk/plugin-entry";
2
+ import type { UnblockMemoryConfig } from "./config.js";
3
+ import { type PeopleRefinementRunner } from "./people-refinement.js";
4
+ export declare function registerPeopleCli(api: OpenClawPluginApi, config: UnblockMemoryConfig["people"], runner?: PeopleRefinementRunner): void;
@@ -0,0 +1,47 @@
1
+ import { join } from "node:path";
2
+ import { resolveAgentDir } from "openclaw/plugin-sdk/memory-core-host-engine-foundation";
3
+ import { PeopleStores } from "./people-store.js";
4
+ import { codexPeopleRefinementRunner, refinePeople, } from "./people-refinement.js";
5
+ export function registerPeopleCli(api, config, runner = codexPeopleRefinementRunner) {
6
+ api.registerCli(({ program, config: openClawConfig }) => {
7
+ const root = program.command("unblock-memory").description("Unblock Memory administration");
8
+ const people = root.command("people").description("Maintain the agent-local people store");
9
+ people
10
+ .command("refine")
11
+ .description("Refine stale enabled people with Codex")
12
+ .requiredOption("--agent <id>", "Agent id")
13
+ .action(async (options) => {
14
+ const agentId = options.agent.trim();
15
+ if (!agentId)
16
+ throw new Error("--agent must be a non-empty string");
17
+ if (!config.enabled)
18
+ throw new Error("PeopleSQL is disabled");
19
+ const stores = new PeopleStores({
20
+ maxOpenTodos: config.todos.maxOpen,
21
+ maxBlurbChars: config.whisperer.maxChars,
22
+ });
23
+ try {
24
+ const summary = await refinePeople({
25
+ store: stores.get(agentId),
26
+ agentId,
27
+ agentDatabasePath: join(resolveAgentDir(openClawConfig, agentId), "openclaw-agent.sqlite"),
28
+ candidateLimit: config.refinement.maxPeoplePerRun,
29
+ maxBlurbChars: config.whisperer.maxChars,
30
+ runner,
31
+ });
32
+ process.stdout.write(`${JSON.stringify(summary)}\n`);
33
+ }
34
+ finally {
35
+ stores.closeAll();
36
+ }
37
+ });
38
+ }, {
39
+ descriptors: [
40
+ {
41
+ name: "unblock-memory",
42
+ description: "Unblock Memory administration",
43
+ hasSubcommands: true,
44
+ },
45
+ ],
46
+ });
47
+ }
@@ -0,0 +1,14 @@
1
+ export type PersonSessionEvidence = {
2
+ source: "session";
3
+ locator: string;
4
+ observedAt: string;
5
+ text: string;
6
+ };
7
+ export declare function readPersonSessionEvidence(params: {
8
+ databasePath: string;
9
+ agentId: string;
10
+ accountScope: string;
11
+ externalId: string;
12
+ limit?: number;
13
+ maxMessageChars?: number;
14
+ }): PersonSessionEvidence[];