@unblocklabs/unblock-memory 0.3.30 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +3 -3
- package/dist/src/config.d.ts +3 -4
- package/dist/src/config.js +31 -11
- package/dist/src/diagnostics.d.ts +1 -3
- package/dist/src/manager.d.ts +4 -8
- package/dist/src/manager.js +17 -45
- package/dist/src/memory-passage.d.ts +26 -0
- package/dist/src/memory-passage.js +48 -0
- package/dist/src/memory-whisperer.d.ts +2 -1
- package/dist/src/memory-whisperer.js +37 -42
- package/dist/src/query-contract.d.ts +31 -0
- package/dist/src/query-contract.js +58 -0
- package/dist/src/query-generator.d.ts +15 -0
- package/dist/src/query-generator.js +136 -0
- package/dist/src/query-tokenizer/contract.json +10 -0
- package/dist/src/response-stages.d.ts +3 -3
- package/dist/src/training-candidates.d.ts +2 -1
- package/dist/src/training-candidates.js +12 -9
- package/dist/src/training-input.d.ts +13 -10
- package/dist/src/training-input.js +22 -18
- package/dist/src/training-judge.d.ts +16 -46
- package/dist/src/training-judge.js +6 -53
- package/dist/src/training-models.d.ts +15 -7
- package/dist/src/training-models.js +36 -38
- package/dist/src/training-queries.d.ts +24 -7
- package/dist/src/training-queries.js +237 -101
- package/dist/src/training-retrieval.d.ts +9 -2
- package/dist/src/training-retrieval.js +36 -12
- package/dist/src/training-runtime.js +8 -7
- package/dist/src/training-store.d.ts +46 -16
- package/dist/src/training-store.js +85 -29
- package/dist/src/training.d.ts +10 -0
- package/dist/src/training.js +7 -5
- package/dist/src/typesafe.d.ts +58 -8
- package/dist/src/typesafe.js +47 -29
- package/dist/src/whisperer-context.d.ts +0 -8
- package/dist/src/whisperer-context.js +0 -23
- package/docs/configuration.md +5 -3
- package/docs/memory-training.md +120 -58
- package/docs/retrieval.md +59 -43
- package/openclaw.plugin.json +15 -6
- package/package.json +4 -1
- package/src/mlx-query-worker.py +5 -8
- package/src/query-tokenizer/LICENSE +71 -0
- package/src/query-tokenizer/README.md +21 -0
- package/src/query-tokenizer/chat_template.jinja +115 -0
- package/src/query-tokenizer/contract.json +10 -0
- package/src/query-tokenizer/tokenizer.json +323830 -0
- package/src/query-tokenizer/tokenizer_config.json +19 -0
- package/src/query_contract.py +60 -0
- package/dist/src/mlx-query.d.ts +0 -22
- package/dist/src/mlx-query.js +0 -155
package/README.md
CHANGED
|
@@ -41,7 +41,7 @@ Optional installation from source:
|
|
|
41
41
|
| [Search and reads](docs/retrieval.md#search-and-read) | Vector recall, then exact indexed source reads | Available with the memory plugin; files only unless more corpora are configured |
|
|
42
42
|
| [Session indexing](docs/retrieval.md#sessions) | Makes past user/assistant exchanges searchable | Opt-in corpus; channel/group by default, DMs explicitly included |
|
|
43
43
|
| [Skill Whisperer](docs/retrieval.md#skill-whisperer) | Suggests one relevant skill; never invokes it | Off; explicit skills corpus; TypeSafe optional |
|
|
44
|
-
| [Memory Whisperer](docs/retrieval.md#memory-whisperer) | Injects up to two useful historical excerpts | Off; approved corpora + TypeSafe |
|
|
44
|
+
| [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 |
|
|
45
45
|
| [People dossiers](docs/peoplesql.md) | Stores agent-authored, evidence-backed recognition snippets | Off; `people.enabled` |
|
|
46
46
|
| [People Primer](docs/peoplesql.md#optional-people-dossier-primer) | Selects evidence and reviews proposed blurbs; never generates/saves dossiers itself | Off; people + approved corpora + TypeSafe |
|
|
47
47
|
| [People Whisperer](docs/peoplesql.md#injection-and-person-state) | Injects a saved blurb for an exact Slack identity | Off; separate global/per-person gates; no model call |
|
|
@@ -66,8 +66,8 @@ They have separate configuration/index boundaries. See
|
|
|
66
66
|
- [Response audit](docs/response-audit.md): operator commands, cadence, sentiment,
|
|
67
67
|
evidence-linked reports and their limits.
|
|
68
68
|
- [Memory training](docs/memory-training.md): resumable, operator-only conversation
|
|
69
|
-
collection, TypeSafe recall gating,
|
|
70
|
-
|
|
69
|
+
collection, TypeSafe recall gating, independent lex/vector Luna candidates, one
|
|
70
|
+
score-feedback revision, and the runtime's shared passage grader for LFM targets.
|
|
71
71
|
|
|
72
72
|
The shared TypeSafe integration defaults on, but its features are opt-in.
|
|
73
73
|
A key activates only features already enabled. Ordinary search and People
|
package/dist/src/config.d.ts
CHANGED
|
@@ -64,14 +64,13 @@ export type UnblockMemoryConfig = {
|
|
|
64
64
|
enabled: boolean;
|
|
65
65
|
complementaryHints: boolean;
|
|
66
66
|
corpora: readonly string[];
|
|
67
|
-
historyMessages: number;
|
|
68
67
|
minUsefulness: number;
|
|
69
68
|
maxHints: number;
|
|
70
69
|
cooldownTurns: number;
|
|
71
70
|
timeoutMs: number;
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
71
|
+
api: {
|
|
72
|
+
endpoint: string;
|
|
73
|
+
apiKeyFile?: string;
|
|
75
74
|
};
|
|
76
75
|
};
|
|
77
76
|
};
|
package/dist/src/config.js
CHANGED
|
@@ -81,18 +81,43 @@ const DEFAULT_SKILL_WHISPERER = {
|
|
|
81
81
|
cooldownTurns: 10,
|
|
82
82
|
};
|
|
83
83
|
const DEFAULT_MEMORY_WHISPERER = {
|
|
84
|
-
enabled: false, complementaryHints: false, corpora: [],
|
|
84
|
+
enabled: false, complementaryHints: false, corpora: [], minUsefulness: 0.7,
|
|
85
85
|
maxHints: 2, cooldownTurns: 10, timeoutMs: 3000,
|
|
86
|
+
api: { endpoint: "http://192.168.1.191:18087" },
|
|
86
87
|
};
|
|
88
|
+
function resolveQueryApi(value) {
|
|
89
|
+
if (value === undefined)
|
|
90
|
+
return { ...DEFAULT_MEMORY_WHISPERER.api };
|
|
91
|
+
if (!value || typeof value !== "object" || Array.isArray(value))
|
|
92
|
+
throw new Error("memoryWhisperer.api must be an object");
|
|
93
|
+
const config = value;
|
|
94
|
+
assertOnlyKeys(config, ["endpoint", "apiKeyFile"], "memoryWhisperer.api");
|
|
95
|
+
const endpoint = config.endpoint ?? DEFAULT_MEMORY_WHISPERER.api.endpoint;
|
|
96
|
+
let url;
|
|
97
|
+
try {
|
|
98
|
+
url = new URL(typeof endpoint === "string" ? endpoint : "");
|
|
99
|
+
}
|
|
100
|
+
catch {
|
|
101
|
+
throw new Error("memoryWhisperer.api.endpoint must be an HTTP(S) URL");
|
|
102
|
+
}
|
|
103
|
+
if (!["http:", "https:"].includes(url.protocol) || url.username || url.password || url.search || url.hash) {
|
|
104
|
+
throw new Error("memoryWhisperer.api.endpoint must be an HTTP(S) URL without credentials, query or fragment");
|
|
105
|
+
}
|
|
106
|
+
const apiKeyFile = config.apiKeyFile;
|
|
107
|
+
if (apiKeyFile !== undefined && (typeof apiKeyFile !== "string" || !isAbsolute(apiKeyFile))) {
|
|
108
|
+
throw new Error("memoryWhisperer.api.apiKeyFile must be an absolute path");
|
|
109
|
+
}
|
|
110
|
+
return { endpoint: url.href.replace(/\/$/u, ""), ...(apiKeyFile ? { apiKeyFile } : {}) };
|
|
111
|
+
}
|
|
87
112
|
function resolveMemoryWhisperer(value, corpora) {
|
|
88
113
|
if (value === undefined)
|
|
89
|
-
return { ...DEFAULT_MEMORY_WHISPERER };
|
|
114
|
+
return { ...DEFAULT_MEMORY_WHISPERER, api: resolveQueryApi(undefined) };
|
|
90
115
|
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
|
91
116
|
throw new Error("unblock-memory memoryWhisperer must be an object");
|
|
92
117
|
}
|
|
93
118
|
const config = value;
|
|
94
119
|
assertOnlyKeys(config, [...Object.keys(DEFAULT_MEMORY_WHISPERER), "mlx"], "memoryWhisperer");
|
|
95
|
-
|
|
120
|
+
// Accept old fleet configs during migration, but never launch a local worker.
|
|
96
121
|
if (config.mlx !== undefined) {
|
|
97
122
|
if (!config.mlx || typeof config.mlx !== "object" || Array.isArray(config.mlx))
|
|
98
123
|
throw new Error("memoryWhisperer.mlx must be an object");
|
|
@@ -102,7 +127,6 @@ function resolveMemoryWhisperer(value, corpora) {
|
|
|
102
127
|
typeof value.modelPath !== "string" || !isAbsolute(value.modelPath)) {
|
|
103
128
|
throw new Error("memoryWhisperer.mlx requires absolute pythonPath and modelPath");
|
|
104
129
|
}
|
|
105
|
-
mlx = { pythonPath: value.pythonPath, modelPath: value.modelPath };
|
|
106
130
|
}
|
|
107
131
|
const enabled = config.enabled ?? false;
|
|
108
132
|
if (typeof enabled !== "boolean")
|
|
@@ -116,11 +140,7 @@ function resolveMemoryWhisperer(value, corpora) {
|
|
|
116
140
|
}
|
|
117
141
|
if (enabled && !selected.length)
|
|
118
142
|
throw new Error("unblock-memory enabled memoryWhisperer requires explicit corpora");
|
|
119
|
-
const historyMessages = config.historyMessages ?? 5;
|
|
120
143
|
const cooldownTurns = config.cooldownTurns ?? 10;
|
|
121
|
-
if (typeof historyMessages !== "number" || !Number.isInteger(historyMessages) || historyMessages < 0 || historyMessages > 50) {
|
|
122
|
-
throw new Error("unblock-memory memoryWhisperer.historyMessages must be an integer between 0 and 50");
|
|
123
|
-
}
|
|
124
144
|
if (typeof cooldownTurns !== "number" || !Number.isInteger(cooldownTurns) || cooldownTurns < 0 || cooldownTurns > 1000) {
|
|
125
145
|
throw new Error("unblock-memory memoryWhisperer.cooldownTurns must be an integer between 0 and 1000");
|
|
126
146
|
}
|
|
@@ -129,10 +149,10 @@ function resolveMemoryWhisperer(value, corpora) {
|
|
|
129
149
|
throw new Error("unblock-memory memoryWhisperer.minUsefulness must be between 0 and 1");
|
|
130
150
|
}
|
|
131
151
|
return {
|
|
132
|
-
enabled, complementaryHints, corpora: [...new Set(selected)],
|
|
152
|
+
enabled, complementaryHints, corpora: [...new Set(selected)], cooldownTurns, minUsefulness,
|
|
133
153
|
maxHints: positiveInteger(config.maxHints, 2, "memoryWhisperer.maxHints", 2),
|
|
134
154
|
timeoutMs: positiveInteger(config.timeoutMs, 3000, "memoryWhisperer.timeoutMs", 10_000),
|
|
135
|
-
|
|
155
|
+
api: resolveQueryApi(config.api),
|
|
136
156
|
};
|
|
137
157
|
}
|
|
138
158
|
function assertOnlyKeys(value, allowed, label) {
|
|
@@ -281,7 +301,7 @@ export function resolveConfig(value) {
|
|
|
281
301
|
peoplePrimer: resolvePeoplePrimer(undefined, DEFAULT_CORPORA, false),
|
|
282
302
|
people: DEFAULT_PEOPLE_CONFIG,
|
|
283
303
|
skillWhisperer: DEFAULT_SKILL_WHISPERER,
|
|
284
|
-
memoryWhisperer: { ...DEFAULT_MEMORY_WHISPERER },
|
|
304
|
+
memoryWhisperer: { ...DEFAULT_MEMORY_WHISPERER, api: resolveQueryApi(undefined) },
|
|
285
305
|
};
|
|
286
306
|
}
|
|
287
307
|
if (typeof value !== "object" || Array.isArray(value)) {
|
|
@@ -1,6 +1,6 @@
|
|
|
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" | "
|
|
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";
|
|
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;
|
|
@@ -23,7 +23,6 @@ export declare class WhispererDiagnostics {
|
|
|
23
23
|
redundancy_unavailable?: number | undefined;
|
|
24
24
|
recall_not_needed?: number | undefined;
|
|
25
25
|
queries_generated?: number | undefined;
|
|
26
|
-
query_fallback?: number | undefined;
|
|
27
26
|
judge_candidate_failed?: number | undefined;
|
|
28
27
|
};
|
|
29
28
|
memory: {
|
|
@@ -41,7 +40,6 @@ export declare class WhispererDiagnostics {
|
|
|
41
40
|
redundancy_unavailable?: number | undefined;
|
|
42
41
|
recall_not_needed?: number | undefined;
|
|
43
42
|
queries_generated?: number | undefined;
|
|
44
|
-
query_fallback?: number | undefined;
|
|
45
43
|
judge_candidate_failed?: number | undefined;
|
|
46
44
|
};
|
|
47
45
|
telemetry: {
|
package/dist/src/manager.d.ts
CHANGED
|
@@ -1,15 +1,16 @@
|
|
|
1
|
-
import type { QMDStore
|
|
1
|
+
import type { QMDStore } from "@unblocklabs/qmd";
|
|
2
2
|
import { type AnalysisRunner, type MemoryAnalysisSummary, type MemoryClusterDetail, type MemoryClusterList, type MemoryClusterSort, type MemoryReclusterOptions } from "./analysis.js";
|
|
3
3
|
import type { CorpusMemorySearchResult, CorpusSearchOptions, MemoryEmbeddingProbeResult, MemoryProviderStatus, MemoryReadResult, MemoryRequestContext, MemorySearchManagerContract, MemorySyncParams } from "./contracts.js";
|
|
4
4
|
import type { ChatType } from "./config.js";
|
|
5
5
|
import { type MaintenanceStatus, type MaintenanceTask, type TemporalBasis } from "./curation.js";
|
|
6
6
|
import { type SessionSyncResult } from "./session-sync.js";
|
|
7
|
-
import { type SessionMessageSpan } from "./session-projector.js";
|
|
8
7
|
import { type ResolvedSource } from "./sources.js";
|
|
9
8
|
import { type QualityCursor } from "./quality-audit.js";
|
|
10
9
|
import { qualityTaskPresence } from "./quality-triage.js";
|
|
11
10
|
import { reviewIndexedClaim } from "./evidence-review.js";
|
|
12
11
|
import { reviewClusterIngestion } from "./cluster-review.js";
|
|
12
|
+
import type { QueryPair } from "./query-contract.js";
|
|
13
|
+
export { expandSessionSearchHit } from "./memory-passage.js";
|
|
13
14
|
export type ManagerStore = Pick<QMDStore, "update" | "embed" | "getStatus" | "listCollections" | "searchLex" | "vsearch" | "get" | "getDocumentBody" | "close">;
|
|
14
15
|
export type ManagerSessionConfig = {
|
|
15
16
|
agentId: string;
|
|
@@ -37,11 +38,6 @@ export declare function buildReadResult(params: {
|
|
|
37
38
|
from?: number;
|
|
38
39
|
lines?: number;
|
|
39
40
|
}): MemoryReadResult;
|
|
40
|
-
export declare function expandSessionSearchHit(result: Pick<VectorSearchResult, "body" | "bestChunk" | "chunkPos" | "chunkLen">, maxTokens: number, countTokens: (text: string) => Promise<number>, maxChars?: number, messages?: SessionMessageSpan[]): Promise<{
|
|
41
|
-
text: string;
|
|
42
|
-
position: number;
|
|
43
|
-
sourceText?: string;
|
|
44
|
-
}>;
|
|
45
41
|
export declare class QmdMemoryManager implements MemorySearchManagerContract {
|
|
46
42
|
#private;
|
|
47
43
|
diagnostics(): Promise<{
|
|
@@ -221,7 +217,7 @@ export declare class QmdMemoryManager implements MemorySearchManagerContract {
|
|
|
221
217
|
}): MaintenanceTask | undefined;
|
|
222
218
|
search(query: string, opts?: CorpusSearchOptions): Promise<CorpusMemorySearchResult[]>;
|
|
223
219
|
/** Whisperer-only discovery: exact trained recipe, no query-conditioned reranker or merged cap. */
|
|
224
|
-
searchWhisperer(queries:
|
|
220
|
+
searchWhisperer(queries: QueryPair, opts: Pick<CorpusSearchOptions, "corpora" | "signal" | "maxSnippetChars">): Promise<CorpusMemorySearchResult[]>;
|
|
225
221
|
searchSkills(query: string, minScore: number, limit: number): Promise<SkillSearchCandidate[]>;
|
|
226
222
|
readFile(params: {
|
|
227
223
|
relPath: string;
|
package/dist/src/manager.js
CHANGED
|
@@ -4,7 +4,6 @@ import { mkdir, stat } from "node:fs/promises";
|
|
|
4
4
|
import { basename, dirname, relative, resolve, sep } from "node:path";
|
|
5
5
|
import chokidar from "chokidar";
|
|
6
6
|
import picomatch from "picomatch";
|
|
7
|
-
import { meetingRevisionAnnotation, meetingSpeakerSpans } from "./loggie-projection.js";
|
|
8
7
|
import { ensureMemoryAnalysisSchema, latestAnalysisCollections, latestAnalysisRunId, markMemoryAnalysisStale, readAnalysisSummary, readCluster, readClusters, runAnalysisWorker, } from "./analysis.js";
|
|
9
8
|
import { CurationStore, chunkFingerprint, } from "./curation.js";
|
|
10
9
|
import { readSessionManifest, sessionMetadataByPath, syncSessionProjections, PROJECTOR_VERSION, } from "./session-sync.js";
|
|
@@ -17,6 +16,8 @@ import { reviewClusterIngestion } from "./cluster-review.js";
|
|
|
17
16
|
import { abortable } from "./abortable.js";
|
|
18
17
|
import { RetrievalTelemetry } from "./retrieval-telemetry.js";
|
|
19
18
|
import { trainingCandidates } from "./training-candidates.js";
|
|
19
|
+
import { expandSessionSearchHit, renderMemoryPassage } from "./memory-passage.js";
|
|
20
|
+
export { expandSessionSearchHit } from "./memory-passage.js";
|
|
20
21
|
const DEFAULT_READ_LINES = 120;
|
|
21
22
|
const MAX_READ_CHARS = 12_000;
|
|
22
23
|
const WATCH_DEBOUNCE_MS = 250;
|
|
@@ -190,35 +191,6 @@ function lineSpan(body, position, text) {
|
|
|
190
191
|
const endLine = startLine + Math.max(0, text.split("\n").length - 1);
|
|
191
192
|
return { startLine, endLine };
|
|
192
193
|
}
|
|
193
|
-
export async function expandSessionSearchHit(result, maxTokens, countTokens, maxChars = Infinity, messages) {
|
|
194
|
-
const leaf = { text: result.bestChunk, position: result.chunkPos };
|
|
195
|
-
const speaker = meetingSpeakerSpans(result.body, result.chunkPos, result.chunkPos + result.chunkLen);
|
|
196
|
-
const annotation = meetingRevisionAnnotation(result.body, result.chunkPos);
|
|
197
|
-
const spans = speaker ?? sessionContextSpans(result.body, result.chunkPos, messages);
|
|
198
|
-
if (!spans && !annotation)
|
|
199
|
-
return leaf;
|
|
200
|
-
const leafEnd = result.chunkPos + result.chunkLen;
|
|
201
|
-
for (const span of spans ? [spans.turn, spans.message] : []) {
|
|
202
|
-
if (span.start > result.chunkPos || span.end < leafEnd)
|
|
203
|
-
continue;
|
|
204
|
-
const sourceText = result.body.slice(span.start, span.end).trimEnd();
|
|
205
|
-
const text = annotation ? `${annotation}\n${sourceText}` : sourceText;
|
|
206
|
-
if (text.length > maxChars)
|
|
207
|
-
continue;
|
|
208
|
-
if (await countTokens(text) <= maxTokens)
|
|
209
|
-
return { text, position: span.start, ...(annotation ? { sourceText } : {}) };
|
|
210
|
-
}
|
|
211
|
-
if ((speaker && speaker.start < result.chunkPos) || annotation) {
|
|
212
|
-
const text = [annotation, speaker && speaker.start < result.chunkPos ? speaker.header : undefined, leaf.text].filter(Boolean).join("\n");
|
|
213
|
-
if (text.length <= maxChars && await countTokens(text) <= maxTokens) {
|
|
214
|
-
return { ...leaf, text, sourceText: leaf.text };
|
|
215
|
-
}
|
|
216
|
-
}
|
|
217
|
-
// Never silently strip supersession when the caller's snippet budget is tiny.
|
|
218
|
-
if (annotation)
|
|
219
|
-
return { ...leaf, text: "", sourceText: "" };
|
|
220
|
-
return leaf;
|
|
221
|
-
}
|
|
222
194
|
function lexicalResult(hit, corpus, session) {
|
|
223
195
|
const body = hit.body ?? hit.title;
|
|
224
196
|
const endLine = Math.max(1, body.split("\n").length);
|
|
@@ -845,21 +817,20 @@ export class QmdMemoryManager {
|
|
|
845
817
|
const store = await this.#getAnalysisStore();
|
|
846
818
|
const hits = new Map();
|
|
847
819
|
// Serialize native QMD work. Cancellation prevents further queries/collections.
|
|
848
|
-
for (const
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
chunkPos: hit.bestChunkPos, chunkLen: hit.bestChunk.length, displayPath: hit.file, score: hit.score });
|
|
856
|
-
}
|
|
820
|
+
for (const lane of ["lex", "vec"]) {
|
|
821
|
+
opts.signal?.throwIfAborted();
|
|
822
|
+
for (const hit of await trainingCandidates(store, queries[lane], collections, lane, opts.signal)) {
|
|
823
|
+
const key = JSON.stringify([hit.file, hit.bestChunkPos, hit.bestChunk]);
|
|
824
|
+
if (!hits.has(key))
|
|
825
|
+
hits.set(key, { file: hit.file, body: hit.body, bestChunk: hit.bestChunk,
|
|
826
|
+
chunkPos: hit.bestChunkPos, chunkLen: hit.bestChunk.length, displayPath: hit.file, score: hit.score });
|
|
857
827
|
}
|
|
828
|
+
}
|
|
858
829
|
opts.signal?.throwIfAborted();
|
|
859
|
-
return this.#renderSearchHits([...hits.values()], store, opts);
|
|
830
|
+
return this.#renderSearchHits([...hits.values()], store, opts, true);
|
|
860
831
|
});
|
|
861
832
|
}
|
|
862
|
-
async #renderSearchHits(hits, store, opts) {
|
|
833
|
+
async #renderSearchHits(hits, store, opts, whisperer = false) {
|
|
863
834
|
const tokenizer = store.internal?.llm;
|
|
864
835
|
const results = [];
|
|
865
836
|
for (const hit of hits) {
|
|
@@ -888,10 +859,11 @@ export class QmdMemoryManager {
|
|
|
888
859
|
const messageTimestamp = messages
|
|
889
860
|
? sessionContextSpans(hit.body, hit.chunkPos, messages)?.message.timestamp
|
|
890
861
|
: undefined;
|
|
891
|
-
const selected =
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
|
|
862
|
+
const selected = whisperer ? await renderMemoryPassage(hit, messages)
|
|
863
|
+
: corpus === "sessions" && this.#sessions && tokenizer
|
|
864
|
+
? await expandSessionSearchHit(hit, this.#sessions.maxExpandedTokens, (text) => tokenizer.countTokens(text), opts?.maxSnippetChars, messages)
|
|
865
|
+
: { text: hit.bestChunk, position: hit.chunkPos };
|
|
866
|
+
if (!selected?.text)
|
|
895
867
|
continue;
|
|
896
868
|
const span = lineSpan(hit.body, selected.position, selected.sourceText ?? selected.text);
|
|
897
869
|
results.push({
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import type { VectorSearchResult } from "@unblocklabs/qmd";
|
|
2
|
+
import { type SessionMessageSpan } from "./session-projector.js";
|
|
3
|
+
export declare const MEMORY_PASSAGE_CHARS = 1200;
|
|
4
|
+
export declare const MEMORY_PASSAGE_VERSION = "complete-excerpt-1200-v1";
|
|
5
|
+
type Passage = Pick<VectorSearchResult, "body" | "bestChunk" | "chunkPos" | "chunkLen">;
|
|
6
|
+
type LocatedPassage = {
|
|
7
|
+
path: string;
|
|
8
|
+
text: string;
|
|
9
|
+
startLine: number;
|
|
10
|
+
endLine: number;
|
|
11
|
+
};
|
|
12
|
+
/** Same passage eligibility in target scoring and runtime hint selection. */
|
|
13
|
+
export declare function duplicateMemoryPassage(passage: LocatedPassage, previous: readonly LocatedPassage[]): boolean;
|
|
14
|
+
/** Preserve a complete match and, when it fits, its whole turn/message context. */
|
|
15
|
+
export declare function expandSessionSearchHit(result: Passage, maxTokens: number, countTokens: (text: string) => Promise<number>, maxChars?: number, messages?: SessionMessageSpan[]): Promise<{
|
|
16
|
+
text: string;
|
|
17
|
+
position: number;
|
|
18
|
+
sourceText?: string;
|
|
19
|
+
}>;
|
|
20
|
+
/** One fixed, model-independent renderer for offline and deployed Whisperer. */
|
|
21
|
+
export declare function renderMemoryPassage(result: Passage, messages?: SessionMessageSpan[]): Promise<{
|
|
22
|
+
sourceText: string;
|
|
23
|
+
text: string;
|
|
24
|
+
position: number;
|
|
25
|
+
} | undefined>;
|
|
26
|
+
export {};
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { meetingRevisionAnnotation, meetingSpeakerSpans } from "./loggie-projection.js";
|
|
2
|
+
import { sessionContextSpans } from "./session-projector.js";
|
|
3
|
+
export const MEMORY_PASSAGE_CHARS = 1200;
|
|
4
|
+
export const MEMORY_PASSAGE_VERSION = "complete-excerpt-1200-v1";
|
|
5
|
+
/** Same passage eligibility in target scoring and runtime hint selection. */
|
|
6
|
+
export function duplicateMemoryPassage(passage, previous) {
|
|
7
|
+
const normalized = (text) => text.replace(/\s+/gu, " ").trim();
|
|
8
|
+
return previous.some(other => normalized(other.text) === normalized(passage.text) ||
|
|
9
|
+
(other.path === passage.path && other.startLine <= passage.endLine && passage.startLine <= other.endLine));
|
|
10
|
+
}
|
|
11
|
+
/** Preserve a complete match and, when it fits, its whole turn/message context. */
|
|
12
|
+
export async function expandSessionSearchHit(result, maxTokens, countTokens, maxChars = Infinity, messages) {
|
|
13
|
+
const leaf = { text: result.bestChunk, position: result.chunkPos };
|
|
14
|
+
const speaker = meetingSpeakerSpans(result.body, result.chunkPos, result.chunkPos + result.chunkLen);
|
|
15
|
+
const annotation = meetingRevisionAnnotation(result.body, result.chunkPos);
|
|
16
|
+
const spans = speaker ?? sessionContextSpans(result.body, result.chunkPos, messages);
|
|
17
|
+
if (!spans && !annotation)
|
|
18
|
+
return leaf;
|
|
19
|
+
const leafEnd = result.chunkPos + result.chunkLen;
|
|
20
|
+
for (const span of spans ? [spans.turn, spans.message] : []) {
|
|
21
|
+
if (span.start > result.chunkPos || span.end < leafEnd)
|
|
22
|
+
continue;
|
|
23
|
+
const sourceText = result.body.slice(span.start, span.end).trimEnd();
|
|
24
|
+
const text = annotation ? `${annotation}\n${sourceText}` : sourceText;
|
|
25
|
+
if (text.length > maxChars)
|
|
26
|
+
continue;
|
|
27
|
+
if (await countTokens(text) <= maxTokens)
|
|
28
|
+
return { text, position: span.start, ...(annotation ? { sourceText } : {}) };
|
|
29
|
+
}
|
|
30
|
+
if ((speaker && speaker.start < result.chunkPos) || annotation) {
|
|
31
|
+
const text = [annotation, speaker && speaker.start < result.chunkPos ? speaker.header : undefined, leaf.text].filter(Boolean).join("\n");
|
|
32
|
+
if (text.length <= maxChars && await countTokens(text) <= maxTokens) {
|
|
33
|
+
return { ...leaf, text, sourceText: leaf.text };
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
if (annotation)
|
|
37
|
+
return { ...leaf, text: "", sourceText: "" };
|
|
38
|
+
return leaf;
|
|
39
|
+
}
|
|
40
|
+
/** One fixed, model-independent renderer for offline and deployed Whisperer. */
|
|
41
|
+
export async function renderMemoryPassage(result, messages) {
|
|
42
|
+
if (!result.bestChunk.trim() || result.bestChunk.length > MEMORY_PASSAGE_CHARS)
|
|
43
|
+
return;
|
|
44
|
+
const selected = messages ? await expandSessionSearchHit(result, Infinity, async () => 0, MEMORY_PASSAGE_CHARS, messages)
|
|
45
|
+
: { text: result.bestChunk, position: result.chunkPos };
|
|
46
|
+
return selected.text.trim() && selected.text.length <= MEMORY_PASSAGE_CHARS
|
|
47
|
+
? { ...selected, sourceText: selected.sourceText ?? selected.text, text: selected.text.trim() } : undefined;
|
|
48
|
+
}
|
|
@@ -2,6 +2,7 @@ import type { OpenClawConfig, OpenClawPluginApi } from "openclaw/plugin-sdk/plug
|
|
|
2
2
|
import type { UnblockMemoryConfig } from "./config.js";
|
|
3
3
|
import type { CorpusMemorySearchResult, CorpusSearchOptions } from "./contracts.js";
|
|
4
4
|
import type { WhispererDiagnostics } from "./diagnostics.js";
|
|
5
|
+
import type { QueryPair } from "./query-contract.js";
|
|
5
6
|
type MemoryWhispererRuntime = {
|
|
6
7
|
getMemorySearchManager(params: {
|
|
7
8
|
cfg: OpenClawConfig;
|
|
@@ -9,7 +10,7 @@ type MemoryWhispererRuntime = {
|
|
|
9
10
|
}): Promise<{
|
|
10
11
|
manager: {
|
|
11
12
|
search(query: string, opts?: CorpusSearchOptions): Promise<CorpusMemorySearchResult[]>;
|
|
12
|
-
searchWhisperer?(queries:
|
|
13
|
+
searchWhisperer?(queries: QueryPair, opts: Pick<CorpusSearchOptions, "corpora" | "signal" | "maxSnippetChars">): Promise<CorpusMemorySearchResult[]>;
|
|
13
14
|
} | null;
|
|
14
15
|
}>;
|
|
15
16
|
};
|
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
import { createHash } from "node:crypto";
|
|
2
2
|
import { judgeTypeSafeMemories } from "./typesafe.js";
|
|
3
3
|
import { resolveTypeSafeApiKey, TypeSafeRequestError } from "./typesafe-client.js";
|
|
4
|
-
import { buildSkillWhispererQuery, memoryConversation } from "./whisperer-context.js";
|
|
5
4
|
import { complementaryIndices, reviewMemoryRedundancy } from "./typesafe-review.js";
|
|
6
|
-
import {
|
|
5
|
+
import { ApiQueryGenerator, QueryApiError, queryConversation } from "./query-generator.js";
|
|
7
6
|
import { judgeTrainingInput, TRAINING_GATE_THRESHOLD } from "./training-gate.js";
|
|
8
|
-
|
|
7
|
+
import { MEMORY_PASSAGE_CHARS, duplicateMemoryPassage } from "./memory-passage.js";
|
|
8
|
+
const MAX_EXCERPT_CHARS = MEMORY_PASSAGE_CHARS;
|
|
9
9
|
function fingerprint(text) {
|
|
10
10
|
return createHash("sha256").update(text.replace(/\s+/gu, " ").trim()).digest("hex");
|
|
11
11
|
}
|
|
@@ -13,11 +13,7 @@ export function registerMemoryWhisperer(api, runtime, config, typesafe, diagnost
|
|
|
13
13
|
if (!config.enabled || !typesafe.enabled)
|
|
14
14
|
return;
|
|
15
15
|
const sessions = new Map();
|
|
16
|
-
const generator =
|
|
17
|
-
if (generator)
|
|
18
|
-
api.on("gateway_start", () => {
|
|
19
|
-
void generator.start().catch(() => api.logger.warn("unblock-memory MLX worker unavailable; query fallback active"));
|
|
20
|
-
});
|
|
16
|
+
const generator = new ApiQueryGenerator(config.api);
|
|
21
17
|
const beforePrompt = async (event, context) => {
|
|
22
18
|
const { agentId, runId, sessionId, sessionKey } = context;
|
|
23
19
|
const scope = sessionId || sessionKey;
|
|
@@ -31,12 +27,13 @@ export function registerMemoryWhisperer(api, runtime, config, typesafe, diagnost
|
|
|
31
27
|
if (previous?.runId === runId)
|
|
32
28
|
return previous.result;
|
|
33
29
|
const started = performance.now();
|
|
30
|
+
const asOf = new Date().toISOString();
|
|
34
31
|
const measurement = { outcome: "skipped", elapsedMs: 0 };
|
|
35
32
|
let stage = "credentials", reason = "completed";
|
|
36
33
|
let recallProbability, queryCount;
|
|
37
34
|
let requestsSucceeded = 0, requestsFailed = 0;
|
|
38
35
|
const log = (level, event, fields) => api.logger[level]("unblock-memory memory_whisperer " + JSON.stringify({ event, agentId, runId, sessionId, stage, ...fields }));
|
|
39
|
-
const failureFields = (error) => error instanceof TypeSafeRequestError
|
|
36
|
+
const failureFields = (error) => error instanceof TypeSafeRequestError || error instanceof QueryApiError
|
|
40
37
|
? { errorCode: error.code, httpStatus: error.status } : { errorCode: "unexpected" };
|
|
41
38
|
previous?.controller.abort();
|
|
42
39
|
const state = {
|
|
@@ -68,11 +65,11 @@ export function registerMemoryWhisperer(api, runtime, config, typesafe, diagnost
|
|
|
68
65
|
return;
|
|
69
66
|
}
|
|
70
67
|
stage = "input";
|
|
71
|
-
const conversation =
|
|
68
|
+
const conversation = queryConversation(event.prompt, event.messages);
|
|
72
69
|
const gateStarted = performance.now();
|
|
73
70
|
const gateSignal = AbortSignal.any([signal, AbortSignal.timeout(typesafe.timeoutMs)]);
|
|
74
71
|
// Speculation is intentional: neither model generation nor QMD waits for the recall judgment.
|
|
75
|
-
const gate =
|
|
72
|
+
const gate = judgeTrainingInput(conversation, apiKey, gateSignal)
|
|
76
73
|
.then(result => {
|
|
77
74
|
if (signal.aborted)
|
|
78
75
|
return false;
|
|
@@ -95,28 +92,29 @@ export function registerMemoryWhisperer(api, runtime, config, typesafe, diagnost
|
|
|
95
92
|
state.controller.abort();
|
|
96
93
|
}
|
|
97
94
|
return false;
|
|
98
|
-
})
|
|
95
|
+
});
|
|
99
96
|
const retrieveAndJudge = async () => {
|
|
100
97
|
let queries;
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
98
|
+
stage = "generation";
|
|
99
|
+
const generationStarted = performance.now();
|
|
100
|
+
try {
|
|
101
|
+
queries = await generator.generate(conversation, signal);
|
|
102
|
+
if (signal.aborted)
|
|
103
|
+
return;
|
|
104
|
+
queryCount = 2;
|
|
105
|
+
diagnostics?.record(agentId, "memory", "queries_generated");
|
|
106
|
+
}
|
|
107
|
+
catch (error) {
|
|
108
|
+
if (signal.aborted)
|
|
109
|
+
return;
|
|
110
|
+
reason = "generation_failed";
|
|
111
|
+
measurement.outcome = "failed";
|
|
112
|
+
log("warn", "generation_failed", { ...failureFields(error), elapsedMs: performance.now() - generationStarted });
|
|
113
|
+
diagnostics?.record(agentId, "memory", "failed");
|
|
114
|
+
return;
|
|
115
|
+
}
|
|
116
|
+
finally {
|
|
117
|
+
measurement.generationMs = performance.now() - generationStarted;
|
|
120
118
|
}
|
|
121
119
|
stage = "retrieval";
|
|
122
120
|
const { manager } = await runtime.getMemorySearchManager({ cfg: api.config, agentId });
|
|
@@ -128,7 +126,9 @@ export function registerMemoryWhisperer(api, runtime, config, typesafe, diagnost
|
|
|
128
126
|
return;
|
|
129
127
|
}
|
|
130
128
|
const retrievalStarted = performance.now();
|
|
131
|
-
|
|
129
|
+
if (!manager.searchWhisperer)
|
|
130
|
+
throw new Error("V2 Whisperer retrieval is unavailable");
|
|
131
|
+
const hits = await manager.searchWhisperer(queries, { corpora, signal, maxSnippetChars: MAX_EXCERPT_CHARS });
|
|
132
132
|
if (signal.aborted)
|
|
133
133
|
return;
|
|
134
134
|
measurement.retrievalMs = performance.now() - retrievalStarted;
|
|
@@ -144,13 +144,9 @@ export function registerMemoryWhisperer(api, runtime, config, typesafe, diagnost
|
|
|
144
144
|
if (excerpt.length > MAX_EXCERPT_CHARS)
|
|
145
145
|
continue;
|
|
146
146
|
const id = fingerprint(excerpt);
|
|
147
|
-
if (!excerpt || state.recent.has(id) || candidates.
|
|
148
|
-
(candidate.hit.path === hit.path && candidate.hit.startLine <= hit.endLine &&
|
|
149
|
-
hit.startLine <= candidate.hit.endLine)))
|
|
147
|
+
if (!excerpt || state.recent.has(id) || duplicateMemoryPassage({ ...hit, text: excerpt }, candidates.map(candidate => ({ ...candidate.hit, text: candidate.excerpt }))))
|
|
150
148
|
continue;
|
|
151
149
|
candidates.push({ hit, excerpt, id });
|
|
152
|
-
if (!queries && candidates.length === 8)
|
|
153
|
-
break;
|
|
154
150
|
}
|
|
155
151
|
measurement.eligible = candidates.length;
|
|
156
152
|
measurement.outcome = "empty";
|
|
@@ -161,8 +157,6 @@ export function registerMemoryWhisperer(api, runtime, config, typesafe, diagnost
|
|
|
161
157
|
}
|
|
162
158
|
stage = "judgment";
|
|
163
159
|
const judgeStarted = performance.now();
|
|
164
|
-
const judgeConversation = conversation ? { ...conversation, truncated: event.messages.length > conversation.history.length }
|
|
165
|
-
: memoryConversation(event.prompt, event.messages);
|
|
166
160
|
// Every eligible passage gets its own request; no request sees another candidate.
|
|
167
161
|
const judged = (await Promise.all(candidates.map(async (candidate, candidateIndex) => {
|
|
168
162
|
const requestStarted = performance.now();
|
|
@@ -172,9 +166,11 @@ export function registerMemoryWhisperer(api, runtime, config, typesafe, diagnost
|
|
|
172
166
|
const { hit, excerpt } = candidate;
|
|
173
167
|
const [probability] = await judgeTypeSafeMemories({
|
|
174
168
|
apiKey, timeoutMs: typesafe.timeoutMs, signal,
|
|
175
|
-
conversation
|
|
169
|
+
conversation, asOf,
|
|
176
170
|
candidates: [{
|
|
177
|
-
excerpt, corpus: hit.corpus,
|
|
171
|
+
excerpt, corpus: hit.corpus, sourcePath: hit.path,
|
|
172
|
+
dates: [...new Set(hit.sessionMessages?.flatMap(message => message.timestamp ? [message.timestamp] : [])
|
|
173
|
+
?? (hit.messageTimestamp ? [hit.messageTimestamp] : []))],
|
|
178
174
|
}],
|
|
179
175
|
});
|
|
180
176
|
if (signal.aborted)
|
|
@@ -302,7 +298,6 @@ export function registerMemoryWhisperer(api, runtime, config, typesafe, diagnost
|
|
|
302
298
|
}
|
|
303
299
|
});
|
|
304
300
|
api.on("gateway_stop", () => {
|
|
305
|
-
generator?.close();
|
|
306
301
|
for (const state of sessions.values())
|
|
307
302
|
state.controller.abort();
|
|
308
303
|
sessions.clear();
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
export type QueryLane = "lex" | "vec";
|
|
2
|
+
export type QueryPair = Record<QueryLane, string>;
|
|
3
|
+
export type QueryConversation = {
|
|
4
|
+
history: {
|
|
5
|
+
role: "user" | "assistant";
|
|
6
|
+
content: string;
|
|
7
|
+
}[];
|
|
8
|
+
currentRequest: string;
|
|
9
|
+
};
|
|
10
|
+
export declare const QUERY_CONTRACT: {
|
|
11
|
+
version: string;
|
|
12
|
+
model: string;
|
|
13
|
+
revision: string;
|
|
14
|
+
conversationTokens: number;
|
|
15
|
+
conversationBytes: number;
|
|
16
|
+
contextTokens: number;
|
|
17
|
+
outputTokens: number;
|
|
18
|
+
system: string;
|
|
19
|
+
};
|
|
20
|
+
export declare class QueryInputBudgetError extends Error {
|
|
21
|
+
constructor();
|
|
22
|
+
}
|
|
23
|
+
export declare function parseQueryPair(value: unknown): QueryPair;
|
|
24
|
+
/** Escape template delimiters identically in the student worker and dataset preparation. */
|
|
25
|
+
export declare function serializeQueryConversation(conversation: QueryConversation): string;
|
|
26
|
+
export declare function queryTokenIds(text: string): number[];
|
|
27
|
+
/** Never alter the current request or split a history message. */
|
|
28
|
+
export declare function prepareQueryConversation(history: QueryConversation["history"], currentRequest: string): {
|
|
29
|
+
conversation: QueryConversation;
|
|
30
|
+
contextLimited: boolean;
|
|
31
|
+
};
|