@unblocklabs/unblock-memory 0.3.24 → 0.3.25

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.
@@ -0,0 +1,300 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { openMemoryDatabase } from "./memory-database.js";
3
+ import { trainingHash, TRAINING_PREPARATION } from "./training-input.js";
4
+ import { TRAINING_GATE_MODEL, TRAINING_GATE_THRESHOLD, TRAINING_GATE_VERSION } from "./training-gate.js";
5
+ const LEASE_MS = 120_000;
6
+ export class TrainingStore {
7
+ #db;
8
+ #owner = randomUUID();
9
+ #nodeId;
10
+ #agentId;
11
+ constructor(path, agentId) {
12
+ this.#db = openMemoryDatabase(path);
13
+ this.#agentId = agentId;
14
+ try {
15
+ const version = this.#db.prepare("SELECT version FROM memory_schema WHERE component='training'").get()?.version;
16
+ if (version !== undefined && version !== 1)
17
+ throw new Error("Unsupported training database version");
18
+ this.#db.exec(`
19
+ CREATE TABLE IF NOT EXISTS training_identity (singleton INTEGER PRIMARY KEY CHECK(singleton=1), node_id TEXT NOT NULL, agent_id TEXT NOT NULL) STRICT;
20
+ CREATE TABLE IF NOT EXISTS training_lock (singleton INTEGER PRIMARY KEY CHECK(singleton=1), owner TEXT NOT NULL, expires INTEGER NOT NULL) STRICT;
21
+ CREATE TABLE IF NOT EXISTS training_inputs (hash TEXT PRIMARY KEY, preparation TEXT NOT NULL, input_json TEXT NOT NULL) STRICT;
22
+ CREATE TABLE IF NOT EXISTS training_examples (
23
+ id TEXT PRIMARY KEY, session_id TEXT NOT NULL, event_seq INTEGER NOT NULL, timestamp INTEGER NOT NULL,
24
+ input_hash TEXT NOT NULL REFERENCES training_inputs(hash), context_limited INTEGER NOT NULL,
25
+ active INTEGER NOT NULL, UNIQUE(session_id,event_seq)
26
+ ) STRICT;
27
+ CREATE TABLE IF NOT EXISTS training_gates (
28
+ id TEXT PRIMARY KEY, input_hash TEXT NOT NULL REFERENCES training_inputs(hash),
29
+ prompt_version TEXT NOT NULL, requested_model TEXT NOT NULL,
30
+ status TEXT NOT NULL CHECK(status IN ('pending','attempted','complete','failed','ambiguous')),
31
+ probability REAL, actual_model TEXT, input_tokens INTEGER, output_tokens INTEGER,
32
+ error TEXT, completed_at INTEGER,
33
+ UNIQUE(input_hash,prompt_version,requested_model)
34
+ ) STRICT;
35
+ CREATE TABLE IF NOT EXISTS training_attempts (
36
+ id INTEGER PRIMARY KEY, gate_id TEXT NOT NULL REFERENCES training_gates(id),
37
+ started_at INTEGER NOT NULL, finished_at INTEGER, status TEXT NOT NULL,
38
+ error TEXT, result_json TEXT
39
+ ) STRICT;
40
+ CREATE INDEX IF NOT EXISTS training_examples_input ON training_examples(input_hash,active);
41
+ CREATE TABLE IF NOT EXISTS training_steps (
42
+ id TEXT PRIMARY KEY, stage TEXT NOT NULL, request_json TEXT NOT NULL,
43
+ status TEXT NOT NULL CHECK(status IN ('pending','attempted','complete','failed','ambiguous')),
44
+ result_json TEXT, error TEXT, completed_at INTEGER
45
+ ) STRICT;
46
+ CREATE TABLE IF NOT EXISTS training_step_attempts (
47
+ id INTEGER PRIMARY KEY, step_id TEXT NOT NULL REFERENCES training_steps(id), started_at INTEGER NOT NULL,
48
+ finished_at INTEGER, status TEXT NOT NULL, error TEXT, result_json TEXT
49
+ ) STRICT;
50
+ INSERT OR IGNORE INTO memory_schema VALUES ('training',1);
51
+ `);
52
+ this.#db.prepare("INSERT OR IGNORE INTO training_identity VALUES (1,?,?)").run(randomUUID(), agentId);
53
+ const identity = this.#db.prepare("SELECT node_id,agent_id FROM training_identity WHERE singleton=1").get();
54
+ if (identity.agent_id !== agentId)
55
+ throw new Error("Training database belongs to a different agent");
56
+ this.#nodeId = String(identity.node_id);
57
+ }
58
+ catch (error) {
59
+ this.#db.close();
60
+ throw error;
61
+ }
62
+ }
63
+ #transaction(fn) {
64
+ this.#db.exec("BEGIN IMMEDIATE");
65
+ try {
66
+ const result = fn();
67
+ this.#db.exec("COMMIT");
68
+ return result;
69
+ }
70
+ catch (error) {
71
+ this.#db.exec("ROLLBACK");
72
+ throw error;
73
+ }
74
+ }
75
+ #checkLease() {
76
+ const lease = this.#db.prepare("SELECT owner,expires FROM training_lock WHERE singleton=1").get();
77
+ if (lease?.owner !== this.#owner || Number(lease.expires) <= Date.now())
78
+ throw new Error("Training lease lost; no further API requests permitted");
79
+ }
80
+ async locked(fn) {
81
+ this.#transaction(() => {
82
+ const lock = this.#db.prepare(`INSERT INTO training_lock VALUES (1,?,?) ON CONFLICT(singleton)
83
+ DO UPDATE SET owner=excluded.owner,expires=excluded.expires WHERE training_lock.expires<=?`)
84
+ .run(this.#owner, Date.now() + LEASE_MS, Date.now());
85
+ if (!lock.changes)
86
+ throw new Error("Another memory-training command is running");
87
+ // A request may have been billed before a crashed process saved the response.
88
+ this.#db.exec(`UPDATE training_gates SET status='ambiguous',error='interrupted' WHERE status='attempted';
89
+ UPDATE training_attempts SET status='ambiguous',error='interrupted' WHERE status='attempted';
90
+ UPDATE training_steps SET status='ambiguous',error='interrupted' WHERE status='attempted';
91
+ UPDATE training_step_attempts SET status='ambiguous',error='interrupted' WHERE status='attempted';`);
92
+ });
93
+ let leaseError;
94
+ const heartbeat = setInterval(() => { try {
95
+ this.renew();
96
+ }
97
+ catch (error) {
98
+ leaseError = error;
99
+ } }, 10_000);
100
+ heartbeat.unref();
101
+ try {
102
+ const value = await fn();
103
+ if (leaseError)
104
+ throw leaseError;
105
+ return value;
106
+ }
107
+ finally {
108
+ clearInterval(heartbeat);
109
+ this.#db.prepare("DELETE FROM training_lock WHERE owner=?").run(this.#owner);
110
+ }
111
+ }
112
+ renew() {
113
+ this.#transaction(() => {
114
+ this.#checkLease();
115
+ this.#db.prepare("UPDATE training_lock SET expires=? WHERE owner=?").run(Date.now() + LEASE_MS, this.#owner);
116
+ });
117
+ }
118
+ sessions() { return this.#db.prepare("SELECT DISTINCT session_id FROM training_examples").all().map(r => String(r.session_id)); }
119
+ syncSession(sessionId, examples, since, until, existingOnly) {
120
+ return this.#transaction(() => {
121
+ this.#checkLease();
122
+ const previous = new Map(this.#db.prepare("SELECT event_seq,input_hash,active FROM training_examples WHERE session_id=?").all(sessionId)
123
+ .map(row => [Number(row.event_seq), row]));
124
+ this.#db.prepare("UPDATE training_examples SET active=0 WHERE session_id=?").run(sessionId);
125
+ const counts = { added: 0, changed: 0, unchanged: 0, retired: 0 };
126
+ const retained = new Set();
127
+ for (const example of examples) {
128
+ const old = previous.get(example.seq);
129
+ if (!old && (existingOnly || example.timestamp < since || example.timestamp >= until))
130
+ continue;
131
+ retained.add(example.seq);
132
+ this.#db.prepare("INSERT OR IGNORE INTO training_inputs VALUES (?,?,?)")
133
+ .run(example.inputHash, TRAINING_PREPARATION, JSON.stringify(example.input));
134
+ this.#db.prepare(`INSERT INTO training_examples VALUES (?,?,?,?,?,?,1)
135
+ ON CONFLICT(session_id,event_seq) DO UPDATE SET timestamp=excluded.timestamp,input_hash=excluded.input_hash,
136
+ context_limited=excluded.context_limited,active=1`)
137
+ .run(trainingHash([this.#nodeId, this.#agentId, sessionId, example.seq]), sessionId, example.seq, example.timestamp, example.inputHash, Number(example.contextLimited));
138
+ const id = trainingHash([example.inputHash, TRAINING_GATE_VERSION, TRAINING_GATE_MODEL]);
139
+ this.#db.prepare(`INSERT OR IGNORE INTO training_gates (id,input_hash,prompt_version,requested_model,status) VALUES (?,?,?,?,'pending')`)
140
+ .run(id, example.inputHash, TRAINING_GATE_VERSION, TRAINING_GATE_MODEL);
141
+ if (!old)
142
+ counts.added++;
143
+ else if (old.input_hash !== example.inputHash || old.active !== 1)
144
+ counts.changed++;
145
+ else
146
+ counts.unchanged++;
147
+ }
148
+ counts.retired = [...previous].filter(([seq, row]) => row.active === 1 && !retained.has(seq)).length;
149
+ return counts;
150
+ });
151
+ }
152
+ unavailable(sessionId) {
153
+ this.#checkLease();
154
+ this.#db.prepare("UPDATE training_examples SET active=-1 WHERE session_id=? AND active=1").run(sessionId);
155
+ }
156
+ #scope = `g.prompt_version=? AND g.requested_model=? AND EXISTS
157
+ (SELECT 1 FROM training_examples e WHERE e.input_hash=g.input_hash AND e.active=1)`;
158
+ pending(limit) {
159
+ return this.#db.prepare(`SELECT g.id,g.input_hash inputHash,i.input_json inputJson
160
+ FROM training_gates g JOIN training_inputs i ON i.hash=g.input_hash
161
+ WHERE ${this.#scope} AND g.status='pending' ORDER BY
162
+ (SELECT MAX(timestamp) FROM training_examples WHERE input_hash=g.input_hash AND active=1) DESC,g.id LIMIT ?`)
163
+ .all(TRAINING_GATE_VERSION, TRAINING_GATE_MODEL, limit ?? -1);
164
+ }
165
+ start(id) {
166
+ return this.#transaction(() => {
167
+ this.#checkLease();
168
+ if (!this.#db.prepare("UPDATE training_gates SET status='attempted',error=NULL WHERE id=? AND status='pending'").run(id).changes) {
169
+ throw new Error("Training gate is not pending");
170
+ }
171
+ return Number(this.#db.prepare("INSERT INTO training_attempts (gate_id,started_at,status) VALUES (?,?,'attempted')")
172
+ .run(id, Date.now()).lastInsertRowid);
173
+ });
174
+ }
175
+ finish(id, attempt, result) {
176
+ this.#transaction(() => {
177
+ this.#checkLease();
178
+ const ok = "probability" in result;
179
+ const status = ok ? "complete" : result.status, error = ok ? null : result.error;
180
+ this.#db.prepare(`UPDATE training_gates SET status=?,probability=?,actual_model=?,input_tokens=?,output_tokens=?,error=?,completed_at=? WHERE id=?`)
181
+ .run(status, ok ? result.probability : null, ok ? result.model : null, ok ? result.usage.input_tokens : null, ok ? result.usage.output_tokens : null, error, Date.now(), id);
182
+ this.#db.prepare("UPDATE training_attempts SET status=?,finished_at=?,error=?,result_json=? WHERE id=? AND gate_id=?")
183
+ .run(status, Date.now(), error, ok ? JSON.stringify(result) : null, attempt, id);
184
+ });
185
+ }
186
+ retry(includeAmbiguous) {
187
+ this.#checkLease();
188
+ return this.#transaction(() => ["training_gates", "training_steps"].reduce((n, table) => n + Number(this.#db.prepare(`UPDATE ${table} SET status='pending',error=NULL WHERE status='failed' ${includeAmbiguous ? "OR status='ambiguous'" : ""}`).run().changes), 0));
189
+ }
190
+ activeExamples() {
191
+ return this.#db.prepare(`SELECT e.id,e.input_hash inputHash,i.input_json inputJson,e.session_id sessionId,e.timestamp
192
+ FROM training_examples e JOIN training_inputs i ON i.hash=e.input_hash
193
+ WHERE e.active=1 ORDER BY e.timestamp DESC,e.id`).all();
194
+ }
195
+ queryExamples(threshold = TRAINING_GATE_THRESHOLD) {
196
+ if (!Number.isFinite(threshold) || threshold < 0 || threshold > 1)
197
+ throw new Error("Threshold must be between 0 and 1");
198
+ const probabilities = new Map(this.#db.prepare(`SELECT g.input_hash,g.probability FROM training_gates g
199
+ WHERE ${this.#scope} AND g.status='complete' AND g.probability>=?`)
200
+ .all(TRAINING_GATE_VERSION, TRAINING_GATE_MODEL, threshold).map(row => [String(row.input_hash), Number(row.probability)]));
201
+ return this.activeExamples().flatMap(example => {
202
+ const recallProbability = probabilities.get(example.inputHash);
203
+ return recallProbability === undefined ? [] : [{ ...example, recallProbability }];
204
+ });
205
+ }
206
+ step(stage, request) {
207
+ const id = trainingHash([stage, request]);
208
+ const row = this.#db.prepare("SELECT status,result_json FROM training_steps WHERE id=? AND stage=?").get(id, stage);
209
+ return { id, stage, request, status: (row?.status ?? "pending"),
210
+ result: row?.status === "complete" ? JSON.parse(String(row.result_json)) : undefined };
211
+ }
212
+ judgmentExcluded(identity) {
213
+ return !!this.#db.prepare(`SELECT 1 FROM training_steps WHERE stage='judge' AND status='complete'
214
+ AND json_extract(request_json,'$.identity')=? AND json_extract(result_json,'$.excluded')=1 LIMIT 1`).get(identity);
215
+ }
216
+ startStep(stage, id, request) {
217
+ return this.#transaction(() => {
218
+ this.#checkLease();
219
+ this.#db.prepare("INSERT OR IGNORE INTO training_steps (id,stage,request_json,status) VALUES (?,?,?,'pending')")
220
+ .run(id, stage, JSON.stringify(request));
221
+ if (!this.#db.prepare("UPDATE training_steps SET status='attempted',error=NULL WHERE id=? AND status='pending'").run(id).changes) {
222
+ throw new Error("Training step is not pending");
223
+ }
224
+ return Number(this.#db.prepare("INSERT INTO training_step_attempts (step_id,started_at,status) VALUES (?,?,'attempted')")
225
+ .run(id, Date.now()).lastInsertRowid);
226
+ });
227
+ }
228
+ finishStep(stage, id, attempt, outcome) {
229
+ this.#transaction(() => {
230
+ this.#checkLease();
231
+ const ok = "result" in outcome, status = ok ? "complete" : outcome.status;
232
+ const json = ok ? JSON.stringify(outcome.result) : null, error = ok ? null : outcome.error;
233
+ if (!this.#db.prepare(`UPDATE training_steps SET status=?,result_json=?,error=?,completed_at=? WHERE id=? AND stage=? AND status='attempted'`)
234
+ .run(status, json, error, Date.now(), id, stage).changes)
235
+ throw new Error("Training step attempt lost");
236
+ if (!this.#db.prepare(`UPDATE training_step_attempts SET status=?,finished_at=?,error=?,result_json=? WHERE id=? AND step_id=? AND status='attempted'`)
237
+ .run(status, Date.now(), error, json, attempt, id).changes)
238
+ throw new Error("Training attempt lost");
239
+ });
240
+ }
241
+ completedEvaluations(versions) {
242
+ return this.#db.prepare(`SELECT result_json FROM training_steps WHERE stage='evaluate' AND status='complete'
243
+ AND (? IS NULL OR (json_extract(request_json,'$.version')=? AND json_extract(request_json,'$.retrievalVersion')=?)) ORDER BY completed_at DESC,id`)
244
+ .all(versions?.selection ?? null, versions?.selection ?? null, versions?.retrieval ?? null)
245
+ .map(row => JSON.parse(String(row.result_json)));
246
+ }
247
+ sourceDetails(id) {
248
+ const row = this.#db.prepare(`SELECT session_id sessionId,event_seq userEventId,timestamp,context_limited contextLimited
249
+ FROM training_examples WHERE id=? AND active=1`).get(id);
250
+ if (!row)
251
+ throw new Error("Inactive training source");
252
+ return { nodeId: this.#nodeId, agentId: this.#agentId, sourceId: id, ...row };
253
+ }
254
+ stepRecord(id) {
255
+ const row = this.#db.prepare("SELECT id,stage,request_json,result_json,completed_at FROM training_steps WHERE id=? AND status='complete'").get(id);
256
+ if (!row)
257
+ throw new Error("Missing training provenance checkpoint");
258
+ return { id, stage: row.stage, request: JSON.parse(String(row.request_json)),
259
+ result: JSON.parse(String(row.result_json)), completedAt: row.completed_at };
260
+ }
261
+ status(threshold) {
262
+ const stages = this.#db.prepare(`SELECT g.status,COUNT(*) count FROM training_gates g WHERE ${this.#scope} GROUP BY g.status`)
263
+ .all(TRAINING_GATE_VERSION, TRAINING_GATE_MODEL);
264
+ const labels = this.#db.prepare(`SELECT COUNT(*) complete,COALESCE(SUM(g.probability>=?),0) positive,
265
+ COALESCE(SUM(g.input_tokens),0) inputTokens,COALESCE(SUM(g.output_tokens),0) outputTokens
266
+ FROM training_gates g WHERE ${this.#scope} AND g.status='complete'`).get(threshold, TRAINING_GATE_VERSION, TRAINING_GATE_MODEL);
267
+ return { nodeId: this.#nodeId, agentId: this.#agentId, preparation: TRAINING_PREPARATION,
268
+ promptVersion: TRAINING_GATE_VERSION, requestedModel: TRAINING_GATE_MODEL, threshold,
269
+ examples: this.#db.prepare("SELECT active,COUNT(*) count FROM training_examples GROUP BY active").all(),
270
+ collectedInputs: Number(this.#db.prepare("SELECT COUNT(DISTINCT input_hash) count FROM training_examples WHERE active=1").get().count),
271
+ queryInputs: Number(labels.positive),
272
+ stages, complete: Number(labels.complete), positive: Number(labels.positive),
273
+ inputTokens: Number(labels.inputTokens), outputTokens: Number(labels.outputTokens),
274
+ negative: Number(labels.complete) - Number(labels.positive),
275
+ queryStages: this.#db.prepare("SELECT stage,status,COUNT(*) count FROM training_steps GROUP BY stage,status ORDER BY stage,status").all(),
276
+ queryAttempts: this.#db.prepare(`SELECT s.stage,a.status,COUNT(*) count,
277
+ COUNT(json_extract(a.result_json,'$.usage.input_tokens')) usageReported,
278
+ SUM(json_extract(a.result_json,'$.usage.input_tokens')) inputTokens,
279
+ SUM(json_extract(a.result_json,'$.usage.output_tokens')) outputTokens
280
+ FROM training_step_attempts a JOIN training_steps s ON s.id=a.step_id GROUP BY s.stage,a.status`).all(),
281
+ attempts: this.#db.prepare(`SELECT status,COUNT(*) count,
282
+ COALESCE(SUM(json_extract(result_json,'$.usage.input_tokens')),0) inputTokens,
283
+ COALESCE(SUM(json_extract(result_json,'$.usage.output_tokens')),0) outputTokens
284
+ FROM training_attempts GROUP BY status`).all() };
285
+ }
286
+ *exportRows(threshold) {
287
+ const rows = this.#db.prepare(`SELECT g.*,i.input_json FROM training_gates g JOIN training_inputs i ON i.hash=g.input_hash
288
+ WHERE ${this.#scope} AND g.status='complete' ORDER BY g.id`).iterate(TRAINING_GATE_VERSION, TRAINING_GATE_MODEL);
289
+ for (const row of rows) {
290
+ yield { stage: "recall-gate", inputHash: row.input_hash, preparation: TRAINING_PREPARATION,
291
+ input: JSON.parse(String(row.input_json)), recallProbability: row.probability,
292
+ recallNeeded: Number(row.probability) >= threshold, threshold, model: row.actual_model, promptVersion: row.prompt_version,
293
+ usage: { input_tokens: row.input_tokens, output_tokens: row.output_tokens },
294
+ sources: this.#db.prepare(`SELECT id,session_id sessionId,event_seq userEventId,timestamp,context_limited contextLimited
295
+ FROM training_examples WHERE input_hash=? AND active=1 ORDER BY session_id,event_seq`).all(String(row.input_hash))
296
+ .map(source => ({ nodeId: this.#nodeId, agentId: this.#agentId, ...source })) };
297
+ }
298
+ }
299
+ close() { this.#db.close(); }
300
+ }
@@ -0,0 +1,57 @@
1
+ import type { UnblockMemoryConfig } from "./config.js";
2
+ import type { TrainingStore } from "./training-store.js";
3
+ type Source = {
4
+ databasePath: string;
5
+ agentId: string;
6
+ };
7
+ /** A full active-branch rescan is cheap/local; only changed exact inputs need inference. */
8
+ export declare function collectTraining(source: Source, store?: TrainingStore, options?: {
9
+ since?: number;
10
+ until?: number;
11
+ existingOnly?: boolean;
12
+ }): {
13
+ sessions: number;
14
+ excludedSessions: number;
15
+ oversizedSessions: number;
16
+ eligible: number;
17
+ users: number;
18
+ filtered: number;
19
+ oversized: number;
20
+ unanswered: number;
21
+ added: number;
22
+ changed: number;
23
+ unchanged: number;
24
+ retired: number;
25
+ };
26
+ /** Parallel, bounded paid work. Negative results are just as cacheable as positives. */
27
+ export declare function runTraining(source: Source, store: TrainingStore, config: UnblockMemoryConfig, options: {
28
+ maxExamples?: number;
29
+ maxInputBytes: number;
30
+ concurrency?: number;
31
+ dryRun?: boolean;
32
+ }): Promise<{
33
+ refreshed: {
34
+ sessions: number;
35
+ excludedSessions: number;
36
+ oversizedSessions: number;
37
+ eligible: number;
38
+ users: number;
39
+ filtered: number;
40
+ oversized: number;
41
+ unanswered: number;
42
+ added: number;
43
+ changed: number;
44
+ unchanged: number;
45
+ retired: number;
46
+ };
47
+ pendingSelected: number;
48
+ calls: number;
49
+ completed: number;
50
+ failed: number;
51
+ ambiguous: number;
52
+ inputBytes: number;
53
+ inputTokens: number;
54
+ outputTokens: number;
55
+ budgetLimited: boolean;
56
+ }>;
57
+ export {};
@@ -0,0 +1,104 @@
1
+ import { TypeSafeHttpError } from "./typesafe-transport.js";
2
+ import { resolveTypeSafeApiKey } from "./typesafe.js";
3
+ import { TrainingTranscriptReader } from "./training-input.js";
4
+ import { judgeTrainingInput, TRAINING_GATE_QUESTIONS } from "./training-gate.js";
5
+ /** A full active-branch rescan is cheap/local; only changed exact inputs need inference. */
6
+ export function collectTraining(source, store, options = {}) {
7
+ const since = options.since ?? 0, until = options.until ?? Number.MAX_SAFE_INTEGER;
8
+ const reader = new TrainingTranscriptReader(source.databasePath, source.agentId);
9
+ const result = { sessions: 0, excludedSessions: 0, oversizedSessions: 0, eligible: 0,
10
+ users: 0, filtered: 0, oversized: 0, unanswered: 0, added: 0, changed: 0, unchanged: 0, retired: 0 };
11
+ try {
12
+ const sessions = new Set(options.existingOnly ? store?.sessions() : [...reader.sessions(), ...store?.sessions() ?? []]);
13
+ for (const id of sessions) {
14
+ store?.renew();
15
+ const extracted = reader.read(id);
16
+ result.sessions++;
17
+ if (extracted && "oversized" in extracted) {
18
+ result.oversizedSessions++;
19
+ store?.unavailable(id);
20
+ continue;
21
+ }
22
+ if (!extracted)
23
+ result.excludedSessions++;
24
+ else {
25
+ for (const key of ["users", "filtered", "oversized", "unanswered"])
26
+ result[key] += extracted.coverage[key];
27
+ result.eligible += extracted.examples.filter(e => e.timestamp >= since && e.timestamp < until).length;
28
+ }
29
+ const counts = store?.syncSession(id, extracted?.examples ?? [], since, until, options.existingOnly ?? false);
30
+ if (counts)
31
+ for (const key of ["added", "changed", "unchanged", "retired"])
32
+ result[key] += counts[key];
33
+ }
34
+ return result;
35
+ }
36
+ finally {
37
+ reader.close();
38
+ }
39
+ }
40
+ /** Parallel, bounded paid work. Negative results are just as cacheable as positives. */
41
+ export async function runTraining(source, store, config, options) {
42
+ if ((options.maxExamples !== undefined && (!Number.isSafeInteger(options.maxExamples) || options.maxExamples < 1)) ||
43
+ !Number.isSafeInteger(options.maxInputBytes) || options.maxInputBytes < 1)
44
+ throw new Error("Invalid training run bounds");
45
+ const refreshed = collectTraining(source, store, { existingOnly: true });
46
+ const concurrency = options.concurrency ?? 256;
47
+ if (!Number.isSafeInteger(concurrency) || concurrency < 1)
48
+ throw new Error("Invalid training concurrency");
49
+ const jobs = store.pending(options.maxExamples);
50
+ const result = { refreshed, pendingSelected: jobs.length, calls: 0, completed: 0, failed: 0, ambiguous: 0,
51
+ inputBytes: 0, inputTokens: 0, outputTokens: 0, budgetLimited: false };
52
+ // Resolve only when there is work. Reruns with no pending inputs need no credentials.
53
+ const key = jobs.length && !options.dryRun ? await resolveTypeSafeApiKey(config.typesafe) : undefined;
54
+ if (jobs.length && !options.dryRun && !key)
55
+ throw new Error("TypeSafe is disabled or its credential is unavailable");
56
+ let next = 0, stopped = false;
57
+ const workers = await Promise.allSettled(Array.from({ length: Math.min(concurrency, jobs.length) }, async () => {
58
+ while (!stopped && !result.budgetLimited && !result.failed && !result.ambiguous) {
59
+ const job = jobs[next++];
60
+ if (!job)
61
+ return;
62
+ try {
63
+ const input = JSON.parse(job.inputJson);
64
+ const bytes = Buffer.byteLength(JSON.stringify({ state: input, questions: TRAINING_GATE_QUESTIONS }));
65
+ if (result.inputBytes + bytes > options.maxInputBytes) {
66
+ result.budgetLimited = true;
67
+ return;
68
+ }
69
+ result.inputBytes += bytes;
70
+ if (options.dryRun)
71
+ continue;
72
+ store.renew();
73
+ const attempt = store.start(job.id); // Commit BEFORE the request can leave this process.
74
+ result.calls++;
75
+ let judgment;
76
+ try {
77
+ // Offline labeling has its own deadline, not Whisperer's latency-sensitive timeout.
78
+ judgment = await judgeTrainingInput(input, key, AbortSignal.timeout(30_000));
79
+ }
80
+ catch (error) {
81
+ const definite = error instanceof TypeSafeHttpError && error.status >= 400 && error.status < 500;
82
+ const status = definite ? "failed" : "ambiguous";
83
+ store.finish(job.id, attempt, { status, error: error instanceof TypeSafeHttpError ? `http_${error.status}` : "request_or_response_uncertain" });
84
+ result[status]++;
85
+ // Stop on the first failure rather than spending the rest of the budget during an outage.
86
+ return;
87
+ }
88
+ // Storage failures must not be misclassified as provider failures/retried.
89
+ store.finish(job.id, attempt, judgment);
90
+ result.completed++;
91
+ result.inputTokens += judgment.usage.input_tokens;
92
+ result.outputTokens += judgment.usage.output_tokens;
93
+ }
94
+ catch (error) {
95
+ stopped = true;
96
+ throw error;
97
+ }
98
+ }
99
+ }));
100
+ const failure = workers.find(item => item.status === "rejected");
101
+ if (failure)
102
+ throw failure.reason;
103
+ return result;
104
+ }
@@ -0,0 +1,147 @@
1
+ # Memory training collector
2
+
3
+ Operator-only collection for **LFM2.5-230M-Base**. No scheduler, runtime recall
4
+ changes, memory writes, live-index mutation or automatic background inference.
5
+
6
+ ## Recipe
7
+
8
+ 1. Collect eligible historical user turns and preceding visible conversation.
9
+ 2. TypeSafe `jev-1.13.0` recall probability **>=0.7** gates query generation.
10
+ Preserve negative labels for audit; greetings do not need query targets.
11
+ 3. Isolated `openai/gpt-6-luna`, **xhigh**, generates exactly **10 distinct
12
+ single-line queries**, using the tested v3 prompt, 12,000 output-token allowance
13
+ and 300-second deadline. No fallback model or agent tools.
14
+ 4. Retrieve **10 literal vector + 10 BM25 matches** from the originating agent's
15
+ historical sessions snapshot. Retain all unique eligible passages, with no
16
+ merged passage-count cap.
17
+ 5. TypeSafe judges each passage's additional utility for the **original
18
+ conversation**. It receives conversation, as-of time, passage text/source/dates,
19
+ never generated query/ID, rank, retrieval score or method.
20
+ 6. Sum the **five highest** normalized passage grades; retain the top **three exact
21
+ queries**. Ties preserve teacher order. No score cutoff, answer requirement or
22
+ cross-query novelty rule. An empty retrieval scores zero; failures never do.
23
+
24
+ The four-level rubric distinguishes no, marginal, useful and direct high-value
25
+ additional context, requires correct identity and temporal applicability, discounts
26
+ repetition and unsupported premises, and treats all content as untrusted.
27
+ Full distributions and reported usage are persisted.
28
+
29
+ ## Commands
30
+
31
+ ```sh
32
+ openclaw memory-training collect --agent main --dry-run
33
+ openclaw memory-training collect --agent main
34
+ openclaw memory-training run --agent main --concurrency 256
35
+ openclaw memory-training generate --agent main --concurrency 8
36
+ openclaw memory-training evaluate --agent main --concurrency 4
37
+ openclaw memory-training status --agent main
38
+ openclaw memory-training export --agent main --output /private/query-training.jsonl
39
+ openclaw memory-training export --agent main --stage recall-gate --output /private/recall-gate.jsonl
40
+ ```
41
+
42
+ Collection supports inclusive `--since` and exclusive `--until YYYY-MM-DD` UTC,
43
+ using user-event time, not session start. Omit dates for all eligible history.
44
+ Appends enter only through a later collect; narrow bounds do not delete old cohorts.
45
+ Use the same `--threshold` for generate/evaluate/export/status (default 0.7).
46
+
47
+ Optional `--max-examples` bounds new work. Run and generate default to a
48
+ 3,000,000 serialized input-byte budget, **not tokens**; rerun to drain pending work.
49
+ Evaluate's optional `--max-calls` counts new retrieval operations plus uncached
50
+ passage judgments. There is no default example/call-count cap.
51
+
52
+ Recall defaults to 256 concurrent requests, generation to 8 isolated completions.
53
+ Evaluation defaults to 4 inputs with 10 parallel queries each. Distinct remote
54
+ passage judgments overlap (up to 800 memberships before dedup at default depth).
55
+ Native vector work is serialized per snapshot. Raise concurrency within machine
56
+ and provider capacity. Failures stop new dispatch; in-flight operations drain
57
+ and persist before snapshots close. Dry runs make no inference calls.
58
+
59
+ ## Input and historical boundaries
60
+
61
+ Read original agent SQLite active-branch conversations (schema 17–19), including
62
+ direct/group/channel chats, excluding cron, heartbeat, spawned/subagent, hook/plugin
63
+ and untyped diagnostic sessions. Exclude explicit bots, synthetic messages,
64
+ analysis/thinking, errors and tool payloads. Ordinary older users need not have
65
+ enriched sender metadata. Strip recognized transport envelopes.
66
+
67
+ A following assistant reply/tool action establishes eligibility before the next
68
+ user/context boundary; delivery mirrors count, duplicate visible replies appear once.
69
+ The qualifying **future answer never enters its input**. Earlier visible replies
70
+ may appear in later inputs. Compaction/internal messages break history continuity.
71
+ Keep at most 32 preceding whole messages within 24,000 serialized UTF-8 bytes;
72
+ drop oldest whole messages, never slice the latest request. Oversized requests
73
+ are skipped. Sessions above 50,000 events or 32 MB become unavailable, not deleted.
74
+
75
+ Snapshots require matching projection hashes and trusted message spans. Copy
76
+ only prefixes before the **entire second of the user's timestamp**, stopping at
77
+ unknown/future dates. Never infer dates from quoted headings. Copy existing vectors
78
+ only for complete chunks within the safe prefix; BM25 sees that same prefix.
79
+ Validate returned passages/dates again. No reembedding or temporary transcripts.
80
+
81
+ QMD 2.10.1 cannot independently set per-method depth through its public search API.
82
+ A small discovery adapter retains its tokenization, FTS-highlight chunk selection,
83
+ source-aware dedup and installed chunk helpers, but requests ten per method and
84
+ omits query-conditioned scoring. It does not rewrite QMD. Its existing
85
+ 12,000-character passage eligibility rule remains; no passage is truncated.
86
+
87
+ Honor configured session chat types and each node's DM policy. Time-unversioned
88
+ files and Loggie projections are excluded. This is a historical text-prefix
89
+ evaluation of the currently retained corpus, not a reconstruction of the old index:
90
+ later edits/deletions cannot be undone. Persist coverage/exclusion counts.
91
+
92
+ ## Checkpoints, retries and permissions
93
+
94
+ Private database: `<state>/agents/<agent>/unblock-memory/training.sqlite` (0600).
95
+ Never commit, publish or index this file or its exports.
96
+
97
+ Source identity includes persisted node ID, agent, session and user-event sequence.
98
+ Exact inputs share recall/teacher checkpoints. The new teacher policy
99
+ `query-teacher-v3-xhigh` distinguishes old low-reasoning results without deleting
100
+ them. Retrieval keys include query, source/cutoff, corpus fingerprint and settings.
101
+ Passage keys include original input, as-of time, exact passage/position and full
102
+ rubric: unchanged judgments survive changes in queries/corpus. Selection is
103
+ separately versioned.
104
+
105
+ Run/generate/evaluate/export revalidate collected inputs; edits change affected
106
+ hashes, branch removals retire sources, and paid checkpoints remain intact.
107
+ Status does not rescan; stage/attempt totals include historical recipes, not just
108
+ current-cohort progress. Exports filter to the current recipe and active recall gate.
109
+
110
+ A renewable SQLite lease serializes modifying commands. Attempts commit **before**
111
+ dispatch. Crashes, uncertain transport and malformed responses become ambiguous;
112
+ 4xx/host authorization errors are definite failures. Storage errors propagate
113
+ separately. There are no automatic paid retries or model/route fallbacks.
114
+
115
+ ```sh
116
+ openclaw memory-training retry-failed --agent main
117
+ # Explicit acceptance of possible duplicate billing:
118
+ openclaw memory-training retry-failed --agent main --include-ambiguous
119
+ ```
120
+
121
+ These reset that agent's failed checkpoints, including historical recipes, preserving
122
+ attempts. Inspect before use. They do not themselves send requests.
123
+
124
+ Explicitly authorized exclusions use `evaluate --exclude-judgment <sha256>`.
125
+ The hash is SHA256 of JSON `[judgeVersion,inputHash,passagePosition,fullJudgeRequest]`.
126
+ Exclusions persist, appear in provenance, and are omitted rather than scored zero.
127
+ No blanket failure skipping.
128
+
129
+ The host must support isolated completion and grant
130
+ `plugins.entries.unblock-memory.llm.allowModelOverride: true` with
131
+ `allowedModels: ["openai/gpt-6-luna"]`. Preserve other grants. The plugin does not
132
+ change its own permissions. Credentials stay with the host, never in provenance.
133
+
134
+ ## Export and consolidation
135
+
136
+ Export creates a new 0600 JSONL file and refuses overwrite. Query rows contain
137
+ exact inputs, three targets, all query totals/passage references, source/time,
138
+ recall probability, corpus coverage and teacher/retrieval/judgment provenance.
139
+ Recall-gate exports include negatives. Export revalidates input sources but does
140
+ not rerun retrieval; evaluate first if a fresh corpus assessment is desired.
141
+
142
+ Transfer privately and verify hashes. Deduplicate identical inputs while retaining
143
+ source provenance; quarantine suspected secrets. Keep connected session and
144
+ identical-input groups together across nodes for train/validation. Use the actual
145
+ **LFM2.5-230M-Base** tokenizer and an explicit causal-LM input/target format before
146
+ fine-tuning; the interim byte limit is not a token count. Runtime abstention remains
147
+ a separate TypeSafe gate; positive-only query training does not teach abstention.
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "id": "unblock-memory",
3
3
  "name": "Unblock Memory",
4
- "version": "0.3.24",
4
+ "version": "0.3.25",
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.24",
3
+ "version": "0.3.25",
4
4
  "description": "Workspace-native memory for OpenClaw, powered by QMD",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -26,6 +26,7 @@
26
26
  "docs/retrieval.md",
27
27
  "docs/peoplesql.md",
28
28
  "docs/response-audit.md",
29
+ "docs/memory-training.md",
29
30
  "openclaw.plugin.json"
30
31
  ],
31
32
  "scripts": {