@unblocklabs/unblock-memory 0.7.3 → 0.7.4

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,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
+ }
@@ -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
  }
@@ -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,6 +1,6 @@
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
6
  export const RESPONSE_EXTRACTOR_VERSION = 5;
@@ -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]);
@@ -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;
@@ -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
  }
@@ -1,6 +1,6 @@
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 { conversationUserText } from "./response-text.js";
6
6
  import { prepareQueryConversation, QUERY_CONTRACT, QueryInputBudgetError } from "./query-contract.js";
@@ -133,12 +133,13 @@ export function trainingExamples(rows, renew) {
133
133
  /** Only active events; no Markdown projections, archived branches, or tool bodies. */
134
134
  export class TrainingTranscriptReader {
135
135
  #db;
136
+ #transcripts;
136
137
  #lineage;
137
138
  constructor(path, agentId) {
138
139
  this.#db = new DatabaseSync(path, { readOnly: true });
139
140
  try {
140
141
  this.#db.exec("PRAGMA query_only=ON; PRAGMA busy_timeout=1000");
141
- assertAgentTranscriptSchema(this.#db, agentId, "Unsupported training transcript schema or agent");
142
+ this.#transcripts = new AgentTranscriptReader(this.#db, agentId, "Unsupported training transcript schema or agent");
142
143
  const columns = this.#db.prepare("PRAGMA table_info(session_windows)").all().map(c => c.name);
143
144
  this.#lineage = ["parent_session_key", "spawned_by", "plugin_owner_id", "hook_external_content_source"].every(c => columns.includes(c));
144
145
  }
@@ -161,11 +162,12 @@ export class TrainingTranscriptReader {
161
162
  /:(?:cron|subagent|heartbeat|hook)(?::|$)/i.test(String(session.session_key)) ||
162
163
  session.parent_session_key || session.spawned_by || session.plugin_owner_id || session.hook_external_content_source)
163
164
  return null;
164
- const size = this.#db.prepare(ACTIVE_EVENT_COUNT_SQL).get(sessionId);
165
- if (Number(size.n) > 50_000 || Number(size.bytes) > 32_000_000)
165
+ const snapshot = this.#transcripts.read(sessionId, { maxEvents: 50_000, maxBytes: 32_000_000 });
166
+ if (snapshot.kind === "oversized")
166
167
  return { oversized: true };
167
- const rows = this.#db.prepare(ACTIVE_EVENT_ROWS_SQL).iterate(sessionId);
168
- return trainingExamples(rows, renew);
168
+ if (snapshot.kind !== "ready")
169
+ return { unavailable: true };
170
+ return trainingExamples(snapshot.rows, renew);
169
171
  }
170
172
  finally {
171
173
  this.#db.exec("COMMIT");
@@ -34,6 +34,7 @@ export declare function generateTrainingQueries(source: Source, store: TrainingS
34
34
  sessions: number;
35
35
  excludedSessions: number;
36
36
  oversizedSessions: number;
37
+ unavailableSessions: number;
37
38
  eligible: number;
38
39
  users: number;
39
40
  filtered: number;
@@ -79,6 +80,7 @@ export declare function evaluateTrainingQueries(source: Source, store: TrainingS
79
80
  sessions: number;
80
81
  excludedSessions: number;
81
82
  oversizedSessions: number;
83
+ unavailableSessions: number;
82
84
  eligible: number;
83
85
  users: number;
84
86
  filtered: number;
@@ -13,6 +13,7 @@ export declare function collectTraining(source: Source, store?: TrainingStore, o
13
13
  sessions: number;
14
14
  excludedSessions: number;
15
15
  oversizedSessions: number;
16
+ unavailableSessions: number;
16
17
  eligible: number;
17
18
  users: number;
18
19
  filtered: number;
@@ -39,6 +40,7 @@ export declare function runTraining(source: Source, store: TrainingStore, config
39
40
  sessions: number;
40
41
  excludedSessions: number;
41
42
  oversizedSessions: number;
43
+ unavailableSessions: number;
42
44
  eligible: number;
43
45
  users: number;
44
46
  filtered: number;
@@ -5,7 +5,7 @@ import { judgeTrainingInput, TRAINING_GATE_QUESTIONS } from "./training-gate.js"
5
5
  export function collectTraining(source, store, options = {}) {
6
6
  const since = options.since ?? 0, until = options.until ?? Number.MAX_SAFE_INTEGER;
7
7
  const reader = new TrainingTranscriptReader(source.databasePath, source.agentId);
8
- const result = { sessions: 0, excludedSessions: 0, oversizedSessions: 0, eligible: 0,
8
+ const result = { sessions: 0, excludedSessions: 0, oversizedSessions: 0, unavailableSessions: 0, eligible: 0,
9
9
  users: 0, filtered: 0, oversized: 0, unanswered: 0, added: 0, changed: 0, unchanged: 0, retired: 0,
10
10
  review: [] };
11
11
  try {
@@ -14,6 +14,11 @@ export function collectTraining(source, store, options = {}) {
14
14
  store?.renew();
15
15
  const extracted = reader.read(id, () => store?.renew());
16
16
  result.sessions++;
17
+ if (extracted && "unavailable" in extracted) {
18
+ result.unavailableSessions++;
19
+ store?.unavailable(id);
20
+ continue;
21
+ }
17
22
  if (extracted && "oversized" in extracted) {
18
23
  result.oversizedSessions++;
19
24
  store?.unavailable(id);
@@ -78,7 +78,9 @@ bytes, so changed corpus content still invalidates caches.
78
78
 
79
79
  ## Input and historical boundaries
80
80
 
81
- Read original agent SQLite active-branch conversations (schema 17–19), including
81
+ Read original agent SQLite active-branch conversations through the shared read-only
82
+ adapter, including compressed payloads, while deferring cold or oversized sessions
83
+ without retiring their previously collected examples. This includes
82
84
  direct/group/channel chats, excluding cron, heartbeat, spawned/subagent, hook/plugin
83
85
  and untyped diagnostic sessions. Exclude explicit bots, synthetic messages,
84
86
  analysis/thinking, errors and tool payloads. Ordinary older users need not have
@@ -202,8 +202,8 @@ factual verification. Week buckets
202
202
  may be partial, and several exchanges in one session are not independent. Wilson
203
203
  intervals are descriptive, not calibrated confidence about overall agent ability.
204
204
 
205
- Each run selects at most 100 recent sessions for inference, each at most 2,000 active events/2M
206
- characters; episodes must fit 24K characters and six feedback messages without
205
+ Each run selects at most 100 recent sessions containing approved-sender messages for inference,
206
+ each at most 2,000 active events/2M decoded UTF-8 bytes; episodes must fit 24K characters and six feedback messages without
207
207
  truncating the answer. Coverage counts describe the scanned sessions; only episodes
208
208
  within `lookbackDays` are judged. Caps, failures and no-feedback cases remain visible.
209
209
  Saved sessions in the report window are also reconciled independently of that
package/docs/retrieval.md CHANGED
@@ -155,13 +155,19 @@ fits. The default is `500`; the original semantic chunk is preserved when
155
155
  neither complete context fits, so expansion never clips the matched evidence.
156
156
  Run
157
157
  `memory_sync_sessions` to start a refresh, then use `memory_sync_status` to
158
- check its progress or result. The read-only adapter explicitly supports OpenClaw
159
- agent database schemas 17, 18, and 19 and validates its required columns before
160
- reading. Projections are private derived Markdown under the
158
+ check its progress or result. The shared read-only transcript adapter validates
159
+ agent identity and the storage capabilities it needs, rather than rejecting every
160
+ new schema number. It reads both text and bounded Zstd payloads (schema 23+),
161
+ counts decoded UTF-8 bytes for budgets, and preserves previous projections when
162
+ history is cold, oversized, or unreadable. Cold history is deferred until restored
163
+ by OpenClaw; the plugin never restores or migrates the source database. The public
164
+ full-transcript SDK reader currently permits restoration, while its read-only
165
+ catalog redacts and truncates text, so neither supplies this full-fidelity read-only
166
+ contract. Extraction retains its supported host cursor API. Projections are private derived Markdown under the
161
167
  agent's `unblock-memory/sessions` state directory and can be rebuilt from
162
168
  OpenClaw at any time. Their embedded text contains only `# Transcript` and
163
169
  role-labeled, timestamped speaker messages; filtering metadata remains in the
164
- session manifest. Projection v7 also retains message metadata and exact character
170
+ session manifest. Projection v8 retains message metadata and exact character
165
171
  boundaries there, without duplicating message bodies. Readers use those boundaries
166
172
  only when the projection hash matches the indexed document. Older/mismatched snapshots
167
173
  use a conservative heading parser that skips code fences and blockquotes; an unfenced
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "id": "unblock-memory",
3
3
  "name": "Unblock Memory",
4
- "version": "0.7.3",
4
+ "version": "0.7.4",
5
5
  "description": "Indexes, retrieves, and analyzes configured workspace memory with existing QMD vectors.",
6
6
  "kind": "memory",
7
7
  "activation": { "onStartup": true },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@unblocklabs/unblock-memory",
3
- "version": "0.7.3",
3
+ "version": "0.7.4",
4
4
  "description": "Workspace-native memory for OpenClaw, powered by QMD",
5
5
  "type": "module",
6
6
  "license": "MIT",