@unblocklabs/unblock-memory 0.8.0 → 0.8.2
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/dist/src/contracts.d.ts +3 -0
- package/dist/src/diagnostics.d.ts +31 -0
- package/dist/src/manager.d.ts +31 -0
- package/dist/src/manager.js +55 -29
- package/dist/src/memory-search.d.ts +2 -1
- package/dist/src/memory-search.js +62 -40
- package/dist/src/memory-whisperer.js +4 -3
- package/dist/src/plugin.js +6 -1
- package/dist/src/retrieval-telemetry.d.ts +34 -2
- package/dist/src/retrieval-telemetry.js +9 -2
- package/dist/src/retrieval-timing.d.ts +12 -0
- package/dist/src/retrieval-timing.js +52 -0
- package/dist/src/training-candidates.d.ts +2 -2
- package/dist/src/training-candidates.js +2 -2
- package/docs/retrieval.md +25 -0
- package/openclaw.plugin.json +1 -1
- package/package.json +2 -2
package/dist/src/contracts.d.ts
CHANGED
|
@@ -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;
|
package/dist/src/manager.d.ts
CHANGED
|
@@ -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;
|
package/dist/src/manager.js
CHANGED
|
@@ -18,6 +18,7 @@ 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;
|
|
@@ -304,10 +305,12 @@ export class QmdMemoryManager {
|
|
|
304
305
|
async #refreshSessionMetadata() {
|
|
305
306
|
const sessions = this.#sessions;
|
|
306
307
|
if (!sessions)
|
|
307
|
-
return;
|
|
308
|
+
return false;
|
|
308
309
|
const mtimeNs = await this.#manifestMtimeNs(sessions.manifestPath);
|
|
309
|
-
if (mtimeNs
|
|
310
|
-
|
|
310
|
+
if (mtimeNs === this.#sessionManifestMtimeNs)
|
|
311
|
+
return false;
|
|
312
|
+
await this.#reloadSessionMetadata();
|
|
313
|
+
return true;
|
|
311
314
|
}
|
|
312
315
|
#startWatcher() {
|
|
313
316
|
const paths = [...new Set([...this.#sources.values()]
|
|
@@ -833,34 +836,57 @@ export class QmdMemoryManager {
|
|
|
833
836
|
}
|
|
834
837
|
/** Shared discovery: exact trained recipe, scoped before either retrieval lane. */
|
|
835
838
|
async searchCandidates(queries, opts) {
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
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"]) {
|
|
839
|
+
const { timings, signal } = opts;
|
|
840
|
+
const endQueueWait = startTiming("queueWaitMs", timings, signal);
|
|
841
|
+
try {
|
|
842
|
+
return await this.#enqueue(async () => {
|
|
843
|
+
endQueueWait();
|
|
853
844
|
opts.signal?.throwIfAborted();
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
845
|
+
const collections = this.#collectionNames(opts.corpora);
|
|
846
|
+
if (!collections.length)
|
|
847
|
+
return [];
|
|
848
|
+
const allowedPaths = await timeStage("metadataMs", timings, signal, async () => {
|
|
849
|
+
if (this.#sessions && collections.includes(this.#sessions.collection)) {
|
|
850
|
+
const reloaded = await this.#refreshSessionMetadata();
|
|
851
|
+
if (timings && !signal?.aborted)
|
|
852
|
+
timings.metadataReloads = Number(reloaded);
|
|
853
|
+
}
|
|
854
|
+
const sessions = this.#sessions;
|
|
855
|
+
let allowed = opts.sessionFilter && sessions && collections.includes(sessions.collection)
|
|
856
|
+
? sessionAllowedPaths(this.#sessionMetadata, sessions.collection, opts.sessionFilter) : undefined;
|
|
857
|
+
if (collections.includes(EXTRACTED_COLLECTION)) {
|
|
858
|
+
allowed = { ...allowed, [EXTRACTED_COLLECTION]: this.#currentExtracted(undefined, opts.sessionFilter).map(extractedPath) };
|
|
859
|
+
}
|
|
860
|
+
return allowed;
|
|
861
|
+
});
|
|
862
|
+
const store = await timeStage("storeSetupMs", timings, signal, () => this.#getAnalysisStore());
|
|
863
|
+
const hits = new Map();
|
|
864
|
+
// Serialize native QMD work. Cancellation prevents further queries/collections.
|
|
865
|
+
for (const lane of ["lex", "vec"]) {
|
|
866
|
+
opts.signal?.throwIfAborted();
|
|
867
|
+
let candidates;
|
|
868
|
+
try {
|
|
869
|
+
candidates = await timeStage(lane === "lex" ? "bm25Ms" : "vectorMs", timings, signal, () => trainingCandidates(store, queries[lane], collections, lane, opts.signal, allowedPaths, lane === "vec" ? vectorTrace(timings, signal) : undefined));
|
|
870
|
+
}
|
|
871
|
+
finally {
|
|
872
|
+
if (lane === "vec" && timings?.vectorMs !== undefined) {
|
|
873
|
+
timings.vectorOtherMs = unaccounted(timings.vectorMs, timings, vectorParts);
|
|
874
|
+
}
|
|
875
|
+
}
|
|
876
|
+
for (const hit of candidates) {
|
|
877
|
+
const key = JSON.stringify([hit.file, hit.bestChunkPos, hit.bestChunk]);
|
|
878
|
+
if (!hits.has(key))
|
|
879
|
+
hits.set(key, { file: hit.file, body: hit.body, bestChunk: hit.bestChunk,
|
|
880
|
+
chunkPos: hit.bestChunkPos, chunkLen: hit.bestChunk.length, displayPath: hit.file, score: hit.score });
|
|
881
|
+
}
|
|
859
882
|
}
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
}
|
|
883
|
+
opts.signal?.throwIfAborted();
|
|
884
|
+
return timeStage("passageMs", timings, signal, () => this.#renderSearchHits([...hits.values()], store, opts, true));
|
|
885
|
+
});
|
|
886
|
+
}
|
|
887
|
+
finally {
|
|
888
|
+
endQueueWait();
|
|
889
|
+
}
|
|
864
890
|
}
|
|
865
891
|
async #renderSearchHits(hits, store, opts, whisperer = false) {
|
|
866
892
|
const tokenizer = store.internal?.llm;
|
|
@@ -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
|
|
13
|
-
const
|
|
14
|
-
|
|
15
|
-
|
|
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:
|
|
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
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
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
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
const
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
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
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
73
|
+
}
|
|
74
|
+
}))).flat();
|
|
75
|
+
}
|
|
76
|
+
finally {
|
|
77
|
+
endJudge();
|
|
78
|
+
}
|
|
57
79
|
signal?.throwIfAborted();
|
|
58
|
-
observation.judgeMs =
|
|
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;
|
package/dist/src/plugin.js
CHANGED
|
@@ -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
|
-
|
|
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
|
package/openclaw.plugin.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"id": "unblock-memory",
|
|
3
3
|
"name": "Unblock Memory",
|
|
4
|
-
"version": "0.8.
|
|
4
|
+
"version": "0.8.2",
|
|
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.
|
|
3
|
+
"version": "0.8.2",
|
|
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": "
|
|
51
|
+
"@unblocklabs/qmd": "3.0.0",
|
|
52
52
|
"chokidar": "5.0.0",
|
|
53
53
|
"js-tiktoken": "1.0.21",
|
|
54
54
|
"picomatch": "^4.0.5",
|