@unblocklabs/unblock-memory 0.3.25 → 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
|
-
/**
|
|
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;
|
|
@@ -2,6 +2,7 @@ import { createHash } from "node:crypto";
|
|
|
2
2
|
import { existsSync } from "node:fs";
|
|
3
3
|
import { join } from "node:path";
|
|
4
4
|
import { createRequire } from "node:module";
|
|
5
|
+
import { setImmediate as yieldToEventLoop } from "node:timers/promises";
|
|
5
6
|
import { readSessionManifest } from "./session-sync.js";
|
|
6
7
|
import { resolveSessionSource } from "./sources.js";
|
|
7
8
|
import { trainingCandidates } from "./training-candidates.js";
|
|
@@ -42,7 +43,7 @@ export function historicalPrefix(body, spans, cutoff) {
|
|
|
42
43
|
return;
|
|
43
44
|
return { body: body.slice(0, parsed[safe.length]?.span.start ?? body.length), spans: safe.map(s => s.span) };
|
|
44
45
|
}
|
|
45
|
-
/**
|
|
46
|
+
/** Fingerprint a read-only source snapshot; build its search index only on demand.
|
|
46
47
|
* No filesystem projection, live-index mutation, model re-embedding or dependency patch. */
|
|
47
48
|
export async function historicalTrainingSearch(stateDir, chatTypes, cutoff, openStore) {
|
|
48
49
|
const indexPath = join(stateDir, "index.sqlite");
|
|
@@ -51,120 +52,175 @@ export async function historicalTrainingSearch(stateDir, chatTypes, cutoff, open
|
|
|
51
52
|
const manifest = await readSessionManifest(join(stateDir, "sessions-manifest.json"));
|
|
52
53
|
const source = resolveSessionSource(join(stateDir, "sessions"), chatTypes);
|
|
53
54
|
const createStore = openStore ?? (await import("@unblocklabs/qmd")).createStore;
|
|
54
|
-
const qmd = await createStore({ dbPath: ":memory:", keepModelsWarm: true,
|
|
55
|
-
config: { collections: { [source.collection]: { path: source.root, pattern: "**/*.md" } } } });
|
|
56
|
-
// A snapshot has one native embedding context. Serialize its vector stage only;
|
|
57
|
-
// QMD's remote TypeSafe scoring remains concurrent across all ten queries.
|
|
58
|
-
const searchVec = qmd.internal.searchVec;
|
|
59
|
-
let vectorTail = Promise.resolve();
|
|
60
|
-
qmd.internal.searchVec = (...args) => {
|
|
61
|
-
const result = vectorTail.then(() => searchVec(...args));
|
|
62
|
-
vectorTail = result.then(() => { }, () => { });
|
|
63
|
-
return result;
|
|
64
|
-
};
|
|
65
|
-
const db = qmd.internal.db;
|
|
66
55
|
const maxDate = new Date(cutoff).toISOString();
|
|
67
56
|
const report = { sessions: 0, chunks: 0, excluded: 0, truncated: 0, excludedChunks: 0 };
|
|
68
57
|
const metadata = new Map();
|
|
69
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;
|
|
70
70
|
try {
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
const
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
if (typeof extensionPath !== "string")
|
|
84
|
-
throw new Error("QMD sqlite-vec path unavailable");
|
|
85
|
-
sourceDb.loadExtension(extensionPath);
|
|
86
|
-
sourceDb.exec("PRAGMA query_only=ON; PRAGMA busy_timeout=5000; BEGIN");
|
|
87
|
-
db.exec("BEGIN");
|
|
88
|
-
const settings = sourceDb.prepare("SELECT key,value FROM store_config WHERE key='embedding_chunk_strategy'").all();
|
|
89
|
-
for (const { key, value } of settings)
|
|
90
|
-
db.prepare("INSERT OR REPLACE INTO store_config VALUES (?,?)").run(key, value);
|
|
91
|
-
fingerprint.update(JSON.stringify(settings));
|
|
92
|
-
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
|
|
93
83
|
WHERE d.active=1 AND d.collection=? AND d.path=?`);
|
|
94
|
-
|
|
84
|
+
const chunks = sourceDb.prepare(`SELECT cv.seq,cv.pos,cv.chunk_len,cv.model,cv.embed_fingerprint,v.embedding
|
|
95
85
|
FROM content_vectors cv JOIN vectors_vec v ON v.hash_seq=cv.hash||'_'||cv.seq
|
|
96
86
|
WHERE cv.hash=? ORDER BY cv.seq`);
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
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)) {
|
|
120
|
+
if (performance.now() >= yieldAt) {
|
|
121
|
+
await yieldToEventLoop();
|
|
122
|
+
yieldAt = performance.now() + 25;
|
|
109
123
|
}
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
report.excluded++;
|
|
124
|
+
if (!historicalChunk(chunk, prefix.body.length)) {
|
|
125
|
+
report.excludedChunks++;
|
|
113
126
|
continue;
|
|
114
127
|
}
|
|
115
|
-
const
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
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) {
|
|
161
|
+
if (performance.now() >= yieldAt) {
|
|
162
|
+
await yieldToEventLoop();
|
|
163
|
+
yieldAt = performance.now() + 25;
|
|
128
164
|
}
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
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);
|
|
136
178
|
}
|
|
137
|
-
if (dimensions !== size)
|
|
138
|
-
throw new Error("Mixed historical embedding dimensions");
|
|
139
|
-
const vector = new Float32Array(size);
|
|
140
|
-
for (let i = 0; i < size; i++)
|
|
141
|
-
vector[i] = bytes.readFloatLE(i * 4);
|
|
142
|
-
qmd.internal.insertEmbedding(hash, chunk.seq, chunk.pos, vector, chunk.model, maxDate, 1, chunk.embed_fingerprint, chunk.chunk_len);
|
|
143
|
-
fingerprint.update(JSON.stringify([chunk.seq, chunk.pos, chunk.chunk_len, chunk.model, chunk.embed_fingerprint])).update(bytes);
|
|
144
|
-
report.chunks++;
|
|
145
179
|
}
|
|
180
|
+
db.exec("COMMIT");
|
|
181
|
+
return qmd;
|
|
146
182
|
}
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
183
|
+
catch (error) {
|
|
184
|
+
await qmd?.close();
|
|
185
|
+
throw error;
|
|
186
|
+
}
|
|
187
|
+
finally {
|
|
188
|
+
closeSource();
|
|
189
|
+
}
|
|
190
|
+
};
|
|
153
191
|
return { corpusHash, report, maxDate,
|
|
154
192
|
search: async (query) => {
|
|
193
|
+
if (closed)
|
|
194
|
+
throw new Error("Historical snapshot is closed");
|
|
155
195
|
if (!report.sessions)
|
|
156
196
|
return [];
|
|
157
197
|
if (!report.chunks)
|
|
158
198
|
throw new Error("Historical snapshot has no vectors; refusing a BM25-only evaluation");
|
|
199
|
+
const qmd = await (indexed ??= materialize());
|
|
159
200
|
const hits = await trainingCandidates(qmd, query, source.collection, `Historical request made at ${maxDate}. Current, now and latest refer to that timestamp.`);
|
|
160
201
|
return hits.map(hit => trainingHit(hit, metadata, cutoff));
|
|
161
|
-
}, 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
|
+
} };
|
|
162
214
|
}
|
|
163
215
|
catch (error) {
|
|
164
|
-
|
|
216
|
+
closeSource();
|
|
165
217
|
throw error;
|
|
166
218
|
}
|
|
167
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
|
+
}
|
|
168
224
|
function trainingHit(hit, metadata, cutoff) {
|
|
169
225
|
const spans = metadata.get(hit.file)?.filter(s => s.start < hit.bestChunkPos + hit.bestChunk.length && s.end > hit.bestChunkPos);
|
|
170
226
|
if (!spans?.length || spans.some(s => !(Date.parse(s.timestamp) < Math.floor(cutoff / 1000) * 1000)) ||
|
|
@@ -43,6 +43,8 @@ export class TrainingStore {
|
|
|
43
43
|
status TEXT NOT NULL CHECK(status IN ('pending','attempted','complete','failed','ambiguous')),
|
|
44
44
|
result_json TEXT, error TEXT, completed_at INTEGER
|
|
45
45
|
) STRICT;
|
|
46
|
+
CREATE INDEX IF NOT EXISTS training_judgment_exclusions ON training_steps(json_extract(request_json,'$.identity'))
|
|
47
|
+
WHERE stage='judge' AND status='complete' AND json_extract(result_json,'$.excluded')=1;
|
|
46
48
|
CREATE TABLE IF NOT EXISTS training_step_attempts (
|
|
47
49
|
id INTEGER PRIMARY KEY, step_id TEXT NOT NULL REFERENCES training_steps(id), started_at INTEGER NOT NULL,
|
|
48
50
|
finished_at INTEGER, status TEXT NOT NULL, error TEXT, result_json TEXT
|
package/docs/memory-training.md
CHANGED
|
@@ -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
|
|
@@ -128,8 +133,13 @@ No blanket failure skipping.
|
|
|
128
133
|
|
|
129
134
|
The host must support isolated completion and grant
|
|
130
135
|
`plugins.entries.unblock-memory.llm.allowModelOverride: true` with
|
|
131
|
-
`allowedModels: ["openai/gpt-6-luna"]`.
|
|
132
|
-
|
|
136
|
+
`allowedModels: ["openai/gpt-6-luna"]`. The operator CLI explicitly selects an
|
|
137
|
+
agent, so an unbound CLI runtime also requires
|
|
138
|
+
`plugins.entries.unblock-memory.llm.allowAgentIdOverride: true`. Preserve other
|
|
139
|
+
grants. The host's model catalog and selected runtime must support that exact
|
|
140
|
+
model with usable host-managed authentication; a model permission grant alone
|
|
141
|
+
does not establish model availability. The plugin does not change its own
|
|
142
|
+
permissions. Credentials stay with the host, never in provenance.
|
|
133
143
|
|
|
134
144
|
## Export and consolidation
|
|
135
145
|
|
|
@@ -145,3 +155,16 @@ identical-input groups together across nodes for train/validation. Use the actua
|
|
|
145
155
|
**LFM2.5-230M-Base** tokenizer and an explicit causal-LM input/target format before
|
|
146
156
|
fine-tuning; the interim byte limit is not a token count. Runtime abstention remains
|
|
147
157
|
a separate TypeSafe gate; positive-only query training does not teach abstention.
|
|
158
|
+
|
|
159
|
+
The repository helper uses a pinned official tokenizer revision and chat template,
|
|
160
|
+
with assistant-only loss labels, a conservative 32,768-token training ceiling
|
|
161
|
+
(the model config supports 128,000 positions), and deterministic grouped splits:
|
|
162
|
+
|
|
163
|
+
```sh
|
|
164
|
+
python scripts/prepare-query-training.py /private/node1-queries.jsonl /private/node2-queries.jsonl \
|
|
165
|
+
--output /private/new-prepared-directory --cache-dir /private/tokenizer-cache
|
|
166
|
+
```
|
|
167
|
+
|
|
168
|
+
Install `transformers` and `jinja2` in a separate environment first. It downloads
|
|
169
|
+
tokenizer files only, never model weights. Regex secret screening is not an
|
|
170
|
+
exhaustive privacy audit; inspect quarantine references before including those rows.
|
package/openclaw.plugin.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"id": "unblock-memory",
|
|
3
3
|
"name": "Unblock Memory",
|
|
4
|
-
"version": "0.3.
|
|
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 },
|