@unblocklabs/unblock-memory 0.7.4 → 0.8.1
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 +10 -10
- package/dist/src/contracts.d.ts +1 -1
- package/dist/src/diagnostics.d.ts +5 -1
- package/dist/src/diagnostics.js +3 -0
- package/dist/src/manager.d.ts +3 -2
- package/dist/src/manager.js +9 -3
- package/dist/src/memory-search.d.ts +31 -0
- package/dist/src/memory-search.js +61 -0
- package/dist/src/memory-whisperer.d.ts +2 -6
- package/dist/src/memory-whisperer.js +39 -73
- package/dist/src/plugin.js +72 -34
- package/dist/src/response-episodes.d.ts +1 -1
- package/dist/src/response-episodes.js +1 -1
- package/dist/src/response-text.js +13 -0
- package/dist/src/retrieval-telemetry.d.ts +3 -2
- package/dist/src/review-tools.js +1 -1
- package/dist/src/training-candidates.d.ts +2 -2
- package/dist/src/training-candidates.js +2 -2
- package/docs/configuration.md +3 -2
- package/docs/retrieval.md +49 -55
- package/openclaw.plugin.json +2 -2
- package/package.json +2 -2
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
|
-
|
|
6
|
-
|
|
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) |
|
|
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
|
-
**
|
|
56
|
-
|
|
57
|
-
|
|
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
|
|
77
|
-
|
|
78
|
-
|
|
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
|
|
package/dist/src/contracts.d.ts
CHANGED
|
@@ -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
|
|
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;
|
package/dist/src/diagnostics.js
CHANGED
|
@@ -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 },
|
package/dist/src/manager.d.ts
CHANGED
|
@@ -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
|
-
/**
|
|
223
|
-
|
|
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;
|
package/dist/src/manager.js
CHANGED
|
@@ -831,8 +831,8 @@ export class QmdMemoryManager {
|
|
|
831
831
|
opts?.signal?.throwIfAborted();
|
|
832
832
|
return this.#renderSearchHits(hits, store, opts);
|
|
833
833
|
}
|
|
834
|
-
/**
|
|
835
|
-
async
|
|
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
|
|
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 {
|
|
8
|
-
|
|
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 = "
|
|
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
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
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.
|
|
135
|
-
|
|
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 (!
|
|
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 =
|
|
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) {
|
package/dist/src/plugin.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
63
|
-
const
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
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"],
|
|
@@ -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 =
|
|
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;
|
package/dist/src/review-tools.js
CHANGED
|
@@ -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
|
|
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 },
|
package/docs/configuration.md
CHANGED
|
@@ -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
|
-
|
|
|
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;
|
|
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.
|
|
10
|
-
`
|
|
11
|
-
|
|
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
|
-
{ "
|
|
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
|
|
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.
|
|
28
|
-
|
|
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
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
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
|
-
"
|
|
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
|
-
|
|
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
|
|
319
|
-
|
|
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.
|
|
326
|
-
|
|
327
|
-
|
|
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;
|
package/openclaw.plugin.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"id": "unblock-memory",
|
|
3
3
|
"name": "Unblock Memory",
|
|
4
|
-
"version": "0.
|
|
4
|
+
"version": "0.8.1",
|
|
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.
|
|
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.
|
|
3
|
+
"version": "0.8.1",
|
|
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",
|