@unblocklabs/unblock-memory 0.7.3 → 0.8.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 CHANGED
@@ -2,8 +2,9 @@
2
2
 
3
3
  Workspace-native memory for OpenClaw, powered internally by `@unblocklabs/qmd`.
4
4
  It keeps one warm QMD store per agent and exposes `memory_search` and `memory_get`.
5
- Ordinary recall is local vector search over semantic chunks: no query expansion,
6
- reranker or TypeSafe key is required.
5
+ Recall combines BM25 and vector candidates, then independently ranks complete
6
+ passages with TypeSafe. The agent supplies both queries; no query generation or
7
+ "do we need memory?" gate runs for explicit search. A TypeSafe key is required.
7
8
 
8
9
  ## Start here
9
10
 
@@ -38,7 +39,7 @@ Optional installation from source:
38
39
 
39
40
  | Capability | What it does | Default / prerequisite |
40
41
  | --- | --- | --- |
41
- | [Search and reads](docs/retrieval.md#search-and-read) | Vector recall, then exact indexed source reads | Available with the memory plugin; files only unless more corpora are configured |
42
+ | [Search and reads](docs/retrieval.md#search-and-read) | BM25 + vector recall with TypeSafe ranking, then exact indexed reads | Search requires TypeSafe; reads stay local; files only unless more corpora are configured |
42
43
  | [Session indexing](docs/retrieval.md#sessions) | Makes past user/assistant exchanges searchable | Opt-in corpus; channel/group by default, DMs explicitly included |
43
44
  | [Skill Whisperer](docs/retrieval.md#skill-whisperer) | Suggests one relevant skill; never invokes it | Off; explicit skills corpus; TypeSafe optional |
44
45
  | [Memory Whisperer](docs/retrieval.md#memory-whisperer) | Injects up to two useful historical excerpts; query generation via resident API | Off; approved corpora + TypeSafe + query API credential |
@@ -52,10 +53,9 @@ Optional installation from source:
52
53
  | [Session memory extraction](docs/extraction.md) | Luna proposes lasting facts; Jev verifies them; optional QMD search | Off; approved session types + TypeSafe + host LLM permission; shadow-only by default |
53
54
  | [Compaction memory flush](docs/configuration.md#compaction-memory-writes) | Supplies the host an append-only daily-memory write plan | Offered unless the host's memory-flush setting is false; separate from whisperers |
54
55
 
55
- **Search is not one interchangeable API:** plugin `memory_search` is vector-only;
56
- standalone QMD `query` is hybrid vector + BM25 with TypeSafe ranking.
57
- They have separate configuration/index boundaries. See
58
- [search modes and the xsearch migration](docs/retrieval.md#qmd-search-modes).
56
+ **One agent search pipeline:** `memory_search` and Memory Whisperer share
57
+ candidate discovery, passage rendering, deduplication and TypeSafe ranking.
58
+ Whisperer additionally generates queries and applies its automatic recall/delivery policy.
59
59
 
60
60
  ## Configuration and operating guides
61
61
 
@@ -73,9 +73,9 @@ They have separate configuration/index boundaries. See
73
73
  - [Session extraction](docs/extraction.md): incremental/backfill runs, configurable
74
74
  acceptance thresholds, shadow evaluation, and publishing lasting facts to search.
75
75
 
76
- The shared TypeSafe integration defaults on, but its features are opt-in.
77
- A key activates only features already enabled. Ordinary search and People
78
- Whisperer's injection stay local; Skill Whisperer has a local fallback.
76
+ The shared TypeSafe integration defaults on. Ordinary search requires a key;
77
+ other TypeSafe features remain opt-in. People Whisperer's injection stays local;
78
+ Skill Whisperer has a local fallback.
79
79
  [Provider gates and failure behavior](docs/configuration.md#feature-gates-and-fallbacks)
80
80
  differ by feature.
81
81
 
@@ -1,8 +1,39 @@
1
1
  import type { DatabaseSync } from "node:sqlite";
2
- /** The OpenClaw agent database versions whose transcript layout we read. */
2
+ /** Identity fence, not a payload-format allowlist. Required capabilities are checked below. */
3
3
  export declare function agentTranscriptSchemaVersion(db: DatabaseSync, errorMessage?: string): number;
4
4
  export declare function assertAgentTranscriptIdentity(db: DatabaseSync, agentId: string, version: number, errorMessage?: string): void;
5
- export declare function assertAgentTranscriptSchema(db: DatabaseSync, agentId: string, errorMessage?: string): void;
6
- export declare const ACTIVE_EVENTS_FROM = "FROM session_transcript_active_events a\n JOIN transcript_events e ON e.session_id=a.session_id AND e.seq=a.event_seq";
7
- export declare const ACTIVE_EVENT_COUNT_SQL = "SELECT COUNT(*) n,COALESCE(SUM(length(e.event_json)),0) bytes\n FROM session_transcript_active_events a\n JOIN transcript_events e ON e.session_id=a.session_id AND e.seq=a.event_seq WHERE a.session_id=?";
8
- export declare const ACTIVE_EVENT_ROWS_SQL = "SELECT e.seq,e.event_json eventJson,e.created_at createdAt\n FROM session_transcript_active_events a\n JOIN transcript_events e ON e.session_id=a.session_id AND e.seq=a.event_seq WHERE a.session_id=? ORDER BY a.active_position";
5
+ type AgentTranscriptRow = {
6
+ seq: number;
7
+ eventJson: string;
8
+ createdAt: number;
9
+ };
10
+ type TranscriptSnapshot = {
11
+ kind: "ready";
12
+ rows: AgentTranscriptRow[];
13
+ } | {
14
+ kind: "cold";
15
+ } | {
16
+ kind: "oversized";
17
+ } | {
18
+ kind: "unreadable";
19
+ };
20
+ /**
21
+ * One read-only storage boundary for full-fidelity active transcripts. The public
22
+ * SDK's full reader may restore cold archives; its read-only catalog truncates and
23
+ * redacts content. Neither currently provides the contract these consumers need.
24
+ * Keep payload SQL here until the SDK offers a bounded, full-fidelity read-only API.
25
+ */
26
+ export declare class AgentTranscriptReader {
27
+ #private;
28
+ readonly db: DatabaseSync;
29
+ constructor(db: DatabaseSync, agentId: string, errorMessage?: string);
30
+ /** Include storage availability in incremental fingerprints, not just hot row counts. */
31
+ coldSql(sessionIdExpression: string): string;
32
+ /** Caller owns a transaction so metadata, cold marker, bounds and rows share one snapshot. */
33
+ read(sessionId: string, limits?: {
34
+ maxEvents?: number;
35
+ maxBytes?: number;
36
+ maxSeq?: number;
37
+ }): TranscriptSnapshot;
38
+ }
39
+ export {};
@@ -1,8 +1,9 @@
1
- /** The OpenClaw agent database versions whose transcript layout we read. */
1
+ import { zstdDecompressSync } from "node:zlib";
2
+ /** Identity fence, not a payload-format allowlist. Required capabilities are checked below. */
2
3
  export function agentTranscriptSchemaVersion(db, errorMessage) {
3
4
  const version = db.prepare("PRAGMA user_version").get()?.user_version;
4
- if (typeof version !== "number" || ![17, 18, 19].includes(version)) {
5
- throw new Error(errorMessage ?? "unsupported OpenClaw agent database schema: expected one of 17, 18, 19, " +
5
+ if (typeof version !== "number" || !Number.isSafeInteger(version) || version < 17) {
6
+ throw new Error(errorMessage ?? "unsupported OpenClaw agent database schema: expected 17 or newer, " +
6
7
  `found ${String(version ?? "unknown")}`);
7
8
  }
8
9
  return version;
@@ -18,12 +19,101 @@ export function assertAgentTranscriptIdentity(db, agentId, version, errorMessage
18
19
  fail(`OpenClaw agent database belongs to ${String(meta?.agentId)}, not ${agentId}`);
19
20
  }
20
21
  }
21
- export function assertAgentTranscriptSchema(db, agentId, errorMessage) {
22
- assertAgentTranscriptIdentity(db, agentId, agentTranscriptSchemaVersion(db, errorMessage), errorMessage);
23
- }
24
- export const ACTIVE_EVENTS_FROM = `FROM session_transcript_active_events a
22
+ const ACTIVE_EVENTS_FROM = `FROM session_transcript_active_events a
25
23
  JOIN transcript_events e ON e.session_id=a.session_id AND e.seq=a.event_seq`;
26
- export const ACTIVE_EVENT_COUNT_SQL = `SELECT COUNT(*) n,COALESCE(SUM(length(e.event_json)),0) bytes
27
- ${ACTIVE_EVENTS_FROM} WHERE a.session_id=?`;
28
- export const ACTIVE_EVENT_ROWS_SQL = `SELECT e.seq,e.event_json eventJson,e.created_at createdAt
29
- ${ACTIVE_EVENTS_FROM} WHERE a.session_id=? ORDER BY a.active_position`;
24
+ const MAX_COMPRESSED_BYTES = 4 * 1024 * 1024;
25
+ const utf8 = new TextDecoder("utf-8", { fatal: true, ignoreBOM: true });
26
+ /**
27
+ * One read-only storage boundary for full-fidelity active transcripts. The public
28
+ * SDK's full reader may restore cold archives; its read-only catalog truncates and
29
+ * redacts content. Neither currently provides the contract these consumers need.
30
+ * Keep payload SQL here until the SDK offers a bounded, full-fidelity read-only API.
31
+ */
32
+ export class AgentTranscriptReader {
33
+ db;
34
+ #schemaVersion;
35
+ #compressed;
36
+ #cold;
37
+ #utf8Storage;
38
+ constructor(db, agentId, errorMessage) {
39
+ this.db = db;
40
+ const version = agentTranscriptSchemaVersion(db, errorMessage);
41
+ this.#schemaVersion = version;
42
+ this.#utf8Storage = db.prepare("PRAGMA encoding").get()?.encoding === "UTF-8";
43
+ assertAgentTranscriptIdentity(db, agentId, version, errorMessage);
44
+ const required = {
45
+ transcript_events: ["session_id", "seq", "event_json", "created_at"],
46
+ session_transcript_active_events: ["session_id", "active_position", "event_seq"],
47
+ };
48
+ const columns = (table) => new Set(db.prepare(`PRAGMA table_info(${table})`).all().map(row => row.name));
49
+ for (const [table, names] of Object.entries(required)) {
50
+ const found = columns(table);
51
+ if (names.some(name => !found.has(name)))
52
+ throw new Error(`Unsupported transcript capabilities: ${table}`);
53
+ }
54
+ const payload = columns("transcript_events");
55
+ this.#compressed = payload.has("event_zstd") && payload.has("event_utf8_bytes");
56
+ if ((version >= 23 || payload.has("event_zstd") || payload.has("event_utf8_bytes")) && !this.#compressed) {
57
+ throw new Error("Unsupported transcript compression capabilities");
58
+ }
59
+ this.#cold = columns("session_transcript_cold_archives").has("session_id");
60
+ if (version >= 20 && !this.#cold)
61
+ throw new Error("Unsupported transcript cold-storage capabilities");
62
+ }
63
+ /** Include storage availability in incremental fingerprints, not just hot row counts. */
64
+ coldSql(sessionIdExpression) {
65
+ return this.#cold ? `EXISTS (SELECT 1 FROM session_transcript_cold_archives cold WHERE cold.session_id=${sessionIdExpression})` : "0";
66
+ }
67
+ /** Caller owns a transaction so metadata, cold marker, bounds and rows share one snapshot. */
68
+ read(sessionId, limits = {}) {
69
+ if (!this.db.isTransaction)
70
+ throw new Error("Transcript read requires a snapshot transaction");
71
+ // Long-lived audit/source readers may outlive a host migration. Reopen before
72
+ // trusting cached capabilities; absence of old hot rows is not deletion proof.
73
+ if (this.db.prepare("PRAGMA user_version").get()?.user_version !== this.#schemaVersion)
74
+ return { kind: "unreadable" };
75
+ if (this.#cold && this.db.prepare("SELECT 1 FROM session_transcript_cold_archives WHERE session_id=?").get(sessionId)) {
76
+ return { kind: "cold" };
77
+ }
78
+ const maxEvents = limits.maxEvents ?? 50_000, maxBytes = limits.maxBytes ?? 64 * 1024 * 1024;
79
+ const maxSeq = limits.maxSeq ?? Number.MAX_SAFE_INTEGER;
80
+ // SQLite's BLOB length measures the database encoding, not decoded UTF-8.
81
+ // Non-UTF-8 stores still enforce the byte budget below during bounded iteration.
82
+ const bytes = !this.#utf8Storage ? "0" : this.#compressed
83
+ ? "COALESCE(length(CAST(e.event_json AS BLOB)),e.event_utf8_bytes,0)" : "length(CAST(e.event_json AS BLOB))";
84
+ const size = this.db.prepare(`SELECT COUNT(*) n,COALESCE(SUM(${bytes}),0) bytes
85
+ ${ACTIVE_EVENTS_FROM} WHERE a.session_id=? AND e.seq<=?`).get(sessionId, maxSeq);
86
+ if (Number(size.n) > maxEvents || Number(size.bytes) > maxBytes)
87
+ return { kind: "oversized" };
88
+ const rows = [];
89
+ let totalBytes = 0;
90
+ for (const row of this.db.prepare(`SELECT e.seq,e.event_json eventJson,e.created_at createdAt
91
+ ${this.#compressed ? ",e.event_zstd compressed,e.event_utf8_bytes rawBytes" : ""}
92
+ ${ACTIVE_EVENTS_FROM} WHERE a.session_id=? AND e.seq<=? ORDER BY a.active_position`).iterate(sessionId, maxSeq)) {
93
+ let eventJson;
94
+ if (typeof row.eventJson === "string")
95
+ eventJson = row.eventJson;
96
+ else {
97
+ const compressed = row.compressed, rawBytes = row.rawBytes;
98
+ if (!(compressed instanceof Uint8Array) || !compressed.byteLength || compressed.byteLength > MAX_COMPRESSED_BYTES ||
99
+ typeof rawBytes !== "number" || !Number.isSafeInteger(rawBytes) || rawBytes <= 0 || rawBytes > MAX_COMPRESSED_BYTES) {
100
+ return { kind: "unreadable" };
101
+ }
102
+ try {
103
+ const decoded = zstdDecompressSync(compressed, { maxOutputLength: rawBytes });
104
+ if (decoded.byteLength !== rawBytes)
105
+ return { kind: "unreadable" };
106
+ eventJson = utf8.decode(decoded);
107
+ }
108
+ catch {
109
+ return { kind: "unreadable" };
110
+ }
111
+ }
112
+ totalBytes += Buffer.byteLength(eventJson);
113
+ if (totalBytes > maxBytes)
114
+ return { kind: "oversized" };
115
+ rows.push({ seq: Number(row.seq), eventJson, createdAt: Number(row.createdAt) });
116
+ }
117
+ return { kind: "ready", rows };
118
+ }
119
+ }
@@ -31,7 +31,7 @@ export type SessionSearchFilter = {
31
31
  export type MemoryRequestContext = Pick<OpenClawPluginToolContext, "sessionKey" | "sessionId" | "messageChannel" | "agentAccountId" | "nativeChannelId" | "deliveryContext">;
32
32
  export type CorpusSearchOptions = Omit<NonNullable<Parameters<MemorySearchManagerContract["search"]>[1]>, "lexicalOnly"> & {
33
33
  corpora?: readonly string[];
34
- /** Internal vector-hint budget; oversized matched chunks are omitted, never sliced. */
34
+ /** Internal passage budget; oversized matched chunks are omitted, never sliced. */
35
35
  maxSnippetChars?: number;
36
36
  sessionFilter?: SessionSearchFilter;
37
37
  requestContext?: MemoryRequestContext;
@@ -1,12 +1,13 @@
1
1
  import { RetrievalTelemetry } from "./retrieval-telemetry.js";
2
2
  type Whisperer = "skill" | "memory";
3
- type Outcome = "missing_key" | "typesafe_disabled" | "no_candidates" | "rejected" | "cooldown" | "emitted" | "failed" | "timed_out" | "cancelled" | "unavailable" | "payload_limit" | "redundancy_unavailable" | "recall_not_needed" | "queries_generated" | "judge_candidate_failed";
3
+ type Outcome = "missing_key" | "typesafe_disabled" | "no_candidates" | "rejected" | "cooldown" | "emitted" | "failed" | "timed_out" | "cancelled" | "unavailable" | "payload_limit" | "redundancy_unavailable" | "recall_not_needed" | "queries_generated" | "judge_candidate_failed" | "input_too_large";
4
4
  /** Process-local, content-free and bounded. Agent IDs are keys, never included in snapshots. */
5
5
  export declare class WhispererDiagnostics {
6
6
  #private;
7
7
  static shared(): WhispererDiagnostics;
8
8
  record(agentId: string, whisperer: Whisperer, outcome: Outcome): void;
9
9
  measureMemory(agentId: string, observation: Parameters<RetrievalTelemetry["record"]>[1]): void;
10
+ measureSearch(agentId: string, observation: Parameters<RetrievalTelemetry["record"]>[1]): void;
10
11
  snapshot(agentId: string): {
11
12
  skill: {
12
13
  cancelled?: number | undefined;
@@ -24,6 +25,7 @@ export declare class WhispererDiagnostics {
24
25
  recall_not_needed?: number | undefined;
25
26
  queries_generated?: number | undefined;
26
27
  judge_candidate_failed?: number | undefined;
28
+ input_too_large?: number | undefined;
27
29
  };
28
30
  memory: {
29
31
  cancelled?: number | undefined;
@@ -41,6 +43,7 @@ export declare class WhispererDiagnostics {
41
43
  recall_not_needed?: number | undefined;
42
44
  queries_generated?: number | undefined;
43
45
  judge_candidate_failed?: number | undefined;
46
+ input_too_large?: number | undefined;
44
47
  };
45
48
  telemetry: {
46
49
  scope: string;
@@ -50,6 +53,7 @@ export declare class WhispererDiagnostics {
50
53
  outcomes: {
51
54
  ok?: number | undefined;
52
55
  cancelled?: number | undefined;
56
+ partial?: number | undefined;
53
57
  skipped?: number | undefined;
54
58
  failed?: number | undefined;
55
59
  empty?: number | undefined;
@@ -24,6 +24,9 @@ export class WhispererDiagnostics {
24
24
  measureMemory(agentId, observation) {
25
25
  this.#entry(agentId).telemetry.record("memoryWhisperer", observation);
26
26
  }
27
+ measureSearch(agentId, observation) {
28
+ this.#entry(agentId).telemetry.record("memorySearch", observation);
29
+ }
27
30
  snapshot(agentId) {
28
31
  const entry = this.#agents.get(agentId);
29
32
  return { skill: { ...entry?.counts.skill }, memory: { ...entry?.counts.memory },
@@ -5,7 +5,7 @@ import { createZstdDecompress, zstdDecompressSync } from "node:zlib";
5
5
  import { createInterface } from "node:readline";
6
6
  import { PassThrough } from "node:stream";
7
7
  import { pipeline } from "node:stream/promises";
8
- import { ACTIVE_EVENTS_FROM, assertAgentTranscriptSchema } from "./agent-transcript.js";
8
+ import { AgentTranscriptReader } from "./agent-transcript.js";
9
9
  /** Files contain branch trees; keep the selected leaf, not abandoned sibling conversations. */
10
10
  function selectedBranch(events) {
11
11
  const nodes = new Map(events.filter(e => e.id).map(e => [e.id, e]));
@@ -57,10 +57,11 @@ export async function* readInsideOutSources(paths, errors) {
57
57
  const db = new DatabaseSync(paths.databasePath, { readOnly: true });
58
58
  try {
59
59
  db.exec("PRAGMA busy_timeout=1000");
60
- assertAgentTranscriptSchema(db, paths.agentId);
60
+ const transcripts = new AgentTranscriptReader(db, paths.agentId);
61
61
  const sessions = db.prepare(`SELECT w.session_id sessionId, COALESCE(w.channel,c.channel,'local') channel,
62
62
  COALESCE(w.account_id,c.account_id,'') account, COALESCE(r.generation,'') revision,
63
- COALESCE((SELECT MAX(seq) FROM transcript_events WHERE session_id=w.session_id),0) tail FROM session_windows w
63
+ COALESCE((SELECT MAX(seq) FROM transcript_events WHERE session_id=w.session_id),0) tail,
64
+ ${transcripts.coldSql("w.session_id")} cold FROM session_windows w
64
65
  LEFT JOIN conversations c ON c.conversation_id=w.primary_conversation_id
65
66
  LEFT JOIN transcript_rewrite_watermarks r ON r.session_id=w.session_id
66
67
  WHERE (? IS NULL OR w.session_id=?) ORDER BY w.started_at,w.session_id`).all(paths.sessionId ?? null, paths.sessionId ?? null);
@@ -83,15 +84,23 @@ export async function* readInsideOutSources(paths, errors) {
83
84
  }
84
85
  for (const s of sessions) {
85
86
  const sessionId = String(s.sessionId), source = `live:${sessionId}`;
86
- yield { source, sessionId, revision: String(s.revision), signature: JSON.stringify([s.revision, s.tail]), read: () => {
87
- const events = db.prepare(`SELECT e.seq,e.event_json eventJson,e.created_at createdAt
88
- ${ACTIVE_EVENTS_FROM} WHERE a.session_id=? AND e.seq<=? ORDER BY a.active_position`).all(sessionId, s.tail).map(row => {
89
- const event = JSON.parse(String(row.eventJson));
90
- event.seq = Number(row.seq);
91
- event.timestamp ??= new Date(Number(row.createdAt)).toISOString();
92
- return event;
93
- });
94
- return { sessionId, source, events, ...metadata.get(sessionId) };
87
+ yield { source, sessionId, revision: String(s.revision), signature: JSON.stringify([s.revision, s.tail, s.cold]), read: () => {
88
+ db.exec("BEGIN");
89
+ try {
90
+ const snapshot = transcripts.read(sessionId, { maxSeq: Number(s.tail) });
91
+ if (snapshot.kind !== "ready")
92
+ throw new Error(`Transcript unavailable: ${snapshot.kind}`);
93
+ const events = snapshot.rows.map(row => {
94
+ const event = JSON.parse(row.eventJson);
95
+ event.seq = row.seq;
96
+ event.timestamp ??= new Date(row.createdAt).toISOString();
97
+ return event;
98
+ });
99
+ return { sessionId, source, events, ...metadata.get(sessionId) };
100
+ }
101
+ finally {
102
+ db.exec("COMMIT");
103
+ }
95
104
  } };
96
105
  }
97
106
  }
@@ -56,6 +56,7 @@ export declare class QmdMemoryManager implements MemorySearchManagerContract {
56
56
  outcomes: {
57
57
  ok?: number | undefined;
58
58
  cancelled?: number | undefined;
59
+ partial?: number | undefined;
59
60
  skipped?: number | undefined;
60
61
  failed?: number | undefined;
61
62
  empty?: number | undefined;
@@ -219,8 +220,8 @@ export declare class QmdMemoryManager implements MemorySearchManagerContract {
219
220
  };
220
221
  }): MaintenanceTask | undefined;
221
222
  search(query: string, opts?: CorpusSearchOptions): Promise<CorpusMemorySearchResult[]>;
222
- /** Whisperer-only discovery: exact trained recipe, no query-conditioned reranker or merged cap. */
223
- searchWhisperer(queries: QueryPair, opts: Pick<CorpusSearchOptions, "corpora" | "signal" | "maxSnippetChars">): Promise<CorpusMemorySearchResult[]>;
223
+ /** Shared discovery: exact trained recipe, scoped before either retrieval lane. */
224
+ searchCandidates(queries: QueryPair, opts: CorpusSearchOptions): Promise<CorpusMemorySearchResult[]>;
224
225
  searchSkills(query: string, minScore: number, limit: number): Promise<SkillSearchCandidate[]>;
225
226
  readFile(params: {
226
227
  relPath: string;
@@ -831,8 +831,8 @@ export class QmdMemoryManager {
831
831
  opts?.signal?.throwIfAborted();
832
832
  return this.#renderSearchHits(hits, store, opts);
833
833
  }
834
- /** Whisperer-only discovery: exact trained recipe, no query-conditioned reranker or merged cap. */
835
- async searchWhisperer(queries, opts) {
834
+ /** Shared discovery: exact trained recipe, scoped before either retrieval lane. */
835
+ async searchCandidates(queries, opts) {
836
836
  return this.#enqueue(async () => {
837
837
  opts.signal?.throwIfAborted();
838
838
  const collections = this.#collectionNames(opts.corpora);
@@ -840,12 +840,18 @@ export class QmdMemoryManager {
840
840
  return [];
841
841
  if (this.#sessions && collections.includes(this.#sessions.collection))
842
842
  await this.#refreshSessionMetadata();
843
+ const sessions = this.#sessions;
844
+ let allowedPaths = opts.sessionFilter && sessions && collections.includes(sessions.collection)
845
+ ? sessionAllowedPaths(this.#sessionMetadata, sessions.collection, opts.sessionFilter) : undefined;
846
+ if (collections.includes(EXTRACTED_COLLECTION)) {
847
+ allowedPaths = { ...allowedPaths, [EXTRACTED_COLLECTION]: this.#currentExtracted(undefined, opts.sessionFilter).map(extractedPath) };
848
+ }
843
849
  const store = await this.#getAnalysisStore();
844
850
  const hits = new Map();
845
851
  // Serialize native QMD work. Cancellation prevents further queries/collections.
846
852
  for (const lane of ["lex", "vec"]) {
847
853
  opts.signal?.throwIfAborted();
848
- for (const hit of await trainingCandidates(store, queries[lane], collections, lane, opts.signal)) {
854
+ for (const hit of await trainingCandidates(store, queries[lane], collections, lane, opts.signal, allowedPaths)) {
849
855
  const key = JSON.stringify([hit.file, hit.bestChunkPos, hit.bestChunk]);
850
856
  if (!hits.has(key))
851
857
  hits.set(key, { file: hit.file, body: hit.body, bestChunk: hit.bestChunk,
@@ -0,0 +1,31 @@
1
+ import type { CorpusMemorySearchResult, CorpusSearchOptions } from "./contracts.js";
2
+ import type { QueryConversation, QueryPair } from "./query-contract.js";
3
+ export type MemoryCandidateManager = {
4
+ searchCandidates(queries: QueryPair, options: CorpusSearchOptions): Promise<CorpusMemorySearchResult[]>;
5
+ };
6
+ export declare function memoryPassageId(text: string): string;
7
+ export type MemorySearchObservation = {
8
+ retrievalMs: number;
9
+ judgeMs: number;
10
+ candidates: number;
11
+ eligible: number;
12
+ requestsSucceeded: number;
13
+ requestsFailed: number;
14
+ };
15
+ /** Both entry points use the same complete passages and independent usefulness judgments. */
16
+ export declare function searchMemory(manager: MemoryCandidateManager, queries: QueryPair, options: CorpusSearchOptions & {
17
+ apiKey: string;
18
+ timeoutMs: number;
19
+ conversation: QueryConversation;
20
+ asOf?: string;
21
+ minUsefulness?: number;
22
+ excludedPassages?: ReadonlyMap<string, number>;
23
+ onCandidates?: (observation: MemorySearchObservation) => void;
24
+ onJudgment?: (event: {
25
+ candidateIndex: number;
26
+ elapsedMs: number;
27
+ error?: unknown;
28
+ }) => void;
29
+ }): Promise<MemorySearchObservation & {
30
+ results: CorpusMemorySearchResult[];
31
+ }>;
@@ -0,0 +1,61 @@
1
+ import { createHash } from "node:crypto";
2
+ import { MEMORY_PASSAGE_CHARS, duplicateMemoryPassage } from "./memory-passage.js";
3
+ import { judgeMemoryPassage, memoryUsefulnessRequest } from "./typesafe.js";
4
+ export function memoryPassageId(text) {
5
+ return createHash("sha256").update(text.replace(/\s+/gu, " ").trim()).digest("hex");
6
+ }
7
+ /** Both entry points use the same complete passages and independent usefulness judgments. */
8
+ export async function searchMemory(manager, queries, options) {
9
+ const { signal } = options;
10
+ signal?.throwIfAborted();
11
+ const asOf = options.asOf ?? new Date().toISOString();
12
+ const started = performance.now();
13
+ const hits = await manager.searchCandidates(queries, {
14
+ corpora: options.corpora, sessionFilter: options.sessionFilter, requestContext: options.requestContext,
15
+ sources: options.sources, signal, maxSnippetChars: MEMORY_PASSAGE_CHARS,
16
+ });
17
+ signal?.throwIfAborted();
18
+ const observation = { retrievalMs: performance.now() - started, judgeMs: 0,
19
+ candidates: hits.length, eligible: 0, requestsSucceeded: 0, requestsFailed: 0 };
20
+ const candidates = [];
21
+ for (const hit of hits) {
22
+ // Defense in depth before disclosing passages to the external judge.
23
+ if (options.corpora && !options.corpora.includes("all") && !options.corpora.includes(hit.corpus))
24
+ continue;
25
+ const excerpt = hit.snippet.trim();
26
+ if (!excerpt || excerpt.length > MEMORY_PASSAGE_CHARS || options.excludedPassages?.has(memoryPassageId(excerpt)) ||
27
+ duplicateMemoryPassage({ ...hit, text: excerpt }, candidates.map(candidate => ({ ...candidate, text: candidate.snippet }))))
28
+ continue;
29
+ candidates.push({ ...hit, snippet: excerpt });
30
+ }
31
+ observation.eligible = candidates.length;
32
+ options.onCandidates?.(observation);
33
+ const judgeStarted = performance.now();
34
+ const judged = (await Promise.all(candidates.map(async (hit, candidateIndex) => {
35
+ const requestStarted = performance.now();
36
+ try {
37
+ const { probability } = await judgeMemoryPassage(memoryUsefulnessRequest(options.conversation, {
38
+ excerpt: hit.snippet, corpus: hit.corpus, sourcePath: hit.path,
39
+ dates: [...new Set(hit.sessionMessages?.flatMap(message => message.timestamp ? [message.timestamp] : [])
40
+ ?? (hit.messageTimestamp ? [hit.messageTimestamp] : []))],
41
+ }, asOf), options);
42
+ signal?.throwIfAborted();
43
+ observation.requestsSucceeded++;
44
+ options.onJudgment?.({ candidateIndex, elapsedMs: performance.now() - requestStarted });
45
+ // Discovery reciprocal ranks are not vector similarity or usefulness.
46
+ const { vectorScore: _vectorScore, textScore: _textScore, ...result } = hit;
47
+ return [{ ...result, score: probability }];
48
+ }
49
+ catch (error) {
50
+ if (signal?.aborted)
51
+ return [];
52
+ observation.requestsFailed++;
53
+ options.onJudgment?.({ candidateIndex, elapsedMs: performance.now() - requestStarted, error });
54
+ return [];
55
+ }
56
+ }))).flat();
57
+ signal?.throwIfAborted();
58
+ observation.judgeMs = performance.now() - judgeStarted;
59
+ return { ...observation, results: judged.filter(hit => hit.score >= (options.minUsefulness ?? 0.7))
60
+ .sort((a, b) => b.score - a.score).slice(0, options.maxResults ?? 5) };
61
+ }
@@ -1,17 +1,13 @@
1
1
  import type { OpenClawConfig, OpenClawPluginApi } from "openclaw/plugin-sdk/plugin-entry";
2
2
  import type { UnblockMemoryConfig } from "./config.js";
3
- import type { CorpusMemorySearchResult, CorpusSearchOptions } from "./contracts.js";
4
3
  import type { WhispererDiagnostics } from "./diagnostics.js";
5
- import type { QueryPair } from "./query-contract.js";
4
+ import { type MemoryCandidateManager } from "./memory-search.js";
6
5
  type MemoryWhispererRuntime = {
7
6
  getMemorySearchManager(params: {
8
7
  cfg: OpenClawConfig;
9
8
  agentId: string;
10
9
  }): Promise<{
11
- manager: {
12
- search(query: string, opts?: CorpusSearchOptions): Promise<CorpusMemorySearchResult[]>;
13
- searchWhisperer?(queries: QueryPair, opts: Pick<CorpusSearchOptions, "corpora" | "signal" | "maxSnippetChars">): Promise<CorpusMemorySearchResult[]>;
14
- } | null;
10
+ manager: MemoryCandidateManager | null;
15
11
  }>;
16
12
  };
17
13
  export declare function registerMemoryWhisperer(api: OpenClawPluginApi, runtime: MemoryWhispererRuntime, config: UnblockMemoryConfig["memoryWhisperer"], typesafe: UnblockMemoryConfig["typesafe"], diagnostics?: WhispererDiagnostics): Parameters<typeof api.on<"before_prompt_build">>[1] | undefined;