@unblocklabs/unblock-memory 0.3.29 → 0.4.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 +2 -2
- package/dist/src/config.d.ts +0 -1
- package/dist/src/config.js +2 -6
- 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 +21 -17
- package/dist/src/mlx-query.d.ts +3 -10
- package/dist/src/mlx-query.js +49 -25
- package/dist/src/query-contract.d.ts +31 -0
- package/dist/src/query-contract.js +58 -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 +23 -7
- package/dist/src/training-queries.js +247 -102
- 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 +83 -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 +3 -3
- package/docs/memory-training.md +114 -58
- package/docs/retrieval.md +34 -24
- package/openclaw.plugin.json +2 -3
- package/package.json +5 -2
- 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/README.md
CHANGED
|
@@ -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
package/dist/src/config.js
CHANGED
|
@@ -81,7 +81,7 @@ 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
86
|
};
|
|
87
87
|
function resolveMemoryWhisperer(value, corpora) {
|
|
@@ -116,11 +116,7 @@ function resolveMemoryWhisperer(value, corpora) {
|
|
|
116
116
|
}
|
|
117
117
|
if (enabled && !selected.length)
|
|
118
118
|
throw new Error("unblock-memory enabled memoryWhisperer requires explicit corpora");
|
|
119
|
-
const historyMessages = config.historyMessages ?? 5;
|
|
120
119
|
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
120
|
if (typeof cooldownTurns !== "number" || !Number.isInteger(cooldownTurns) || cooldownTurns < 0 || cooldownTurns > 1000) {
|
|
125
121
|
throw new Error("unblock-memory memoryWhisperer.cooldownTurns must be an integer between 0 and 1000");
|
|
126
122
|
}
|
|
@@ -129,7 +125,7 @@ function resolveMemoryWhisperer(value, corpora) {
|
|
|
129
125
|
throw new Error("unblock-memory memoryWhisperer.minUsefulness must be between 0 and 1");
|
|
130
126
|
}
|
|
131
127
|
return {
|
|
132
|
-
enabled, complementaryHints, corpora: [...new Set(selected)],
|
|
128
|
+
enabled, complementaryHints, corpora: [...new Set(selected)], cooldownTurns, minUsefulness,
|
|
133
129
|
maxHints: positiveInteger(config.maxHints, 2, "memoryWhisperer.maxHints", 2),
|
|
134
130
|
timeoutMs: positiveInteger(config.timeoutMs, 3000, "memoryWhisperer.timeoutMs", 10_000),
|
|
135
131
|
...(mlx ? { mlx } : {}),
|
|
@@ -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
5
|
import { MlxQueryGenerator, queryConversation } from "./mlx-query.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
|
}
|
|
@@ -16,7 +16,7 @@ export function registerMemoryWhisperer(api, runtime, config, typesafe, diagnost
|
|
|
16
16
|
const generator = config.mlx ? MlxQueryGenerator.shared(config.mlx) : undefined;
|
|
17
17
|
if (generator)
|
|
18
18
|
api.on("gateway_start", () => {
|
|
19
|
-
void generator.start().catch(() => api.logger.warn("unblock-memory MLX worker unavailable;
|
|
19
|
+
void generator.start().catch(() => api.logger.warn("unblock-memory MLX worker unavailable; automatic memory skipped"));
|
|
20
20
|
});
|
|
21
21
|
const beforePrompt = async (event, context) => {
|
|
22
22
|
const { agentId, runId, sessionId, sessionKey } = context;
|
|
@@ -31,6 +31,7 @@ export function registerMemoryWhisperer(api, runtime, config, typesafe, diagnost
|
|
|
31
31
|
if (previous?.runId === runId)
|
|
32
32
|
return previous.result;
|
|
33
33
|
const started = performance.now();
|
|
34
|
+
const asOf = new Date().toISOString();
|
|
34
35
|
const measurement = { outcome: "skipped", elapsedMs: 0 };
|
|
35
36
|
let stage = "credentials", reason = "completed";
|
|
36
37
|
let recallProbability, queryCount;
|
|
@@ -68,11 +69,11 @@ export function registerMemoryWhisperer(api, runtime, config, typesafe, diagnost
|
|
|
68
69
|
return;
|
|
69
70
|
}
|
|
70
71
|
stage = "input";
|
|
71
|
-
const conversation =
|
|
72
|
+
const conversation = queryConversation(event.prompt, event.messages);
|
|
72
73
|
const gateStarted = performance.now();
|
|
73
74
|
const gateSignal = AbortSignal.any([signal, AbortSignal.timeout(typesafe.timeoutMs)]);
|
|
74
75
|
// Speculation is intentional: neither model generation nor QMD waits for the recall judgment.
|
|
75
|
-
const gate =
|
|
76
|
+
const gate = generator ? judgeTrainingInput(conversation, apiKey, gateSignal)
|
|
76
77
|
.then(result => {
|
|
77
78
|
if (signal.aborted)
|
|
78
79
|
return false;
|
|
@@ -98,21 +99,24 @@ export function registerMemoryWhisperer(api, runtime, config, typesafe, diagnost
|
|
|
98
99
|
}) : Promise.resolve(true);
|
|
99
100
|
const retrieveAndJudge = async () => {
|
|
100
101
|
let queries;
|
|
101
|
-
if (generator
|
|
102
|
+
if (generator) {
|
|
102
103
|
stage = "generation";
|
|
103
104
|
const generationStarted = performance.now();
|
|
104
105
|
try {
|
|
105
106
|
queries = await generator.generate(conversation, signal);
|
|
106
107
|
if (signal.aborted)
|
|
107
108
|
return;
|
|
108
|
-
queryCount =
|
|
109
|
+
queryCount = 2;
|
|
109
110
|
diagnostics?.record(agentId, "memory", "queries_generated");
|
|
110
111
|
}
|
|
111
112
|
catch (error) {
|
|
112
113
|
if (signal.aborted)
|
|
113
114
|
return;
|
|
114
|
-
|
|
115
|
-
|
|
115
|
+
reason = "generation_failed";
|
|
116
|
+
measurement.outcome = "failed";
|
|
117
|
+
log("warn", "generation_failed", { ...failureFields(error), elapsedMs: performance.now() - generationStarted });
|
|
118
|
+
diagnostics?.record(agentId, "memory", "failed");
|
|
119
|
+
return;
|
|
116
120
|
}
|
|
117
121
|
finally {
|
|
118
122
|
measurement.generationMs = performance.now() - generationStarted;
|
|
@@ -128,7 +132,9 @@ export function registerMemoryWhisperer(api, runtime, config, typesafe, diagnost
|
|
|
128
132
|
return;
|
|
129
133
|
}
|
|
130
134
|
const retrievalStarted = performance.now();
|
|
131
|
-
|
|
135
|
+
if (queries && !manager.searchWhisperer)
|
|
136
|
+
throw new Error("V2 Whisperer retrieval is unavailable");
|
|
137
|
+
const hits = queries ? await manager.searchWhisperer(queries, { corpora, signal, maxSnippetChars: MAX_EXCERPT_CHARS }) : await manager.search(JSON.stringify(conversation), { corpora, maxResults: 8, minScore: -1, signal, maxSnippetChars: MAX_EXCERPT_CHARS });
|
|
132
138
|
if (signal.aborted)
|
|
133
139
|
return;
|
|
134
140
|
measurement.retrievalMs = performance.now() - retrievalStarted;
|
|
@@ -144,9 +150,7 @@ export function registerMemoryWhisperer(api, runtime, config, typesafe, diagnost
|
|
|
144
150
|
if (excerpt.length > MAX_EXCERPT_CHARS)
|
|
145
151
|
continue;
|
|
146
152
|
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)))
|
|
153
|
+
if (!excerpt || state.recent.has(id) || duplicateMemoryPassage({ ...hit, text: excerpt }, candidates.map(candidate => ({ ...candidate.hit, text: candidate.excerpt }))))
|
|
150
154
|
continue;
|
|
151
155
|
candidates.push({ hit, excerpt, id });
|
|
152
156
|
if (!queries && candidates.length === 8)
|
|
@@ -161,8 +165,6 @@ export function registerMemoryWhisperer(api, runtime, config, typesafe, diagnost
|
|
|
161
165
|
}
|
|
162
166
|
stage = "judgment";
|
|
163
167
|
const judgeStarted = performance.now();
|
|
164
|
-
const judgeConversation = conversation ? { ...conversation, truncated: event.messages.length > conversation.history.length }
|
|
165
|
-
: memoryConversation(event.prompt, event.messages);
|
|
166
168
|
// Every eligible passage gets its own request; no request sees another candidate.
|
|
167
169
|
const judged = (await Promise.all(candidates.map(async (candidate, candidateIndex) => {
|
|
168
170
|
const requestStarted = performance.now();
|
|
@@ -172,9 +174,11 @@ export function registerMemoryWhisperer(api, runtime, config, typesafe, diagnost
|
|
|
172
174
|
const { hit, excerpt } = candidate;
|
|
173
175
|
const [probability] = await judgeTypeSafeMemories({
|
|
174
176
|
apiKey, timeoutMs: typesafe.timeoutMs, signal,
|
|
175
|
-
conversation
|
|
177
|
+
conversation, asOf,
|
|
176
178
|
candidates: [{
|
|
177
|
-
excerpt, corpus: hit.corpus,
|
|
179
|
+
excerpt, corpus: hit.corpus, sourcePath: hit.path,
|
|
180
|
+
dates: [...new Set(hit.sessionMessages?.flatMap(message => message.timestamp ? [message.timestamp] : [])
|
|
181
|
+
?? (hit.messageTimestamp ? [hit.messageTimestamp] : []))],
|
|
178
182
|
}],
|
|
179
183
|
});
|
|
180
184
|
if (signal.aborted)
|
package/dist/src/mlx-query.d.ts
CHANGED
|
@@ -1,13 +1,7 @@
|
|
|
1
1
|
import type { UnblockMemoryConfig } from "./config.js";
|
|
2
|
-
type
|
|
3
|
-
history: {
|
|
4
|
-
role: "user" | "assistant";
|
|
5
|
-
content: string;
|
|
6
|
-
}[];
|
|
7
|
-
currentRequest: string;
|
|
8
|
-
};
|
|
2
|
+
import { type QueryConversation, type QueryPair } from "./query-contract.js";
|
|
9
3
|
/** Preserve whole visible messages and the complete current request, never tool/thinking text. */
|
|
10
|
-
export declare function queryConversation(prompt: string, messages: readonly unknown[]
|
|
4
|
+
export declare function queryConversation(prompt: string, messages: readonly unknown[]): QueryConversation;
|
|
11
5
|
export declare class MlxQueryGenerator {
|
|
12
6
|
#private;
|
|
13
7
|
private readonly config;
|
|
@@ -16,7 +10,6 @@ export declare class MlxQueryGenerator {
|
|
|
16
10
|
constructor(config: NonNullable<UnblockMemoryConfig["memoryWhisperer"]["mlx"]>);
|
|
17
11
|
get closed(): boolean;
|
|
18
12
|
start(): Promise<void>;
|
|
19
|
-
generate(conversation:
|
|
13
|
+
generate(conversation: QueryConversation, signal: AbortSignal): Promise<QueryPair>;
|
|
20
14
|
close(): void;
|
|
21
15
|
}
|
|
22
|
-
export {};
|
package/dist/src/mlx-query.js
CHANGED
|
@@ -6,32 +6,63 @@ import { createInterface } from "node:readline";
|
|
|
6
6
|
import { abortable } from "./abortable.js";
|
|
7
7
|
import { messageText } from "./whisperer-context.js";
|
|
8
8
|
import { conversationUserText } from "./response-text.js";
|
|
9
|
+
import { parseQueryPair, prepareQueryConversation } from "./query-contract.js";
|
|
10
|
+
const record = (value) => value !== null && typeof value === "object" && !Array.isArray(value) ? value : undefined;
|
|
9
11
|
/** Preserve whole visible messages and the complete current request, never tool/thinking text. */
|
|
10
|
-
export function queryConversation(prompt, messages
|
|
12
|
+
export function queryConversation(prompt, messages) {
|
|
11
13
|
const currentRequest = conversationUserText(prompt)?.text ?? "";
|
|
12
|
-
const visible =
|
|
14
|
+
const visible = [];
|
|
15
|
+
const assistantTexts = new Map();
|
|
16
|
+
for (const item of messages) {
|
|
17
|
+
const event = record(item), message = record(event?.type === "message" ? event.message : item);
|
|
18
|
+
if (event?.type === "compaction" || message?.role === "compactionSummary") {
|
|
19
|
+
visible.length = 0;
|
|
20
|
+
assistantTexts.clear();
|
|
21
|
+
continue;
|
|
22
|
+
}
|
|
23
|
+
if (!message || message.role === "toolResult" || message.role === "tool")
|
|
24
|
+
continue;
|
|
25
|
+
const meta = record(message.__openclaw);
|
|
26
|
+
if (message.provenance !== undefined || (message.role === "user" && record(meta?.senderIdentity)?.senderKind === "bot")) {
|
|
27
|
+
visible.length = 0;
|
|
28
|
+
assistantTexts.clear();
|
|
29
|
+
continue;
|
|
30
|
+
}
|
|
31
|
+
const mirror = message.provider === "openclaw" && message.model === "delivery-mirror";
|
|
32
|
+
if (message.role === "assistant" && (message.channel === "analysis" || message.stopReason === "error" ||
|
|
33
|
+
message.stopReason === "aborted" || (message.provider === "openclaw" && message.model === "gateway-injected") ||
|
|
34
|
+
(mirror && record(message.openclawDeliveryMirror)?.kind === "channel-final-suppressed")))
|
|
35
|
+
continue;
|
|
13
36
|
const text = messageText(message);
|
|
14
|
-
const
|
|
15
|
-
|
|
16
|
-
|
|
37
|
+
const raw = text?.role === "user" && typeof meta?.upstreamUserText === "string" ? meta.upstreamUserText : text?.text;
|
|
38
|
+
const content = text?.role === "user" && raw ? conversationUserText(raw, meta?.senderId ?? message.senderId)?.text : raw;
|
|
39
|
+
if (message.role === "user") {
|
|
40
|
+
assistantTexts.clear();
|
|
41
|
+
if (!content || /^(?:\[OpenClaw heartbeat poll\]|\[Queued messages while agent was busy\]|\[Subagent Context\]|<relevant-memories>)/.test(content)) {
|
|
42
|
+
visible.length = 0;
|
|
43
|
+
continue;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
if (!text || !content || (text.role === "assistant" && (content === "NO_REPLY" || content === "HEARTBEAT_OK")))
|
|
47
|
+
continue;
|
|
48
|
+
if (text.role === "assistant") {
|
|
49
|
+
const previous = assistantTexts.get(content);
|
|
50
|
+
assistantTexts.set(content, mirror);
|
|
51
|
+
if (previous !== undefined && (mirror || previous))
|
|
52
|
+
continue;
|
|
53
|
+
}
|
|
54
|
+
visible.push({ role: text.role, content });
|
|
55
|
+
}
|
|
17
56
|
if (visible.at(-1)?.role === "user" && visible.at(-1)?.content === currentRequest)
|
|
18
57
|
visible.pop();
|
|
19
|
-
|
|
20
|
-
while (history.length && Buffer.byteLength(JSON.stringify({ history, currentRequest })) > 24_000)
|
|
21
|
-
history.shift();
|
|
22
|
-
if (!currentRequest)
|
|
23
|
-
throw new Error("Missing or unparseable current request");
|
|
24
|
-
if (Buffer.byteLength(JSON.stringify({ history, currentRequest })) > 24_000) {
|
|
25
|
-
throw new Error("Query input exceeds context budget");
|
|
26
|
-
}
|
|
27
|
-
return { history, currentRequest };
|
|
58
|
+
return prepareQueryConversation(visible, currentRequest).conversation;
|
|
28
59
|
}
|
|
29
60
|
export class MlxQueryGenerator {
|
|
30
61
|
config;
|
|
31
62
|
/** Gateway hook and tool registries can evaluate/register this plugin separately. */
|
|
32
63
|
static shared(config) {
|
|
33
64
|
const scope = globalThis;
|
|
34
|
-
const symbol = Symbol.for("unblock-memory.mlx-workers.
|
|
65
|
+
const symbol = Symbol.for("unblock-memory.mlx-workers.v2");
|
|
35
66
|
const workers = (scope[symbol] ??= new Map());
|
|
36
67
|
const key = JSON.stringify([config.pythonPath, config.modelPath]);
|
|
37
68
|
let worker = workers.get(key);
|
|
@@ -104,19 +135,12 @@ export class MlxQueryGenerator {
|
|
|
104
135
|
request.reject(new Error("Query generation failed"));
|
|
105
136
|
return;
|
|
106
137
|
}
|
|
107
|
-
let queries;
|
|
108
138
|
try {
|
|
109
|
-
|
|
139
|
+
request.resolve(parseQueryPair(JSON.parse(value.text)));
|
|
110
140
|
}
|
|
111
|
-
catch {
|
|
112
|
-
|
|
113
|
-
.filter((query) => typeof query === "string")
|
|
114
|
-
.map(query => query.trim()).filter(Boolean))] : [];
|
|
115
|
-
if (!usable.length) {
|
|
116
|
-
request.reject(new Error("No usable generated queries"));
|
|
117
|
-
return;
|
|
141
|
+
catch {
|
|
142
|
+
request.reject(new Error("Invalid generated lex/vec query pair"));
|
|
118
143
|
}
|
|
119
|
-
request.resolve(usable);
|
|
120
144
|
}
|
|
121
145
|
catch {
|
|
122
146
|
child.kill();
|
|
@@ -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
|
+
};
|