@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.
Files changed (52) hide show
  1. package/README.md +3 -3
  2. package/dist/src/config.d.ts +3 -4
  3. package/dist/src/config.js +31 -11
  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 +37 -42
  11. package/dist/src/query-contract.d.ts +31 -0
  12. package/dist/src/query-contract.js +58 -0
  13. package/dist/src/query-generator.d.ts +15 -0
  14. package/dist/src/query-generator.js +136 -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 +24 -7
  26. package/dist/src/training-queries.js +237 -101
  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 +85 -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 +5 -3
  39. package/docs/memory-training.md +120 -58
  40. package/docs/retrieval.md +59 -43
  41. package/openclaw.plugin.json +15 -6
  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
  51. package/dist/src/mlx-query.d.ts +0 -22
  52. 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, 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,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
- mlx?: {
73
- pythonPath: string;
74
- modelPath: string;
71
+ api: {
72
+ endpoint: string;
73
+ apiKeyFile?: string;
75
74
  };
76
75
  };
77
76
  };
@@ -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: [], historyMessages: 5, minUsefulness: 0.7,
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
- let mlx;
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)], historyMessages, cooldownTurns, minUsefulness,
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
- ...(mlx ? { mlx } : {}),
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" | "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
- import { MlxQueryGenerator, queryConversation } from "./mlx-query.js";
5
+ import { ApiQueryGenerator, QueryApiError, queryConversation } from "./query-generator.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
  }
@@ -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 = config.mlx ? MlxQueryGenerator.shared(config.mlx) : undefined;
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 = generator ? queryConversation(event.prompt, event.messages, config.historyMessages) : undefined;
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 = conversation ? judgeTrainingInput(conversation, apiKey, gateSignal)
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
- }) : Promise.resolve(true);
95
+ });
99
96
  const retrieveAndJudge = async () => {
100
97
  let queries;
101
- if (generator && conversation) {
102
- stage = "generation";
103
- const generationStarted = performance.now();
104
- try {
105
- queries = await generator.generate(conversation, signal);
106
- if (signal.aborted)
107
- return;
108
- queryCount = queries.length;
109
- diagnostics?.record(agentId, "memory", "queries_generated");
110
- }
111
- catch (error) {
112
- if (signal.aborted)
113
- return;
114
- log("warn", "query_fallback", { ...failureFields(error), elapsedMs: performance.now() - generationStarted });
115
- diagnostics?.record(agentId, "memory", "query_fallback");
116
- }
117
- finally {
118
- measurement.generationMs = performance.now() - generationStarted;
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
- 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 });
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.some(candidate => candidate.id === id ||
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: judgeConversation,
169
+ conversation, asOf,
176
170
  candidates: [{
177
- excerpt, corpus: hit.corpus, ...(hit.messageTimestamp ? { messageTimestamp: hit.messageTimestamp } : {}),
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
+ };