@unblocklabs/unblock-memory 0.7.4 → 0.8.0

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
@@ -2,8 +2,9 @@
2
2
 
3
3
  Workspace-native memory for OpenClaw, powered internally by `@unblocklabs/qmd`.
4
4
  It keeps one warm QMD store per agent and exposes `memory_search` and `memory_get`.
5
- Ordinary recall is local vector search over semantic chunks: no query expansion,
6
- reranker or TypeSafe key is required.
5
+ Recall combines BM25 and vector candidates, then independently ranks complete
6
+ passages with TypeSafe. The agent supplies both queries; no query generation or
7
+ "do we need memory?" gate runs for explicit search. A TypeSafe key is required.
7
8
 
8
9
  ## Start here
9
10
 
@@ -38,7 +39,7 @@ Optional installation from source:
38
39
 
39
40
  | Capability | What it does | Default / prerequisite |
40
41
  | --- | --- | --- |
41
- | [Search and reads](docs/retrieval.md#search-and-read) | Vector recall, then exact indexed source reads | Available with the memory plugin; files only unless more corpora are configured |
42
+ | [Search and reads](docs/retrieval.md#search-and-read) | BM25 + vector recall with TypeSafe ranking, then exact indexed reads | Search requires TypeSafe; reads stay local; files only unless more corpora are configured |
42
43
  | [Session indexing](docs/retrieval.md#sessions) | Makes past user/assistant exchanges searchable | Opt-in corpus; channel/group by default, DMs explicitly included |
43
44
  | [Skill Whisperer](docs/retrieval.md#skill-whisperer) | Suggests one relevant skill; never invokes it | Off; explicit skills corpus; TypeSafe optional |
44
45
  | [Memory Whisperer](docs/retrieval.md#memory-whisperer) | Injects up to two useful historical excerpts; query generation via resident API | Off; approved corpora + TypeSafe + query API credential |
@@ -52,10 +53,9 @@ Optional installation from source:
52
53
  | [Session memory extraction](docs/extraction.md) | Luna proposes lasting facts; Jev verifies them; optional QMD search | Off; approved session types + TypeSafe + host LLM permission; shadow-only by default |
53
54
  | [Compaction memory flush](docs/configuration.md#compaction-memory-writes) | Supplies the host an append-only daily-memory write plan | Offered unless the host's memory-flush setting is false; separate from whisperers |
54
55
 
55
- **Search is not one interchangeable API:** plugin `memory_search` is vector-only;
56
- standalone QMD `query` is hybrid vector + BM25 with TypeSafe ranking.
57
- They have separate configuration/index boundaries. See
58
- [search modes and the xsearch migration](docs/retrieval.md#qmd-search-modes).
56
+ **One agent search pipeline:** `memory_search` and Memory Whisperer share
57
+ candidate discovery, passage rendering, deduplication and TypeSafe ranking.
58
+ Whisperer additionally generates queries and applies its automatic recall/delivery policy.
59
59
 
60
60
  ## Configuration and operating guides
61
61
 
@@ -73,9 +73,9 @@ They have separate configuration/index boundaries. See
73
73
  - [Session extraction](docs/extraction.md): incremental/backfill runs, configurable
74
74
  acceptance thresholds, shadow evaluation, and publishing lasting facts to search.
75
75
 
76
- The shared TypeSafe integration defaults on, but its features are opt-in.
77
- A key activates only features already enabled. Ordinary search and People
78
- Whisperer's injection stay local; Skill Whisperer has a local fallback.
76
+ The shared TypeSafe integration defaults on. Ordinary search requires a key;
77
+ other TypeSafe features remain opt-in. People Whisperer's injection stays local;
78
+ Skill Whisperer has a local fallback.
79
79
  [Provider gates and failure behavior](docs/configuration.md#feature-gates-and-fallbacks)
80
80
  differ by feature.
81
81
 
@@ -31,7 +31,7 @@ export type SessionSearchFilter = {
31
31
  export type MemoryRequestContext = Pick<OpenClawPluginToolContext, "sessionKey" | "sessionId" | "messageChannel" | "agentAccountId" | "nativeChannelId" | "deliveryContext">;
32
32
  export type CorpusSearchOptions = Omit<NonNullable<Parameters<MemorySearchManagerContract["search"]>[1]>, "lexicalOnly"> & {
33
33
  corpora?: readonly string[];
34
- /** Internal vector-hint budget; oversized matched chunks are omitted, never sliced. */
34
+ /** Internal passage budget; oversized matched chunks are omitted, never sliced. */
35
35
  maxSnippetChars?: number;
36
36
  sessionFilter?: SessionSearchFilter;
37
37
  requestContext?: MemoryRequestContext;
@@ -1,12 +1,13 @@
1
1
  import { RetrievalTelemetry } from "./retrieval-telemetry.js";
2
2
  type Whisperer = "skill" | "memory";
3
- type Outcome = "missing_key" | "typesafe_disabled" | "no_candidates" | "rejected" | "cooldown" | "emitted" | "failed" | "timed_out" | "cancelled" | "unavailable" | "payload_limit" | "redundancy_unavailable" | "recall_not_needed" | "queries_generated" | "judge_candidate_failed";
3
+ type Outcome = "missing_key" | "typesafe_disabled" | "no_candidates" | "rejected" | "cooldown" | "emitted" | "failed" | "timed_out" | "cancelled" | "unavailable" | "payload_limit" | "redundancy_unavailable" | "recall_not_needed" | "queries_generated" | "judge_candidate_failed" | "input_too_large";
4
4
  /** Process-local, content-free and bounded. Agent IDs are keys, never included in snapshots. */
5
5
  export declare class WhispererDiagnostics {
6
6
  #private;
7
7
  static shared(): WhispererDiagnostics;
8
8
  record(agentId: string, whisperer: Whisperer, outcome: Outcome): void;
9
9
  measureMemory(agentId: string, observation: Parameters<RetrievalTelemetry["record"]>[1]): void;
10
+ measureSearch(agentId: string, observation: Parameters<RetrievalTelemetry["record"]>[1]): void;
10
11
  snapshot(agentId: string): {
11
12
  skill: {
12
13
  cancelled?: number | undefined;
@@ -24,6 +25,7 @@ export declare class WhispererDiagnostics {
24
25
  recall_not_needed?: number | undefined;
25
26
  queries_generated?: number | undefined;
26
27
  judge_candidate_failed?: number | undefined;
28
+ input_too_large?: number | undefined;
27
29
  };
28
30
  memory: {
29
31
  cancelled?: number | undefined;
@@ -41,6 +43,7 @@ export declare class WhispererDiagnostics {
41
43
  recall_not_needed?: number | undefined;
42
44
  queries_generated?: number | undefined;
43
45
  judge_candidate_failed?: number | undefined;
46
+ input_too_large?: number | undefined;
44
47
  };
45
48
  telemetry: {
46
49
  scope: string;
@@ -50,6 +53,7 @@ export declare class WhispererDiagnostics {
50
53
  outcomes: {
51
54
  ok?: number | undefined;
52
55
  cancelled?: number | undefined;
56
+ partial?: number | undefined;
53
57
  skipped?: number | undefined;
54
58
  failed?: number | undefined;
55
59
  empty?: number | undefined;
@@ -24,6 +24,9 @@ export class WhispererDiagnostics {
24
24
  measureMemory(agentId, observation) {
25
25
  this.#entry(agentId).telemetry.record("memoryWhisperer", observation);
26
26
  }
27
+ measureSearch(agentId, observation) {
28
+ this.#entry(agentId).telemetry.record("memorySearch", observation);
29
+ }
27
30
  snapshot(agentId) {
28
31
  const entry = this.#agents.get(agentId);
29
32
  return { skill: { ...entry?.counts.skill }, memory: { ...entry?.counts.memory },
@@ -56,6 +56,7 @@ export declare class QmdMemoryManager implements MemorySearchManagerContract {
56
56
  outcomes: {
57
57
  ok?: number | undefined;
58
58
  cancelled?: number | undefined;
59
+ partial?: number | undefined;
59
60
  skipped?: number | undefined;
60
61
  failed?: number | undefined;
61
62
  empty?: number | undefined;
@@ -219,8 +220,8 @@ export declare class QmdMemoryManager implements MemorySearchManagerContract {
219
220
  };
220
221
  }): MaintenanceTask | undefined;
221
222
  search(query: string, opts?: CorpusSearchOptions): Promise<CorpusMemorySearchResult[]>;
222
- /** Whisperer-only discovery: exact trained recipe, no query-conditioned reranker or merged cap. */
223
- searchWhisperer(queries: QueryPair, opts: Pick<CorpusSearchOptions, "corpora" | "signal" | "maxSnippetChars">): Promise<CorpusMemorySearchResult[]>;
223
+ /** Shared discovery: exact trained recipe, scoped before either retrieval lane. */
224
+ searchCandidates(queries: QueryPair, opts: CorpusSearchOptions): Promise<CorpusMemorySearchResult[]>;
224
225
  searchSkills(query: string, minScore: number, limit: number): Promise<SkillSearchCandidate[]>;
225
226
  readFile(params: {
226
227
  relPath: string;
@@ -831,8 +831,8 @@ export class QmdMemoryManager {
831
831
  opts?.signal?.throwIfAborted();
832
832
  return this.#renderSearchHits(hits, store, opts);
833
833
  }
834
- /** Whisperer-only discovery: exact trained recipe, no query-conditioned reranker or merged cap. */
835
- async searchWhisperer(queries, opts) {
834
+ /** Shared discovery: exact trained recipe, scoped before either retrieval lane. */
835
+ async searchCandidates(queries, opts) {
836
836
  return this.#enqueue(async () => {
837
837
  opts.signal?.throwIfAborted();
838
838
  const collections = this.#collectionNames(opts.corpora);
@@ -840,12 +840,18 @@ export class QmdMemoryManager {
840
840
  return [];
841
841
  if (this.#sessions && collections.includes(this.#sessions.collection))
842
842
  await this.#refreshSessionMetadata();
843
+ const sessions = this.#sessions;
844
+ let allowedPaths = opts.sessionFilter && sessions && collections.includes(sessions.collection)
845
+ ? sessionAllowedPaths(this.#sessionMetadata, sessions.collection, opts.sessionFilter) : undefined;
846
+ if (collections.includes(EXTRACTED_COLLECTION)) {
847
+ allowedPaths = { ...allowedPaths, [EXTRACTED_COLLECTION]: this.#currentExtracted(undefined, opts.sessionFilter).map(extractedPath) };
848
+ }
843
849
  const store = await this.#getAnalysisStore();
844
850
  const hits = new Map();
845
851
  // Serialize native QMD work. Cancellation prevents further queries/collections.
846
852
  for (const lane of ["lex", "vec"]) {
847
853
  opts.signal?.throwIfAborted();
848
- for (const hit of await trainingCandidates(store, queries[lane], collections, lane, opts.signal)) {
854
+ for (const hit of await trainingCandidates(store, queries[lane], collections, lane, opts.signal, allowedPaths)) {
849
855
  const key = JSON.stringify([hit.file, hit.bestChunkPos, hit.bestChunk]);
850
856
  if (!hits.has(key))
851
857
  hits.set(key, { file: hit.file, body: hit.body, bestChunk: hit.bestChunk,
@@ -0,0 +1,31 @@
1
+ import type { CorpusMemorySearchResult, CorpusSearchOptions } from "./contracts.js";
2
+ import type { QueryConversation, QueryPair } from "./query-contract.js";
3
+ export type MemoryCandidateManager = {
4
+ searchCandidates(queries: QueryPair, options: CorpusSearchOptions): Promise<CorpusMemorySearchResult[]>;
5
+ };
6
+ export declare function memoryPassageId(text: string): string;
7
+ export type MemorySearchObservation = {
8
+ retrievalMs: number;
9
+ judgeMs: number;
10
+ candidates: number;
11
+ eligible: number;
12
+ requestsSucceeded: number;
13
+ requestsFailed: number;
14
+ };
15
+ /** Both entry points use the same complete passages and independent usefulness judgments. */
16
+ export declare function searchMemory(manager: MemoryCandidateManager, queries: QueryPair, options: CorpusSearchOptions & {
17
+ apiKey: string;
18
+ timeoutMs: number;
19
+ conversation: QueryConversation;
20
+ asOf?: string;
21
+ minUsefulness?: number;
22
+ excludedPassages?: ReadonlyMap<string, number>;
23
+ onCandidates?: (observation: MemorySearchObservation) => void;
24
+ onJudgment?: (event: {
25
+ candidateIndex: number;
26
+ elapsedMs: number;
27
+ error?: unknown;
28
+ }) => void;
29
+ }): Promise<MemorySearchObservation & {
30
+ results: CorpusMemorySearchResult[];
31
+ }>;
@@ -0,0 +1,61 @@
1
+ import { createHash } from "node:crypto";
2
+ import { MEMORY_PASSAGE_CHARS, duplicateMemoryPassage } from "./memory-passage.js";
3
+ import { judgeMemoryPassage, memoryUsefulnessRequest } from "./typesafe.js";
4
+ export function memoryPassageId(text) {
5
+ return createHash("sha256").update(text.replace(/\s+/gu, " ").trim()).digest("hex");
6
+ }
7
+ /** Both entry points use the same complete passages and independent usefulness judgments. */
8
+ export async function searchMemory(manager, queries, options) {
9
+ const { signal } = options;
10
+ signal?.throwIfAborted();
11
+ const asOf = options.asOf ?? new Date().toISOString();
12
+ const started = performance.now();
13
+ const hits = await manager.searchCandidates(queries, {
14
+ corpora: options.corpora, sessionFilter: options.sessionFilter, requestContext: options.requestContext,
15
+ sources: options.sources, signal, maxSnippetChars: MEMORY_PASSAGE_CHARS,
16
+ });
17
+ signal?.throwIfAborted();
18
+ const observation = { retrievalMs: performance.now() - started, judgeMs: 0,
19
+ candidates: hits.length, eligible: 0, requestsSucceeded: 0, requestsFailed: 0 };
20
+ const candidates = [];
21
+ for (const hit of hits) {
22
+ // Defense in depth before disclosing passages to the external judge.
23
+ if (options.corpora && !options.corpora.includes("all") && !options.corpora.includes(hit.corpus))
24
+ continue;
25
+ const excerpt = hit.snippet.trim();
26
+ if (!excerpt || excerpt.length > MEMORY_PASSAGE_CHARS || options.excludedPassages?.has(memoryPassageId(excerpt)) ||
27
+ duplicateMemoryPassage({ ...hit, text: excerpt }, candidates.map(candidate => ({ ...candidate, text: candidate.snippet }))))
28
+ continue;
29
+ candidates.push({ ...hit, snippet: excerpt });
30
+ }
31
+ observation.eligible = candidates.length;
32
+ options.onCandidates?.(observation);
33
+ const judgeStarted = performance.now();
34
+ const judged = (await Promise.all(candidates.map(async (hit, candidateIndex) => {
35
+ const requestStarted = performance.now();
36
+ try {
37
+ const { probability } = await judgeMemoryPassage(memoryUsefulnessRequest(options.conversation, {
38
+ excerpt: hit.snippet, corpus: hit.corpus, sourcePath: hit.path,
39
+ dates: [...new Set(hit.sessionMessages?.flatMap(message => message.timestamp ? [message.timestamp] : [])
40
+ ?? (hit.messageTimestamp ? [hit.messageTimestamp] : []))],
41
+ }, asOf), options);
42
+ signal?.throwIfAborted();
43
+ observation.requestsSucceeded++;
44
+ options.onJudgment?.({ candidateIndex, elapsedMs: performance.now() - requestStarted });
45
+ // Discovery reciprocal ranks are not vector similarity or usefulness.
46
+ const { vectorScore: _vectorScore, textScore: _textScore, ...result } = hit;
47
+ return [{ ...result, score: probability }];
48
+ }
49
+ catch (error) {
50
+ if (signal?.aborted)
51
+ return [];
52
+ observation.requestsFailed++;
53
+ options.onJudgment?.({ candidateIndex, elapsedMs: performance.now() - requestStarted, error });
54
+ return [];
55
+ }
56
+ }))).flat();
57
+ signal?.throwIfAborted();
58
+ observation.judgeMs = performance.now() - judgeStarted;
59
+ return { ...observation, results: judged.filter(hit => hit.score >= (options.minUsefulness ?? 0.7))
60
+ .sort((a, b) => b.score - a.score).slice(0, options.maxResults ?? 5) };
61
+ }
@@ -1,17 +1,13 @@
1
1
  import type { OpenClawConfig, OpenClawPluginApi } from "openclaw/plugin-sdk/plugin-entry";
2
2
  import type { UnblockMemoryConfig } from "./config.js";
3
- import type { CorpusMemorySearchResult, CorpusSearchOptions } from "./contracts.js";
4
3
  import type { WhispererDiagnostics } from "./diagnostics.js";
5
- import type { QueryPair } from "./query-contract.js";
4
+ import { type MemoryCandidateManager } from "./memory-search.js";
6
5
  type MemoryWhispererRuntime = {
7
6
  getMemorySearchManager(params: {
8
7
  cfg: OpenClawConfig;
9
8
  agentId: string;
10
9
  }): Promise<{
11
- manager: {
12
- search(query: string, opts?: CorpusSearchOptions): Promise<CorpusMemorySearchResult[]>;
13
- searchWhisperer?(queries: QueryPair, opts: Pick<CorpusSearchOptions, "corpora" | "signal" | "maxSnippetChars">): Promise<CorpusMemorySearchResult[]>;
14
- } | null;
10
+ manager: MemoryCandidateManager | null;
15
11
  }>;
16
12
  };
17
13
  export declare function registerMemoryWhisperer(api: OpenClawPluginApi, runtime: MemoryWhispererRuntime, config: UnblockMemoryConfig["memoryWhisperer"], typesafe: UnblockMemoryConfig["typesafe"], diagnostics?: WhispererDiagnostics): Parameters<typeof api.on<"before_prompt_build">>[1] | undefined;
@@ -1,14 +1,9 @@
1
- import { createHash } from "node:crypto";
2
- import { judgeMemoryPassage, memoryUsefulnessRequest } from "./typesafe.js";
3
1
  import { resolveTypeSafeApiKey, TypeSafeRequestError } from "./typesafe-client.js";
4
2
  import { complementaryIndices, reviewMemoryRedundancy } from "./typesafe-review.js";
5
3
  import { ApiQueryGenerator, QueryApiError, queryConversation } from "./query-generator.js";
6
4
  import { judgeTrainingInput, TRAINING_GATE_THRESHOLD } from "./training-gate.js";
7
- import { MEMORY_PASSAGE_CHARS, duplicateMemoryPassage } from "./memory-passage.js";
8
- const MAX_EXCERPT_CHARS = MEMORY_PASSAGE_CHARS;
9
- function fingerprint(text) {
10
- return createHash("sha256").update(text.replace(/\s+/gu, " ").trim()).digest("hex");
11
- }
5
+ import { QueryInputBudgetError } from "./query-contract.js";
6
+ import { searchMemory, memoryPassageId } from "./memory-search.js";
12
7
  export function registerMemoryWhisperer(api, runtime, config, typesafe, diagnostics) {
13
8
  if (!config.enabled || !typesafe.enabled)
14
9
  return;
@@ -29,7 +24,7 @@ export function registerMemoryWhisperer(api, runtime, config, typesafe, diagnost
29
24
  const started = performance.now();
30
25
  const asOf = new Date().toISOString();
31
26
  const measurement = { outcome: "skipped", elapsedMs: 0 };
32
- let stage = "credentials", reason = "completed";
27
+ let stage = "input", reason = "completed";
33
28
  let recallProbability, queryCount;
34
29
  let requestsSucceeded = 0, requestsFailed = 0;
35
30
  const log = (level, event, fields) => api.logger[level]("unblock-memory memory_whisperer " + JSON.stringify({ event, agentId, runId, sessionId, stage, ...fields }));
@@ -56,6 +51,8 @@ export function registerMemoryWhisperer(api, runtime, config, typesafe, diagnost
56
51
  signal.addEventListener("abort", onAbort, { once: true });
57
52
  });
58
53
  const run = async () => {
54
+ const conversation = queryConversation(event.prompt, event.messages);
55
+ stage = "credentials";
59
56
  const apiKey = await resolveTypeSafeApiKey(typesafe);
60
57
  if (signal.aborted)
61
58
  return;
@@ -64,8 +61,6 @@ export function registerMemoryWhisperer(api, runtime, config, typesafe, diagnost
64
61
  diagnostics?.record(agentId, "memory", "missing_key");
65
62
  return;
66
63
  }
67
- stage = "input";
68
- const conversation = queryConversation(event.prompt, event.messages);
69
64
  const gateStarted = performance.now();
70
65
  const gateSignal = AbortSignal.any([signal, AbortSignal.timeout(typesafe.timeoutMs)]);
71
66
  // Speculation is intentional: neither model generation nor QMD waits for the recall judgment.
@@ -125,81 +120,46 @@ export function registerMemoryWhisperer(api, runtime, config, typesafe, diagnost
125
120
  diagnostics?.record(agentId, "memory", "unavailable");
126
121
  return;
127
122
  }
128
- const retrievalStarted = performance.now();
129
- if (!manager.searchWhisperer)
130
- throw new Error("V2 Whisperer retrieval is unavailable");
131
- const hits = await manager.searchWhisperer(queries, { corpora, signal, maxSnippetChars: MAX_EXCERPT_CHARS });
132
- if (signal.aborted)
123
+ const search = await searchMemory(manager, queries, {
124
+ corpora, signal, apiKey, timeoutMs: typesafe.timeoutMs, conversation, asOf,
125
+ minUsefulness: config.minUsefulness, maxResults: 4, excludedPassages: state.recent,
126
+ onCandidates: observation => {
127
+ measurement.retrievalMs = observation.retrievalMs;
128
+ measurement.candidates = observation.candidates;
129
+ measurement.eligible = observation.eligible;
130
+ measurement.outcome = "empty";
131
+ stage = "judgment";
132
+ },
133
+ onJudgment: ({ candidateIndex, elapsedMs, error }) => {
134
+ const fields = { stage: "judgment", candidateIndex, elapsedMs, timeoutMs: typesafe.timeoutMs };
135
+ if (error !== undefined) {
136
+ requestsFailed++;
137
+ diagnostics?.record(agentId, "memory", "judge_candidate_failed");
138
+ log("warn", "candidate_failed", { ...fields, ...failureFields(error) });
139
+ }
140
+ else {
141
+ requestsSucceeded++;
142
+ log("info", "candidate_completed", fields);
143
+ }
144
+ },
145
+ });
146
+ if (signal.aborted || sessions.get(key) !== state)
133
147
  return;
134
- measurement.retrievalMs = performance.now() - retrievalStarted;
135
- measurement.candidates = hits.length;
136
- const candidates = [];
137
- for (const hit of hits) {
138
- // Enforce scope again before sending anything to the external judge.
139
- if (!corpora.includes(hit.corpus))
140
- continue;
141
- const excerpt = hit.snippet.trim();
142
- // Retrieval bounds context around a complete match. Never replace it
143
- // with a prefix if a manager returns an oversized result.
144
- if (excerpt.length > MAX_EXCERPT_CHARS)
145
- continue;
146
- const id = fingerprint(excerpt);
147
- if (!excerpt || state.recent.has(id) || duplicateMemoryPassage({ ...hit, text: excerpt }, candidates.map(candidate => ({ ...candidate.hit, text: candidate.excerpt }))))
148
- continue;
149
- candidates.push({ hit, excerpt, id });
150
- }
151
- measurement.eligible = candidates.length;
152
- measurement.outcome = "empty";
153
- if (!candidates.length) {
148
+ measurement.judgeMs = search.judgeMs;
149
+ if (!search.eligible) {
154
150
  reason = "no_candidates";
155
151
  diagnostics?.record(agentId, "memory", "no_candidates");
156
152
  return;
157
153
  }
158
- stage = "judgment";
159
- const judgeStarted = performance.now();
160
- // Every eligible passage gets its own request; no request sees another candidate.
161
- const judged = (await Promise.all(candidates.map(async (candidate, candidateIndex) => {
162
- const requestStarted = performance.now();
163
- const fields = { stage: "judgment", candidateIndex,
164
- timeoutMs: typesafe.timeoutMs };
165
- try {
166
- const { hit, excerpt } = candidate;
167
- const { probability } = await judgeMemoryPassage(memoryUsefulnessRequest(conversation, {
168
- excerpt, corpus: hit.corpus, sourcePath: hit.path,
169
- dates: [...new Set(hit.sessionMessages?.flatMap(message => message.timestamp ? [message.timestamp] : [])
170
- ?? (hit.messageTimestamp ? [hit.messageTimestamp] : []))],
171
- }, asOf), { apiKey, timeoutMs: typesafe.timeoutMs, signal });
172
- if (signal.aborted)
173
- return [];
174
- requestsSucceeded++;
175
- log("info", "candidate_completed", { ...fields, elapsedMs: performance.now() - requestStarted });
176
- // Failed candidates have no score, not zero. Keep each score with its own source.
177
- return [{ ...candidate, probability }];
178
- }
179
- catch (error) {
180
- if (signal.aborted)
181
- return [];
182
- requestsFailed++;
183
- diagnostics?.record(agentId, "memory", "judge_candidate_failed");
184
- log("warn", "candidate_failed", { ...fields, ...failureFields(error), elapsedMs: performance.now() - requestStarted });
185
- return [];
186
- }
187
- }))).flat();
188
- if (signal.aborted || sessions.get(key) !== state)
189
- return;
190
- measurement.judgeMs = performance.now() - judgeStarted;
191
154
  if (!await gate || signal.aborted || sessions.get(key) !== state)
192
155
  return;
193
- if (!judged.length) {
156
+ if (!search.requestsSucceeded) {
194
157
  reason = "all_candidates_failed";
195
158
  measurement.outcome = "failed";
196
159
  diagnostics?.record(agentId, "memory", "failed");
197
160
  return;
198
161
  }
199
- const ranked = judged
200
- .filter(candidate => candidate.probability >= config.minUsefulness)
201
- .sort((a, b) => b.probability - a.probability)
202
- .slice(0, 4);
162
+ const ranked = search.results.map(hit => ({ hit, excerpt: hit.snippet, id: memoryPassageId(hit.snippet) }));
203
163
  let selected = ranked.slice(0, config.maxHints);
204
164
  if (!selected.length) {
205
165
  reason = "rejected";
@@ -254,6 +214,12 @@ export function registerMemoryWhisperer(api, runtime, config, typesafe, diagnost
254
214
  return await Promise.race([run(), aborted]);
255
215
  }
256
216
  catch (error) {
217
+ if (error instanceof QueryInputBudgetError) {
218
+ reason = "input_too_large";
219
+ measurement.outcome = "skipped";
220
+ diagnostics?.record(agentId, "memory", "input_too_large");
221
+ return;
222
+ }
257
223
  reason = "stage_failed";
258
224
  measurement.outcome = "failed";
259
225
  if (!signal.aborted) {
@@ -17,9 +17,13 @@ import { registerResponseAudit } from "./response-runtime.js";
17
17
  import { registerInsideOut } from "./inside-out-runtime.js";
18
18
  import { registerMemoryTraining } from "./training-runtime.js";
19
19
  import { resolveTimezone } from "./session-projector.js";
20
+ import { searchMemory } from "./memory-search.js";
21
+ import { prepareQueryConversation, QueryInputBudgetError } from "./query-contract.js";
22
+ import { resolveTypeSafeApiKey } from "./typesafe-client.js";
20
23
  import { registerExtraction } from "./extraction-runtime.js";
21
24
  const searchParameters = Type.Object({
22
- query: Type.String({ pattern: "\\S" }),
25
+ bm25Query: Type.String({ pattern: "\\S", description: "Distinctive names, identifiers and keywords for BM25 retrieval." }),
26
+ vectorQuery: Type.String({ pattern: "\\S", description: "Natural-language question describing the information needed; also the intent used to judge usefulness." }),
23
27
  corpora: Type.Optional(Type.Array(Type.String({ pattern: "\\S" }), {
24
28
  minItems: 1, description: 'Configured corpus names; default is all non-skill corpora. Use ["all"] alone for explicit all-corpora recall.',
25
29
  })),
@@ -38,7 +42,7 @@ const searchParameters = Type.Object({
38
42
  conversationId: Type.Optional(Type.String({ pattern: "\\S" })),
39
43
  }, { additionalProperties: false, description: "Restricts session documents only; selected file corpora remain eligible. Not an audience access control." })),
40
44
  maxResults: Type.Optional(Type.Integer({ minimum: 1, maximum: 20, description: "Maximum hits; default 5." })),
41
- minScore: Type.Optional(Type.Number({ minimum: 0, maximum: 1, description: "Minimum vector similarity; default 0.3. Not confidence in factual truth." })),
45
+ minUsefulness: Type.Optional(Type.Number({ minimum: 0, maximum: 1, description: "Minimum TypeSafe usefulness probability; default 0.7. Not confidence in factual truth." })),
42
46
  }, { additionalProperties: false });
43
47
  const getParameters = Type.Object({
44
48
  path: Type.String({ pattern: "\\S" }),
@@ -49,45 +53,79 @@ const syncSessionsParameters = Type.Object({
49
53
  force: Type.Optional(Type.Boolean()),
50
54
  }, { additionalProperties: false });
51
55
  const syncStatusParameters = Type.Object({}, { additionalProperties: false });
52
- function createSearchTool(runtime, ctx) {
56
+ function createSearchTool(runtime, ctx, typesafe, diagnostics) {
53
57
  const active = getContext(ctx);
54
58
  if (!active)
55
59
  return null;
56
60
  return {
57
61
  name: "memory_search",
58
62
  label: "Memory Search",
59
- description: "Search this agent's configured memory corpora with local vector retrieval, not QMD's hybrid query. Skills are excluded. Session snippets are arrays of messages (type, name, timestamp, body; partial when incomplete); file snippets are strings. Results are evidence leads; inspect source context with memory_get. Empty results or errors do not prove absence of a fact.",
63
+ description: "Search this agent's configured memory corpora with BM25 and vector queries, then TypeSafe usefulness ranking. Supply keywords in bm25Query and the information needed in vectorQuery; no query generation or recall gate runs. Queries and candidate passages are sent to TypeSafe. Skills are excluded. Session snippets are message arrays; file snippets are strings. Inspect source context with memory_get. Empty results or errors do not prove absence of a fact.",
60
64
  parameters: searchParameters,
61
65
  async execute(_toolCallId, params, signal) {
62
- const { query: untrimmedQuery, corpora, sessionFilter, maxResults, minScore, } = Value.Parse(searchParameters, params);
63
- const query = untrimmedQuery.trim();
64
- const { manager, error } = await runtime.getMemorySearchManager(active);
65
- if (!manager)
66
- return jsonResult({ results: [], error: error ?? "memory unavailable" });
67
- const results = await manager.search(query, {
68
- corpora: corpora?.map((corpus) => corpus.trim()),
69
- sessionFilter,
70
- maxResults,
71
- minScore,
72
- signal,
73
- requestContext: active.requestContext,
74
- });
75
- // Compact only the public tool response; internal ranking and consumers keep
76
- // full-precision scores and the host's source/citation compatibility fields.
77
- const payload = {
78
- results: results.map(({ source: _source, citation: _citation, session, sessionMessages, ...result }) => ({
79
- ...result,
80
- snippet: result.corpus === "sessions" ? sessionMessages ?? [{ body: result.snippet, partial: true }] : result.snippet,
81
- score: Number(result.score.toFixed(2)),
82
- ...(result.vectorScore !== undefined ? { vectorScore: Number(result.vectorScore.toFixed(2)) } : {}),
83
- ...(result.textScore !== undefined ? { textScore: Number(result.textScore.toFixed(2)) } : {}),
84
- ...(session ? { session: { ...session, startedAt: new Date(session.startedAt).toISOString() } } : {}),
85
- })),
86
- };
87
- return {
88
- content: [{ type: "text", text: JSON.stringify(payload) }],
89
- details: payload,
90
- };
66
+ const started = performance.now();
67
+ const measurement = { outcome: "failed", elapsedMs: 0 };
68
+ try {
69
+ signal?.throwIfAborted();
70
+ const { bm25Query, vectorQuery, corpora, sessionFilter, maxResults, minUsefulness, } = Value.Parse(searchParameters, params);
71
+ const queries = { lex: bm25Query.trim(), vec: vectorQuery.trim() };
72
+ // Reject oversized explicit inputs before credential resolution or any service call.
73
+ prepareQueryConversation([], queries.lex);
74
+ const { conversation } = prepareQueryConversation([], queries.vec);
75
+ const apiKey = await resolveTypeSafeApiKey(typesafe);
76
+ if (!apiKey) {
77
+ measurement.outcome = "skipped";
78
+ return jsonResult({ results: [], error: "TypeSafe API key not configured or TypeSafe disabled" });
79
+ }
80
+ const { manager, error } = await runtime.getMemorySearchManager(active);
81
+ if (!manager)
82
+ return jsonResult({ results: [], error: error ?? "memory unavailable" });
83
+ const search = await searchMemory(manager, queries, {
84
+ corpora: corpora?.map((corpus) => corpus.trim()),
85
+ sessionFilter,
86
+ maxResults,
87
+ minUsefulness,
88
+ apiKey, timeoutMs: typesafe.timeoutMs, conversation,
89
+ signal,
90
+ requestContext: active.requestContext,
91
+ onCandidates: ({ retrievalMs, candidates, eligible }) => {
92
+ Object.assign(measurement, { retrievalMs, candidates, eligible });
93
+ },
94
+ });
95
+ measurement.judgeMs = search.judgeMs;
96
+ measurement.results = search.results.length;
97
+ measurement.contextChars = search.results.reduce((sum, hit) => sum + hit.snippet.length, 0);
98
+ if (search.eligible && !search.requestsSucceeded)
99
+ return jsonResult({ results: [], error: "All memory usefulness judgments failed" });
100
+ measurement.outcome = search.requestsFailed ? "partial" : search.results.length ? "ok" : "empty";
101
+ // Compact only the public tool response; internal ranking and consumers keep
102
+ // full-precision scores and the host's source/citation compatibility fields.
103
+ const payload = {
104
+ ...(search.requestsFailed ? { warning: `${search.requestsFailed} candidate usefulness judgments failed; results are partial` } : {}),
105
+ results: search.results.map(({ source: _source, citation: _citation, session, sessionMessages, ...result }) => ({
106
+ ...result,
107
+ snippet: result.corpus === "sessions" ? sessionMessages ?? [{ body: result.snippet, partial: true }] : result.snippet,
108
+ score: Number(result.score.toFixed(2)),
109
+ ...(session ? { session: { ...session, startedAt: new Date(session.startedAt).toISOString() } } : {}),
110
+ })),
111
+ };
112
+ return {
113
+ content: [{ type: "text", text: JSON.stringify(payload) }],
114
+ details: payload,
115
+ };
116
+ }
117
+ catch (error) {
118
+ if (error instanceof QueryInputBudgetError)
119
+ measurement.outcome = "skipped";
120
+ throw error;
121
+ }
122
+ finally {
123
+ measurement.elapsedMs = performance.now() - started;
124
+ if (signal?.aborted)
125
+ measurement.outcome = signal.reason instanceof Error && signal.reason.name === "TimeoutError"
126
+ ? "timed_out" : "cancelled";
127
+ diagnostics.measureSearch(active.agentId, measurement);
128
+ }
91
129
  },
92
130
  };
93
131
  }
@@ -455,7 +493,7 @@ export function registerUnblockMemory(api) {
455
493
  skill: registerSkillWhisperer(api, runtime, config.skillWhisperer, config.typesafe, diagnostics),
456
494
  people: peopleWhisperer,
457
495
  });
458
- api.registerTool((ctx) => createSearchTool(runtime, ctx), { names: ["memory_search"] });
496
+ api.registerTool((ctx) => createSearchTool(runtime, ctx, config.typesafe, diagnostics), { names: ["memory_search"] });
459
497
  api.registerTool((ctx) => createGetTool(runtime, ctx), { names: ["memory_get"] });
460
498
  api.registerTool((ctx) => createSyncSessionsTool(runtime, ctx), {
461
499
  names: ["memory_sync_sessions"],
@@ -1,5 +1,5 @@
1
1
  import type { ResponseAuditConfig } from "./response-config.js";
2
- export declare const RESPONSE_EXTRACTOR_VERSION = 5;
2
+ export declare const RESPONSE_EXTRACTOR_VERSION = 6;
3
3
  type Row = {
4
4
  seq: number;
5
5
  eventJson: string;
@@ -3,7 +3,7 @@ import { DatabaseSync } from "node:sqlite";
3
3
  import { AgentTranscriptReader } from "./agent-transcript.js";
4
4
  import { messageText } from "./whisperer-context.js";
5
5
  import { responseUserText } from "./response-text.js";
6
- export const RESPONSE_EXTRACTOR_VERSION = 5;
6
+ export const RESPONSE_EXTRACTOR_VERSION = 6;
7
7
  const MAX_EVENTS = 2000, MAX_SESSION_BYTES = 2_000_000, MAX_EPISODE_CHARS = 24_000;
8
8
  function record(v) {
9
9
  return v !== null && typeof v === "object" && !Array.isArray(v) ? v : undefined;
@@ -43,6 +43,19 @@ export function responseUserText(input, senderId) {
43
43
  const marker = markers[0];
44
44
  contextLimited = meta.history_truncated === true || rest.slice(0, marker.index).includes("Chat history since last reply:");
45
45
  text = rest.slice(marker.index + marker[0].length).trim();
46
+ // OpenClaw appends channel metadata after the current Slack message. Only
47
+ // recognize its exact boundary inside an already validated envelope.
48
+ const contexts = [...text.matchAll(/\r?\n\r?\nContext: ⟦openclaw:ctx⟧\r?\n/g)];
49
+ if (contexts.length) {
50
+ if (contexts.length !== 1)
51
+ return undefined;
52
+ const context = contexts[0];
53
+ const suffix = text.slice(context.index + context[0].length);
54
+ if (!suffix.trim() || suffix.includes("⟦openclaw:ctx⟧") || /^Chat history since last reply:/m.test(suffix))
55
+ return undefined;
56
+ text = text.slice(0, context.index).trim();
57
+ contextLimited = true;
58
+ }
46
59
  }
47
60
  else {
48
61
  const from = /^From: [^\r\n]+ \(([^()\r\n]+)\)\r?\n/.exec(text);
@@ -1,5 +1,5 @@
1
- type RetrievalOperation = "vector" | "memoryWhisperer";
2
- type RetrievalOutcome = "ok" | "empty" | "failed" | "cancelled" | "timed_out" | "skipped";
1
+ type RetrievalOperation = "vector" | "memoryWhisperer" | "memorySearch";
2
+ type RetrievalOutcome = "ok" | "partial" | "empty" | "failed" | "cancelled" | "timed_out" | "skipped";
3
3
  declare const fields: readonly ["elapsedMs", "retrievalMs", "judgeMs", "gateMs", "generationMs", "candidates", "eligible", "results", "contextChars"];
4
4
  type Field = typeof fields[number];
5
5
  export type RetrievalObservation = {
@@ -18,6 +18,7 @@ export declare class RetrievalTelemetry {
18
18
  outcomes: {
19
19
  ok?: number | undefined;
20
20
  cancelled?: number | undefined;
21
+ partial?: number | undefined;
21
22
  skipped?: number | undefined;
22
23
  failed?: number | undefined;
23
24
  empty?: number | undefined;
@@ -32,7 +32,7 @@ export function registerReviewTools(api, runtime, config, diagnostics) {
32
32
  return null;
33
33
  return {
34
34
  name: "memory_diagnostics", label: "Memory Diagnostics",
35
- description: "Read content-free whisperer counters, configuration state, projection version and index readiness. Does not call TypeSafe.",
35
+ description: "Read content-free manual-search and whisperer telemetry, configuration state, projection version and index readiness. Does not call TypeSafe.",
36
36
  parameters: noParameters,
37
37
  async execute(_id, params) {
38
38
  Value.Parse(noParameters, params);
@@ -1,7 +1,7 @@
1
- import type { QMDStore } from "@unblocklabs/qmd";
1
+ import type { AllowedDocumentPaths, QMDStore } from "@unblocklabs/qmd";
2
2
  import type { QueryLane } from "./query-contract.js";
3
3
  /** Runtime and training share the exact ten-candidate recipe, not hybrid ranking. */
4
- export declare function trainingCandidates(qmd: QMDStore, query: string, collection: string | string[], lane: QueryLane, signal?: AbortSignal): Promise<{
4
+ export declare function trainingCandidates(qmd: QMDStore, query: string, collection: string | string[], lane: QueryLane, signal?: AbortSignal, allowedPaths?: AllowedDocumentPaths): Promise<{
5
5
  vector?: {
6
6
  score: number;
7
7
  rank: number;
@@ -1,8 +1,8 @@
1
1
  /** Runtime and training share the exact ten-candidate recipe, not hybrid ranking. */
2
- export async function trainingCandidates(qmd, query, collection, lane, signal) {
2
+ export async function trainingCandidates(qmd, query, collection, lane, signal, allowedPaths) {
3
3
  const hits = await qmd.discoverCandidates({ query, lane, collection, limit: 10, signal,
4
4
  // Preserve keyword chunk tie-breaking against this lane's own query.
5
- intent: query });
5
+ intent: query, allowedPaths });
6
6
  return hits.map(({ method, rank, score, file, body, bestChunk, bestChunkPos }) => {
7
7
  const retrieval = {
8
8
  [method]: { score, rank: rank + 1 },
@@ -11,7 +11,8 @@ settings; a rotated credential file is reread without a restart.
11
11
 
12
12
  | Feature | Required settings/dependencies | TypeSafe disabled / no key | Provider or unreadable-key failure |
13
13
  | --- | --- | --- | --- |
14
- | Ordinary search/get | Installed/enabled memory slot; configured corpora | Unchanged local retrieval | Unchanged; its own indexing/embedding errors still matter |
14
+ | `memory_search` | Installed/enabled memory slot; configured corpora; TypeSafe key | Explicit unavailable error | Successful judged siblings returned with a partial warning; total failure is an error; no vector fallback |
15
+ | `memory_get` | Installed/enabled memory slot; configured corpora | Unchanged local indexed reads | Unchanged; its own indexing errors still matter |
15
16
  | Skill Whisperer | `skillWhisperer.enabled`, skills corpus, host hooks | Best local vector candidate meeting `minScore` | Exclude failed candidates; successful siblings may qualify; no vector fallback |
16
17
  | Memory Whisperer | `memoryWhisperer.enabled`, explicit approved corpora, host hooks | No hints | Exclude failed candidates; recall/deadline failures still suppress the turn |
17
18
  | Complementary hints | Enabled Memory Whisperer + `complementaryHints` | No additional judgment; base hints also require a key | Keep baseline hints unless the total deadline expires |
@@ -202,7 +203,7 @@ contract; these tables explain their effects.
202
203
  | `corpora[].kind` | Required for explicit entries | `files`, `sessions`, or `skills` |
203
204
  | `corpora[].paths` | Required for files/skills | Nonempty exact-file/directory/glob list; workspace-relative, absolute or `~/`; directory means recursive Markdown; does not grant host write trust |
204
205
  | `corpora[sessions].chatTypes` | `['channel','group']` | Nonempty subset of channel/group/direct; DMs require `direct` |
205
- | `corpora[sessions].maxExpandedTokens` | `500` | 1–10,000; ordinary search uses full turn/message when it fits, otherwise preserves the matched chunk. Whisperer instead shares a fixed 1,200-character renderer with training. |
206
+ | `corpora[sessions].maxExpandedTokens` | `500` | 1–10,000; internal host vector retrieval expands a full turn/message when it fits. Public search and Whisperer share the fixed 1,200-character complete-passage renderer with training. |
206
207
  | `corpora[sessions].syncIntervalMinutes` | `60` | 0–1,440; zero manual-only; first scheduled sync after one interval; requires running Gateway |
207
208
  | `keepEmbeddingModelWarm` | `true` | Retain embedding model/context after first use; false allows five-minute idle disposal |
208
209
  | `analysis.executable` | Unset | Optional absolute local worker path; enables ability to recluster, not automatic scheduling |
package/docs/retrieval.md CHANGED
@@ -6,26 +6,31 @@
6
6
 
7
7
  `memory_search` searches every configured **non-skill** corpus for this agent by
8
8
  default. Select named corpora, or use `["all"]` alone. An empty list or unknown
9
- name is an error. Default: **5 results**, vector `minScore: 0.3`; configurable
10
- `maxResults` is 1–20 and `minScore` is 0–1. No TypeSafe, BM25, query expansion
11
- or reranker runs in this tool.
9
+ name is an error. Supply `bm25Query` (distinctive keywords/names) and
10
+ `vectorQuery` (a natural-language question describing the information needed).
11
+ Each lane retrieves ten candidates; complete passages are deduplicated and
12
+ independently judged by TypeSafe against `vectorQuery`. No query generation or
13
+ recall gate runs. Default: **5 results**, `minUsefulness: 0.7`;
14
+ `maxResults` is 1–20 and `minUsefulness` is 0–1. The old `query` and vector
15
+ `minScore` parameters are removed, not silently reinterpreted.
12
16
 
13
17
  Example tool input (the `memory` corpus exists by default):
14
18
 
15
19
  ```json
16
- { "query": "Who approved the staging rollout?", "corpora": ["memory"], "maxResults": 5 }
20
+ { "bm25Query": "staging rollout approval", "vectorQuery": "Who approved the staging rollout?", "corpora": ["memory"], "maxResults": 5 }
17
21
  ```
18
22
 
19
23
  Results use compact JSON and carry `path`, `startLine`, `endLine`, `snippet`,
20
- `corpus`, and `score`/`vectorScore` rounded to hundredths. Ranking and threshold
24
+ `corpus`, and a usefulness `score` rounded to hundredths. Ranking and threshold
21
25
  filtering still use full precision. The constant `source` and top-level `provider`
22
26
  fields are omitted; `path` plus line numbers replace the redundant `citation`.
23
27
  Session hits also carry session metadata and, when available,
24
28
  `messageTimestamp`: the original timestamp text (including timezone) of the message
25
29
  containing the matched chunk. It stays tied to that message even when the excerpt
26
30
  expands to the surrounding turn. Missing timestamps are omitted, not replaced by
27
- session start time. Vector similarity is a retrieval signal, not confidence in
28
- the truth of a claim.
31
+ session start time. Usefulness is not confidence in the truth of a claim.
32
+ Passages retain complete evidence within 1,200 characters; oversized matches
33
+ are omitted, never trimmed into a misleading prefix.
29
34
 
30
35
  Session `snippet` values are arrays of messages, in source order:
31
36
 
@@ -61,56 +66,40 @@ Search snippets are leads. Inspect attribution, qualifications, dates and adjace
61
66
  context before relying on a factual claim. An old plan is not proof it happened,
62
67
  and memory does not grant permission to act. No results may mean the wrong corpus,
63
68
  a high threshold, an unsynced session or an unavailable index—not absence of the fact.
69
+ Search requires enabled TypeSafe and a configured key. It sends the semantic
70
+ query, candidate excerpts, dates, corpus and source paths to TypeSafe. Missing
71
+ credentials and total judging failure include an `error` alongside empty `results`;
72
+ partial judging failure includes a `warning` and only successfully judged results.
73
+ Oversized queries are rejected before credentials, retrieval or provider calls.
64
74
  Search initialization errors include an `error` alongside empty `results`;
65
75
  other failures may surface as tool errors. Investigate them instead of reporting
66
76
  “nothing is remembered.”
77
+ `memory_diagnostics` exposes content-free manual-search measurements under
78
+ `whisperers.telemetry.operations.memorySearch`: end-to-end latency, retrieval and
79
+ judging time, candidate/result counts and outcomes (including partial failures).
80
+ These counters are separate from automatic `memoryWhisperer` and internal `vector`
81
+ retrieval; no query or evidence text is recorded.
67
82
 
68
83
  Corpora and session filters select evidence; they are not audience ACLs. Normal
69
84
  tools can access this agent's configured non-skill corpora, not just its current
70
85
  chat. Only index material suitable for the agent's tool callers. Per-feature
71
86
  TypeSafe allowlists do not restrict ordinary retrieval.
72
87
 
73
- ## QMD search modes
74
-
75
- The following describes QMD's TypeSafe-ranked `query` API (2.10+). Use the exact
76
- dependency in the installed plugin's package metadata when diagnosing an older
77
- installation.
78
-
79
- | Surface | Retrieval/ranking | Score / useful distinction |
80
- | --- | --- | --- |
81
- | Plugin `memory_search` | Direct vector search, expansion off | Vector similarity; ordinary agent recall |
82
- | QMD CLI `search` / SDK `searchLex` | Local BM25 | Normalized lexical relevance; useful for names, identifiers and exact phrases |
83
- | QMD CLI/SDK `vsearch` | Local vectors; standalone default includes local query expansion | Vector similarity; `--no-expand` / `expand:false` selects literal retrieval |
84
- | QMD CLI/MCP `query` / SDK `search` | Literal vectors + BM25, deduplicated source excerpts, independent TypeSafe usefulness ranking | Usefulness divided by 3, not cosine similarity; default limit 10/minScore 0 |
85
- | QMD `query --no-rerank` / SDK/MCP `rerank:false` | Local retrieval, best reciprocal retrieval-rank ordering | 1 / rank; explicitly skips remote scoring |
86
-
87
- Plain `query` takes `ceil(1.5 × limit)` candidates per backend, without local
88
- expansion or local reranking. Explicit `lex`/`vec`/`hyde` variants share the
89
- TypeSafe ranking policy by default. A supplied hypothetical `hyde` passage is
90
- a retrieval input, not evidence. Scores across modes are not interchangeable.
91
-
92
- **Migration:** Memory 0.3.22 removed the temporary `memory_xsearch` tool. QMD's
93
- `query` provides that hybrid retrieval/ranking functionality; there is no plugin
94
- `memory_query`, and `memory_search` did not become hybrid.
95
-
96
- Standalone QMD is a separate entry point. Its project/named/global index is not
97
- automatically this agent's `unblock-memory/index.sqlite`. Plugin corpus names
98
- also are not QMD collection IDs: each configured path maps to a `source-<hash>`
99
- collection, and a corpus can contain several. Establish the intended index,
100
- collection scope and installed QMD version before using CLI/MCP as an alternative.
101
- Do not run standalone collection/update/embed maintenance against a live
102
- plugin-managed index as a casual search fallback.
103
-
104
- QMD CLI/MCP read `TYPESAFE_API_KEY` or `TYPESAFE_API_KEY_FILE` from their own
105
- process; SDK callers can supply credentials. Plugin `typesafe.apiKeyFile` does
106
- not export a key to those processes. Ranked query sends the query, intent,
107
- selected excerpts, source paths and evaluation time to TypeSafe. Missing keys or
108
- scoring failures return errors, not vector fallback or a successful empty answer.
109
- Use an explicitly local mode when appropriate.
110
-
111
- See the [QMD guide](https://github.com/unblocklabs-ai/qmd#readme) for CLI/MCP syntax.
112
- Private skill frontmatter retrieval and response-audit lexical investigations are
113
- internal workflows, not extra public plugin search modes.
88
+ ## QMD entry points
89
+
90
+ `memory_search` is the agent-facing ranked search. The QMD CLI `query`,
91
+ `search` and `deep-search` alias, MCP `query` tool, and HTTP `/query` and
92
+ `/search` routes are removed in the companion QMD change. Existing installations
93
+ retain those commands until that QMD change is released and installed.
94
+
95
+ QMD remains the local indexing/retrieval backend. Its BM25/vector SDK primitives,
96
+ vector-only `vsearch`, indexed reads, collection maintenance and benchmark APIs
97
+ remain available. Private skill retrieval and response-audit lexical investigations
98
+ are internal workflows, not extra public plugin search tools.
99
+
100
+ Standalone QMD and the plugin need not use the same index. Do not run standalone
101
+ collection/update/embed maintenance against a live plugin-managed index as a
102
+ casual search fallback.
114
103
 
115
104
  ## Sessions
116
105
 
@@ -127,7 +116,8 @@ corpora searchable. Supported fields are `startedFrom` and `startedTo`
127
116
 
128
117
  ```json
129
118
  {
130
- "query": "deployment decision",
119
+ "bm25Query": "deployment decision",
120
+ "vectorQuery": "What deployment decision was made?",
131
121
  "sessionFilter": {
132
122
  "startedFrom": "2026-08-01T00:00:00Z",
133
123
  "provider": "slack",
@@ -300,7 +290,9 @@ through BM25 and vectors respectively, retrieving ten candidates per backend acr
300
290
  the complete approved collection scope (not ten per collection). The lexical adapter
301
291
  uses literal OR keywords; quotation marks do not enable exact-phrase matching.
302
292
  API or generated-output failures skip the hint; there is no direct-vector fallback.
303
- TypeSafe evaluates one independent
293
+ After query generation Whisperer uses the same discovery, deduplication and
294
+ ranking core as `memory_search`, but judges against the bounded conversation
295
+ rather than the explicit tool's semantic query alone. TypeSafe evaluates one independent
304
296
  Noul question per candidate in its own concurrent HTTP request: would a careful
305
297
  assistant use a specific factual detail from the excerpt when answering the current
306
298
  request? Partial answers and concrete leads count; repeated facts, topic/name matches
@@ -315,16 +307,18 @@ Explicit configured thresholds are preserved. Evaluate it on your own conversati
315
307
  **Privacy and budgets:** training and runtime prepare the same visible conversation:
316
308
  at most 8,192 pinned-LFM tokens and 24,000 UTF-8 bytes after serialization. The whole
317
309
  current request and most recent whole messages are retained; an oversized current
318
- request skips automatic memory. The judge receives that conversation plus one complete
319
- 1,200-character excerpt, its corpus/source path, all represented message timestamps,
310
+ request skips automatic memory before query generation or the TypeSafe recall gate.
311
+ Diagnostics count `input_too_large`; completed logs report `outcome: "skipped"` and
312
+ `reason: "input_too_large"`, without recording the input. The judge receives that
313
+ conversation plus one complete 1,200-character excerpt, its corpus/source path, all represented message timestamps,
320
314
  and the request's evaluation time per HTTP request. A v2 query pair can therefore
321
315
  produce up to twenty passage requests before deduplication and eligibility filtering.
322
316
  Dates record when source messages were said, without inferring event dates.
323
317
  Session excerpts retain a complete turn or message when it fits,
324
318
  otherwise the complete matched chunk. Chunks exceeding the excerpt budget are
325
- skipped, never sliced. This shared Whisperer renderer uses the fixed 1,200-character
326
- budget independently of `sessions.maxExpandedTokens`; that setting still controls
327
- ordinary `memory_search` expansion, which is unchanged.
319
+ skipped, never sliced. Public `memory_search` and Whisperer use the same fixed
320
+ 1,200-character renderer, independently of `sessions.maxExpandedTokens`.
321
+ That setting controls only internal host-vector retrieval expansion.
328
322
  It does not fetch a complete historical transcript; the host may
329
323
  already have compacted the available context. System messages, thinking blocks,
330
324
  images, and tool-result messages are omitted;
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "id": "unblock-memory",
3
3
  "name": "Unblock Memory",
4
- "version": "0.7.4",
4
+ "version": "0.8.0",
5
5
  "description": "Indexes, retrieves, and analyzes configured workspace memory with existing QMD vectors.",
6
6
  "kind": "memory",
7
7
  "activation": { "onStartup": true },
@@ -64,7 +64,7 @@
64
64
  },
65
65
  "typesafe.enabled": {
66
66
  "label": "TypeSafe Judgments",
67
- "help": "Shared provider gate for opted-in whisperers, people primer/save review, evidence/quality reviews and response audits. Does not enable features or approve corpora by itself. Ordinary search and People Whisperer injection stay local."
67
+ "help": "Shared provider gate for opted-in whisperers, people primer/save review, evidence/quality reviews and response audits. Does not enable features or approve corpora by itself. Memory search discovers locally and uses TypeSafe to rank usefulness; People Whisperer injection stays local."
68
68
  },
69
69
  "typesafe.apiKey": { "label": "TypeSafe API Key", "sensitive": true },
70
70
  "typesafe.timeoutMs": { "label": "TypeSafe Request Timeout", "help": "Per-request timeout, not the whole operation. People primer/dossier review use peoplePrimer.timeoutMs instead; Memory Whisperer has its own total deadline, and Skill Whisperer has a fixed 3-second total budget." },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@unblocklabs/unblock-memory",
3
- "version": "0.7.4",
3
+ "version": "0.8.0",
4
4
  "description": "Workspace-native memory for OpenClaw, powered by QMD",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -48,7 +48,7 @@
48
48
  },
49
49
  "dependencies": {
50
50
  "@huggingface/tokenizers": "0.2.0",
51
- "@unblocklabs/qmd": "https://github.com/unblocklabs-ai/qmd/releases/download/v2.11.1/unblocklabs-qmd-2.11.1.tgz",
51
+ "@unblocklabs/qmd": "https://github.com/unblocklabs-ai/qmd/releases/download/v3.0.0/unblocklabs-qmd-3.0.0.tgz",
52
52
  "chokidar": "5.0.0",
53
53
  "js-tiktoken": "1.0.21",
54
54
  "picomatch": "^4.0.5",