@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.
@@ -1,14 +1,9 @@
1
- import { createHash } from "node:crypto";
2
- import { judgeMemoryPassage, memoryUsefulnessRequest } from "./typesafe.js";
3
1
  import { resolveTypeSafeApiKey, TypeSafeRequestError } from "./typesafe-client.js";
4
2
  import { complementaryIndices, reviewMemoryRedundancy } from "./typesafe-review.js";
5
3
  import { ApiQueryGenerator, QueryApiError, queryConversation } from "./query-generator.js";
6
4
  import { judgeTrainingInput, TRAINING_GATE_THRESHOLD } from "./training-gate.js";
7
- import { MEMORY_PASSAGE_CHARS, duplicateMemoryPassage } from "./memory-passage.js";
8
- const MAX_EXCERPT_CHARS = MEMORY_PASSAGE_CHARS;
9
- function fingerprint(text) {
10
- return createHash("sha256").update(text.replace(/\s+/gu, " ").trim()).digest("hex");
11
- }
5
+ import { QueryInputBudgetError } from "./query-contract.js";
6
+ import { searchMemory, memoryPassageId } from "./memory-search.js";
12
7
  export function registerMemoryWhisperer(api, runtime, config, typesafe, diagnostics) {
13
8
  if (!config.enabled || !typesafe.enabled)
14
9
  return;
@@ -29,7 +24,7 @@ export function registerMemoryWhisperer(api, runtime, config, typesafe, diagnost
29
24
  const started = performance.now();
30
25
  const asOf = new Date().toISOString();
31
26
  const measurement = { outcome: "skipped", elapsedMs: 0 };
32
- let stage = "credentials", reason = "completed";
27
+ let stage = "input", reason = "completed";
33
28
  let recallProbability, queryCount;
34
29
  let requestsSucceeded = 0, requestsFailed = 0;
35
30
  const log = (level, event, fields) => api.logger[level]("unblock-memory memory_whisperer " + JSON.stringify({ event, agentId, runId, sessionId, stage, ...fields }));
@@ -56,6 +51,8 @@ export function registerMemoryWhisperer(api, runtime, config, typesafe, diagnost
56
51
  signal.addEventListener("abort", onAbort, { once: true });
57
52
  });
58
53
  const run = async () => {
54
+ const conversation = queryConversation(event.prompt, event.messages);
55
+ stage = "credentials";
59
56
  const apiKey = await resolveTypeSafeApiKey(typesafe);
60
57
  if (signal.aborted)
61
58
  return;
@@ -64,8 +61,6 @@ export function registerMemoryWhisperer(api, runtime, config, typesafe, diagnost
64
61
  diagnostics?.record(agentId, "memory", "missing_key");
65
62
  return;
66
63
  }
67
- stage = "input";
68
- const conversation = queryConversation(event.prompt, event.messages);
69
64
  const gateStarted = performance.now();
70
65
  const gateSignal = AbortSignal.any([signal, AbortSignal.timeout(typesafe.timeoutMs)]);
71
66
  // Speculation is intentional: neither model generation nor QMD waits for the recall judgment.
@@ -125,81 +120,46 @@ export function registerMemoryWhisperer(api, runtime, config, typesafe, diagnost
125
120
  diagnostics?.record(agentId, "memory", "unavailable");
126
121
  return;
127
122
  }
128
- const retrievalStarted = performance.now();
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
- if (signal.aborted)
123
+ const search = await searchMemory(manager, queries, {
124
+ corpora, signal, apiKey, timeoutMs: typesafe.timeoutMs, conversation, asOf,
125
+ minUsefulness: config.minUsefulness, maxResults: 4, excludedPassages: state.recent,
126
+ onCandidates: observation => {
127
+ measurement.retrievalMs = observation.retrievalMs;
128
+ measurement.candidates = observation.candidates;
129
+ measurement.eligible = observation.eligible;
130
+ measurement.outcome = "empty";
131
+ stage = "judgment";
132
+ },
133
+ onJudgment: ({ candidateIndex, elapsedMs, error }) => {
134
+ const fields = { stage: "judgment", candidateIndex, elapsedMs, timeoutMs: typesafe.timeoutMs };
135
+ if (error !== undefined) {
136
+ requestsFailed++;
137
+ diagnostics?.record(agentId, "memory", "judge_candidate_failed");
138
+ log("warn", "candidate_failed", { ...fields, ...failureFields(error) });
139
+ }
140
+ else {
141
+ requestsSucceeded++;
142
+ log("info", "candidate_completed", fields);
143
+ }
144
+ },
145
+ });
146
+ if (signal.aborted || sessions.get(key) !== state)
133
147
  return;
134
- measurement.retrievalMs = performance.now() - retrievalStarted;
135
- measurement.candidates = hits.length;
136
- const candidates = [];
137
- for (const hit of hits) {
138
- // Enforce scope again before sending anything to the external judge.
139
- if (!corpora.includes(hit.corpus))
140
- continue;
141
- const excerpt = hit.snippet.trim();
142
- // Retrieval bounds context around a complete match. Never replace it
143
- // with a prefix if a manager returns an oversized result.
144
- if (excerpt.length > MAX_EXCERPT_CHARS)
145
- continue;
146
- const id = fingerprint(excerpt);
147
- if (!excerpt || state.recent.has(id) || duplicateMemoryPassage({ ...hit, text: excerpt }, candidates.map(candidate => ({ ...candidate.hit, text: candidate.excerpt }))))
148
- continue;
149
- candidates.push({ hit, excerpt, id });
150
- }
151
- measurement.eligible = candidates.length;
152
- measurement.outcome = "empty";
153
- if (!candidates.length) {
148
+ measurement.judgeMs = search.judgeMs;
149
+ if (!search.eligible) {
154
150
  reason = "no_candidates";
155
151
  diagnostics?.record(agentId, "memory", "no_candidates");
156
152
  return;
157
153
  }
158
- stage = "judgment";
159
- const judgeStarted = performance.now();
160
- // Every eligible passage gets its own request; no request sees another candidate.
161
- const judged = (await Promise.all(candidates.map(async (candidate, candidateIndex) => {
162
- const requestStarted = performance.now();
163
- const fields = { stage: "judgment", candidateIndex,
164
- timeoutMs: typesafe.timeoutMs };
165
- try {
166
- const { hit, excerpt } = candidate;
167
- const { probability } = await judgeMemoryPassage(memoryUsefulnessRequest(conversation, {
168
- excerpt, corpus: hit.corpus, sourcePath: hit.path,
169
- dates: [...new Set(hit.sessionMessages?.flatMap(message => message.timestamp ? [message.timestamp] : [])
170
- ?? (hit.messageTimestamp ? [hit.messageTimestamp] : []))],
171
- }, asOf), { apiKey, timeoutMs: typesafe.timeoutMs, signal });
172
- if (signal.aborted)
173
- return [];
174
- requestsSucceeded++;
175
- log("info", "candidate_completed", { ...fields, elapsedMs: performance.now() - requestStarted });
176
- // Failed candidates have no score, not zero. Keep each score with its own source.
177
- return [{ ...candidate, probability }];
178
- }
179
- catch (error) {
180
- if (signal.aborted)
181
- return [];
182
- requestsFailed++;
183
- diagnostics?.record(agentId, "memory", "judge_candidate_failed");
184
- log("warn", "candidate_failed", { ...fields, ...failureFields(error), elapsedMs: performance.now() - requestStarted });
185
- return [];
186
- }
187
- }))).flat();
188
- if (signal.aborted || sessions.get(key) !== state)
189
- return;
190
- measurement.judgeMs = performance.now() - judgeStarted;
191
154
  if (!await gate || signal.aborted || sessions.get(key) !== state)
192
155
  return;
193
- if (!judged.length) {
156
+ if (!search.requestsSucceeded) {
194
157
  reason = "all_candidates_failed";
195
158
  measurement.outcome = "failed";
196
159
  diagnostics?.record(agentId, "memory", "failed");
197
160
  return;
198
161
  }
199
- const ranked = judged
200
- .filter(candidate => candidate.probability >= config.minUsefulness)
201
- .sort((a, b) => b.probability - a.probability)
202
- .slice(0, 4);
162
+ const ranked = search.results.map(hit => ({ hit, excerpt: hit.snippet, id: memoryPassageId(hit.snippet) }));
203
163
  let selected = ranked.slice(0, config.maxHints);
204
164
  if (!selected.length) {
205
165
  reason = "rejected";
@@ -254,6 +214,12 @@ export function registerMemoryWhisperer(api, runtime, config, typesafe, diagnost
254
214
  return await Promise.race([run(), aborted]);
255
215
  }
256
216
  catch (error) {
217
+ if (error instanceof QueryInputBudgetError) {
218
+ reason = "input_too_large";
219
+ measurement.outcome = "skipped";
220
+ diagnostics?.record(agentId, "memory", "input_too_large");
221
+ return;
222
+ }
257
223
  reason = "stage_failed";
258
224
  measurement.outcome = "failed";
259
225
  if (!signal.aborted) {
@@ -17,9 +17,13 @@ import { registerResponseAudit } from "./response-runtime.js";
17
17
  import { registerInsideOut } from "./inside-out-runtime.js";
18
18
  import { registerMemoryTraining } from "./training-runtime.js";
19
19
  import { resolveTimezone } from "./session-projector.js";
20
+ import { searchMemory } from "./memory-search.js";
21
+ import { prepareQueryConversation, QueryInputBudgetError } from "./query-contract.js";
22
+ import { resolveTypeSafeApiKey } from "./typesafe-client.js";
20
23
  import { registerExtraction } from "./extraction-runtime.js";
21
24
  const searchParameters = Type.Object({
22
- query: Type.String({ pattern: "\\S" }),
25
+ bm25Query: Type.String({ pattern: "\\S", description: "Distinctive names, identifiers and keywords for BM25 retrieval." }),
26
+ vectorQuery: Type.String({ pattern: "\\S", description: "Natural-language question describing the information needed; also the intent used to judge usefulness." }),
23
27
  corpora: Type.Optional(Type.Array(Type.String({ pattern: "\\S" }), {
24
28
  minItems: 1, description: 'Configured corpus names; default is all non-skill corpora. Use ["all"] alone for explicit all-corpora recall.',
25
29
  })),
@@ -38,7 +42,7 @@ const searchParameters = Type.Object({
38
42
  conversationId: Type.Optional(Type.String({ pattern: "\\S" })),
39
43
  }, { additionalProperties: false, description: "Restricts session documents only; selected file corpora remain eligible. Not an audience access control." })),
40
44
  maxResults: Type.Optional(Type.Integer({ minimum: 1, maximum: 20, description: "Maximum hits; default 5." })),
41
- minScore: Type.Optional(Type.Number({ minimum: 0, maximum: 1, description: "Minimum vector similarity; default 0.3. Not confidence in factual truth." })),
45
+ minUsefulness: Type.Optional(Type.Number({ minimum: 0, maximum: 1, description: "Minimum TypeSafe usefulness probability; default 0.7. Not confidence in factual truth." })),
42
46
  }, { additionalProperties: false });
43
47
  const getParameters = Type.Object({
44
48
  path: Type.String({ pattern: "\\S" }),
@@ -49,45 +53,79 @@ const syncSessionsParameters = Type.Object({
49
53
  force: Type.Optional(Type.Boolean()),
50
54
  }, { additionalProperties: false });
51
55
  const syncStatusParameters = Type.Object({}, { additionalProperties: false });
52
- function createSearchTool(runtime, ctx) {
56
+ function createSearchTool(runtime, ctx, typesafe, diagnostics) {
53
57
  const active = getContext(ctx);
54
58
  if (!active)
55
59
  return null;
56
60
  return {
57
61
  name: "memory_search",
58
62
  label: "Memory Search",
59
- description: "Search this agent's configured memory corpora with local vector retrieval, not QMD's hybrid query. Skills are excluded. Session snippets are arrays of messages (type, name, timestamp, body; partial when incomplete); file snippets are strings. Results are evidence leads; inspect source context with memory_get. Empty results or errors do not prove absence of a fact.",
63
+ description: "Search this agent's configured memory corpora with BM25 and vector queries, then TypeSafe usefulness ranking. Supply keywords in bm25Query and the information needed in vectorQuery; no query generation or recall gate runs. Queries and candidate passages are sent to TypeSafe. Skills are excluded. Session snippets are message arrays; file snippets are strings. Inspect source context with memory_get. Empty results or errors do not prove absence of a fact.",
60
64
  parameters: searchParameters,
61
65
  async execute(_toolCallId, params, signal) {
62
- const { query: untrimmedQuery, corpora, sessionFilter, maxResults, minScore, } = Value.Parse(searchParameters, params);
63
- const query = untrimmedQuery.trim();
64
- const { manager, error } = await runtime.getMemorySearchManager(active);
65
- if (!manager)
66
- return jsonResult({ results: [], error: error ?? "memory unavailable" });
67
- const results = await manager.search(query, {
68
- corpora: corpora?.map((corpus) => corpus.trim()),
69
- sessionFilter,
70
- maxResults,
71
- minScore,
72
- signal,
73
- requestContext: active.requestContext,
74
- });
75
- // Compact only the public tool response; internal ranking and consumers keep
76
- // full-precision scores and the host's source/citation compatibility fields.
77
- const payload = {
78
- results: results.map(({ source: _source, citation: _citation, session, sessionMessages, ...result }) => ({
79
- ...result,
80
- snippet: result.corpus === "sessions" ? sessionMessages ?? [{ body: result.snippet, partial: true }] : result.snippet,
81
- score: Number(result.score.toFixed(2)),
82
- ...(result.vectorScore !== undefined ? { vectorScore: Number(result.vectorScore.toFixed(2)) } : {}),
83
- ...(result.textScore !== undefined ? { textScore: Number(result.textScore.toFixed(2)) } : {}),
84
- ...(session ? { session: { ...session, startedAt: new Date(session.startedAt).toISOString() } } : {}),
85
- })),
86
- };
87
- return {
88
- content: [{ type: "text", text: JSON.stringify(payload) }],
89
- details: payload,
90
- };
66
+ const started = performance.now();
67
+ const measurement = { outcome: "failed", elapsedMs: 0 };
68
+ try {
69
+ signal?.throwIfAborted();
70
+ const { bm25Query, vectorQuery, corpora, sessionFilter, maxResults, minUsefulness, } = Value.Parse(searchParameters, params);
71
+ const queries = { lex: bm25Query.trim(), vec: vectorQuery.trim() };
72
+ // Reject oversized explicit inputs before credential resolution or any service call.
73
+ prepareQueryConversation([], queries.lex);
74
+ const { conversation } = prepareQueryConversation([], queries.vec);
75
+ const apiKey = await resolveTypeSafeApiKey(typesafe);
76
+ if (!apiKey) {
77
+ measurement.outcome = "skipped";
78
+ return jsonResult({ results: [], error: "TypeSafe API key not configured or TypeSafe disabled" });
79
+ }
80
+ const { manager, error } = await runtime.getMemorySearchManager(active);
81
+ if (!manager)
82
+ return jsonResult({ results: [], error: error ?? "memory unavailable" });
83
+ const search = await searchMemory(manager, queries, {
84
+ corpora: corpora?.map((corpus) => corpus.trim()),
85
+ sessionFilter,
86
+ maxResults,
87
+ minUsefulness,
88
+ apiKey, timeoutMs: typesafe.timeoutMs, conversation,
89
+ signal,
90
+ requestContext: active.requestContext,
91
+ onCandidates: ({ retrievalMs, candidates, eligible }) => {
92
+ Object.assign(measurement, { retrievalMs, candidates, eligible });
93
+ },
94
+ });
95
+ measurement.judgeMs = search.judgeMs;
96
+ measurement.results = search.results.length;
97
+ measurement.contextChars = search.results.reduce((sum, hit) => sum + hit.snippet.length, 0);
98
+ if (search.eligible && !search.requestsSucceeded)
99
+ return jsonResult({ results: [], error: "All memory usefulness judgments failed" });
100
+ measurement.outcome = search.requestsFailed ? "partial" : search.results.length ? "ok" : "empty";
101
+ // Compact only the public tool response; internal ranking and consumers keep
102
+ // full-precision scores and the host's source/citation compatibility fields.
103
+ const payload = {
104
+ ...(search.requestsFailed ? { warning: `${search.requestsFailed} candidate usefulness judgments failed; results are partial` } : {}),
105
+ results: search.results.map(({ source: _source, citation: _citation, session, sessionMessages, ...result }) => ({
106
+ ...result,
107
+ snippet: result.corpus === "sessions" ? sessionMessages ?? [{ body: result.snippet, partial: true }] : result.snippet,
108
+ score: Number(result.score.toFixed(2)),
109
+ ...(session ? { session: { ...session, startedAt: new Date(session.startedAt).toISOString() } } : {}),
110
+ })),
111
+ };
112
+ return {
113
+ content: [{ type: "text", text: JSON.stringify(payload) }],
114
+ details: payload,
115
+ };
116
+ }
117
+ catch (error) {
118
+ if (error instanceof QueryInputBudgetError)
119
+ measurement.outcome = "skipped";
120
+ throw error;
121
+ }
122
+ finally {
123
+ measurement.elapsedMs = performance.now() - started;
124
+ if (signal?.aborted)
125
+ measurement.outcome = signal.reason instanceof Error && signal.reason.name === "TimeoutError"
126
+ ? "timed_out" : "cancelled";
127
+ diagnostics.measureSearch(active.agentId, measurement);
128
+ }
91
129
  },
92
130
  };
93
131
  }
@@ -455,7 +493,7 @@ export function registerUnblockMemory(api) {
455
493
  skill: registerSkillWhisperer(api, runtime, config.skillWhisperer, config.typesafe, diagnostics),
456
494
  people: peopleWhisperer,
457
495
  });
458
- api.registerTool((ctx) => createSearchTool(runtime, ctx), { names: ["memory_search"] });
496
+ api.registerTool((ctx) => createSearchTool(runtime, ctx, config.typesafe, diagnostics), { names: ["memory_search"] });
459
497
  api.registerTool((ctx) => createGetTool(runtime, ctx), { names: ["memory_get"] });
460
498
  api.registerTool((ctx) => createSyncSessionsTool(runtime, ctx), {
461
499
  names: ["memory_sync_sessions"],
@@ -1,5 +1,5 @@
1
1
  import type { ResponseAuditConfig } from "./response-config.js";
2
- export declare const RESPONSE_EXTRACTOR_VERSION = 5;
2
+ export declare const RESPONSE_EXTRACTOR_VERSION = 6;
3
3
  type Row = {
4
4
  seq: number;
5
5
  eventJson: string;
@@ -48,12 +48,12 @@ export declare function responseEpisodes(session: ResponseSession, rows: readonl
48
48
  episodes: ResponseEpisode[];
49
49
  coverage: ResponseCoverage;
50
50
  };
51
- /** Bounded, read-only active transcript snapshot; archived/deleted branches are excluded. */
51
+ /** Bounded, read-only active transcript snapshot; cold history is deferred, inactive branches excluded. */
52
52
  export declare class ResponseTranscriptReader {
53
53
  #private;
54
54
  constructor(path: string, agentId: string);
55
55
  sessions(config: ResponseAuditConfig, now: number, after?: string): ResponseSession[];
56
- /** null = confirmed absent/ineligible; undefined = over budget, not evidence of deletion. */
56
+ /** null = confirmed absent/ineligible; undefined = unavailable/over budget, never evidence of deletion. */
57
57
  read(input: ResponseSession | string, config: ResponseAuditConfig): (ReturnType<typeof responseEpisodes> & {
58
58
  revision: string;
59
59
  }) | null | undefined;
@@ -1,9 +1,9 @@
1
1
  import { createHash } from "node:crypto";
2
2
  import { DatabaseSync } from "node:sqlite";
3
- import { ACTIVE_EVENT_COUNT_SQL, ACTIVE_EVENT_ROWS_SQL, assertAgentTranscriptSchema } from "./agent-transcript.js";
3
+ import { AgentTranscriptReader } from "./agent-transcript.js";
4
4
  import { messageText } from "./whisperer-context.js";
5
5
  import { responseUserText } from "./response-text.js";
6
- export const RESPONSE_EXTRACTOR_VERSION = 5;
6
+ export const RESPONSE_EXTRACTOR_VERSION = 6;
7
7
  const MAX_EVENTS = 2000, MAX_SESSION_BYTES = 2_000_000, MAX_EPISODE_CHARS = 24_000;
8
8
  function record(v) {
9
9
  return v !== null && typeof v === "object" && !Array.isArray(v) ? v : undefined;
@@ -176,14 +176,15 @@ export function responseEpisodes(session, rows, config) {
176
176
  }
177
177
  return { episodes, coverage };
178
178
  }
179
- /** Bounded, read-only active transcript snapshot; archived/deleted branches are excluded. */
179
+ /** Bounded, read-only active transcript snapshot; cold history is deferred, inactive branches excluded. */
180
180
  export class ResponseTranscriptReader {
181
181
  #db;
182
+ #transcripts;
182
183
  constructor(path, agentId) {
183
184
  this.#db = new DatabaseSync(path, { readOnly: true });
184
185
  try {
185
186
  this.#db.exec("PRAGMA query_only=ON; PRAGMA busy_timeout=1000");
186
- assertAgentTranscriptSchema(this.#db, agentId, "Unsupported response-audit transcript schema or agent");
187
+ this.#transcripts = new AgentTranscriptReader(this.#db, agentId, "Unsupported response-audit transcript schema or agent");
187
188
  }
188
189
  catch (error) {
189
190
  this.#db.close();
@@ -191,17 +192,55 @@ export class ResponseTranscriptReader {
191
192
  }
192
193
  }
193
194
  sessions(config, now, after = "") {
194
- return this.#db.prepare(`SELECT w.session_id sessionId, COALESCE(w.account_id,c.account_id,'') accountId,
195
+ // Page metadata candidates, then apply the approved-sender filter to decoded
196
+ // events before the audit cap. Unrelated Slack traffic must not hide previews.
197
+ const candidates = this.#db.prepare(`SELECT w.session_id sessionId, COALESCE(w.account_id,c.account_id,'') accountId,
195
198
  w.chat_type chatType, w.primary_conversation_id conversationId
196
199
  FROM session_windows w JOIN conversations c ON c.conversation_id=w.primary_conversation_id
197
200
  WHERE COALESCE(w.channel,c.channel)='slack' AND w.chat_type IN (${config.chatTypes.map(() => "?").join(",")})
198
201
  AND w.session_id>?
199
202
  AND EXISTS (SELECT 1 FROM transcript_events e JOIN session_transcript_active_events a
200
- ON a.session_id=e.session_id AND a.event_seq=e.seq WHERE e.session_id=w.session_id AND e.created_at >= ?
201
- AND json_extract(e.event_json,'$.message.role')='user'
202
- AND json_extract(e.event_json,'$.message.__openclaw.senderId') IN (${config.senderIds.map(() => "?").join(",")}))
203
- ORDER BY w.session_id LIMIT 101`)
204
- .all(...config.chatTypes, after, now - config.lookbackDays * 86400_000, ...config.senderIds);
203
+ ON a.session_id=e.session_id AND a.event_seq=e.seq WHERE e.session_id=w.session_id AND e.created_at >= ?)
204
+ ORDER BY w.session_id LIMIT 101`);
205
+ const since = now - config.lookbackDays * 86400_000;
206
+ const sessions = [];
207
+ this.#db.exec("BEGIN");
208
+ try {
209
+ while (sessions.length < 101) {
210
+ const page = candidates.all(...config.chatTypes, after, since);
211
+ for (const session of page) {
212
+ const snapshot = this.#transcripts.read(session.sessionId, { maxEvents: MAX_EVENTS, maxBytes: MAX_SESSION_BYTES });
213
+ // Saved unavailable sessions are reconciled separately by the caller.
214
+ if (snapshot.kind !== "ready")
215
+ continue;
216
+ const approved = snapshot.rows.some(row => {
217
+ if (row.createdAt < since)
218
+ return false;
219
+ let event;
220
+ try {
221
+ event = record(JSON.parse(row.eventJson));
222
+ }
223
+ catch {
224
+ return false;
225
+ }
226
+ const message = record(event?.message), sender = record(message?.__openclaw)?.senderId;
227
+ return event?.type === "message" && message?.role === "user" &&
228
+ typeof sender === "string" && config.senderIds.includes(sender);
229
+ });
230
+ if (approved)
231
+ sessions.push(session);
232
+ if (sessions.length === 101)
233
+ break;
234
+ }
235
+ if (page.length < 101)
236
+ break;
237
+ after = page.at(-1).sessionId;
238
+ }
239
+ return sessions;
240
+ }
241
+ finally {
242
+ this.#db.exec("COMMIT");
243
+ }
205
244
  }
206
245
  read(input, config, previousRevision) {
207
246
  this.#db.exec("BEGIN");
@@ -216,10 +255,10 @@ export class ResponseTranscriptReader {
216
255
  return null;
217
256
  const session = { sessionId, accountId: String(window.accountId),
218
257
  conversationId: String(window.conversationId), chatType: String(window.chatType) };
219
- const count = this.#db.prepare(ACTIVE_EVENT_COUNT_SQL).get(session.sessionId);
220
- if (Number(count.n) > MAX_EVENTS || Number(count.bytes) > MAX_SESSION_BYTES)
258
+ const snapshot = this.#transcripts.read(session.sessionId, { maxEvents: MAX_EVENTS, maxBytes: MAX_SESSION_BYTES });
259
+ if (snapshot.kind !== "ready")
221
260
  return undefined;
222
- const rows = this.#db.prepare(ACTIVE_EVENT_ROWS_SQL).all(session.sessionId);
261
+ const rows = snapshot.rows;
223
262
  // Exact active content catches in-place edits and branch changes even when writer
224
263
  // watermarks are absent. Raw text is never retained in the checkpoint database.
225
264
  const revision = hash([RESPONSE_EXTRACTOR_VERSION, session, config.historyMessages, [...config.senderIds].sort(), rows]);
@@ -43,6 +43,19 @@ export function responseUserText(input, senderId) {
43
43
  const marker = markers[0];
44
44
  contextLimited = meta.history_truncated === true || rest.slice(0, marker.index).includes("Chat history since last reply:");
45
45
  text = rest.slice(marker.index + marker[0].length).trim();
46
+ // OpenClaw appends channel metadata after the current Slack message. Only
47
+ // recognize its exact boundary inside an already validated envelope.
48
+ const contexts = [...text.matchAll(/\r?\n\r?\nContext: ⟦openclaw:ctx⟧\r?\n/g)];
49
+ if (contexts.length) {
50
+ if (contexts.length !== 1)
51
+ return undefined;
52
+ const context = contexts[0];
53
+ const suffix = text.slice(context.index + context[0].length);
54
+ if (!suffix.trim() || suffix.includes("⟦openclaw:ctx⟧") || /^Chat history since last reply:/m.test(suffix))
55
+ return undefined;
56
+ text = text.slice(0, context.index).trim();
57
+ contextLimited = true;
58
+ }
46
59
  }
47
60
  else {
48
61
  const from = /^From: [^\r\n]+ \(([^()\r\n]+)\)\r?\n/.exec(text);
@@ -1,5 +1,5 @@
1
- type RetrievalOperation = "vector" | "memoryWhisperer";
2
- type RetrievalOutcome = "ok" | "empty" | "failed" | "cancelled" | "timed_out" | "skipped";
1
+ type RetrievalOperation = "vector" | "memoryWhisperer" | "memorySearch";
2
+ type RetrievalOutcome = "ok" | "partial" | "empty" | "failed" | "cancelled" | "timed_out" | "skipped";
3
3
  declare const fields: readonly ["elapsedMs", "retrievalMs", "judgeMs", "gateMs", "generationMs", "candidates", "eligible", "results", "contextChars"];
4
4
  type Field = typeof fields[number];
5
5
  export type RetrievalObservation = {
@@ -18,6 +18,7 @@ export declare class RetrievalTelemetry {
18
18
  outcomes: {
19
19
  ok?: number | undefined;
20
20
  cancelled?: number | undefined;
21
+ partial?: number | undefined;
21
22
  skipped?: number | undefined;
22
23
  failed?: number | undefined;
23
24
  empty?: number | undefined;
@@ -32,7 +32,7 @@ export function registerReviewTools(api, runtime, config, diagnostics) {
32
32
  return null;
33
33
  return {
34
34
  name: "memory_diagnostics", label: "Memory Diagnostics",
35
- description: "Read content-free whisperer counters, configuration state, projection version and index readiness. Does not call TypeSafe.",
35
+ description: "Read content-free manual-search and whisperer telemetry, configuration state, projection version and index readiness. Does not call TypeSafe.",
36
36
  parameters: noParameters,
37
37
  async execute(_id, params) {
38
38
  Value.Parse(noParameters, params);
@@ -1,6 +1,6 @@
1
1
  import type { ChatType } from "./config.js";
2
2
  import { type SessionMetadata, type SessionProjectionInput, type SessionMessageSpan } from "./session-projector.js";
3
- export declare const PROJECTOR_VERSION = 7;
3
+ export declare const PROJECTOR_VERSION = 8;
4
4
  type IndexedSession = SessionMetadata & {
5
5
  sourceGeneration: string;
6
6
  maxSeq: number;
@@ -3,10 +3,10 @@ import { existsSync, lstatSync, readFileSync, statSync } from "node:fs";
3
3
  import { chmod, mkdir, readFile, rename, unlink, utimes, writeFile } from "node:fs/promises";
4
4
  import { dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
5
5
  import { DatabaseSync } from "node:sqlite";
6
- import { ACTIVE_EVENTS_FROM, agentTranscriptSchemaVersion, assertAgentTranscriptIdentity } from "./agent-transcript.js";
6
+ import { AgentTranscriptReader, agentTranscriptSchemaVersion, assertAgentTranscriptIdentity } from "./agent-transcript.js";
7
7
  import { projectSessionDocument, sessionDocumentPath, } from "./session-projector.js";
8
8
  const MANIFEST_VERSION = 1;
9
- export const PROJECTOR_VERSION = 7;
9
+ export const PROJECTOR_VERSION = 8;
10
10
  // Source lives in src/, published code in dist/src/. Read our own pinned dependency
11
11
  // metadata, not QMD internals (which may also be substituted by runtime inspectors).
12
12
  const sourcePackage = new URL("../package.json", import.meta.url);
@@ -91,6 +91,7 @@ function readSnapshot(params) {
91
91
  try {
92
92
  db.exec("PRAGMA query_only = ON; PRAGMA busy_timeout = 5000; BEGIN");
93
93
  assertSchema(db, params.agentId);
94
+ const transcripts = new AgentTranscriptReader(db, params.agentId);
94
95
  const placeholders = params.chatTypes.map(() => "?").join(", ");
95
96
  const windows = db.prepare(`
96
97
  SELECT
@@ -104,7 +105,8 @@ function readSnapshot(params) {
104
105
  COALESCE(window.started_at, window.created_at) AS startedAt,
105
106
  rewrite.generation AS sourceGeneration,
106
107
  MAX(active.event_seq) AS maxSeq,
107
- COUNT(active.event_seq) AS activeEventCount
108
+ COUNT(active.event_seq) AS activeEventCount,
109
+ ${transcripts.coldSql("window.session_id")} AS cold
108
110
  FROM session_windows AS window
109
111
  LEFT JOIN conversations AS conversation
110
112
  ON conversation.conversation_id = window.primary_conversation_id
@@ -116,15 +118,9 @@ function readSnapshot(params) {
116
118
  GROUP BY window.session_id
117
119
  ORDER BY window.created_at, window.session_id
118
120
  `).all(...params.chatTypes);
119
- const readEvents = db.prepare(`
120
- SELECT a.session_id AS sessionId, e.event_json AS eventJson,
121
- e.created_at AS createdAt
122
- ${ACTIVE_EVENTS_FROM}
123
- WHERE a.session_id = ?
124
- ORDER BY a.active_position
125
- `);
126
121
  const events = new Map();
127
122
  const changed = new Set();
123
+ const unavailable = new Set();
128
124
  for (const window of windows) {
129
125
  const metadata = {
130
126
  sessionId: window.sessionId,
@@ -137,7 +133,7 @@ function readSnapshot(params) {
137
133
  const previous = params.previousManifest.sessions[window.sessionId];
138
134
  const documentPath = sessionDocumentPath(metadata);
139
135
  const sourceFingerprint = JSON.stringify(window);
140
- const unchanged = !params.force &&
136
+ const unchanged = !window.cold && !params.force &&
141
137
  (previous ? previous.sourceFingerprint === sourceFingerprint &&
142
138
  previous.projectorVersion === PROJECTOR_VERSION &&
143
139
  previous.documentPath === documentPath &&
@@ -145,12 +141,22 @@ function readSnapshot(params) {
145
141
  params.previousManifest.ignoredSessions?.[window.sessionId] === sourceFingerprint);
146
142
  if (!unchanged) {
147
143
  changed.add(window.sessionId);
148
- if (!params.metadataOnly)
149
- events.set(window.sessionId, readEvents.all(window.sessionId));
144
+ if (!params.metadataOnly) {
145
+ try {
146
+ const snapshot = transcripts.read(window.sessionId);
147
+ if (snapshot.kind === "ready")
148
+ events.set(window.sessionId, snapshot.rows.map(row => ({ ...row, sessionId: window.sessionId })));
149
+ else
150
+ unavailable.add(window.sessionId);
151
+ }
152
+ catch {
153
+ unavailable.add(window.sessionId);
154
+ }
155
+ }
150
156
  }
151
157
  }
152
158
  db.exec("COMMIT");
153
- return { windows, events, changed };
159
+ return { windows, events, changed, unavailable };
154
160
  }
155
161
  catch (error) {
156
162
  try {
@@ -262,6 +268,12 @@ export async function syncSessionProjections(params) {
262
268
  startedAt: window.startedAt,
263
269
  };
264
270
  const documentPath = sessionDocumentPath(metadata);
271
+ if (snapshot.unavailable.has(window.sessionId)) {
272
+ counts.failed += 1;
273
+ if (previous)
274
+ sessions[window.sessionId] = previous;
275
+ continue;
276
+ }
265
277
  if (events === undefined) {
266
278
  if (previous)
267
279
  sessions[window.sessionId] = previous;
@@ -1,7 +1,7 @@
1
- import type { QMDStore } from "@unblocklabs/qmd";
1
+ import type { AllowedDocumentPaths, QMDStore } from "@unblocklabs/qmd";
2
2
  import type { QueryLane } from "./query-contract.js";
3
3
  /** Runtime and training share the exact ten-candidate recipe, not hybrid ranking. */
4
- export declare function trainingCandidates(qmd: QMDStore, query: string, collection: string | string[], lane: QueryLane, signal?: AbortSignal): Promise<{
4
+ export declare function trainingCandidates(qmd: QMDStore, query: string, collection: string | string[], lane: QueryLane, signal?: AbortSignal, allowedPaths?: AllowedDocumentPaths): Promise<{
5
5
  vector?: {
6
6
  score: number;
7
7
  rank: number;
@@ -1,8 +1,8 @@
1
1
  /** Runtime and training share the exact ten-candidate recipe, not hybrid ranking. */
2
- export async function trainingCandidates(qmd, query, collection, lane, signal) {
2
+ export async function trainingCandidates(qmd, query, collection, lane, signal, allowedPaths) {
3
3
  const hits = await qmd.discoverCandidates({ query, lane, collection, limit: 10, signal,
4
4
  // Preserve keyword chunk tie-breaking against this lane's own query.
5
- intent: query });
5
+ intent: query, allowedPaths });
6
6
  return hits.map(({ method, rank, score, file, body, bestChunk, bestChunkPos }) => {
7
7
  const retrieval = {
8
8
  [method]: { score, rank: rank + 1 },
@@ -48,6 +48,10 @@ export declare class TrainingTranscriptReader {
48
48
  }[];
49
49
  } | {
50
50
  oversized: true;
51
+ unavailable?: undefined;
52
+ } | {
53
+ unavailable: true;
54
+ oversized?: undefined;
51
55
  } | null;
52
56
  close(): void;
53
57
  }