@unblocklabs/unblock-memory 0.3.11 → 0.3.13

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
@@ -80,6 +80,12 @@ directories, or globs into named corpora:
80
80
  minScore: 0.5,
81
81
  cooldownTurns: 10,
82
82
  },
83
+ typesafe: {
84
+ enabled: true, // Default; shared by enabled Skill and Memory Whisperers.
85
+ // Alternatively set TYPESAFE_API_KEY in the Gateway environment.
86
+ apiKeyFile: "/absolute/path/to/.env",
87
+ timeoutMs: 1500,
88
+ },
83
89
  people: {
84
90
  enabled: false,
85
91
  whisperer: { enabled: false, maxChars: 1200 },
@@ -110,6 +116,68 @@ idle unload behavior.
110
116
  request all of them explicitly. Search results include their corpus name and
111
117
  remain readable by passing the returned `qmd://` path to `memory_get`.
112
118
 
119
+ ### Memory Whisperer
120
+
121
+ Memory Whisperer is optional and **off by default**. It proactively retrieves
122
+ historical context before user-triggered turns, without changing `memory_search`
123
+ or `memory_get`. Enable it in the plugin config with an explicit corpus allowlist:
124
+
125
+ ```json5
126
+ memoryWhisperer: {
127
+ enabled: true,
128
+ corpora: ["knowledge"], // Must exist in corpora; approve its contents for all agent audiences.
129
+ historyMessages: 5,
130
+ minUsefulness: 0.9,
131
+ maxHints: 2,
132
+ cooldownTurns: 10,
133
+ timeoutMs: 3000,
134
+ },
135
+ ```
136
+
137
+ Requires `hooks.allowConversationAccess: true` on the plugin entry, prompt
138
+ injection permission, and the shared TypeSafe credentials described below.
139
+ An empty allowlist is invalid when enabled; `all`, unknown names, and `skills`
140
+ are not accepted. File corpora are approved for **every audience using the agent**:
141
+ do not allowlist private dossiers for an agent that also serves shared channels.
142
+ If `sessions` is allowlisted, only the exact current session is searched, including
143
+ its older indexed messages. Missing session identity excludes that corpus. Other
144
+ sessions, even in the same channel, are excluded before sending excerpts to TypeSafe.
145
+ Session availability still depends on the normal indexing/sync schedule.
146
+
147
+ QMD searches the current request plus the last N user/assistant messages (at most
148
+ 12,000 characters), retrieving up to eight vector candidates without query expansion,
149
+ the local reranker, or a similarity-score cutoff. TypeSafe evaluates one independent
150
+ Noul question per candidate in a single request: does the excerpt add material value
151
+ beyond what the conversation already contains? Merely related, redundant,
152
+ wrong-person/project, and clearly superseded information should be rejected;
153
+ useful contradictory evidence can qualify. `minUsefulness` thresholds the probability
154
+ of yes, not a calibrated guarantee of accuracy. Evaluate it on your own conversations.
155
+
156
+ **Privacy and budgets:** this feature sends up to 16,000 characters of the available
157
+ user/assistant conversation, prioritizing the current request and recent messages,
158
+ plus up to eight 1,200-character excerpts, corpus names, and session dates to
159
+ `api.typesafe.ai`. Session excerpts retain a complete turn or message when it fits,
160
+ otherwise the complete matched chunk. Chunks exceeding the excerpt budget are
161
+ skipped, never sliced; ordinary `memory_search` is unchanged.
162
+ It does not fetch a complete historical transcript; the host may
163
+ already have compacted the available context. Truncation is marked in the judge's
164
+ input. System messages, thinking blocks, images, and tool-result messages are omitted;
165
+ anything quoted in ordinary user/assistant text can still be transmitted.
166
+
167
+ At most two qualifying excerpts are injected verbatim with source references and
168
+ historical/untrusted-data framing. Excerpts are deduplicated by normalized content
169
+ and overlapping source lines; recently injected content has a ten-user-turn cooldown
170
+ by default. Cooldown state is in memory and resets on session end or Gateway restart.
171
+ The complete hint payload is capped at 5,000 characters plus a short framing paragraph.
172
+
173
+ Unlike Skill Whisperer, **disabled TypeSafe, a missing key, no qualifying hits, or any
174
+ failure means no memory hint**—there is no vector-only fallback. The overall process
175
+ has a 3-second deadline, with the shared 1.5-second TypeSafe request deadline inside it;
176
+ neither performs retries. Timed-out or superseded runs cannot inject late hints.
177
+ Already-running local QMD work may finish in the background, but does not keep the
178
+ agent waiting beyond the deadline. No new indexing, clustering, or summarization runs
179
+ are triggered by this feature beyond the memory manager's normal initialization.
180
+
113
181
  ### Skill Whisperer
114
182
 
115
183
  Skill Whisperer is an optional semantic reminder for user turns. Configure one
@@ -117,13 +185,40 @@ isolated `skills` corpus, set `skillWhisperer.enabled` to `true`, and authorize
117
185
  `plugins.entries.unblock-memory.hooks.allowConversationAccess`. The feature
118
186
  embeds the current prompt plus the configured number of prior user/assistant
119
187
  messages, compares it with each configured skill's frontmatter `name` and
120
- `description`, and prepends at most one name/path hint when the best match
121
- reaches `minScore`. Full skill procedures do not influence routing. The plugin
122
- never opens or invokes a skill automatically.
123
-
124
- The defaults use five prior messages, a calibrated score threshold of `0.5`,
188
+ `description`. With TypeSafe enabled and a key available, the top three valid
189
+ candidates are sent to TypeSafe, without a vector-score cutoff. TypeSafe chooses
190
+ one skill or none. A "none" decision never falls back to a vector hint. Full skill
191
+ procedures do not influence routing; no skill is invoked automatically.
192
+
193
+ The shared `typesafe` configuration defaults to `enabled: true` and
194
+ `timeoutMs: 1500`. Skill and Memory Whisperers share it. Credentials come from
195
+ `typesafe.apiKey`, an absolute `typesafe.apiKeyFile`, or (when neither is set)
196
+ the Gateway's `TYPESAFE_API_KEY` environment variable. Configure at most one of
197
+ `apiKey` and `apiKeyFile`. A key file may contain just the key or dotenv entries
198
+ including `TYPESAFE_API_KEY`; it is reread each turn to support rotation. A dotenv
199
+ file is not sourced as shell code and does not change the process environment.
200
+ Missing/empty files or dotenv files without that variable count as no key;
201
+ an explicit file never falls back to an unrelated environment key. Protect key
202
+ files with owner-only permissions. Workspace `.env` files are not auto-discovered:
203
+ point `apiKeyFile` at the intended file or load the variable into the Gateway.
204
+
205
+ If TypeSafe is disabled or no key is found, selection uses the original local
206
+ vector process and `skillWhisperer.minScore`. With a key present, an API error,
207
+ invalid response, or timeout emits no hint and logs a sanitized warning; it does
208
+ not switch to vector-only selection. There are no automatic HTTP retries. Other
209
+ credential-file read errors likewise produce a warning and no hint.
210
+
211
+ **Privacy:** enabled TypeSafe selection sends up to 12,000 characters of current
212
+ prompt/recent user-assistant text, plus the shortlisted names/descriptions, to
213
+ `api.typesafe.ai`. Source-path fields, full skill procedures, tool-result messages,
214
+ and system messages are excluded; dossiers and ordinary memory files are not read
215
+ for this call. Material already quoted in user/assistant text can still be included.
216
+ Disable `typesafe.enabled` to keep Skill Whisperer entirely local. The pinned model
217
+ is `jev-1.13.0`.
218
+
219
+ The defaults use five prior messages, a vector-only score threshold of `0.5`,
125
220
  and a ten-turn cooldown. A skill is cooling down after either a suggestion or a
126
- successful direct `read` of its indexed `SKILL.md`. When the best qualifying
221
+ successful direct `read` of its indexed `SKILL.md`. When the selected
127
222
  skill is cooling down, no hint is emitted; Skill Whisperer does not fall through
128
223
  to a weaker match. Cooldown state is per session and intentionally resets with
129
224
  the Gateway. Shell-command reads are not tracked.
@@ -254,9 +349,20 @@ role-labeled, timestamped speaker messages; filtering metadata remains in the
254
349
  session manifest. The projected file modification time matches the session
255
350
  start time for meaningful chronological cluster reads. Session results include
256
351
  provider, chat type, conversation identity, and start time as an ISO 8601 timestamp. They
257
- participate in the same search and clustering index as file memory. This phase
258
- does not sync sessions at startup or on a schedule; refreshes are manual through
259
- `memory_sync_sessions`.
352
+ participate in the same search and clustering index as file memory. The plugin
353
+ automatically refreshes each configured agent's sessions every 15 minutes while
354
+ the Gateway runs. Set `syncIntervalMinutes` on the `sessions` corpus to an integer
355
+ from `1` to `1440`, or `0` for manual-only syncing. For example:
356
+
357
+ ```json
358
+ { "name": "sessions", "kind": "sessions", "syncIntervalMinutes": 15 }
359
+ ```
360
+
361
+ The first refresh runs after one interval, not during startup. Restart the
362
+ Gateway after changing the interval. Refreshes are incremental; an already-running
363
+ sync is skipped, and failures are visible through `memory_sync_status` and retried
364
+ at the next interval. `memory_sync_sessions` still provides an immediate manual
365
+ refresh. Syncing and embedding run inside the Gateway process, without an LLM turn.
260
366
 
261
367
  Indexes live at `~/.openclaw/agents/<agentId>/unblock-memory/index.sqlite` (or the
262
368
  equivalent configured OpenClaw state directory). Durable agent-supplied event
@@ -264,6 +370,60 @@ dates and maintenance proposals live separately in `curation.sqlite`, so a QMD
264
370
  index rebuild does not discard them. The first lookup builds the index;
265
371
  Markdown filesystem changes queue a debounced, serialized background refresh.
266
372
 
373
+ ## Memory quality audit
374
+
375
+ `memory_audit_quality` is an on-demand, source-read-only audit. TypeSafe flags likely
376
+ ingestion noise for agent investigation; it never deletes, rewrites, or suppresses
377
+ memory. Enable it with explicit approval for the corpora sent to TypeSafe:
378
+
379
+ ```json5
380
+ qualityAudit: {
381
+ enabled: true,
382
+ corpora: ["memory", "knowledge"], // Must be configured non-skill corpora.
383
+ minNoise: 0.8,
384
+ },
385
+ ```
386
+
387
+ Off by default. Uses the shared TypeSafe credentials and request timeout. Missing
388
+ credentials or disabled TypeSafe produces no audit. Approval includes transmission
389
+ of full eligible chunks and visibility of findings to all audiences using the agent.
390
+ Unlike Memory Whisperer, approving `sessions` includes **all indexed sessions** in
391
+ that corpus, including configured direct conversations. Only approve that when intended.
392
+
393
+ Call with `{ "limit": 10 }` (maximum 20 indexed chunk occurrences per page), then
394
+ pass the returned `next` as `after` until `done` is true. A `partial` result preserves
395
+ the completed cursor; retry there, or from the beginning if no cursor exists. This
396
+ is not a full-document audit: unindexed content is not scanned. Chunks over 6,000
397
+ characters are counted as skipped, not silently truncated. No clustering is required.
398
+
399
+ Two independent Noul questions distinguish ingestion noise from identifiable useful
400
+ evidence. High values for both can indicate valuable content trapped in a wrapper.
401
+ Low evidence alone does not create a junk finding. JSON, logs, code, terse facts,
402
+ historical records, and missing context are not automatically defects. Empty chunks
403
+ are detected locally. A JSON string that decodes to a message envelope is also
404
+ flagged as a possible double-encoding defect, even when its content is useful.
405
+ An ordinary JSON message object is not flagged from its shape alone. These are
406
+ review clues, never verdicts about whether the information should be kept.
407
+
408
+ At most four unique chunks (24,000 characters) and their source kinds are sent in
409
+ one request, without conversation context or source paths. Requests do not retry
410
+ automatically and stop starting new work after a 30-second audit deadline; existing
411
+ manager initialization/indexing may finish later. Judgments are cached in the
412
+ curation database by content, source kind, model and question version. A rescan from
413
+ the beginning reuses cached results, including after corpus/index changes. Changes
414
+ behind a page cursor are picked up on the next rescan.
415
+
416
+ Suspect chunks become `quality_review` tasks in `memory_list_maintenance_tasks`.
417
+ The audit returns page-local groups by configured source and suspected issue,
418
+ with up to three examples each, not a claim that a whole cluster is defective.
419
+ Findings include source references, bounded previews, probabilities and content
420
+ fingerprints. Reviewed tasks are not reopened for unchanged content. The curator
421
+ inspects the original source and ingestion path, proposes or performs authorized
422
+ repairs, and verifies the resulting source/index before resolving with a required
423
+ note. Prefer repairing a common extractor or inclusion rule over many symptoms;
424
+ never manually edit generated session projections. Thresholds need evaluation on
425
+ your data; model probability is not proof of a defect.
426
+
267
427
  ## Memory analysis
268
428
 
269
429
  Analysis is opt-in. Core indexing, `memory_search`, and `memory_get` need only
@@ -15,6 +15,7 @@ type SessionCorpusConfig = {
15
15
  kind: "sessions";
16
16
  chatTypes: readonly ChatType[];
17
17
  maxExpandedTokens: number;
18
+ syncIntervalMinutes: number;
18
19
  };
19
20
  export type CorpusConfig = FileCorpusConfig | SkillCorpusConfig | SessionCorpusConfig;
20
21
  export declare const DEFAULT_CORPORA: readonly FileCorpusConfig[];
@@ -24,6 +25,17 @@ export type UnblockMemoryConfig = {
24
25
  analysis: {
25
26
  executable?: string;
26
27
  };
28
+ typesafe: {
29
+ enabled: boolean;
30
+ apiKey?: string;
31
+ apiKeyFile?: string;
32
+ timeoutMs: number;
33
+ };
34
+ qualityAudit: {
35
+ enabled: boolean;
36
+ corpora: readonly string[];
37
+ minNoise: number;
38
+ };
27
39
  people: {
28
40
  enabled: boolean;
29
41
  whisperer: {
@@ -40,6 +52,15 @@ export type UnblockMemoryConfig = {
40
52
  minScore: number;
41
53
  cooldownTurns: number;
42
54
  };
55
+ memoryWhisperer: {
56
+ enabled: boolean;
57
+ corpora: readonly string[];
58
+ historyMessages: number;
59
+ minUsefulness: number;
60
+ maxHints: number;
61
+ cooldownTurns: number;
62
+ timeoutMs: number;
63
+ };
43
64
  };
44
65
  export declare const DEFAULT_PEOPLE_CONFIG: UnblockMemoryConfig["people"];
45
66
  export declare function resolveConfig(value: unknown): UnblockMemoryConfig;
@@ -15,12 +15,108 @@ export const DEFAULT_PEOPLE_CONFIG = {
15
15
  whisperer: { enabled: false, maxChars: 1200 },
16
16
  todos: { maxOpen: 1000 },
17
17
  };
18
+ const DEFAULT_TYPESAFE_CONFIG = {
19
+ enabled: true,
20
+ timeoutMs: 1500,
21
+ };
22
+ const DEFAULT_QUALITY_AUDIT = {
23
+ enabled: false, corpora: [], minNoise: 0.8,
24
+ };
25
+ function resolveQualityAudit(value, corpora) {
26
+ if (value === undefined)
27
+ return { ...DEFAULT_QUALITY_AUDIT };
28
+ if (!value || typeof value !== "object" || Array.isArray(value))
29
+ throw new Error("qualityAudit must be an object");
30
+ const config = value;
31
+ assertOnlyKeys(config, ["enabled", "corpora", "minNoise"], "qualityAudit");
32
+ const enabled = config.enabled ?? false;
33
+ const selected = config.corpora ?? [];
34
+ const minNoise = config.minNoise ?? DEFAULT_QUALITY_AUDIT.minNoise;
35
+ if (typeof enabled !== "boolean")
36
+ throw new Error("qualityAudit.enabled must be a boolean");
37
+ if (!Array.isArray(selected) || !selected.every((name) => typeof name === "string" && corpora.some(corpus => corpus.name === name && corpus.kind !== "skills"))) {
38
+ throw new Error("qualityAudit.corpora must list configured non-skill corpora");
39
+ }
40
+ if (enabled && !selected.length)
41
+ throw new Error("enabled qualityAudit requires explicit corpora");
42
+ if (typeof minNoise !== "number" || !Number.isFinite(minNoise) || minNoise < 0 || minNoise > 1) {
43
+ throw new Error("qualityAudit.minNoise must be between 0 and 1");
44
+ }
45
+ return { enabled, corpora: [...new Set(selected)], minNoise };
46
+ }
47
+ function resolveTypeSafe(value) {
48
+ if (value === undefined)
49
+ return { ...DEFAULT_TYPESAFE_CONFIG };
50
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
51
+ throw new Error("unblock-memory typesafe must be an object");
52
+ }
53
+ const config = value;
54
+ assertOnlyKeys(config, ["enabled", "apiKey", "apiKeyFile", "timeoutMs"], "typesafe");
55
+ const enabled = config.enabled ?? true;
56
+ if (typeof enabled !== "boolean")
57
+ throw new Error("unblock-memory typesafe.enabled must be a boolean");
58
+ for (const key of ["apiKey", "apiKeyFile"]) {
59
+ if (config[key] !== undefined && (typeof config[key] !== "string" || !config[key].trim())) {
60
+ throw new Error(`unblock-memory typesafe.${key} must be a non-empty string`);
61
+ }
62
+ }
63
+ const apiKey = typeof config.apiKey === "string" ? config.apiKey.trim() : undefined;
64
+ const apiKeyFile = typeof config.apiKeyFile === "string" ? config.apiKeyFile.trim() : undefined;
65
+ if (apiKey && apiKeyFile)
66
+ throw new Error("unblock-memory typesafe accepts apiKey or apiKeyFile, not both");
67
+ if (apiKeyFile && !isAbsolute(apiKeyFile)) {
68
+ throw new Error("unblock-memory typesafe.apiKeyFile must be an absolute path");
69
+ }
70
+ return {
71
+ enabled, ...(apiKey ? { apiKey } : {}), ...(apiKeyFile ? { apiKeyFile } : {}),
72
+ timeoutMs: positiveInteger(config.timeoutMs, DEFAULT_TYPESAFE_CONFIG.timeoutMs, "typesafe.timeoutMs", 10_000),
73
+ };
74
+ }
18
75
  const DEFAULT_SKILL_WHISPERER = {
19
76
  enabled: false,
20
77
  historyMessages: 5,
21
78
  minScore: 0.5,
22
79
  cooldownTurns: 10,
23
80
  };
81
+ const DEFAULT_MEMORY_WHISPERER = {
82
+ enabled: false, corpora: [], historyMessages: 5, minUsefulness: 0.9,
83
+ maxHints: 2, cooldownTurns: 10, timeoutMs: 3000,
84
+ };
85
+ function resolveMemoryWhisperer(value, corpora) {
86
+ if (value === undefined)
87
+ return { ...DEFAULT_MEMORY_WHISPERER };
88
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
89
+ throw new Error("unblock-memory memoryWhisperer must be an object");
90
+ }
91
+ const config = value;
92
+ assertOnlyKeys(config, Object.keys(DEFAULT_MEMORY_WHISPERER), "memoryWhisperer");
93
+ const enabled = config.enabled ?? false;
94
+ if (typeof enabled !== "boolean")
95
+ throw new Error("unblock-memory memoryWhisperer.enabled must be a boolean");
96
+ const selected = config.corpora ?? [];
97
+ if (!Array.isArray(selected) || !selected.every((name) => typeof name === "string" && corpora.some(corpus => corpus.name === name && corpus.kind !== "skills"))) {
98
+ throw new Error("unblock-memory memoryWhisperer.corpora must list configured non-skill corpora");
99
+ }
100
+ if (enabled && !selected.length)
101
+ throw new Error("unblock-memory enabled memoryWhisperer requires explicit corpora");
102
+ const historyMessages = config.historyMessages ?? 5;
103
+ const cooldownTurns = config.cooldownTurns ?? 10;
104
+ if (typeof historyMessages !== "number" || !Number.isInteger(historyMessages) || historyMessages < 0 || historyMessages > 50) {
105
+ throw new Error("unblock-memory memoryWhisperer.historyMessages must be an integer between 0 and 50");
106
+ }
107
+ if (typeof cooldownTurns !== "number" || !Number.isInteger(cooldownTurns) || cooldownTurns < 0 || cooldownTurns > 1000) {
108
+ throw new Error("unblock-memory memoryWhisperer.cooldownTurns must be an integer between 0 and 1000");
109
+ }
110
+ const minUsefulness = config.minUsefulness ?? 0.9;
111
+ if (typeof minUsefulness !== "number" || !Number.isFinite(minUsefulness) || minUsefulness < 0 || minUsefulness > 1) {
112
+ throw new Error("unblock-memory memoryWhisperer.minUsefulness must be between 0 and 1");
113
+ }
114
+ return {
115
+ enabled, corpora: [...new Set(selected)], historyMessages, cooldownTurns, minUsefulness,
116
+ maxHints: positiveInteger(config.maxHints, 2, "memoryWhisperer.maxHints", 2),
117
+ timeoutMs: positiveInteger(config.timeoutMs, 3000, "memoryWhisperer.timeoutMs", 10_000),
118
+ };
119
+ }
24
120
  function assertOnlyKeys(value, allowed, label) {
25
121
  const unknown = Object.keys(value).find((key) => !allowed.includes(key));
26
122
  if (unknown)
@@ -61,7 +157,7 @@ function resolveCorpora(value) {
61
157
  return { name: "skills", kind: "skills", paths: corpus.paths.map((path) => path.trim()) };
62
158
  }
63
159
  if (corpus.kind === "sessions") {
64
- assertOnlyKeys(corpus, ["name", "kind", "chatTypes", "maxExpandedTokens"], `corpora[${index}]`);
160
+ assertOnlyKeys(corpus, ["name", "kind", "chatTypes", "maxExpandedTokens", "syncIntervalMinutes"], `corpora[${index}]`);
65
161
  if (name !== "sessions") {
66
162
  throw new Error('unblock-memory session corpus must be named "sessions"');
67
163
  }
@@ -71,8 +167,14 @@ function resolveCorpora(value) {
71
167
  !chatTypes.every((chatType) => CHAT_TYPES.includes(chatType))) {
72
168
  throw new Error(`unblock-memory corpus sessions chatTypes must contain channel, group, or direct`);
73
169
  }
170
+ const syncIntervalMinutes = corpus.syncIntervalMinutes ?? 15;
171
+ if (typeof syncIntervalMinutes !== "number" || !Number.isInteger(syncIntervalMinutes) ||
172
+ syncIntervalMinutes < 0 || syncIntervalMinutes > 1440) {
173
+ throw new Error("unblock-memory corpus sessions syncIntervalMinutes must be an integer between 0 and 1440");
174
+ }
74
175
  return {
75
176
  name: "sessions",
177
+ syncIntervalMinutes,
76
178
  kind: "sessions",
77
179
  chatTypes: [...new Set(chatTypes)],
78
180
  maxExpandedTokens: positiveInteger(corpus.maxExpandedTokens, DEFAULT_SESSION_MAX_EXPANDED_TOKENS, "corpus sessions maxExpandedTokens", MAX_SESSION_MAX_EXPANDED_TOKENS),
@@ -154,15 +256,18 @@ export function resolveConfig(value) {
154
256
  corpora: DEFAULT_CORPORA,
155
257
  keepEmbeddingModelWarm: true,
156
258
  analysis: {},
259
+ typesafe: { ...DEFAULT_TYPESAFE_CONFIG },
260
+ qualityAudit: { ...DEFAULT_QUALITY_AUDIT },
157
261
  people: DEFAULT_PEOPLE_CONFIG,
158
262
  skillWhisperer: DEFAULT_SKILL_WHISPERER,
263
+ memoryWhisperer: { ...DEFAULT_MEMORY_WHISPERER },
159
264
  };
160
265
  }
161
266
  if (typeof value !== "object" || Array.isArray(value)) {
162
267
  throw new Error("unblock-memory config must be an object");
163
268
  }
164
269
  const config = value;
165
- assertOnlyKeys(config, ["corpora", "keepEmbeddingModelWarm", "analysis", "people", "skillWhisperer"], "config");
270
+ assertOnlyKeys(config, ["corpora", "keepEmbeddingModelWarm", "analysis", "people", "skillWhisperer", "memoryWhisperer", "typesafe", "qualityAudit"], "config");
166
271
  const corpora = resolveCorpora(config.corpora);
167
272
  const people = resolvePeople(config.people);
168
273
  if (config.keepEmbeddingModelWarm !== undefined &&
@@ -221,5 +326,7 @@ export function resolveConfig(value) {
221
326
  if (skillWhisperer.enabled && !corpora.some((corpus) => corpus.kind === "skills")) {
222
327
  throw new Error('unblock-memory enabled skillWhisperer requires a corpus named "skills" with kind "skills"');
223
328
  }
224
- return { corpora, keepEmbeddingModelWarm, analysis: analysisConfig, people, skillWhisperer };
329
+ return { corpora, keepEmbeddingModelWarm, analysis: analysisConfig, people, skillWhisperer,
330
+ qualityAudit: resolveQualityAudit(config.qualityAudit, corpora),
331
+ memoryWhisperer: resolveMemoryWhisperer(config.memoryWhisperer, corpora), typesafe: resolveTypeSafe(config.typesafe) };
225
332
  }
@@ -12,6 +12,8 @@ export type CorpusMemorySearchResult = MemorySearchResult & {
12
12
  session?: SessionMetadata;
13
13
  };
14
14
  export type SessionSearchFilter = {
15
+ /** Internal exact-session scope used by proactive hints; not exposed by the search tool. */
16
+ sessionId?: string;
15
17
  startedFrom?: string;
16
18
  startedTo?: string;
17
19
  provider?: string;
@@ -22,6 +24,8 @@ export type SessionSearchFilter = {
22
24
  export type MemoryRequestContext = Pick<OpenClawPluginToolContext, "sessionKey" | "sessionId" | "messageChannel" | "agentAccountId" | "nativeChannelId" | "deliveryContext">;
23
25
  export type CorpusSearchOptions = NonNullable<Parameters<MemorySearchManagerContract["search"]>[1]> & {
24
26
  corpora?: readonly string[];
27
+ /** Internal vector-hint budget; oversized matched chunks are omitted, never sliced. */
28
+ maxSnippetChars?: number;
25
29
  sessionFilter?: SessionSearchFilter;
26
30
  requestContext?: MemoryRequestContext;
27
31
  };
@@ -1,6 +1,7 @@
1
+ import type { QualityJudgment } from "./typesafe.js";
1
2
  declare const TEMPORAL_BASES: readonly ["path", "frontmatter", "session", "agent_verified"];
2
3
  export type TemporalBasis = typeof TEMPORAL_BASES[number];
3
- declare const MAINTENANCE_TASK_TYPES: readonly ["ambiguous_event_time", "exact_duplicate"];
4
+ declare const MAINTENANCE_TASK_TYPES: readonly ["ambiguous_event_time", "exact_duplicate", "quality_review"];
4
5
  export type MaintenanceTaskType = typeof MAINTENANCE_TASK_TYPES[number];
5
6
  declare const MAINTENANCE_STATUSES: readonly ["pending", "resolved", "deferred", "irrelevant"];
6
7
  export type MaintenanceStatus = typeof MAINTENANCE_STATUSES[number];
@@ -36,6 +37,8 @@ export declare class CurationStore {
36
37
  #private;
37
38
  constructor(path: string);
38
39
  close(): void;
40
+ qualityJudgment(key: string): QualityJudgment | undefined;
41
+ cacheQualityJudgment(key: string, judgment: QualityJudgment): void;
39
42
  annotations(): TemporalAnnotation[];
40
43
  addTask(candidate: {
41
44
  type: MaintenanceTaskType;
@@ -45,7 +48,7 @@ export declare class CurationStore {
45
48
  reason: string;
46
49
  contentFingerprint?: string;
47
50
  detail?: string;
48
- }): void;
51
+ }): MaintenanceTask;
49
52
  listTasks(params?: {
50
53
  status?: MaintenanceStatus;
51
54
  limit?: number;
@@ -3,7 +3,7 @@ import { chmodSync, mkdirSync } from "node:fs";
3
3
  import { dirname } from "node:path";
4
4
  import { DatabaseSync } from "node:sqlite";
5
5
  const TEMPORAL_BASES = ["path", "frontmatter", "session", "agent_verified"];
6
- const MAINTENANCE_TASK_TYPES = ["ambiguous_event_time", "exact_duplicate"];
6
+ const MAINTENANCE_TASK_TYPES = ["ambiguous_event_time", "exact_duplicate", "quality_review"];
7
7
  const MAINTENANCE_STATUSES = ["pending", "resolved", "deferred", "irrelevant"];
8
8
  function annotation(row) {
9
9
  return {
@@ -66,12 +66,17 @@ export class CurationStore {
66
66
 
67
67
  `);
68
68
  this.#ensureMaintenanceSchema();
69
+ this.#db.exec(`CREATE TABLE IF NOT EXISTS quality_judgments (
70
+ cache_key TEXT PRIMARY KEY,
71
+ noise REAL NOT NULL CHECK (noise BETWEEN 0 AND 1),
72
+ evidence REAL NOT NULL CHECK (evidence BETWEEN 0 AND 1)
73
+ )`);
69
74
  }
70
75
  #ensureMaintenanceSchema() {
71
76
  this.#db.exec(`
72
77
  CREATE TABLE IF NOT EXISTS maintenance_tasks (
73
78
  id TEXT PRIMARY KEY,
74
- type TEXT NOT NULL CHECK (type IN ('ambiguous_event_time', 'exact_duplicate')),
79
+ type TEXT NOT NULL CHECK (type IN ('ambiguous_event_time', 'exact_duplicate', 'quality_review')),
75
80
  corpus TEXT NOT NULL,
76
81
  collection TEXT NOT NULL,
77
82
  path TEXT NOT NULL,
@@ -85,6 +90,23 @@ export class CurationStore {
85
90
  UNIQUE (type, corpus, collection, path, reason, content_fingerprint)
86
91
  );
87
92
  `);
93
+ const schema = this.#db.prepare("SELECT sql FROM sqlite_master WHERE name = 'maintenance_tasks'")
94
+ .get();
95
+ if (!schema.sql.includes("'quality_review'")) {
96
+ this.#db.exec("BEGIN IMMEDIATE");
97
+ try {
98
+ this.#db.exec(schema.sql.replace("maintenance_tasks", "maintenance_tasks_quality")
99
+ .replace("'exact_duplicate'", "'exact_duplicate', 'quality_review'"));
100
+ this.#db.exec(`INSERT INTO maintenance_tasks_quality SELECT * FROM maintenance_tasks;
101
+ DROP TABLE maintenance_tasks;
102
+ ALTER TABLE maintenance_tasks_quality RENAME TO maintenance_tasks;`);
103
+ this.#db.exec("COMMIT");
104
+ }
105
+ catch (error) {
106
+ this.#db.exec("ROLLBACK");
107
+ throw error;
108
+ }
109
+ }
88
110
  this.#db.exec(`
89
111
  CREATE INDEX IF NOT EXISTS maintenance_tasks_status_created
90
112
  ON maintenance_tasks(status, created_at);
@@ -93,6 +115,14 @@ export class CurationStore {
93
115
  close() {
94
116
  this.#db.close();
95
117
  }
118
+ qualityJudgment(key) {
119
+ return this.#db.prepare("SELECT noise, evidence FROM quality_judgments WHERE cache_key = ?")
120
+ .get(key);
121
+ }
122
+ cacheQualityJudgment(key, judgment) {
123
+ this.#db.prepare("INSERT OR REPLACE INTO quality_judgments(cache_key, noise, evidence) VALUES (?, ?, ?)")
124
+ .run(key, judgment.noise, judgment.evidence);
125
+ }
96
126
  annotations() {
97
127
  return this.#db.prepare(`
98
128
  SELECT * FROM temporal_annotations
@@ -115,6 +145,9 @@ export class CurationStore {
115
145
  ELSE maintenance_tasks.updated_at
116
146
  END
117
147
  `).run(randomUUID(), candidate.type, candidate.corpus, candidate.collection, candidate.path, candidate.reason, candidate.contentFingerprint ?? "", candidate.detail ?? null, now, now);
148
+ return task(this.#db.prepare(`SELECT * FROM maintenance_tasks
149
+ WHERE type = ? AND corpus = ? AND collection = ? AND path = ? AND reason = ? AND content_fingerprint = ?`)
150
+ .get(candidate.type, candidate.corpus, candidate.collection, candidate.path, candidate.reason, candidate.contentFingerprint ?? ""));
118
151
  }
119
152
  listTasks(params = {}) {
120
153
  const status = params.status ?? "pending";
@@ -136,6 +169,9 @@ export class CurationStore {
136
169
  return undefined;
137
170
  }
138
171
  const now = new Date().toISOString();
172
+ if (row.type === "quality_review" && params.status === "resolved" && !params.note?.trim()) {
173
+ throw new Error("resolving a quality review requires a note describing source/index verification");
174
+ }
139
175
  if (row.type === "ambiguous_event_time" && params.status === "resolved" && !params.annotation) {
140
176
  throw new Error("resolving an ambiguous event-time task requires a date annotation");
141
177
  }
@@ -5,6 +5,7 @@ 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";
7
7
  import { type ResolvedSource } from "./sources.js";
8
+ import { type QualityCursor } from "./quality-audit.js";
8
9
  export type ManagerStore = Pick<QMDStore, "update" | "embed" | "getStatus" | "listCollections" | "searchLex" | "vsearch" | "get" | "getDocumentBody" | "close">;
9
10
  export type ManagerSessionConfig = {
10
11
  agentId: string;
@@ -19,6 +20,7 @@ export type ManagerSessionConfig = {
19
20
  };
20
21
  export type SkillSearchCandidate = {
21
22
  name: string;
23
+ description: string;
22
24
  path: string;
23
25
  score: number;
24
26
  };
@@ -31,7 +33,7 @@ export declare function buildReadResult(params: {
31
33
  from?: number;
32
34
  lines?: number;
33
35
  }): MemoryReadResult;
34
- export declare function expandSessionSearchHit(result: Pick<VectorSearchResult, "body" | "bestChunk" | "chunkPos" | "chunkLen">, maxTokens: number, countTokens: (text: string) => Promise<number>): Promise<{
36
+ export declare function expandSessionSearchHit(result: Pick<VectorSearchResult, "body" | "bestChunk" | "chunkPos" | "chunkLen">, maxTokens: number, countTokens: (text: string) => Promise<number>, maxChars?: number): Promise<{
35
37
  text: string;
36
38
  position: number;
37
39
  }>;
@@ -63,6 +65,38 @@ export declare class QmdMemoryManager implements MemorySearchManagerContract {
63
65
  status?: MaintenanceStatus;
64
66
  limit?: number;
65
67
  }): import("./curation.js").MaintenanceTask[];
68
+ auditQuality(params: {
69
+ corpora: readonly string[];
70
+ apiKey: string;
71
+ timeoutMs: number;
72
+ minNoise: number;
73
+ limit?: number;
74
+ after?: QualityCursor;
75
+ signal: AbortSignal;
76
+ }): Promise<{
77
+ status: "ok" | "partial";
78
+ done: boolean;
79
+ next: QualityCursor | undefined;
80
+ scanned: number;
81
+ judged: number;
82
+ cached: number;
83
+ skippedOversized: number;
84
+ skippedStale: number;
85
+ flagged: number;
86
+ groups: {
87
+ corpus: string;
88
+ source: string;
89
+ reason: string;
90
+ pending: number;
91
+ examples: import("./curation.js").MaintenanceTask[];
92
+ }[];
93
+ policy: string;
94
+ scope: string;
95
+ } | {
96
+ status: "busy";
97
+ } | {
98
+ status: "unavailable";
99
+ }>;
66
100
  updateMaintenanceTask(params: {
67
101
  id: string;
68
102
  status: Exclude<MaintenanceStatus, "pending">;