@unblocklabs/unblock-memory 0.3.26 → 0.3.27

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.
@@ -20,7 +20,7 @@ export declare function historicalPrefix(body: string, spans: readonly SessionMe
20
20
  body: string;
21
21
  spans: SessionMessageSpan[];
22
22
  } | undefined;
23
- /** A read-only source snapshot, copied into a disposable in-memory QMD index.
23
+ /** Fingerprint a read-only source snapshot; build its search index only on demand.
24
24
  * No filesystem projection, live-index mutation, model re-embedding or dependency patch. */
25
25
  export declare function historicalTrainingSearch(stateDir: string, chatTypes: readonly ChatType[], cutoff: number, openStore?: typeof import("@unblocklabs/qmd")["createStore"]): Promise<{
26
26
  corpusHash: string;
@@ -43,7 +43,7 @@ export function historicalPrefix(body, spans, cutoff) {
43
43
  return;
44
44
  return { body: body.slice(0, parsed[safe.length]?.span.start ?? body.length), spans: safe.map(s => s.span) };
45
45
  }
46
- /** A read-only source snapshot, copied into a disposable in-memory QMD index.
46
+ /** Fingerprint a read-only source snapshot; build its search index only on demand.
47
47
  * No filesystem projection, live-index mutation, model re-embedding or dependency patch. */
48
48
  export async function historicalTrainingSearch(stateDir, chatTypes, cutoff, openStore) {
49
49
  const indexPath = join(stateDir, "index.sqlite");
@@ -52,132 +52,175 @@ export async function historicalTrainingSearch(stateDir, chatTypes, cutoff, open
52
52
  const manifest = await readSessionManifest(join(stateDir, "sessions-manifest.json"));
53
53
  const source = resolveSessionSource(join(stateDir, "sessions"), chatTypes);
54
54
  const createStore = openStore ?? (await import("@unblocklabs/qmd")).createStore;
55
- const qmd = await createStore({ dbPath: ":memory:", keepModelsWarm: true,
56
- config: { collections: { [source.collection]: { path: source.root, pattern: "**/*.md" } } } });
57
- // A snapshot has one native embedding context. Serialize its vector stage only;
58
- // QMD's remote TypeSafe scoring remains concurrent across all ten queries.
59
- const searchVec = qmd.internal.searchVec;
60
- let vectorTail = Promise.resolve();
61
- qmd.internal.searchVec = (...args) => {
62
- const result = vectorTail.then(() => searchVec(...args));
63
- vectorTail = result.then(() => { }, () => { });
64
- return result;
65
- };
66
- const db = qmd.internal.db;
67
55
  const maxDate = new Date(cutoff).toISOString();
68
56
  const report = { sessions: 0, chunks: 0, excluded: 0, truncated: 0, excludedChunks: 0 };
69
57
  const metadata = new Map();
70
58
  const fingerprint = createHash("sha256").update(JSON.stringify([TRAINING_RETRIEVAL_VERSION, chatTypes.toSorted(), maxDate]));
59
+ // Use QMD's exact binding, never mix node:sqlite with sqlite-vec.
60
+ const requireQmd = createRequire(import.meta.resolve("@unblocklabs/qmd"));
61
+ const Database = requireQmd("better-sqlite3");
62
+ const sourceDb = new Database(indexPath, { readonly: true, fileMustExist: true });
63
+ const documents = [];
64
+ let sourceClosed = false, closed = false;
65
+ const closeSource = () => { if (!sourceClosed) {
66
+ sourceDb.close();
67
+ sourceClosed = true;
68
+ } documents.length = 0; };
69
+ let indexed;
71
70
  try {
72
- // QMD's SDK only opens writable stores. Use its exact SQLite binding in
73
- // read-only mode; mixing node:sqlite with better-sqlite3 corrupts sqlite-vec's
74
- // process-global SQLite API pointer when both load the native extension.
75
- const requireQmd = createRequire(import.meta.resolve("@unblocklabs/qmd"));
76
- const Database = requireQmd("better-sqlite3");
77
- const sourceDb = new Database(indexPath, { readonly: true, fileMustExist: true });
78
- try {
79
- const extension = requireQmd("sqlite-vec");
80
- if (!extension || typeof extension !== "object" || !("getLoadablePath" in extension) || typeof extension.getLoadablePath !== "function") {
81
- throw new Error("QMD sqlite-vec extension unavailable");
82
- }
83
- const extensionPath = extension.getLoadablePath();
84
- if (typeof extensionPath !== "string")
85
- throw new Error("QMD sqlite-vec path unavailable");
86
- sourceDb.loadExtension(extensionPath);
87
- sourceDb.exec("PRAGMA query_only=ON; PRAGMA busy_timeout=5000; BEGIN");
88
- db.exec("BEGIN");
89
- const settings = sourceDb.prepare("SELECT key,value FROM store_config WHERE key='embedding_chunk_strategy'").all();
90
- for (const { key, value } of settings)
91
- db.prepare("INSERT OR REPLACE INTO store_config VALUES (?,?)").run(key, value);
92
- fingerprint.update(JSON.stringify(settings));
93
- const document = sourceDb.prepare(`SELECT d.hash,c.doc FROM documents d JOIN content c ON c.hash=d.hash
71
+ const extension = requireQmd("sqlite-vec");
72
+ if (!extension || typeof extension !== "object" || !("getLoadablePath" in extension) || typeof extension.getLoadablePath !== "function") {
73
+ throw new Error("QMD sqlite-vec extension unavailable");
74
+ }
75
+ const extensionPath = extension.getLoadablePath();
76
+ if (typeof extensionPath !== "string")
77
+ throw new Error("QMD sqlite-vec path unavailable");
78
+ sourceDb.loadExtension(extensionPath);
79
+ sourceDb.exec("PRAGMA query_only=ON; PRAGMA busy_timeout=5000; BEGIN");
80
+ const settings = sourceDb.prepare("SELECT key,value FROM store_config WHERE key='embedding_chunk_strategy'").all();
81
+ fingerprint.update(JSON.stringify(settings));
82
+ const document = sourceDb.prepare(`SELECT d.hash,c.doc FROM documents d JOIN content c ON c.hash=d.hash
94
83
  WHERE d.active=1 AND d.collection=? AND d.path=?`);
95
- const chunks = sourceDb.prepare(`SELECT cv.seq,cv.pos,cv.chunk_len,cv.model,cv.embed_fingerprint,v.embedding
84
+ const chunks = sourceDb.prepare(`SELECT cv.seq,cv.pos,cv.chunk_len,cv.model,cv.embed_fingerprint,v.embedding
96
85
  FROM content_vectors cv JOIN vectors_vec v ON v.hash_seq=cv.hash||'_'||cv.seq
97
86
  WHERE cv.hash=? ORDER BY cv.seq`);
98
- let dimensions;
99
- // Copy cooperatively: concurrent snapshots must not starve lease timers,
100
- // provider responses or native-model disposal. Keep the source transaction
101
- // open across yields so every row still comes from the same read snapshot.
102
- let yieldAt = performance.now() + 25;
103
- for (const session of Object.values(manifest.sessions).sort((a, b) => a.documentPath.localeCompare(b.documentPath))) {
87
+ let dimensions;
88
+ // Both passes use this same read transaction. Cached evaluations still
89
+ // verify every prefix/vector byte, but never build an index or load a model.
90
+ let yieldAt = performance.now() + 25;
91
+ for (const session of Object.values(manifest.sessions).sort((a, b) => a.documentPath.localeCompare(b.documentPath))) {
92
+ if (performance.now() >= yieldAt) {
93
+ await yieldToEventLoop();
94
+ yieldAt = performance.now() + 25;
95
+ }
96
+ // Loggie projections can retroactively annotate old text using later revisions.
97
+ // Workspace files and meetings lack immutable historical content proof here.
98
+ if (!chatTypes.includes(session.chatType) || session.provider === "loggie" || session.startedAt >= cutoff) {
99
+ report.excluded++;
100
+ continue;
101
+ }
102
+ const row = document.get(source.collection, session.documentPath);
103
+ if (!row || createHash("sha256").update(row.doc).digest("hex") !== session.projectionHash) {
104
+ report.excluded++;
105
+ continue;
106
+ }
107
+ const prefix = historicalPrefix(row.doc, session.messages, cutoff);
108
+ if (!prefix) {
109
+ report.excluded++;
110
+ continue;
111
+ }
112
+ const hash = createHash("sha256").update(prefix.body).digest("hex");
113
+ documents.push({ path: session.documentPath, sourceHash: row.hash, hash, body: prefix.body });
114
+ metadata.set(`qmd://${source.collection}/${session.documentPath}`, prefix.spans);
115
+ fingerprint.update(JSON.stringify([session.documentPath, hash]));
116
+ report.sessions++;
117
+ if (prefix.spans.length < (session.messages?.length ?? 0))
118
+ report.truncated++;
119
+ for (const chunk of chunks.iterate(row.hash)) {
104
120
  if (performance.now() >= yieldAt) {
105
121
  await yieldToEventLoop();
106
122
  yieldAt = performance.now() + 25;
107
123
  }
108
- // Loggie projections can retroactively annotate old text using later revisions.
109
- // Workspace files and meetings lack immutable historical content proof here.
110
- if (!chatTypes.includes(session.chatType) || session.provider === "loggie" || session.startedAt >= cutoff) {
111
- report.excluded++;
124
+ if (!historicalChunk(chunk, prefix.body.length)) {
125
+ report.excludedChunks++;
112
126
  continue;
113
127
  }
114
- const row = document.get(source.collection, session.documentPath);
115
- if (!row || createHash("sha256").update(row.doc).digest("hex") !== session.projectionHash) {
116
- report.excluded++;
117
- continue;
118
- }
119
- const prefix = historicalPrefix(row.doc, session.messages, cutoff);
120
- if (!prefix) {
121
- report.excluded++;
122
- continue;
123
- }
124
- const hash = createHash("sha256").update(prefix.body).digest("hex");
125
- qmd.internal.insertContent(hash, prefix.body, maxDate);
126
- qmd.internal.insertDocument(source.collection, session.documentPath, "Transcript", hash, maxDate, maxDate);
127
- metadata.set(`qmd://${source.collection}/${session.documentPath}`, prefix.spans);
128
- fingerprint.update(JSON.stringify([session.documentPath, hash]));
129
- report.sessions++;
130
- if (prefix.spans.length < (session.messages?.length ?? 0))
131
- report.truncated++;
132
- for (const chunk of chunks.iterate(row.hash)) {
128
+ const bytes = Buffer.from(chunk.embedding);
129
+ if (bytes.length % 4 || !bytes.length)
130
+ throw new Error("Invalid historical embedding");
131
+ const size = bytes.length / 4;
132
+ dimensions ??= size;
133
+ if (dimensions !== size)
134
+ throw new Error("Mixed historical embedding dimensions");
135
+ fingerprint.update(JSON.stringify([chunk.seq, chunk.pos, chunk.chunk_len, chunk.model, chunk.embed_fingerprint])).update(bytes);
136
+ report.chunks++;
137
+ }
138
+ }
139
+ const corpusHash = fingerprint.digest("hex");
140
+ const materialize = async () => {
141
+ let qmd;
142
+ try {
143
+ qmd = await createStore({ dbPath: ":memory:", keepModelsWarm: true,
144
+ config: { collections: { [source.collection]: { path: source.root, pattern: "**/*.md" } } } });
145
+ // One native embedding context per snapshot; serialize only vector search.
146
+ const searchVec = qmd.internal.searchVec;
147
+ let vectorTail = Promise.resolve();
148
+ qmd.internal.searchVec = (...args) => {
149
+ const result = vectorTail.then(() => searchVec(...args));
150
+ vectorTail = result.then(() => { }, () => { });
151
+ return result;
152
+ };
153
+ const db = qmd.internal.db;
154
+ db.exec("BEGIN");
155
+ for (const { key, value } of settings)
156
+ db.prepare("INSERT OR REPLACE INTO store_config VALUES (?,?)").run(key, value);
157
+ if (dimensions !== undefined)
158
+ qmd.internal.ensureVecTable(dimensions);
159
+ yieldAt = performance.now() + 25;
160
+ for (const document of documents) {
133
161
  if (performance.now() >= yieldAt) {
134
162
  await yieldToEventLoop();
135
163
  yieldAt = performance.now() + 25;
136
164
  }
137
- if (!Number.isSafeInteger(chunk.pos) || !Number.isSafeInteger(chunk.chunk_len) || chunk.pos < 0 || chunk.chunk_len <= 0 ||
138
- chunk.pos + chunk.chunk_len > prefix.body.length) {
139
- report.excludedChunks++;
140
- continue;
141
- }
142
- const bytes = Buffer.from(chunk.embedding);
143
- if (bytes.length % 4 || !bytes.length)
144
- throw new Error("Invalid historical embedding");
145
- const size = bytes.length / 4;
146
- if (dimensions === undefined) {
147
- dimensions = size;
148
- qmd.internal.ensureVecTable(size);
165
+ qmd.internal.insertContent(document.hash, document.body, maxDate);
166
+ qmd.internal.insertDocument(source.collection, document.path, "Transcript", document.hash, maxDate, maxDate);
167
+ for (const chunk of chunks.iterate(document.sourceHash)) {
168
+ if (performance.now() >= yieldAt) {
169
+ await yieldToEventLoop();
170
+ yieldAt = performance.now() + 25;
171
+ }
172
+ if (!historicalChunk(chunk, document.body.length))
173
+ continue;
174
+ const bytes = Buffer.from(chunk.embedding), vector = new Float32Array(bytes.length / 4);
175
+ for (let i = 0; i < vector.length; i++)
176
+ vector[i] = bytes.readFloatLE(i * 4);
177
+ qmd.internal.insertEmbedding(document.hash, chunk.seq, chunk.pos, vector, chunk.model, maxDate, 1, chunk.embed_fingerprint, chunk.chunk_len);
149
178
  }
150
- if (dimensions !== size)
151
- throw new Error("Mixed historical embedding dimensions");
152
- const vector = new Float32Array(size);
153
- for (let i = 0; i < size; i++)
154
- vector[i] = bytes.readFloatLE(i * 4);
155
- qmd.internal.insertEmbedding(hash, chunk.seq, chunk.pos, vector, chunk.model, maxDate, 1, chunk.embed_fingerprint, chunk.chunk_len);
156
- fingerprint.update(JSON.stringify([chunk.seq, chunk.pos, chunk.chunk_len, chunk.model, chunk.embed_fingerprint])).update(bytes);
157
- report.chunks++;
158
179
  }
180
+ db.exec("COMMIT");
181
+ return qmd;
159
182
  }
160
- db.exec("COMMIT");
161
- }
162
- finally {
163
- sourceDb.close();
164
- }
165
- const corpusHash = fingerprint.digest("hex");
183
+ catch (error) {
184
+ await qmd?.close();
185
+ throw error;
186
+ }
187
+ finally {
188
+ closeSource();
189
+ }
190
+ };
166
191
  return { corpusHash, report, maxDate,
167
192
  search: async (query) => {
193
+ if (closed)
194
+ throw new Error("Historical snapshot is closed");
168
195
  if (!report.sessions)
169
196
  return [];
170
197
  if (!report.chunks)
171
198
  throw new Error("Historical snapshot has no vectors; refusing a BM25-only evaluation");
199
+ const qmd = await (indexed ??= materialize());
172
200
  const hits = await trainingCandidates(qmd, query, source.collection, `Historical request made at ${maxDate}. Current, now and latest refer to that timestamp.`);
173
201
  return hits.map(hit => trainingHit(hit, metadata, cutoff));
174
- }, close: () => qmd.close() };
202
+ }, close: async () => {
203
+ if (closed)
204
+ return;
205
+ closed = true;
206
+ try {
207
+ if (indexed)
208
+ await indexed.then(qmd => qmd.close(), () => { });
209
+ }
210
+ finally {
211
+ closeSource();
212
+ }
213
+ } };
175
214
  }
176
215
  catch (error) {
177
- await qmd.close();
216
+ closeSource();
178
217
  throw error;
179
218
  }
180
219
  }
220
+ function historicalChunk(chunk, length) {
221
+ return Number.isSafeInteger(chunk.pos) && Number.isSafeInteger(chunk.chunk_len) && chunk.pos >= 0 && chunk.chunk_len > 0 &&
222
+ chunk.pos + chunk.chunk_len <= length;
223
+ }
181
224
  function trainingHit(hit, metadata, cutoff) {
182
225
  const spans = metadata.get(hit.file)?.filter(s => s.start < hit.bestChunkPos + hit.bestChunk.length && s.end > hit.bestChunkPos);
183
226
  if (!spans?.length || spans.some(s => !(Date.parse(s.timestamp) < Math.floor(cutoff / 1000) * 1000)) ||
@@ -56,6 +56,11 @@ Native vector work is serialized per snapshot. Raise concurrency within machine
56
56
  and provider capacity. Failures stop new dispatch; in-flight operations drain
57
57
  and persist before snapshots close. Dry runs make no inference calls.
58
58
 
59
+ Cached evaluations still validate the exact historical text and vector fingerprint,
60
+ but do not rebuild a QMD index. The index is built only on the first uncached search,
61
+ from the same read-only SQLite transaction used to compute that fingerprint.
62
+ Concurrent queries share that index; changed corpus content still invalidates caches.
63
+
59
64
  ## Input and historical boundaries
60
65
 
61
66
  Read original agent SQLite active-branch conversations (schema 17–19), including
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "id": "unblock-memory",
3
3
  "name": "Unblock Memory",
4
- "version": "0.3.26",
4
+ "version": "0.3.27",
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.3.26",
3
+ "version": "0.3.27",
4
4
  "description": "Workspace-native memory for OpenClaw, powered by QMD",
5
5
  "type": "module",
6
6
  "license": "MIT",