@unblocklabs/unblock-memory 0.7.2 → 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
+ }
@@ -260,8 +260,8 @@ function byteSlice(text, maxBytes) {
260
260
  return "";
261
261
  return bytes.subarray(0, maxBytes - 3).toString("utf8").replace(/\uFFFD$/u, "") + "…";
262
262
  }
263
- function members(db, runId, clusterId, limit, offset = 0, sort = "representative", maxExcerptBytes = MAX_EXCERPT_BYTES, maxTotalBytes = MAX_TOTAL_EXCERPT_BYTES, maxTotalAliases = MAX_TOTAL_ALIASES, temporal = {}) {
264
- const representativeOrder = clusterId === -1
263
+ function representativeOrder(clusterId) {
264
+ return clusterId === -1
265
265
  ? "m.outlier_score DESC, m.hash, m.seq"
266
266
  : `CASE WHEN m.representative_rank IS NULL THEN 1 ELSE 0 END,
267
267
  m.representative_rank,
@@ -269,9 +269,11 @@ function members(db, runId, clusterId, limit, offset = 0, sort = "representative
269
269
  m.outlier_score,
270
270
  m.hash,
271
271
  m.seq`;
272
+ }
273
+ function members(db, runId, clusterId, limit, offset = 0, sort = "representative", maxExcerptBytes = MAX_EXCERPT_BYTES, maxTotalBytes = MAX_TOTAL_EXCERPT_BYTES, maxTotalAliases = MAX_TOTAL_ALIASES, temporal = {}) {
272
274
  const score = clusterId === -1 ? "m.outlier_score" : "m.probability";
273
275
  const order = {
274
- representative: representativeOrder,
276
+ representative: representativeOrder(clusterId),
275
277
  score_desc: `${score} DESC, m.hash, m.seq`,
276
278
  score_asc: `${score} ASC, m.hash, m.seq`,
277
279
  date_desc: "julianday(COALESCE(m.event_time, m.source_modified_at)) DESC, m.hash, m.seq",
@@ -395,24 +397,34 @@ function readMetadata(run) {
395
397
  }
396
398
  return { stale: false, staleSince: null, analyzedAt: run.completed_at };
397
399
  }
400
+ function clusterPreview(db, runId, clusterId, maxBytes, aliasLimit) {
401
+ const row = db.prepare(`
402
+ SELECT m.hash, m.seq, m.probability, m.pos, m.chunk_len, m.doc
403
+ FROM memory_analysis_available_memberships m
404
+ WHERE m.run_id = ? AND m.cluster_id = ?
405
+ ORDER BY ${representativeOrder(clusterId)}
406
+ LIMIT 1
407
+ `).get(runId, clusterId);
408
+ if (!row)
409
+ return undefined;
410
+ return {
411
+ hash: row.hash,
412
+ seq: row.seq,
413
+ probability: row.probability,
414
+ text: byteSlice(row.doc.slice(row.pos, row.pos + row.chunk_len), maxBytes),
415
+ sourcePaths: sourcePaths(db, row.hash, Math.min(MAX_ALIASES_PER_MEMBER, aliasLimit)),
416
+ };
417
+ }
398
418
  function toSummary(db, run, row, includePreview, previewBytes = 600, aliasLimit = MAX_TOTAL_ALIASES) {
399
419
  const preview = includePreview
400
- ? members(db, run.id, row.cluster_id, 1, 0, "representative", previewBytes, previewBytes, aliasLimit)[0]
420
+ ? clusterPreview(db, run.id, row.cluster_id, previewBytes, aliasLimit)
401
421
  : undefined;
402
422
  return {
403
423
  clusterId: clusterReference(run.id, row.cluster_id),
404
424
  size: row.size,
405
425
  availableSize: row.available_size,
406
426
  meanProbability: row.mean_probability,
407
- ...(preview ? {
408
- preview: {
409
- hash: preview.hash,
410
- seq: preview.seq,
411
- probability: preview.probability,
412
- text: preview.text,
413
- sourcePaths: preview.sourcePaths,
414
- },
415
- } : {}),
427
+ ...(preview ? { preview } : {}),
416
428
  };
417
429
  }
418
430
  function noiseRow(db, runId) {
@@ -291,23 +291,8 @@ function resolvePeople(value) {
291
291
  };
292
292
  }
293
293
  export function resolveConfig(value) {
294
- if (value === undefined || value === null) {
295
- return {
296
- corpora: DEFAULT_CORPORA,
297
- extraction: resolveExtraction(undefined, DEFAULT_CORPORA),
298
- keepEmbeddingModelWarm: true,
299
- analysis: {},
300
- typesafe: { ...DEFAULT_TYPESAFE_CONFIG },
301
- qualityAudit: { ...DEFAULT_QUALITY_AUDIT },
302
- evidenceReview: { enabled: false, corpora: [] },
303
- responseAudit: resolveResponseAudit(undefined, DEFAULT_CORPORA),
304
- insideOut: resolveInsideOut(undefined),
305
- peoplePrimer: resolvePeoplePrimer(undefined, DEFAULT_CORPORA, false),
306
- people: DEFAULT_PEOPLE_CONFIG,
307
- skillWhisperer: DEFAULT_SKILL_WHISPERER,
308
- memoryWhisperer: { ...DEFAULT_MEMORY_WHISPERER, api: resolveQueryApi(undefined) },
309
- };
310
- }
294
+ if (value === undefined || value === null)
295
+ value = {};
311
296
  if (typeof value !== "object" || Array.isArray(value)) {
312
297
  throw new Error("unblock-memory config must be an object");
313
298
  }
@@ -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
  }
@@ -12,7 +12,7 @@ import { reviewIndexedClaim } from "./evidence-review.js";
12
12
  import { reviewClusterIngestion } from "./cluster-review.js";
13
13
  import type { QueryPair } from "./query-contract.js";
14
14
  export { expandSessionSearchHit } from "./memory-passage.js";
15
- export type ManagerStore = Pick<QMDStore, "update" | "embed" | "getStatus" | "listCollections" | "vsearch" | "get" | "getDocumentBody" | "close">;
15
+ export type ManagerStore = Pick<QMDStore, "update" | "embed" | "getStatus" | "listCollections" | "vsearch" | "get" | "close">;
16
16
  export type ManagerSessionConfig = {
17
17
  agentId: string;
18
18
  agentName: string;
@@ -991,15 +991,12 @@ export class QmdMemoryManager {
991
991
  }
992
992
  await this.#operationChain;
993
993
  const store = await this.#getStore();
994
- const doc = await store.get(safe.normalized);
995
- if ("error" in doc || doc.filepath !== safe.normalized) {
994
+ const doc = await store.get(safe.normalized, { includeBody: true });
995
+ if ("error" in doc || doc.filepath !== safe.normalized || doc.body === undefined) {
996
996
  return { status: "not_found", text: "", path: params.relPath };
997
997
  }
998
- const content = await store.getDocumentBody(safe.normalized);
999
- if (content === null)
1000
- return { status: "not_found", text: "", path: params.relPath };
1001
998
  return buildReadResult({
1002
- content,
999
+ content: doc.body,
1003
1000
  path: safe.normalized,
1004
1001
  from: params.from,
1005
1002
  lines: params.lines,
@@ -10,12 +10,9 @@ export const peoplePrimerSchema = Type.Object({
10
10
  timeoutMs: Type.Integer({ minimum: 1, maximum: 60000, default: 30000 }),
11
11
  }, { additionalProperties: false });
12
12
  export function resolvePeoplePrimer(value, corpora, peopleEnabled) {
13
- if (value === undefined)
14
- return { enabled: false, corpora: [], hitsPerQuestion: 30,
15
- minScore: 0.35, minUsefulness: 0.8, maxEvidencePerQuestion: 3, timeoutMs: 30000 };
16
13
  let config;
17
14
  try {
18
- const withDefaults = Value.Default(peoplePrimerSchema, value);
15
+ const withDefaults = Value.Default(peoplePrimerSchema, value === undefined ? {} : value);
19
16
  if (!Value.Check(peoplePrimerSchema, withDefaults))
20
17
  throw new Error("Invalid config");
21
18
  config = withDefaults;
@@ -147,6 +147,7 @@ export class PeopleStore {
147
147
  #db;
148
148
  #maxOpenTodos;
149
149
  #maxBlurbChars;
150
+ #primerCacheReady = false;
150
151
  constructor(path, options) {
151
152
  this.#db = openMemoryDatabase(path);
152
153
  this.#maxOpenTodos = options.maxOpenTodos;
@@ -167,12 +168,17 @@ export class PeopleStore {
167
168
  // Derived, bounded cache: no source text or credentials. Kept outside the
168
169
  // authoritative dossier schema so older plugin versions can still open it.
169
170
  #ensurePrimerCache() {
171
+ if (this.#primerCacheReady)
172
+ return;
170
173
  this.#db.exec(`CREATE TABLE IF NOT EXISTS person_primer_judgments (
171
174
  cache_key TEXT PRIMARY KEY,
172
175
  person_id TEXT NOT NULL REFERENCES people(id) ON DELETE CASCADE,
173
176
  judgment_json TEXT NOT NULL,
174
177
  created_at TEXT NOT NULL
175
- ) STRICT`);
178
+ ) STRICT;
179
+ CREATE INDEX IF NOT EXISTS person_primer_judgments_created
180
+ ON person_primer_judgments(created_at DESC, cache_key)`);
181
+ this.#primerCacheReady = true;
176
182
  }
177
183
  getPrimerJudgment(key) {
178
184
  this.#ensurePrimerCache();
@@ -50,7 +50,7 @@ export async function auditQualityPage(params) {
50
50
  return result("ok", true);
51
51
  const limit = Math.max(1, Math.min(20, Math.floor(params.limit ?? 10)));
52
52
  const rows = db.prepare(`SELECT d.id AS document_id, cv.seq, d.collection, d.path,
53
- d.hash, cv.pos, cv.chunk_len, c.doc
53
+ d.hash, cv.pos, cv.chunk_len
54
54
  FROM documents d JOIN content c ON c.hash = d.hash JOIN content_vectors cv ON cv.hash = d.hash
55
55
  WHERE d.active = 1 AND d.collection IN (${[...sources].map(() => "?").join(",")})
56
56
  AND (d.id > ? OR (d.id = ? AND cv.seq > ?))
@@ -59,9 +59,18 @@ export async function auditQualityPage(params) {
59
59
  WHERE d.id = ? AND d.active = 1 AND d.collection = ? AND d.path = ? AND d.hash = ?
60
60
  AND cv.seq = ? AND cv.pos = ? AND cv.chunk_len = ?`);
61
61
  const page = rows.slice(0, limit);
62
+ const bodies = new Map();
63
+ const readBody = db.prepare("SELECT doc FROM content WHERE hash = ?");
62
64
  try {
63
65
  check();
64
- const batch = page.map(row => {
66
+ const batch = page.map(metadata => {
67
+ let doc = bodies.get(metadata.hash);
68
+ if (doc === undefined) {
69
+ // Content is hash-addressed; this capture does not yield before inference.
70
+ doc = readBody.get(metadata.hash).doc;
71
+ bodies.set(metadata.hash, doc);
72
+ }
73
+ const row = { ...metadata, doc };
65
74
  const source = sources.get(row.collection);
66
75
  const text = row.doc.slice(row.pos, row.pos + row.chunk_len);
67
76
  const fingerprint = chunkFingerprint(text);
@@ -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]);
@@ -8,7 +8,7 @@ export declare class ResponseReviews {
8
8
  constructor(db: DatabaseSync);
9
9
  sync(cohort: string, episodeId: string, inputHash: string, result: ResponseResult, now: number): void;
10
10
  refresh(cohort: string, since: number): number;
11
- reconcile(cohort: string): void;
11
+ reconcile(cohort: string, sessionId: string): void;
12
12
  list(cohort: string, id?: string): {
13
13
  id: string;
14
14
  episodeId: string;
@@ -68,11 +68,13 @@ export class ResponseReviews {
68
68
  throw error;
69
69
  }
70
70
  }
71
- reconcile(cohort) {
71
+ reconcile(cohort, sessionId) {
72
72
  this.db.prepare(`UPDATE response_review_evidence AS e SET active=CASE WHEN EXISTS (
73
73
  SELECT 1 FROM response_review_tasks t JOIN response_results r ON r.id=t.episode_id
74
74
  WHERE t.id=e.task_id AND r.cohort=e.cohort AND r.input_hash=e.input_hash AND r.active=1 AND r.status='ok')
75
- THEN 1 ELSE 0 END WHERE cohort=? AND active>=0`).run(cohort);
75
+ THEN 1 ELSE 0 END WHERE cohort=? AND active>=0 AND task_id IN (
76
+ SELECT t.id FROM response_results r JOIN response_review_tasks t ON t.episode_id=r.id
77
+ WHERE r.cohort=? AND r.session_id=?)`).run(cohort, cohort, sessionId);
76
78
  }
77
79
  list(cohort, id) {
78
80
  return this.db.prepare(`SELECT t.*,e.input_hash,e.active,e.detail FROM response_review_tasks t
@@ -87,7 +87,7 @@ export class ResponseAuditStore {
87
87
  result=CASE WHEN input_hash=excluded.input_hash THEN result ELSE NULL END`);
88
88
  for (const e of episodes)
89
89
  insert.run(cohort, e.id, sessionId, e.inputHash, e.timestamp);
90
- this.reviews.reconcile(cohort);
90
+ this.reviews.reconcile(cohort, sessionId);
91
91
  this.#db.exec("COMMIT");
92
92
  }
93
93
  catch (error) {
@@ -106,7 +106,8 @@ export class QmdMemoryRuntime {
106
106
  return { manager: await pending };
107
107
  }
108
108
  catch (error) {
109
- this.#managers.delete(params.agentId);
109
+ if (this.#managers.get(params.agentId) === pending)
110
+ this.#managers.delete(params.agentId);
110
111
  return { manager: null, error: error instanceof Error ? error.message : String(error) };
111
112
  }
112
113
  }
@@ -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;
@@ -332,8 +344,9 @@ export async function syncSessionProjections(params) {
332
344
  else
333
345
  counts.unchanged += 1;
334
346
  }
347
+ const liveIds = new Set(snapshot.windows.map(window => window.sessionId));
335
348
  for (const [sessionId, session] of Object.entries(previousManifest.sessions)) {
336
- if (sessions[sessionId] || snapshot.windows.some((window) => window.sessionId === sessionId))
349
+ if (sessions[sessionId] || liveIds.has(sessionId))
337
350
  continue;
338
351
  await remove(projectionPath(params.outputDir, session.documentPath));
339
352
  counts.removed += 1;
@@ -1,7 +1,7 @@
1
1
  import { basename } from "node:path";
2
2
  import { selectTypeSafeSkill } from "./typesafe.js";
3
3
  import { resolveTypeSafeApiKey, TypeSafeRequestError } from "./typesafe-client.js";
4
- import { buildSkillWhispererQuery, messageText } from "./whisperer-context.js";
4
+ import { buildSkillWhispererQuery, recentSkillMessages } from "./whisperer-context.js";
5
5
  import { abortable } from "./abortable.js";
6
6
  const CANDIDATE_LIMIT = 10;
7
7
  const MAX_QUERY_CHARS = 12_000;
@@ -10,18 +10,15 @@ const TOTAL_TIMEOUT_MS = 3000;
10
10
  function isRecord(value) {
11
11
  return value !== null && typeof value === "object" && !Array.isArray(value);
12
12
  }
13
- function typeSafeConversation(prompt, messages, historyMessages) {
13
+ function typeSafeConversation(prompt, messages) {
14
14
  const currentRequest = prompt.trim().slice(-MAX_QUERY_CHARS);
15
15
  let remaining = MAX_QUERY_CHARS - currentRequest.length;
16
- const available = messages.flatMap(message => {
17
- const parsed = messageText(message);
18
- return parsed ? [{ role: parsed.role, content: parsed.text }] : [];
19
- });
20
16
  const history = [];
21
- for (const message of (historyMessages ? available.slice(-historyMessages) : []).reverse()) {
17
+ for (let i = messages.length - 1; i >= 0; i--) {
22
18
  if (remaining <= 0)
23
19
  break;
24
- const content = message.content.slice(-remaining);
20
+ const message = messages[i];
21
+ const content = message.text.slice(-remaining);
25
22
  history.unshift({ role: message.role, content });
26
23
  remaining -= content.length;
27
24
  }
@@ -73,7 +70,8 @@ export function registerSkillWhisperer(api, runtime, config, typesafe, diagnosti
73
70
  signal.throwIfAborted();
74
71
  if (!apiKey)
75
72
  diagnostics?.record(agentId, "skill", typesafe.enabled ? "missing_key" : "typesafe_disabled");
76
- const candidates = await abortable(runtime.searchSkills(runtimeParams, buildSkillWhispererQuery(event.prompt, event.messages, config.historyMessages), apiKey ? -1 : config.minScore, CANDIDATE_LIMIT), signal);
73
+ const recentMessages = recentSkillMessages(event.messages, config.historyMessages);
74
+ const candidates = await abortable(runtime.searchSkills(runtimeParams, buildSkillWhispererQuery(event.prompt, recentMessages), apiKey ? -1 : config.minScore, CANDIDATE_LIMIT), signal);
77
75
  signal.throwIfAborted();
78
76
  const resolvedCandidates = candidates.flatMap((candidate) => {
79
77
  const canonicalPath = runtime.resolveSkillPath(runtimeParams, candidate.path);
@@ -96,7 +94,7 @@ export function registerSkillWhisperer(api, runtime, config, typesafe, diagnosti
96
94
  }
97
95
  const selectedIndex = await abortable(selectTypeSafeSkill({
98
96
  apiKey, timeoutMs: typesafe.timeoutMs, signal,
99
- ...typeSafeConversation(event.prompt, event.messages, config.historyMessages),
97
+ ...typeSafeConversation(event.prompt, recentMessages),
100
98
  candidates: shortlist.map(({ candidate }) => candidate),
101
99
  onCandidateFailure: (candidateIndex, error) => {
102
100
  if (signal.aborted)
@@ -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;
@@ -85,15 +85,9 @@ export declare class TrainingStore {
85
85
  error: string;
86
86
  }): void;
87
87
  retry(includeAmbiguous: boolean, ids: readonly string[]): number;
88
- activeExamples(): TrainingSourceExample[];
89
- queryExamples(threshold?: number): {
88
+ queryExamples(threshold?: number): (TrainingSourceExample & {
90
89
  recallProbability: number;
91
- id: string;
92
- inputHash: string;
93
- inputJson: string;
94
- sessionId: string;
95
- timestamp: number;
96
- }[];
90
+ })[];
97
91
  step<S extends keyof TrainingStepResults>(stage: S, parameters: Record<string, unknown>): {
98
92
  id: string;
99
93
  stage: S;
@@ -136,20 +136,21 @@ export class TrainingStore {
136
136
  this.#db.prepare("UPDATE training_examples SET active=0 WHERE session_id=?").run(sessionId);
137
137
  const counts = { added: 0, changed: 0, unchanged: 0, retired: 0 };
138
138
  const retained = new Set();
139
+ const input = this.#db.prepare("INSERT OR IGNORE INTO training_inputs VALUES (?,?,?)");
140
+ const source = this.#db.prepare(`INSERT INTO training_examples VALUES (?,?,?,?,?,?,1)
141
+ ON CONFLICT(session_id,event_seq) DO UPDATE SET timestamp=excluded.timestamp,input_hash=excluded.input_hash,
142
+ context_limited=excluded.context_limited,active=1`);
143
+ const gate = this.#db.prepare(`INSERT OR IGNORE INTO training_gates
144
+ (id,input_hash,prompt_version,requested_model,status) VALUES (?,?,?,?,'pending')`);
139
145
  for (const example of examples) {
140
146
  const old = previous.get(example.seq);
141
147
  if (!old && (existingOnly || example.timestamp < since || example.timestamp >= until))
142
148
  continue;
143
149
  retained.add(example.seq);
144
- this.#db.prepare("INSERT OR IGNORE INTO training_inputs VALUES (?,?,?)")
145
- .run(example.inputHash, TRAINING_PREPARATION, JSON.stringify(example.input));
146
- this.#db.prepare(`INSERT INTO training_examples VALUES (?,?,?,?,?,?,1)
147
- ON CONFLICT(session_id,event_seq) DO UPDATE SET timestamp=excluded.timestamp,input_hash=excluded.input_hash,
148
- context_limited=excluded.context_limited,active=1`)
149
- .run(trainingHash([this.#nodeId, this.#agentId, sessionId, example.seq]), sessionId, example.seq, example.timestamp, example.inputHash, Number(example.contextLimited));
150
+ input.run(example.inputHash, TRAINING_PREPARATION, JSON.stringify(example.input));
151
+ source.run(trainingHash([this.#nodeId, this.#agentId, sessionId, example.seq]), sessionId, example.seq, example.timestamp, example.inputHash, Number(example.contextLimited));
150
152
  const id = trainingHash([example.inputHash, TRAINING_GATE_VERSION, TRAINING_GATE_MODEL]);
151
- this.#db.prepare(`INSERT OR IGNORE INTO training_gates (id,input_hash,prompt_version,requested_model,status) VALUES (?,?,?,?,'pending')`)
152
- .run(id, example.inputHash, TRAINING_GATE_VERSION, TRAINING_GATE_MODEL);
153
+ gate.run(id, example.inputHash, TRAINING_GATE_VERSION, TRAINING_GATE_MODEL);
153
154
  if (!old)
154
155
  counts.added++;
155
156
  else if (old.input_hash !== example.inputHash || old.active !== 1)
@@ -211,21 +212,15 @@ export class TrainingStore {
211
212
  return count + Number(gates.changes) + Number(steps.changes);
212
213
  }, 0));
213
214
  }
214
- activeExamples() {
215
- return this.#db.prepare(`SELECT e.id,e.input_hash inputHash,i.input_json inputJson,e.session_id sessionId,e.timestamp
216
- FROM training_examples e JOIN training_inputs i ON i.hash=e.input_hash
217
- WHERE e.active=1 AND i.preparation=? ORDER BY e.timestamp DESC,e.id`).all(TRAINING_PREPARATION);
218
- }
219
215
  queryExamples(threshold = TRAINING_GATE_THRESHOLD) {
220
216
  if (!Number.isFinite(threshold) || threshold < 0 || threshold > 1)
221
217
  throw new Error("Threshold must be between 0 and 1");
222
- const probabilities = new Map(this.#db.prepare(`SELECT g.input_hash,g.probability FROM training_gates g
223
- WHERE ${this.#scope} AND g.status='complete' AND g.probability>=?`)
224
- .all(TRAINING_GATE_VERSION, TRAINING_GATE_MODEL, TRAINING_PREPARATION, threshold).map(row => [String(row.input_hash), Number(row.probability)]));
225
- return this.activeExamples().flatMap(example => {
226
- const recallProbability = probabilities.get(example.inputHash);
227
- return recallProbability === undefined ? [] : [{ ...example, recallProbability }];
228
- });
218
+ return this.#db.prepare(`SELECT e.id,e.input_hash inputHash,i.input_json inputJson,e.session_id sessionId,e.timestamp,
219
+ g.probability recallProbability FROM training_examples e JOIN training_inputs i ON i.hash=e.input_hash
220
+ JOIN training_gates g ON g.input_hash=e.input_hash
221
+ WHERE e.active=1 AND i.preparation=? AND g.prompt_version=? AND g.requested_model=?
222
+ AND g.status='complete' AND g.probability>=? ORDER BY e.timestamp DESC,e.id`)
223
+ .all(TRAINING_PREPARATION, TRAINING_GATE_VERSION, TRAINING_GATE_MODEL, threshold);
229
224
  }
230
225
  step(stage, parameters) {
231
226
  const request = { ...parameters, recipe: TRAINING_RECIPE_VERSION };
@@ -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);
@@ -3,4 +3,8 @@ export declare function messageText(message: unknown): {
3
3
  role: "user" | "assistant";
4
4
  text: string;
5
5
  } | undefined;
6
- export declare function buildSkillWhispererQuery(prompt: string, messages: readonly unknown[], historyMessages: number): string;
6
+ export declare function recentSkillMessages(messages: readonly unknown[], limit: number): {
7
+ role: "user" | "assistant";
8
+ text: string;
9
+ }[];
10
+ export declare function buildSkillWhispererQuery(prompt: string, history: readonly NonNullable<ReturnType<typeof messageText>>[]): string;
@@ -11,11 +11,16 @@ export function messageText(message) {
11
11
  }).join("\n").trim() : "";
12
12
  return text ? { role: message.role, text } : undefined;
13
13
  }
14
- export function buildSkillWhispererQuery(prompt, messages, historyMessages) {
15
- const availableHistory = messages.flatMap(message => {
16
- const parsed = messageText(message);
17
- return parsed ? [`${parsed.role}: ${parsed.text}`] : [];
18
- });
19
- const history = historyMessages === 0 ? [] : availableHistory.slice(-historyMessages);
20
- return [...history, `user: ${prompt.trim()}`].join("\n\n").slice(-MAX_SKILL_QUERY_CHARS);
14
+ export function recentSkillMessages(messages, limit) {
15
+ const history = [];
16
+ for (let i = messages.length - 1; i >= 0 && history.length < limit; i--) {
17
+ const parsed = messageText(messages[i]);
18
+ if (parsed)
19
+ history.push(parsed);
20
+ }
21
+ return history.reverse();
22
+ }
23
+ export function buildSkillWhispererQuery(prompt, history) {
24
+ return [...history.map(message => `${message.role}: ${message.text}`), `user: ${prompt.trim()}`]
25
+ .join("\n\n").slice(-MAX_SKILL_QUERY_CHARS);
21
26
  }
@@ -2,40 +2,47 @@ import fs from "node:fs/promises";
2
2
  import path from "node:path";
3
3
  import { isPathStrictlyInside } from "openclaw/plugin-sdk/file-access-runtime";
4
4
  import { readMemoryArtifactProvenance } from "openclaw/plugin-sdk/memory-core-host-runtime-core";
5
- export const classifyWorkspaceMemoryPaths = async (params) => await Promise.all(params.relativePaths.map(async (relativePath) => {
5
+ export const classifyWorkspaceMemoryPaths = async (params) => {
6
+ if (!params.relativePaths.length)
7
+ return [];
6
8
  let workspacePath;
7
- let filePath;
8
9
  try {
9
- [workspacePath, filePath] = await Promise.all([
10
- fs.realpath(params.workspaceDir),
11
- fs.realpath(path.resolve(params.workspaceDir, relativePath)),
12
- ]);
10
+ workspacePath = await fs.realpath(params.workspaceDir);
13
11
  }
14
12
  catch {
15
- return { relativePath, originClass: "untrusted" };
13
+ return params.relativePaths.map(relativePath => ({ relativePath, originClass: "untrusted" }));
16
14
  }
17
- if (!isPathStrictlyInside(workspacePath, filePath)) {
18
- return { relativePath, originClass: "untrusted" };
19
- }
20
- const workspaceRelativePath = path.relative(workspacePath, filePath);
21
- const segments = workspaceRelativePath.split(path.sep);
22
- const curatedRoot = segments.length === 1 &&
23
- ["MEMORY.md", "memory.md", "USER.md"].includes(segments[0]);
24
- if ((segments.length === 1 && ["DREAMS.md", "dreams.md"].includes(segments[0])) ||
25
- (segments[0] === "memory" && ["dreaming", ".dreams"].includes(segments[1]))) {
26
- return { relativePath, originClass: "system" };
27
- }
28
- const isWorkspaceMemory = curatedRoot ||
29
- (segments[0] === "memory" && segments.at(-1)?.endsWith(".md") === true);
30
- const normalizedPath = workspaceRelativePath.replaceAll(path.sep, "/");
31
- const recorded = isWorkspaceMemory
32
- ? await readMemoryArtifactProvenance({
33
- workspaceDir: params.workspaceDir,
34
- relativePath: normalizedPath,
35
- })
36
- : undefined;
37
- return {
38
- relativePath,
39
- originClass: recorded?.originClass ?? (isWorkspaceMemory ? "agent" : "untrusted"),
40
- };
41
- }));
15
+ return await Promise.all(params.relativePaths.map(async (relativePath) => {
16
+ let filePath;
17
+ try {
18
+ filePath = await fs.realpath(path.resolve(params.workspaceDir, relativePath));
19
+ }
20
+ catch {
21
+ return { relativePath, originClass: "untrusted" };
22
+ }
23
+ if (!isPathStrictlyInside(workspacePath, filePath)) {
24
+ return { relativePath, originClass: "untrusted" };
25
+ }
26
+ const workspaceRelativePath = path.relative(workspacePath, filePath);
27
+ const segments = workspaceRelativePath.split(path.sep);
28
+ const curatedRoot = segments.length === 1 &&
29
+ ["MEMORY.md", "memory.md", "USER.md"].includes(segments[0]);
30
+ if ((segments.length === 1 && ["DREAMS.md", "dreams.md"].includes(segments[0])) ||
31
+ (segments[0] === "memory" && ["dreaming", ".dreams"].includes(segments[1]))) {
32
+ return { relativePath, originClass: "system" };
33
+ }
34
+ const isWorkspaceMemory = curatedRoot ||
35
+ (segments[0] === "memory" && segments.at(-1)?.endsWith(".md") === true);
36
+ const normalizedPath = workspaceRelativePath.replaceAll(path.sep, "/");
37
+ const recorded = isWorkspaceMemory
38
+ ? await readMemoryArtifactProvenance({
39
+ workspaceDir: params.workspaceDir,
40
+ relativePath: normalizedPath,
41
+ })
42
+ : undefined;
43
+ return {
44
+ relativePath,
45
+ originClass: recorded?.originClass ?? (isWorkspaceMemory ? "agent" : "untrusted"),
46
+ };
47
+ }));
48
+ };
@@ -70,13 +70,17 @@ continue without retrying failed requests. Storage/lease failures still stop the
70
70
  In-flight work drains and persists before snapshots close. Dry runs make no inference calls.
71
71
 
72
72
  Cached evaluations still validate the exact historical text and vector fingerprint,
73
- but do not rebuild a QMD index. The index is built only on the first uncached search,
74
- from the same read-only SQLite transaction used to compute that fingerprint.
75
- Concurrent queries share that index; changed corpus content still invalidates caches.
73
+ but do not rebuild a QMD index. Each evaluation run captures immutable source text
74
+ and vector bytes once, then closes the source reader. Each exact cutoff derives its
75
+ own fingerprint and builds a private index only on its first uncached search.
76
+ Concurrent queries at that cutoff share the index; later runs capture fresh source
77
+ bytes, so changed corpus content still invalidates caches.
76
78
 
77
79
  ## Input and historical boundaries
78
80
 
79
- 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
80
84
  direct/group/channel chats, excluding cron, heartbeat, spawned/subagent, hook/plugin
81
85
  and untyped diagnostic sessions. Exclude explicit bots, synthetic messages,
82
86
  analysis/thinking, errors and tool payloads. Ordinary older users need not have
@@ -99,10 +103,9 @@ unknown/future dates. Never infer dates from quoted headings. Copy existing vect
99
103
  only for complete chunks within the safe prefix; BM25 sees that same prefix.
100
104
  Validate returned passages/dates again. No reembedding or temporary transcripts.
101
105
 
102
- QMD cannot independently set per-method depth through its public search API.
103
- A small discovery adapter retains its tokenization, FTS-highlight chunk selection,
104
- source-aware dedup and installed chunk helpers, but requests ten from the selected
105
- backend and omits query-conditioned scoring. It does not rewrite QMD. Training and
106
+ QMD's public `discoverCandidates` API owns tokenization, FTS-highlight chunk
107
+ selection and source-aware deduplication. A small adapter requests ten from the
108
+ selected backend and omits query-conditioned scoring. Training and
106
109
  runtime share complete-excerpt rendering, including the 1,200-character cap;
107
110
  oversized matches are discarded rather than truncated.
108
111
 
@@ -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.2",
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.2",
3
+ "version": "0.7.4",
4
4
  "description": "Workspace-native memory for OpenClaw, powered by QMD",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -37,16 +37,18 @@
37
37
  "build": "tsc -p tsconfig.build.json",
38
38
  "typecheck": "tsc -p tsconfig.json --noEmit",
39
39
  "knip": "knip --reporter compact",
40
- "test": "node --import tsx --test --test-concurrency=1 tests/**/*.test.ts tests/**/*.test.mjs",
40
+ "test": "npm run test:node && npm run test:python",
41
+ "test:node": "node --import tsx --test --test-concurrency=1 tests/**/*.test.ts tests/**/*.test.mjs",
42
+ "test:python": "python3 -B tests/prepare-query-training.test.py",
41
43
  "eval:retrieval": "node --import tsx eval/retrieval/lab.ts",
42
44
  "plugin:inspect": "plugin-inspector check --config plugin-inspector.config.json --no-openclaw",
43
- "plugin:inspect:runtime": "plugin-inspector check --config plugin-inspector.config.json --no-openclaw --runtime --mock-sdk --allow-execute",
45
+ "plugin:inspect:runtime": "plugin-inspector check --config plugin-inspector.config.json --no-openclaw --runtime --real-sdk --allow-execute",
44
46
  "release:check": "node scripts/check-release-version.mjs",
45
47
  "preflight": "npm run knip && npm run build && npm run typecheck && npm test && npm run plugin:inspect && npm run plugin:inspect:runtime && npm pack --dry-run"
46
48
  },
47
49
  "dependencies": {
48
50
  "@huggingface/tokenizers": "0.2.0",
49
- "@unblocklabs/qmd": "https://github.com/unblocklabs-ai/qmd/releases/download/v2.11.0/unblocklabs-qmd-2.11.0.tgz",
51
+ "@unblocklabs/qmd": "https://github.com/unblocklabs-ai/qmd/releases/download/v2.11.1/unblocklabs-qmd-2.11.1.tgz",
50
52
  "chokidar": "5.0.0",
51
53
  "js-tiktoken": "1.0.21",
52
54
  "picomatch": "^4.0.5",