@unblocklabs/unblock-memory 0.8.1 → 0.8.3

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.
@@ -2,6 +2,7 @@ import type { MemoryPluginCapability } from "openclaw/plugin-sdk/memory-host-cor
2
2
  import type { OpenClawPluginToolContext } from "openclaw/plugin-sdk/plugin-entry";
3
3
  import type { SessionMetadata, SessionSnippetMessage } from "./session-projector.js";
4
4
  import type { ChatType } from "./config.js";
5
+ import type { RetrievalTimings } from "./retrieval-timing.js";
5
6
  export type MemoryPluginRuntimeContract = NonNullable<MemoryPluginCapability["runtime"]>;
6
7
  type ManagerLookup = Awaited<ReturnType<MemoryPluginRuntimeContract["getMemorySearchManager"]>>;
7
8
  export type MemorySearchManagerContract = NonNullable<ManagerLookup["manager"]>;
@@ -35,6 +36,8 @@ export type CorpusSearchOptions = Omit<NonNullable<Parameters<MemorySearchManage
35
36
  maxSnippetChars?: number;
36
37
  sessionFilter?: SessionSearchFilter;
37
38
  requestContext?: MemoryRequestContext;
39
+ /** Internal, per-call numeric timing sink; never part of tool input/output. */
40
+ timings?: RetrievalTimings;
38
41
  };
39
42
  export type MemoryEmbeddingProbeResult = Awaited<ReturnType<MemorySearchManagerContract["probeEmbeddingAvailability"]>>;
40
43
  export type MemorySyncParams = Parameters<NonNullable<MemorySearchManagerContract["sync"]>>[0];
@@ -59,6 +59,37 @@ export declare class WhispererDiagnostics {
59
59
  empty?: number | undefined;
60
60
  timed_out?: number | undefined;
61
61
  };
62
+ slowestRecent: {
63
+ outcome: "ok" | "cancelled" | "partial" | "skipped" | "failed" | "empty" | "timed_out";
64
+ setupMs?: number | undefined;
65
+ queueWaitMs?: number | undefined;
66
+ metadataMs?: number | undefined;
67
+ storeSetupMs?: number | undefined;
68
+ bm25Ms?: number | undefined;
69
+ vectorMs?: number | undefined;
70
+ vectorIndexCheckMs?: number | undefined;
71
+ vectorScopeMs?: number | undefined;
72
+ embeddingMs?: number | undefined;
73
+ vectorScanMs?: number | undefined;
74
+ hydrationMs?: number | undefined;
75
+ vectorMappingMs?: number | undefined;
76
+ vectorOtherMs?: number | undefined;
77
+ passageMs?: number | undefined;
78
+ candidatePrepMs?: number | undefined;
79
+ retrievalOtherMs?: number | undefined;
80
+ otherMs?: number | undefined;
81
+ retrievalMs?: number | undefined;
82
+ judgeMs?: number | undefined;
83
+ metadataReloads?: number | undefined;
84
+ vectorScanAttempts?: number | undefined;
85
+ elapsedMs?: number | undefined;
86
+ gateMs?: number | undefined;
87
+ generationMs?: number | undefined;
88
+ candidates?: number | undefined;
89
+ eligible?: number | undefined;
90
+ results?: number | undefined;
91
+ contextChars?: number | undefined;
92
+ }[];
62
93
  measurements: {
63
94
  [k: string]: {
64
95
  total: number;
@@ -62,6 +62,37 @@ export declare class QmdMemoryManager implements MemorySearchManagerContract {
62
62
  empty?: number | undefined;
63
63
  timed_out?: number | undefined;
64
64
  };
65
+ slowestRecent: {
66
+ outcome: "ok" | "cancelled" | "partial" | "skipped" | "failed" | "empty" | "timed_out";
67
+ setupMs?: number | undefined;
68
+ queueWaitMs?: number | undefined;
69
+ metadataMs?: number | undefined;
70
+ storeSetupMs?: number | undefined;
71
+ bm25Ms?: number | undefined;
72
+ vectorMs?: number | undefined;
73
+ vectorIndexCheckMs?: number | undefined;
74
+ vectorScopeMs?: number | undefined;
75
+ embeddingMs?: number | undefined;
76
+ vectorScanMs?: number | undefined;
77
+ hydrationMs?: number | undefined;
78
+ vectorMappingMs?: number | undefined;
79
+ vectorOtherMs?: number | undefined;
80
+ passageMs?: number | undefined;
81
+ candidatePrepMs?: number | undefined;
82
+ retrievalOtherMs?: number | undefined;
83
+ otherMs?: number | undefined;
84
+ retrievalMs?: number | undefined;
85
+ judgeMs?: number | undefined;
86
+ metadataReloads?: number | undefined;
87
+ vectorScanAttempts?: number | undefined;
88
+ elapsedMs?: number | undefined;
89
+ gateMs?: number | undefined;
90
+ generationMs?: number | undefined;
91
+ candidates?: number | undefined;
92
+ eligible?: number | undefined;
93
+ results?: number | undefined;
94
+ contextChars?: number | undefined;
95
+ }[];
65
96
  measurements: {
66
97
  [k: string]: {
67
98
  total: number;
@@ -18,11 +18,11 @@ import { abortable } from "./abortable.js";
18
18
  import { RetrievalTelemetry } from "./retrieval-telemetry.js";
19
19
  import { trainingCandidates } from "./training-candidates.js";
20
20
  import { expandSessionSearchHit, renderMemoryPassage } from "./memory-passage.js";
21
+ import { startTiming, timeStage, vectorTrace, vectorParts, unaccounted } from "./retrieval-timing.js";
21
22
  export { expandSessionSearchHit } from "./memory-passage.js";
22
23
  const DEFAULT_READ_LINES = 120;
23
24
  const MAX_READ_CHARS = 12_000;
24
25
  const WATCH_DEBOUNCE_MS = 250;
25
- const qmdModule = import("@unblocklabs/qmd");
26
26
  function readSkillDocuments(source) {
27
27
  const documents = [];
28
28
  const visitedDirectories = new Set();
@@ -304,10 +304,12 @@ export class QmdMemoryManager {
304
304
  async #refreshSessionMetadata() {
305
305
  const sessions = this.#sessions;
306
306
  if (!sessions)
307
- return;
307
+ return false;
308
308
  const mtimeNs = await this.#manifestMtimeNs(sessions.manifestPath);
309
- if (mtimeNs !== this.#sessionManifestMtimeNs)
310
- await this.#reloadSessionMetadata();
309
+ if (mtimeNs === this.#sessionManifestMtimeNs)
310
+ return false;
311
+ await this.#reloadSessionMetadata();
312
+ return true;
311
313
  }
312
314
  #startWatcher() {
313
315
  const paths = [...new Set([...this.#sources.values()]
@@ -362,7 +364,7 @@ export class QmdMemoryManager {
362
364
  }
363
365
  return this.#store;
364
366
  }
365
- const { createStore } = await qmdModule;
367
+ const { createStore } = await import("@unblocklabs/qmd");
366
368
  const store = await createStore({
367
369
  dbPath: this.#dbPath,
368
370
  keepModelsWarm: this.#keepModelsWarm,
@@ -833,34 +835,57 @@ export class QmdMemoryManager {
833
835
  }
834
836
  /** Shared discovery: exact trained recipe, scoped before either retrieval lane. */
835
837
  async searchCandidates(queries, opts) {
836
- return this.#enqueue(async () => {
837
- opts.signal?.throwIfAborted();
838
- const collections = this.#collectionNames(opts.corpora);
839
- if (!collections.length)
840
- return [];
841
- if (this.#sessions && collections.includes(this.#sessions.collection))
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
- }
849
- const store = await this.#getAnalysisStore();
850
- const hits = new Map();
851
- // Serialize native QMD work. Cancellation prevents further queries/collections.
852
- for (const lane of ["lex", "vec"]) {
838
+ const { timings, signal } = opts;
839
+ const endQueueWait = startTiming("queueWaitMs", timings, signal);
840
+ try {
841
+ return await this.#enqueue(async () => {
842
+ endQueueWait();
853
843
  opts.signal?.throwIfAborted();
854
- for (const hit of await trainingCandidates(store, queries[lane], collections, lane, opts.signal, allowedPaths)) {
855
- const key = JSON.stringify([hit.file, hit.bestChunkPos, hit.bestChunk]);
856
- if (!hits.has(key))
857
- hits.set(key, { file: hit.file, body: hit.body, bestChunk: hit.bestChunk,
858
- chunkPos: hit.bestChunkPos, chunkLen: hit.bestChunk.length, displayPath: hit.file, score: hit.score });
844
+ const collections = this.#collectionNames(opts.corpora);
845
+ if (!collections.length)
846
+ return [];
847
+ const allowedPaths = await timeStage("metadataMs", timings, signal, async () => {
848
+ if (this.#sessions && collections.includes(this.#sessions.collection)) {
849
+ const reloaded = await this.#refreshSessionMetadata();
850
+ if (timings && !signal?.aborted)
851
+ timings.metadataReloads = Number(reloaded);
852
+ }
853
+ const sessions = this.#sessions;
854
+ let allowed = opts.sessionFilter && sessions && collections.includes(sessions.collection)
855
+ ? sessionAllowedPaths(this.#sessionMetadata, sessions.collection, opts.sessionFilter) : undefined;
856
+ if (collections.includes(EXTRACTED_COLLECTION)) {
857
+ allowed = { ...allowed, [EXTRACTED_COLLECTION]: this.#currentExtracted(undefined, opts.sessionFilter).map(extractedPath) };
858
+ }
859
+ return allowed;
860
+ });
861
+ const store = await timeStage("storeSetupMs", timings, signal, () => this.#getAnalysisStore());
862
+ const hits = new Map();
863
+ // Serialize native QMD work. Cancellation prevents further queries/collections.
864
+ for (const lane of ["lex", "vec"]) {
865
+ opts.signal?.throwIfAborted();
866
+ let candidates;
867
+ try {
868
+ candidates = await timeStage(lane === "lex" ? "bm25Ms" : "vectorMs", timings, signal, () => trainingCandidates(store, queries[lane], collections, lane, opts.signal, allowedPaths, lane === "vec" ? vectorTrace(timings, signal) : undefined));
869
+ }
870
+ finally {
871
+ if (lane === "vec" && timings?.vectorMs !== undefined) {
872
+ timings.vectorOtherMs = unaccounted(timings.vectorMs, timings, vectorParts);
873
+ }
874
+ }
875
+ for (const hit of candidates) {
876
+ const key = JSON.stringify([hit.file, hit.bestChunkPos, hit.bestChunk]);
877
+ if (!hits.has(key))
878
+ hits.set(key, { file: hit.file, body: hit.body, bestChunk: hit.bestChunk,
879
+ chunkPos: hit.bestChunkPos, chunkLen: hit.bestChunk.length, displayPath: hit.file, score: hit.score });
880
+ }
859
881
  }
860
- }
861
- opts.signal?.throwIfAborted();
862
- return this.#renderSearchHits([...hits.values()], store, opts, true);
863
- });
882
+ opts.signal?.throwIfAborted();
883
+ return timeStage("passageMs", timings, signal, () => this.#renderSearchHits([...hits.values()], store, opts, true));
884
+ });
885
+ }
886
+ finally {
887
+ endQueueWait();
888
+ }
864
889
  }
865
890
  async #renderSearchHits(hits, store, opts, whisperer = false) {
866
891
  const tokenizer = store.internal?.llm;
@@ -932,7 +957,7 @@ export class QmdMemoryManager {
932
957
  if (!store.internal?.llm)
933
958
  throw new Error("Skill Whisperer requires the QMD embedding model");
934
959
  const llm = store.internal.llm;
935
- const { formatDocForEmbedding, formatQueryForEmbedding } = await qmdModule;
960
+ const { formatDocForEmbedding, formatQueryForEmbedding } = await import("@unblocklabs/qmd");
936
961
  let skillIndex = this.#skillIndex;
937
962
  if (!skillIndex) {
938
963
  const pending = (async () => {
@@ -1,10 +1,11 @@
1
1
  import type { CorpusMemorySearchResult, CorpusSearchOptions } from "./contracts.js";
2
2
  import type { QueryConversation, QueryPair } from "./query-contract.js";
3
+ import { type RetrievalTimings } from "./retrieval-timing.js";
3
4
  export type MemoryCandidateManager = {
4
5
  searchCandidates(queries: QueryPair, options: CorpusSearchOptions): Promise<CorpusMemorySearchResult[]>;
5
6
  };
6
7
  export declare function memoryPassageId(text: string): string;
7
- export type MemorySearchObservation = {
8
+ export type MemorySearchObservation = RetrievalTimings & {
8
9
  retrievalMs: number;
9
10
  judgeMs: number;
10
11
  candidates: number;
@@ -1,6 +1,7 @@
1
1
  import { createHash } from "node:crypto";
2
2
  import { MEMORY_PASSAGE_CHARS, duplicateMemoryPassage } from "./memory-passage.js";
3
3
  import { judgeMemoryPassage, memoryUsefulnessRequest } from "./typesafe.js";
4
+ import { startTiming, retrievalParts, unaccounted } from "./retrieval-timing.js";
4
5
  export function memoryPassageId(text) {
5
6
  return createHash("sha256").update(text.replace(/\s+/gu, " ").trim()).digest("hex");
6
7
  }
@@ -9,53 +10,74 @@ export async function searchMemory(manager, queries, options) {
9
10
  const { signal } = options;
10
11
  signal?.throwIfAborted();
11
12
  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
- });
13
+ const timings = options.timings ?? {};
14
+ const endRetrieval = startTiming("retrievalMs", timings, signal);
15
+ let hits;
16
+ try {
17
+ hits = await manager.searchCandidates(queries, {
18
+ corpora: options.corpora, sessionFilter: options.sessionFilter, requestContext: options.requestContext,
19
+ sources: options.sources, signal, maxSnippetChars: MEMORY_PASSAGE_CHARS, timings,
20
+ });
21
+ }
22
+ finally {
23
+ endRetrieval();
24
+ if (timings.retrievalMs !== undefined)
25
+ timings.retrievalOtherMs = unaccounted(timings.retrievalMs, timings, retrievalParts);
26
+ }
17
27
  signal?.throwIfAborted();
18
- const observation = { retrievalMs: performance.now() - started, judgeMs: 0,
28
+ const observation = { retrievalMs: timings.retrievalMs ?? 0, judgeMs: 0,
19
29
  candidates: hits.length, eligible: 0, requestsSucceeded: 0, requestsFailed: 0 };
20
30
  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 });
31
+ const endCandidatePrep = startTiming("candidatePrepMs", timings, signal);
32
+ try {
33
+ for (const hit of hits) {
34
+ // Defense in depth before disclosing passages to the external judge.
35
+ if (options.corpora && !options.corpora.includes("all") && !options.corpora.includes(hit.corpus))
36
+ continue;
37
+ const excerpt = hit.snippet.trim();
38
+ if (!excerpt || excerpt.length > MEMORY_PASSAGE_CHARS || options.excludedPassages?.has(memoryPassageId(excerpt)) ||
39
+ duplicateMemoryPassage({ ...hit, text: excerpt }, candidates.map(candidate => ({ ...candidate, text: candidate.snippet }))))
40
+ continue;
41
+ candidates.push({ ...hit, snippet: excerpt });
42
+ }
43
+ }
44
+ finally {
45
+ endCandidatePrep();
30
46
  }
31
47
  observation.eligible = candidates.length;
32
48
  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)
49
+ const endJudge = startTiming("judgeMs", timings, signal);
50
+ let judged;
51
+ try {
52
+ judged = (await Promise.all(candidates.map(async (hit, candidateIndex) => {
53
+ const requestStarted = performance.now();
54
+ try {
55
+ const { probability } = await judgeMemoryPassage(memoryUsefulnessRequest(options.conversation, {
56
+ excerpt: hit.snippet, corpus: hit.corpus, sourcePath: hit.path,
57
+ dates: [...new Set(hit.sessionMessages?.flatMap(message => message.timestamp ? [message.timestamp] : [])
58
+ ?? (hit.messageTimestamp ? [hit.messageTimestamp] : []))],
59
+ }, asOf), options);
60
+ signal?.throwIfAborted();
61
+ observation.requestsSucceeded++;
62
+ options.onJudgment?.({ candidateIndex, elapsedMs: performance.now() - requestStarted });
63
+ // Discovery reciprocal ranks are not vector similarity or usefulness.
64
+ const { vectorScore: _vectorScore, textScore: _textScore, ...result } = hit;
65
+ return [{ ...result, score: probability }];
66
+ }
67
+ catch (error) {
68
+ if (signal?.aborted)
69
+ return [];
70
+ observation.requestsFailed++;
71
+ options.onJudgment?.({ candidateIndex, elapsedMs: performance.now() - requestStarted, error });
51
72
  return [];
52
- observation.requestsFailed++;
53
- options.onJudgment?.({ candidateIndex, elapsedMs: performance.now() - requestStarted, error });
54
- return [];
55
- }
56
- }))).flat();
73
+ }
74
+ }))).flat();
75
+ }
76
+ finally {
77
+ endJudge();
78
+ }
57
79
  signal?.throwIfAborted();
58
- observation.judgeMs = performance.now() - judgeStarted;
59
- return { ...observation, results: judged.filter(hit => hit.score >= (options.minUsefulness ?? 0.7))
80
+ observation.judgeMs = timings.judgeMs ?? 0;
81
+ return { ...timings, ...observation, results: judged.filter(hit => hit.score >= (options.minUsefulness ?? 0.7))
60
82
  .sort((a, b) => b.score - a.score).slice(0, options.maxResults ?? 5) };
61
83
  }
@@ -4,6 +4,7 @@ import { ApiQueryGenerator, QueryApiError, queryConversation } from "./query-gen
4
4
  import { judgeTrainingInput, TRAINING_GATE_THRESHOLD } from "./training-gate.js";
5
5
  import { QueryInputBudgetError } from "./query-contract.js";
6
6
  import { searchMemory, memoryPassageId } from "./memory-search.js";
7
+ import { timeStage } from "./retrieval-timing.js";
7
8
  export function registerMemoryWhisperer(api, runtime, config, typesafe, diagnostics) {
8
9
  if (!config.enabled || !typesafe.enabled)
9
10
  return;
@@ -53,7 +54,7 @@ export function registerMemoryWhisperer(api, runtime, config, typesafe, diagnost
53
54
  const run = async () => {
54
55
  const conversation = queryConversation(event.prompt, event.messages);
55
56
  stage = "credentials";
56
- const apiKey = await resolveTypeSafeApiKey(typesafe);
57
+ const apiKey = await timeStage("setupMs", measurement, signal, () => resolveTypeSafeApiKey(typesafe));
57
58
  if (signal.aborted)
58
59
  return;
59
60
  if (!apiKey) {
@@ -112,7 +113,7 @@ export function registerMemoryWhisperer(api, runtime, config, typesafe, diagnost
112
113
  measurement.generationMs = performance.now() - generationStarted;
113
114
  }
114
115
  stage = "retrieval";
115
- const { manager } = await runtime.getMemorySearchManager({ cfg: api.config, agentId });
116
+ const { manager } = await timeStage("setupMs", measurement, signal, () => runtime.getMemorySearchManager({ cfg: api.config, agentId }));
116
117
  if (signal.aborted)
117
118
  return;
118
119
  if (!manager) {
@@ -121,7 +122,7 @@ export function registerMemoryWhisperer(api, runtime, config, typesafe, diagnost
121
122
  return;
122
123
  }
123
124
  const search = await searchMemory(manager, queries, {
124
- corpora, signal, apiKey, timeoutMs: typesafe.timeoutMs, conversation, asOf,
125
+ corpora, signal, apiKey, timeoutMs: typesafe.timeoutMs, conversation, asOf, timings: measurement,
125
126
  minUsefulness: config.minUsefulness, maxResults: 4, excludedPassages: state.recent,
126
127
  onCandidates: observation => {
127
128
  measurement.retrievalMs = observation.retrievalMs;
@@ -78,6 +78,7 @@ function createSearchTool(runtime, ctx, typesafe, diagnostics) {
78
78
  return jsonResult({ results: [], error: "TypeSafe API key not configured or TypeSafe disabled" });
79
79
  }
80
80
  const { manager, error } = await runtime.getMemorySearchManager(active);
81
+ measurement.setupMs = performance.now() - started;
81
82
  if (!manager)
82
83
  return jsonResult({ results: [], error: error ?? "memory unavailable" });
83
84
  const search = await searchMemory(manager, queries, {
@@ -86,7 +87,7 @@ function createSearchTool(runtime, ctx, typesafe, diagnostics) {
86
87
  maxResults,
87
88
  minUsefulness,
88
89
  apiKey, timeoutMs: typesafe.timeoutMs, conversation,
89
- signal,
90
+ signal, timings: measurement,
90
91
  requestContext: active.requestContext,
91
92
  onCandidates: ({ retrievalMs, candidates, eligible }) => {
92
93
  Object.assign(measurement, { retrievalMs, candidates, eligible });
@@ -121,6 +122,10 @@ function createSearchTool(runtime, ctx, typesafe, diagnostics) {
121
122
  }
122
123
  finally {
123
124
  measurement.elapsedMs = performance.now() - started;
125
+ measurement.setupMs ??= measurement.elapsedMs;
126
+ // Manual search is sequential. Whisperer stages overlap and cannot use this subtraction.
127
+ measurement.otherMs = Math.max(0, measurement.elapsedMs - measurement.setupMs -
128
+ (measurement.retrievalMs ?? 0) - (measurement.candidatePrepMs ?? 0) - (measurement.judgeMs ?? 0));
124
129
  if (signal?.aborted)
125
130
  measurement.outcome = signal.reason instanceof Error && signal.reason.name === "TimeoutError"
126
131
  ? "timed_out" : "cancelled";
@@ -1,11 +1,12 @@
1
+ import { type RetrievalTimings } from "./retrieval-timing.js";
1
2
  type RetrievalOperation = "vector" | "memoryWhisperer" | "memorySearch";
2
3
  type RetrievalOutcome = "ok" | "partial" | "empty" | "failed" | "cancelled" | "timed_out" | "skipped";
3
- declare const fields: readonly ["elapsedMs", "retrievalMs", "judgeMs", "gateMs", "generationMs", "candidates", "eligible", "results", "contextChars"];
4
+ declare const fields: readonly ["elapsedMs", "retrievalMs", "judgeMs", "gateMs", "generationMs", "setupMs", "queueWaitMs", "metadataMs", "storeSetupMs", "bm25Ms", "vectorMs", "vectorIndexCheckMs", "vectorScopeMs", "embeddingMs", "vectorScanMs", "hydrationMs", "vectorMappingMs", "vectorOtherMs", "passageMs", "candidatePrepMs", "retrievalOtherMs", "otherMs", "metadataReloads", "vectorScanAttempts", "candidates", "eligible", "results", "contextChars"];
4
5
  type Field = typeof fields[number];
5
6
  export type RetrievalObservation = {
6
7
  outcome: RetrievalOutcome;
7
8
  elapsedMs: number;
8
- } & Partial<Record<Field, number>>;
9
+ } & RetrievalTimings & Partial<Record<Field, number>>;
9
10
  /** Only fixed operation/outcome names and nonnegative numbers enter this store. */
10
11
  export declare class RetrievalTelemetry {
11
12
  #private;
@@ -24,6 +25,37 @@ export declare class RetrievalTelemetry {
24
25
  empty?: number | undefined;
25
26
  timed_out?: number | undefined;
26
27
  };
28
+ slowestRecent: {
29
+ outcome: RetrievalOutcome;
30
+ setupMs?: number | undefined;
31
+ queueWaitMs?: number | undefined;
32
+ metadataMs?: number | undefined;
33
+ storeSetupMs?: number | undefined;
34
+ bm25Ms?: number | undefined;
35
+ vectorMs?: number | undefined;
36
+ vectorIndexCheckMs?: number | undefined;
37
+ vectorScopeMs?: number | undefined;
38
+ embeddingMs?: number | undefined;
39
+ vectorScanMs?: number | undefined;
40
+ hydrationMs?: number | undefined;
41
+ vectorMappingMs?: number | undefined;
42
+ vectorOtherMs?: number | undefined;
43
+ passageMs?: number | undefined;
44
+ candidatePrepMs?: number | undefined;
45
+ retrievalOtherMs?: number | undefined;
46
+ otherMs?: number | undefined;
47
+ retrievalMs?: number | undefined;
48
+ judgeMs?: number | undefined;
49
+ metadataReloads?: number | undefined;
50
+ vectorScanAttempts?: number | undefined;
51
+ elapsedMs?: number | undefined;
52
+ gateMs?: number | undefined;
53
+ generationMs?: number | undefined;
54
+ candidates?: number | undefined;
55
+ eligible?: number | undefined;
56
+ results?: number | undefined;
57
+ contextChars?: number | undefined;
58
+ }[];
27
59
  measurements: {
28
60
  [k: string]: {
29
61
  total: number;
@@ -1,15 +1,20 @@
1
- const fields = ["elapsedMs", "retrievalMs", "judgeMs", "gateMs", "generationMs", "candidates", "eligible", "results", "contextChars"];
1
+ import { timingFields } from "./retrieval-timing.js";
2
+ const fields = ["elapsedMs", "retrievalMs", "judgeMs", "gateMs", "generationMs", ...timingFields,
3
+ "metadataReloads", "vectorScanAttempts", "candidates", "eligible", "results", "contextChars"];
2
4
  const recentLimit = 256;
5
+ const outcomes = ["ok", "partial", "empty", "failed", "cancelled", "timed_out", "skipped"];
3
6
  const boundedAdd = (left, right) => Math.min(Number.MAX_SAFE_INTEGER, left + right);
4
7
  /** Only fixed operation/outcome names and nonnegative numbers enter this store. */
5
8
  export class RetrievalTelemetry {
6
9
  #entries = new Map();
7
10
  record(operation, observation) {
11
+ if (!["vector", "memoryWhisperer", "memorySearch"].includes(operation) || !outcomes.includes(observation.outcome))
12
+ return;
8
13
  const entry = this.#entries.get(operation) ?? { calls: 0, outcomes: {}, totals: {}, recent: [] };
9
14
  this.#entries.set(operation, entry);
10
15
  entry.calls = boundedAdd(entry.calls, 1);
11
16
  entry.outcomes[observation.outcome] = boundedAdd(entry.outcomes[observation.outcome] ?? 0, 1);
12
- const sample = {};
17
+ const sample = { outcome: observation.outcome };
13
18
  for (const field of fields) {
14
19
  const value = observation[field];
15
20
  if (value === undefined || !Number.isFinite(value) || value < 0)
@@ -28,6 +33,8 @@ export class RetrievalTelemetry {
28
33
  operations: Object.fromEntries([...this.#entries].map(([operation, entry]) => [operation, {
29
34
  calls: entry.calls,
30
35
  outcomes: { ...entry.outcomes },
36
+ slowestRecent: entry.recent.filter(sample => sample.elapsedMs !== undefined)
37
+ .toSorted((a, b) => b.elapsedMs - a.elapsedMs).slice(0, 5).map(sample => ({ ...sample })),
31
38
  measurements: Object.fromEntries(fields.map(field => {
32
39
  const values = entry.recent.flatMap(sample => sample[field] === undefined ? [] : [sample[field]]).sort((a, b) => a - b);
33
40
  const percentile = (fraction) => values.length ? values[Math.ceil(values.length * fraction) - 1] : null;
@@ -0,0 +1,12 @@
1
+ import type { VectorSearchTrace } from "@unblocklabs/qmd";
2
+ export declare const timingFields: readonly ["setupMs", "queueWaitMs", "metadataMs", "storeSetupMs", "bm25Ms", "vectorMs", "vectorIndexCheckMs", "vectorScopeMs", "embeddingMs", "vectorScanMs", "hydrationMs", "vectorMappingMs", "vectorOtherMs", "passageMs", "candidatePrepMs", "retrievalOtherMs", "otherMs"];
3
+ type TimingField = typeof timingFields[number] | "retrievalMs" | "judgeMs";
4
+ export type RetrievalTimings = Partial<Record<TimingField | "metadataReloads" | "vectorScanAttempts", number>>;
5
+ /** Freeze in-flight duration at abort, without changing the work's cancellation behavior. */
6
+ export declare function startTiming(field: TimingField, timings?: RetrievalTimings, signal?: AbortSignal): () => void;
7
+ export declare function timeStage<T>(field: TimingField, timings: RetrievalTimings | undefined, signal: AbortSignal | undefined, run: () => Promise<T>): Promise<T>;
8
+ export declare function vectorTrace(timings?: RetrievalTimings, signal?: AbortSignal): VectorSearchTrace | undefined;
9
+ export declare function unaccounted(total: number, timings: RetrievalTimings, fields: readonly TimingField[]): number;
10
+ export declare const vectorParts: ("vectorIndexCheckMs" | "vectorScopeMs" | "embeddingMs" | "vectorScanMs" | "hydrationMs" | "vectorMappingMs")[];
11
+ export declare const retrievalParts: readonly ["queueWaitMs", "metadataMs", "storeSetupMs", "bm25Ms", "vectorMs", "passageMs"];
12
+ export {};
@@ -0,0 +1,52 @@
1
+ export const timingFields = ["setupMs", "queueWaitMs", "metadataMs", "storeSetupMs", "bm25Ms", "vectorMs",
2
+ "vectorIndexCheckMs", "vectorScopeMs", "embeddingMs", "vectorScanMs", "hydrationMs", "vectorMappingMs",
3
+ "vectorOtherMs", "passageMs", "candidatePrepMs", "retrievalOtherMs", "otherMs"];
4
+ /** Freeze in-flight duration at abort, without changing the work's cancellation behavior. */
5
+ export function startTiming(field, timings, signal) {
6
+ if (!timings || signal?.aborted)
7
+ return () => { };
8
+ const started = performance.now();
9
+ const previous = timings[field] ?? 0;
10
+ let ended = false;
11
+ const end = () => {
12
+ if (ended)
13
+ return;
14
+ ended = true;
15
+ timings[field] = previous + performance.now() - started;
16
+ signal?.removeEventListener("abort", end);
17
+ };
18
+ signal?.addEventListener("abort", end, { once: true });
19
+ return end;
20
+ }
21
+ export async function timeStage(field, timings, signal, run) {
22
+ const end = startTiming(field, timings, signal);
23
+ try {
24
+ return await run();
25
+ }
26
+ finally {
27
+ end();
28
+ }
29
+ }
30
+ const vectorFields = {
31
+ index_check: "vectorIndexCheckMs", scope_resolution: "vectorScopeMs", embedding: "embeddingMs",
32
+ vector_scan_knn: "vectorScanMs", hydration: "hydrationMs", result_mapping: "vectorMappingMs",
33
+ };
34
+ export function vectorTrace(timings, signal) {
35
+ if (!timings)
36
+ return;
37
+ return timing => {
38
+ if (signal?.aborted || !Number.isFinite(timing.durationMs) || timing.durationMs < 0)
39
+ return;
40
+ const field = vectorFields[timing.stage];
41
+ if (!field)
42
+ return;
43
+ timings[field] = (timings[field] ?? 0) + timing.durationMs;
44
+ if (timing.stage === "vector_scan_knn")
45
+ timings.vectorScanAttempts = (timings.vectorScanAttempts ?? 0) + 1;
46
+ };
47
+ }
48
+ export function unaccounted(total, timings, fields) {
49
+ return Math.max(0, total - fields.reduce((sum, field) => sum + (timings[field] ?? 0), 0));
50
+ }
51
+ export const vectorParts = Object.values(vectorFields);
52
+ export const retrievalParts = ["queueWaitMs", "metadataMs", "storeSetupMs", "bm25Ms", "vectorMs", "passageMs"];
@@ -1,7 +1,7 @@
1
- import type { AllowedDocumentPaths, QMDStore } from "@unblocklabs/qmd";
1
+ import type { AllowedDocumentPaths, QMDStore, VectorSearchTrace } 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, allowedPaths?: AllowedDocumentPaths): Promise<{
4
+ export declare function trainingCandidates(qmd: QMDStore, query: string, collection: string | string[], lane: QueryLane, signal?: AbortSignal, allowedPaths?: AllowedDocumentPaths, trace?: VectorSearchTrace): 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, allowedPaths) {
2
+ export async function trainingCandidates(qmd, query, collection, lane, signal, allowedPaths, trace) {
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, allowedPaths });
5
+ intent: query, allowedPaths, trace });
6
6
  return hits.map(({ method, rank, score, file, body, bestChunk, bestChunkPos }) => {
7
7
  const retrieval = {
8
8
  [method]: { score, rank: rank + 1 },
package/docs/retrieval.md CHANGED
@@ -80,6 +80,31 @@ judging time, candidate/result counts and outcomes (including partial failures).
80
80
  These counters are separate from automatic `memoryWhisperer` and internal `vector`
81
81
  retrieval; no query or evidence text is recorded.
82
82
 
83
+ Both `memorySearch` and `memoryWhisperer` also report per-call stage measurements:
84
+
85
+ - `setupMs`: credentials and manager initialization (manual search also includes input validation).
86
+ - `queueWaitMs`: waiting for admission behind other index operations.
87
+ - `metadataMs` / `metadataReloads`: session metadata refresh and evidence filtering; reload count is
88
+ present only when the session corpus is selected and refresh completes.
89
+ - `storeSetupMs`: opening or obtaining the QMD store.
90
+ - `bm25Ms` and `vectorMs`: complete sequential candidate-discovery lanes.
91
+ - Inside `vectorMs`, QMD reports `vectorIndexCheckMs`, `vectorScopeMs`, `embeddingMs`,
92
+ `vectorScanMs`, `hydrationMs`, and `vectorMappingMs`. Repeated scan/hydration stages are summed;
93
+ `vectorScanAttempts` counts native scans. Embedding time includes any lazy model/context setup.
94
+ - `passageMs`: rendering merged candidates; `candidatePrepMs`: eligibility checks and deduplication
95
+ before disclosure to TypeSafe. `judgeMs` covers the concurrent candidate judgments, not their sum.
96
+ - `vectorOtherMs` and `retrievalOtherMs` expose time outside measured child stages. `otherMs` accounts
97
+ for the remaining manual tool execution. Whisperer stages overlap, so no additive whole-run
98
+ residual is reported for it. Parent durations must not be added to their child durations.
99
+
100
+ Each measurement includes lifetime totals/sample counts and p50/p95 over up to 256 recent calls.
101
+ `slowestRecent` contains the five slowest calls **within that same window**, each with its outcome
102
+ and numeric breakdown, so stages can be correlated without combining unrelated percentiles.
103
+ With 12 samples, nearest-rank p95 is the largest sample. Whole-tool time excludes Gateway transport.
104
+ Failed/cancelled calls retain completed stages and in-flight durations through cancellation;
105
+ unexecuted stages and unfinished native trace stages are absent, not zero. No query, passage,
106
+ path, credential or session identifier is retained; samples are process-local and reset on recreation.
107
+
83
108
  Corpora and session filters select evidence; they are not audience ACLs. Normal
84
109
  tools can access this agent's configured non-skill corpora, not just its current
85
110
  chat. Only index material suitable for the agent's tool callers. Per-feature
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "id": "unblock-memory",
3
3
  "name": "Unblock Memory",
4
- "version": "0.8.1",
4
+ "version": "0.8.3",
5
5
  "description": "Indexes, retrieves, and analyzes configured workspace memory with existing QMD vectors.",
6
6
  "kind": "memory",
7
7
  "activation": { "onStartup": true },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@unblocklabs/unblock-memory",
3
- "version": "0.8.1",
3
+ "version": "0.8.3",
4
4
  "description": "Workspace-native memory for OpenClaw, powered by QMD",
5
5
  "type": "module",
6
6
  "license": "MIT",