@unblocklabs/unblock-memory 0.3.30 → 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.
Files changed (50) hide show
  1. package/README.md +2 -2
  2. package/dist/src/config.d.ts +0 -1
  3. package/dist/src/config.js +2 -6
  4. package/dist/src/diagnostics.d.ts +1 -3
  5. package/dist/src/manager.d.ts +4 -8
  6. package/dist/src/manager.js +17 -45
  7. package/dist/src/memory-passage.d.ts +26 -0
  8. package/dist/src/memory-passage.js +48 -0
  9. package/dist/src/memory-whisperer.d.ts +2 -1
  10. package/dist/src/memory-whisperer.js +21 -17
  11. package/dist/src/mlx-query.d.ts +3 -10
  12. package/dist/src/mlx-query.js +49 -25
  13. package/dist/src/query-contract.d.ts +31 -0
  14. package/dist/src/query-contract.js +58 -0
  15. package/dist/src/query-tokenizer/contract.json +10 -0
  16. package/dist/src/response-stages.d.ts +3 -3
  17. package/dist/src/training-candidates.d.ts +2 -1
  18. package/dist/src/training-candidates.js +12 -9
  19. package/dist/src/training-input.d.ts +13 -10
  20. package/dist/src/training-input.js +22 -18
  21. package/dist/src/training-judge.d.ts +16 -46
  22. package/dist/src/training-judge.js +6 -53
  23. package/dist/src/training-models.d.ts +15 -7
  24. package/dist/src/training-models.js +36 -38
  25. package/dist/src/training-queries.d.ts +23 -7
  26. package/dist/src/training-queries.js +247 -102
  27. package/dist/src/training-retrieval.d.ts +9 -2
  28. package/dist/src/training-retrieval.js +36 -12
  29. package/dist/src/training-runtime.js +8 -7
  30. package/dist/src/training-store.d.ts +46 -16
  31. package/dist/src/training-store.js +83 -29
  32. package/dist/src/training.d.ts +10 -0
  33. package/dist/src/training.js +7 -5
  34. package/dist/src/typesafe.d.ts +58 -8
  35. package/dist/src/typesafe.js +47 -29
  36. package/dist/src/whisperer-context.d.ts +0 -8
  37. package/dist/src/whisperer-context.js +0 -23
  38. package/docs/configuration.md +3 -3
  39. package/docs/memory-training.md +113 -57
  40. package/docs/retrieval.md +34 -24
  41. package/openclaw.plugin.json +2 -3
  42. package/package.json +4 -1
  43. package/src/mlx-query-worker.py +5 -8
  44. package/src/query-tokenizer/LICENSE +71 -0
  45. package/src/query-tokenizer/README.md +21 -0
  46. package/src/query-tokenizer/chat_template.jinja +115 -0
  47. package/src/query-tokenizer/contract.json +10 -0
  48. package/src/query-tokenizer/tokenizer.json +323830 -0
  49. package/src/query-tokenizer/tokenizer_config.json +19 -0
  50. 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, xhigh Luna queries and conversation-only grading of historical QMD hits
70
- for the LFM query-generator project.
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
@@ -64,7 +64,6 @@ 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;
@@ -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: [], historyMessages: 5, minUsefulness: 0.7,
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)], historyMessages, cooldownTurns, minUsefulness,
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" | "query_fallback" | "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";
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: {
@@ -1,15 +1,16 @@
1
- import type { QMDStore, VectorSearchResult } from "@unblocklabs/qmd";
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: readonly string[], opts: Pick<CorpusSearchOptions, "corpora" | "signal" | "maxSnippetChars">): Promise<CorpusMemorySearchResult[]>;
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;
@@ -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 query of queries)
849
- for (const collection of collections) {
850
- opts.signal?.throwIfAborted();
851
- for (const hit of await trainingCandidates(store, query, collection, query, opts.signal)) {
852
- const key = JSON.stringify([hit.file, hit.bestChunkPos, hit.bestChunk]);
853
- if (!hits.has(key))
854
- hits.set(key, { file: hit.file, body: hit.body, bestChunk: hit.bestChunk,
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 = corpus === "sessions" && this.#sessions && tokenizer
892
- ? await expandSessionSearchHit(hit, this.#sessions.maxExpandedTokens, (text) => tokenizer.countTokens(text), opts?.maxSnippetChars, messages)
893
- : { text: hit.bestChunk, position: hit.chunkPos };
894
- if (!selected.text)
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: readonly string[], opts: Pick<CorpusSearchOptions, "corpora" | "signal" | "maxSnippetChars">): Promise<CorpusMemorySearchResult[]>;
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
- const MAX_EXCERPT_CHARS = 1200;
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; query fallback active"));
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 = generator ? queryConversation(event.prompt, event.messages, config.historyMessages) : undefined;
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 = conversation ? judgeTrainingInput(conversation, apiKey, gateSignal)
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 && conversation) {
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 = queries.length;
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
- log("warn", "query_fallback", { ...failureFields(error), elapsedMs: performance.now() - generationStarted });
115
- diagnostics?.record(agentId, "memory", "query_fallback");
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
- const hits = queries && manager.searchWhisperer ? await manager.searchWhisperer(queries, { corpora, signal, maxSnippetChars: MAX_EXCERPT_CHARS }) : await manager.search(buildSkillWhispererQuery(event.prompt, event.messages, config.historyMessages), { corpora, maxResults: 8, minScore: -1, signal, maxSnippetChars: MAX_EXCERPT_CHARS });
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.some(candidate => candidate.id === id ||
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: judgeConversation,
177
+ conversation, asOf,
176
178
  candidates: [{
177
- excerpt, corpus: hit.corpus, ...(hit.messageTimestamp ? { messageTimestamp: hit.messageTimestamp } : {}),
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)
@@ -1,13 +1,7 @@
1
1
  import type { UnblockMemoryConfig } from "./config.js";
2
- type Conversation = {
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[], historyMessages: number): Conversation;
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: Conversation, signal: AbortSignal): Promise<string[]>;
13
+ generate(conversation: QueryConversation, signal: AbortSignal): Promise<QueryPair>;
20
14
  close(): void;
21
15
  }
22
- export {};
@@ -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, historyMessages) {
12
+ export function queryConversation(prompt, messages) {
11
13
  const currentRequest = conversationUserText(prompt)?.text ?? "";
12
- const visible = messages.flatMap(message => {
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 content = text?.role === "user" ? conversationUserText(text.text)?.text : text?.text;
15
- return text && content ? [{ role: text.role, content }] : [];
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
- const history = historyMessages ? visible.slice(-historyMessages) : [];
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.v1");
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
- queries = JSON.parse(value.text).queries;
139
+ request.resolve(parseQueryPair(JSON.parse(value.text)));
110
140
  }
111
- catch { /* reject below */ }
112
- const usable = Array.isArray(queries) ? [...new Set(queries
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
+ };