@unblocklabs/unblock-memory 0.3.30 → 0.5.0

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.
Files changed (52) hide show
  1. package/README.md +3 -3
  2. package/dist/src/config.d.ts +3 -4
  3. package/dist/src/config.js +31 -11
  4. package/dist/src/diagnostics.d.ts +1 -3
  5. package/dist/src/manager.d.ts +4 -8
  6. package/dist/src/manager.js +17 -45
  7. package/dist/src/memory-passage.d.ts +26 -0
  8. package/dist/src/memory-passage.js +48 -0
  9. package/dist/src/memory-whisperer.d.ts +2 -1
  10. package/dist/src/memory-whisperer.js +37 -42
  11. package/dist/src/query-contract.d.ts +31 -0
  12. package/dist/src/query-contract.js +58 -0
  13. package/dist/src/query-generator.d.ts +15 -0
  14. package/dist/src/query-generator.js +136 -0
  15. package/dist/src/query-tokenizer/contract.json +10 -0
  16. package/dist/src/response-stages.d.ts +3 -3
  17. package/dist/src/training-candidates.d.ts +2 -1
  18. package/dist/src/training-candidates.js +12 -9
  19. package/dist/src/training-input.d.ts +13 -10
  20. package/dist/src/training-input.js +22 -18
  21. package/dist/src/training-judge.d.ts +16 -46
  22. package/dist/src/training-judge.js +6 -53
  23. package/dist/src/training-models.d.ts +15 -7
  24. package/dist/src/training-models.js +36 -38
  25. package/dist/src/training-queries.d.ts +24 -7
  26. package/dist/src/training-queries.js +237 -101
  27. package/dist/src/training-retrieval.d.ts +9 -2
  28. package/dist/src/training-retrieval.js +36 -12
  29. package/dist/src/training-runtime.js +8 -7
  30. package/dist/src/training-store.d.ts +46 -16
  31. package/dist/src/training-store.js +85 -29
  32. package/dist/src/training.d.ts +10 -0
  33. package/dist/src/training.js +7 -5
  34. package/dist/src/typesafe.d.ts +58 -8
  35. package/dist/src/typesafe.js +47 -29
  36. package/dist/src/whisperer-context.d.ts +0 -8
  37. package/dist/src/whisperer-context.js +0 -23
  38. package/docs/configuration.md +5 -3
  39. package/docs/memory-training.md +120 -58
  40. package/docs/retrieval.md +59 -43
  41. package/openclaw.plugin.json +15 -6
  42. package/package.json +4 -1
  43. package/src/mlx-query-worker.py +5 -8
  44. package/src/query-tokenizer/LICENSE +71 -0
  45. package/src/query-tokenizer/README.md +21 -0
  46. package/src/query-tokenizer/chat_template.jinja +115 -0
  47. package/src/query-tokenizer/contract.json +10 -0
  48. package/src/query-tokenizer/tokenizer.json +323830 -0
  49. package/src/query-tokenizer/tokenizer_config.json +19 -0
  50. package/src/query_contract.py +60 -0
  51. package/dist/src/mlx-query.d.ts +0 -22
  52. package/dist/src/mlx-query.js +0 -155
@@ -2,10 +2,12 @@ import { collectTraining } from "./training.js";
2
2
  import { trainingHash } from "./training-input.js";
3
3
  import { TRAINING_GATE_THRESHOLD } from "./training-gate.js";
4
4
  import { resolveTypeSafeApiKey, TypeSafeRequestError } from "./typesafe-client.js";
5
- import { trainingTeacher, trainingTeacherMessage, TRAINING_TEACHER_MODEL, TRAINING_TEACHER_PROMPT, TRAINING_TEACHER_VERSION } from "./training-models.js";
6
- import { historicalTrainingSearch, TRAINING_RETRIEVAL_VERSION, TRAINING_SEARCH_OPTIONS } from "./training-retrieval.js";
5
+ import { trainingTeacher, trainingTeacherMessage, trainingTeacherPrompt, TRAINING_TEACHER_MODEL, TRAINING_TEACHER_VERSION } from "./training-models.js";
6
+ import { historicalTrainingSearch, HistoricalCorpusUnavailableError, TRAINING_RETRIEVAL_VERSION, TRAINING_SEARCH_OPTIONS } from "./training-retrieval.js";
7
7
  import { contextJudgeRequest, judgeTrainingPassage, CONTEXT_JUDGE_VERSION } from "./training-judge.js";
8
- const SELECTION_VERSION = "conversation-utility-top5-sum-v2";
8
+ const LANES = ["lex", "vec"];
9
+ const SELECTION_VERSION = "independent-lanes-top3-mean-v2";
10
+ const TARGET_POLICY = "best-per-lane-no-minimum-v1";
9
11
  export const TRAINING_EVALUATION_CONCURRENCY = 4;
10
12
  function bounds(options) {
11
13
  for (const value of [options.maxExamples, options.maxCalls, options.maxInputBytes, options.concurrency]) {
@@ -13,13 +15,14 @@ function bounds(options) {
13
15
  throw new Error("Training bounds must be positive integers");
14
16
  }
15
17
  }
16
- function teacherRequest(example) {
18
+ function teacherRequest(example, lane, feedback) {
17
19
  return { version: TRAINING_TEACHER_VERSION, model: TRAINING_TEACHER_MODEL, inputHash: example.inputHash,
18
- input: JSON.parse(example.inputJson) };
20
+ input: JSON.parse(example.inputJson), lane, round: feedback ? 2 : 1,
21
+ ...(feedback ? { feedback: feedback.map(({ query, score }) => ({ query, score })) } : {}) };
19
22
  }
20
23
  const summary = () => ({ examples: 0, calls: 0, completed: 0, cached: 0, failed: 0, ambiguous: 0,
21
- blocked: 0, inputBytes: 0, budgetLimited: false });
22
- /** One fenced checkpoint per paid operation, with no hidden transport retries. */
24
+ blocked: 0, flagged: 0, inputBytes: 0, budgetLimited: false });
25
+ /** One fenced checkpoint per operation. Storage failures are fatal; provider failures are local. */
23
26
  async function checkpoint(store, step, result, operation) {
24
27
  if (step.result !== undefined) {
25
28
  result.cached++;
@@ -37,14 +40,19 @@ async function checkpoint(store, step, result, operation) {
37
40
  value = await operation();
38
41
  }
39
42
  catch (error) {
43
+ // Historical retrieval is local code. Only an explicitly unavailable corpus
44
+ // is a reviewable data case; do not swallow SQLite, I/O or snapshot-integrity errors.
45
+ if (step.stage === "retrieve" && !(error instanceof HistoricalCorpusUnavailableError))
46
+ throw error;
40
47
  const hostCode = error && typeof error === "object" && "code" in error && typeof error.code === "string" &&
41
48
  /^LLM_[A-Z_]+$/u.test(error.code) ? error.code : undefined;
42
- const status = hostCode === "LLM_COMPLETION_NOT_AUTHORIZED" ||
49
+ const status = error instanceof HistoricalCorpusUnavailableError || hostCode === "LLM_COMPLETION_NOT_AUTHORIZED" ||
43
50
  (error instanceof TypeSafeRequestError && error.code === "http_error" &&
44
51
  error.status !== undefined && error.status >= 400 && error.status < 500) ? "failed" : "ambiguous";
45
52
  store.finishStep(step.stage, step.id, attempt, { status,
46
53
  error: error instanceof TypeSafeRequestError && error.code === "http_error" ?
47
- `http_${error.status}` : hostCode ?? "request_or_response_uncertain" });
54
+ `http_${error.status}` : error instanceof HistoricalCorpusUnavailableError ? "historical_corpus_unavailable" :
55
+ hostCode ?? "request_or_response_uncertain" });
48
56
  result[status]++;
49
57
  return;
50
58
  }
@@ -52,6 +60,18 @@ async function checkpoint(store, step, result, operation) {
52
60
  result.completed++;
53
61
  return value;
54
62
  }
63
+ function save(store, step, value) {
64
+ const attempt = store.startStep(step.stage, step.id, step.request);
65
+ store.finishStep(step.stage, step.id, attempt, { result: value });
66
+ return value;
67
+ }
68
+ async function drain(tasks) {
69
+ const settled = await Promise.allSettled(tasks);
70
+ const failure = settled.find(item => item.status === "rejected");
71
+ if (failure)
72
+ throw failure.reason;
73
+ return settled.flatMap(item => item.status === "fulfilled" ? [item.value] : []);
74
+ }
55
75
  export async function generateTrainingQueries(source, store, runtime, options) {
56
76
  bounds(options);
57
77
  const refreshed = collectTraining(source, store, { existingOnly: true }), result = summary();
@@ -59,8 +79,8 @@ export async function generateTrainingQueries(source, store, runtime, options) {
59
79
  let teacher;
60
80
  const examples = store.queryExamples(options.threshold);
61
81
  let next = 0, stopped = false;
62
- const workers = await Promise.allSettled(Array.from({ length: Math.min(options.concurrency ?? 8, examples.length) }, async () => {
63
- while (!stopped && !result.budgetLimited && !result.failed && !result.ambiguous) {
82
+ await drain(Array.from({ length: Math.min(options.concurrency ?? 8, examples.length) }, async () => {
83
+ while (!stopped && !result.budgetLimited) {
64
84
  const example = examples[next++];
65
85
  if (!example)
66
86
  return;
@@ -68,29 +88,49 @@ export async function generateTrainingQueries(source, store, runtime, options) {
68
88
  if (seen.has(example.inputHash))
69
89
  continue;
70
90
  seen.add(example.inputHash);
71
- const request = teacherRequest(example), step = store.step("generate", request);
72
- if (step.result) {
73
- result.cached++;
91
+ const steps = LANES.map(lane => ({ lane, step: store.step("generate", teacherRequest(example, lane)) }));
92
+ if (steps.every(({ step }) => step.result)) {
93
+ result.cached += steps.length;
74
94
  continue;
75
95
  }
76
- if (step.status !== "pending") {
77
- result.blocked++;
96
+ if (steps.every(({ step }) => step.result || step.status !== "pending")) {
97
+ const problems = steps.filter(({ step }) => !step.result).map(({ step }) => step.id);
98
+ result.blocked += problems.length;
99
+ result.flagged++;
100
+ store.flagReview(example, "generation-unresolved", { steps: problems });
78
101
  continue;
79
102
  }
80
103
  if (options.maxExamples !== undefined && result.examples >= options.maxExamples)
81
104
  return;
82
- const bytes = Buffer.byteLength(trainingTeacherMessage(request.input)) + Buffer.byteLength(TRAINING_TEACHER_PROMPT);
83
- if (result.inputBytes + bytes > options.maxInputBytes) {
84
- result.budgetLimited = true;
85
- return;
86
- }
87
105
  result.examples++;
88
- result.inputBytes += bytes;
89
- if (options.dryRun)
90
- continue;
91
- teacher ??= trainingTeacher(runtime, source.agentId); // Capability check BEFORE committing a paid attempt.
92
- if (!await checkpoint(store, step, result, () => teacher(request.input)))
93
- return;
106
+ const problems = [];
107
+ for (const { lane, step } of steps) {
108
+ if (step.result) {
109
+ result.cached++;
110
+ continue;
111
+ }
112
+ if (step.status !== "pending") {
113
+ result.blocked++;
114
+ problems.push(step.id);
115
+ continue;
116
+ }
117
+ const input = JSON.parse(example.inputJson);
118
+ const bytes = Buffer.byteLength(trainingTeacherMessage(input, lane)) + Buffer.byteLength(trainingTeacherPrompt(lane));
119
+ if (result.inputBytes + bytes > options.maxInputBytes) {
120
+ result.budgetLimited = true;
121
+ break;
122
+ }
123
+ result.inputBytes += bytes;
124
+ if (options.dryRun)
125
+ continue;
126
+ teacher ??= trainingTeacher(runtime, source.agentId);
127
+ if (!await checkpoint(store, step, result, () => teacher(input, lane)))
128
+ problems.push(step.id);
129
+ }
130
+ if (problems.length) {
131
+ store.flagReview(example, "generation-unresolved", { steps: problems });
132
+ result.flagged++;
133
+ }
94
134
  }
95
135
  catch (error) {
96
136
  stopped = true;
@@ -98,142 +138,237 @@ export async function generateTrainingQueries(source, store, runtime, options) {
98
138
  }
99
139
  }
100
140
  }));
101
- const failure = workers.find(item => item.status === "rejected");
102
- if (failure)
103
- throw failure.reason;
104
141
  return { refreshed, ...result, threshold: options.threshold ?? TRAINING_GATE_THRESHOLD, model: TRAINING_TEACHER_MODEL };
105
142
  }
143
+ /** Raw top-three average, including values below runtime's injection threshold. */
144
+ export function trainingQueryScore(probabilities) {
145
+ const top = probabilities.toSorted((a, b) => b - a).slice(0, 3);
146
+ return top.length ? top.reduce((sum, value) => sum + value, 0) / top.length : 0;
147
+ }
106
148
  export function selectTrainingQueries(queries) {
107
- // Stable ties preserve teacher order; neither low scores nor overlapping hits reject a query.
108
- return queries.toSorted((a, b) => b.score - a.score).slice(0, 3).map(q => q.query);
149
+ const winner = (lane) => {
150
+ const ranked = queries.filter(query => query.lane === lane).toSorted((a, b) => b.score - a.score);
151
+ if (!ranked[0])
152
+ throw new Error(`No resolved ${lane} candidates`);
153
+ return ranked[0].query; // Stable ties preserve round and teacher order.
154
+ };
155
+ return { lex: winner("lex"), vec: winner("vec") };
109
156
  }
110
- export async function evaluateTrainingQueries(source, store, config, options, createSearch = historicalTrainingSearch) {
157
+ export async function evaluateTrainingQueries(source, store, config, runtime, options, createSearch = historicalTrainingSearch) {
111
158
  bounds(options);
112
- if (options.excludeJudgments?.some(id => !/^[a-f0-9]{64}$/u.test(id)))
113
- throw new Error("Invalid excluded judgment hash");
114
159
  const corpus = config.corpora.find(c => c.kind === "sessions");
115
160
  if (!corpus)
116
161
  throw new Error("Training retrieval requires a configured sessions corpus");
117
162
  const refreshed = collectTraining(source, store, { existingOnly: true });
118
163
  const result = { ...summary(), retrievals: 0, evaluated: 0, awaitingTeacher: 0 };
119
164
  const concurrency = options.concurrency ?? TRAINING_EVALUATION_CONCURRENCY;
120
- let key;
165
+ let key, teacher;
121
166
  const apiKey = () => key ??= resolveTypeSafeApiKey(config.typesafe).then(value => {
122
167
  if (!value)
123
168
  throw new Error("TypeSafe is disabled or its credential is unavailable");
124
169
  return value;
125
170
  });
126
171
  let stopped = false;
127
- const shouldStop = () => stopped || result.budgetLimited || result.failed > 0 || result.ambiguous > 0 || result.blocked > 0;
172
+ const shouldStop = () => stopped || result.budgetLimited;
173
+ const room = () => {
174
+ if (shouldStop())
175
+ return false;
176
+ if (options.maxCalls !== undefined && result.calls >= options.maxCalls) {
177
+ result.budgetLimited = true;
178
+ return false;
179
+ }
180
+ return true;
181
+ };
128
182
  const judgments = new Map();
183
+ const revisions = new Map();
184
+ const reviews = new Map(store.reviews().map(review => [review.sourceId, review]));
129
185
  const evaluate = async (example) => {
130
- const teacher = store.step("generate", teacherRequest(example));
131
- if (!teacher.result) {
132
- result.awaitingTeacher++;
186
+ const previousReview = reviews.get(example.id);
187
+ if (previousReview && "steps" in previousReview.details) {
188
+ const blocked = previousReview.details.steps.filter(id => {
189
+ const status = store.stepRecordStatus(id);
190
+ return status !== undefined && status !== "pending" && status !== "complete";
191
+ });
192
+ // A known failed example cannot resolve before manual retry. Do not scan its
193
+ // historical corpus or consume the example budget on every invocation.
194
+ if (blocked.length) {
195
+ result.blocked += blocked.length;
196
+ result.flagged++;
197
+ return;
198
+ }
199
+ }
200
+ const initial = LANES.map(lane => ({ lane, step: store.step("generate", teacherRequest(example, lane)) }));
201
+ if (initial.some(({ step }) => !step.result)) {
202
+ const blocked = initial.filter(({ step }) => !step.result && step.status !== "pending");
203
+ if (blocked.length) {
204
+ result.blocked += blocked.length;
205
+ result.flagged++;
206
+ store.flagReview(example, "generation-unresolved", { steps: blocked.map(({ step }) => step.id) });
207
+ }
208
+ else
209
+ result.awaitingTeacher++;
133
210
  return;
134
211
  }
135
212
  if (options.maxExamples !== undefined && result.examples >= options.maxExamples)
136
213
  return;
137
214
  store.renew();
138
- const snapshot = await createSearch(source.stateDir, corpus.chatTypes, example.timestamp);
215
+ let snapshot;
216
+ try {
217
+ snapshot = await createSearch(source.stateDir, corpus.chatTypes, example.timestamp);
218
+ }
219
+ catch (error) {
220
+ if (!(error instanceof HistoricalCorpusUnavailableError))
221
+ throw error;
222
+ store.flagReview(example, "historical-snapshot-unavailable", { timestamp: example.timestamp });
223
+ result.flagged++;
224
+ return;
225
+ }
139
226
  try {
140
227
  const evaluation = store.step("evaluate", { version: SELECTION_VERSION, sourceId: example.id, inputHash: example.inputHash,
141
- timestamp: example.timestamp, teacherId: teacher.id, corpusHash: snapshot.corpusHash,
228
+ timestamp: example.timestamp, teacherIds: initial.map(({ step }) => step.id), corpusHash: snapshot.corpusHash,
142
229
  retrievalVersion: TRAINING_RETRIEVAL_VERSION, judgeVersion: CONTEXT_JUDGE_VERSION,
143
- exclusions: options.excludeJudgments?.toSorted() ?? [], options: TRAINING_SEARCH_OPTIONS });
230
+ options: TRAINING_SEARCH_OPTIONS });
144
231
  if (evaluation.result) {
145
232
  result.cached++;
233
+ store.clearReview(example.id);
146
234
  return;
147
235
  }
148
236
  if (evaluation.status !== "pending") {
149
237
  result.blocked++;
238
+ result.flagged++;
239
+ store.flagReview(example, "evaluation-unresolved", { steps: [evaluation.id] });
150
240
  return;
151
241
  }
152
- // Snapshot creation is async: reserve the shared example budget only after it settles.
153
242
  if (shouldStop() || (options.maxExamples !== undefined && result.examples >= options.maxExamples))
154
243
  return;
155
244
  result.examples++;
156
245
  if (options.dryRun)
157
246
  return;
158
247
  const input = JSON.parse(example.inputJson);
248
+ const problems = new Set();
159
249
  const judge = (hit) => {
160
250
  const request = contextJudgeRequest(input, snapshot.maxDate, hit);
161
- // Same identity as the experiment: duplicate query memberships share one judgment.
162
- const identity = trainingHash([CONTEXT_JUDGE_VERSION, example.inputHash, hit.position, request]);
251
+ const identity = trainingHash([CONTEXT_JUDGE_VERSION, request]);
163
252
  let pending = judgments.get(identity);
164
253
  if (!pending) {
165
254
  pending = (async () => {
166
- const excluded = options.excludeJudgments?.includes(identity) || store.judgmentExcluded(identity);
167
- const step = store.step("judge", { identity, request, excluded });
255
+ const step = store.step("judge", { identity, request });
168
256
  if (step.result) {
169
257
  result.cached++;
170
258
  return { id: step.id, result: step.result };
171
259
  }
172
- if (excluded) {
173
- const attempt = store.startStep("judge", step.id, step.request);
174
- const value = { excluded: true, reason: "operator-exclusion" };
175
- store.finishStep("judge", step.id, attempt, { result: value });
176
- return { id: step.id, result: value };
260
+ if (step.status !== "pending") {
261
+ result.blocked++;
262
+ return { id: step.id, result: undefined };
177
263
  }
178
264
  const credential = await apiKey();
179
- if (shouldStop())
265
+ if (!room())
180
266
  return { id: step.id, result: undefined };
181
- if (options.maxCalls !== undefined && result.calls >= options.maxCalls) {
182
- result.budgetLimited = true;
183
- return { id: step.id, result: undefined };
184
- }
185
267
  return { id: step.id, result: await checkpoint(store, step, result, () => judgeTrainingPassage(request, credential)) };
186
268
  })();
187
269
  judgments.set(identity, pending);
188
270
  }
189
271
  return pending;
190
272
  };
191
- // Keep teacher order, and drain every in-flight operation before closing its snapshot.
192
- const settled = await Promise.allSettled(teacher.result.queries.map(async (query) => {
273
+ const scoreQueries = async (lane, round, generation, generationId) => await drain(generation.queries.map(async (query) => {
193
274
  try {
194
- if (shouldStop())
275
+ const score = store.step("score", { version: SELECTION_VERSION, lane, round, generationId, query,
276
+ sourceId: example.id, corpusHash: snapshot.corpusHash, judgeVersion: CONTEXT_JUDGE_VERSION,
277
+ retrievalVersion: TRAINING_RETRIEVAL_VERSION });
278
+ if (score.result) {
279
+ result.cached++;
280
+ return score.result;
281
+ }
282
+ if (score.status !== "pending") {
283
+ problems.add(score.id);
284
+ result.blocked++;
195
285
  return;
196
- const retrieval = store.step("retrieve", { version: TRAINING_RETRIEVAL_VERSION, query,
286
+ }
287
+ const retrieval = store.step("retrieve", { version: TRAINING_RETRIEVAL_VERSION, query, lane,
197
288
  sourceId: example.id, maxDate: snapshot.maxDate, corpusHash: snapshot.corpusHash, options: TRAINING_SEARCH_OPTIONS });
198
- if (shouldStop())
289
+ if (!retrieval.result && retrieval.status === "pending" && !room())
199
290
  return;
200
- // No await between this check and checkpoint's synchronous reservation of result.calls.
201
- if (!retrieval.result && options.maxCalls !== undefined && result.calls >= options.maxCalls) {
202
- result.budgetLimited = true;
291
+ const retrieved = await checkpoint(store, retrieval, result, async () => ({ query, lane, maxDate: snapshot.maxDate,
292
+ corpusHash: snapshot.corpusHash, hits: await snapshot.search(query, lane) }));
293
+ if (!retrieved) {
294
+ problems.add(retrieval.id);
203
295
  return;
204
296
  }
205
- const retrieved = await checkpoint(store, retrieval, result, async () => ({ query, maxDate: snapshot.maxDate,
206
- corpusHash: snapshot.corpusHash, hits: await snapshot.search(query) }));
207
- if (!retrieved)
208
- return;
209
297
  if (!retrieval.result)
210
298
  result.retrievals++;
211
- const settledHits = await Promise.allSettled(retrieved.hits.map(judge));
212
- const failure = settledHits.find(item => item.status === "rejected");
213
- if (failure)
214
- throw failure.reason;
215
- const scored = settledHits.flatMap(item => item.status === "fulfilled" ? [item.value] : []);
216
- if (scored.some(item => !item.result))
299
+ const scored = await drain(retrieved.hits.map(judge));
300
+ const missing = scored.filter(item => !item.result);
301
+ if (missing.length) {
302
+ for (const item of missing) {
303
+ const record = store.stepRecordStatus(item.id);
304
+ if (record && record !== "pending")
305
+ problems.add(item.id);
306
+ }
307
+ return;
308
+ }
309
+ const probabilities = scored.map(item => item.result.probability);
310
+ return save(store, score, { query, lane, round, retrievalId: retrieval.id,
311
+ score: trainingQueryScore(probabilities), maxProbability: Math.max(0, ...probabilities), judgments: scored.map(item => item.id) });
312
+ }
313
+ catch (error) {
314
+ stopped = true;
315
+ throw error;
316
+ }
317
+ }));
318
+ const lanes = await drain(initial.map(async ({ lane, step }) => {
319
+ try {
320
+ const first = await scoreQueries(lane, 1, step.result, step.id);
321
+ if (first.some(item => !item) || shouldStop())
322
+ return;
323
+ const feedback = first.map(item => ({ query: item.query, score: item.score }));
324
+ // The only feedback sent to Luna is this lane's exact earlier queries and scores.
325
+ const revision = store.step("generate", teacherRequest(example, lane, feedback));
326
+ let pending = revisions.get(revision.id);
327
+ if (!pending) {
328
+ pending = (async () => {
329
+ if (!revision.result && revision.status === "pending") {
330
+ const bytes = Buffer.byteLength(trainingTeacherMessage(input, lane, feedback)) + Buffer.byteLength(trainingTeacherPrompt(lane));
331
+ if (!room())
332
+ return;
333
+ if (result.inputBytes + bytes > (options.maxInputBytes ?? 3_000_000)) {
334
+ result.budgetLimited = true;
335
+ return;
336
+ }
337
+ result.inputBytes += bytes;
338
+ teacher ??= trainingTeacher(runtime, source.agentId);
339
+ }
340
+ return checkpoint(store, revision, result, () => teacher(input, lane, feedback));
341
+ })();
342
+ revisions.set(revision.id, pending);
343
+ }
344
+ const revised = await pending;
345
+ if (!revised) {
346
+ if (store.stepRecordStatus(revision.id))
347
+ problems.add(revision.id);
348
+ return;
349
+ }
350
+ const second = await scoreQueries(lane, 2, revised, revision.id);
351
+ if (second.some(item => !item))
217
352
  return;
218
- const scores = scored.flatMap(item => item.result && "score" in item.result ? [item.result.score] : []);
219
- return { query, retrievalId: retrieval.id, score: scores.sort((a, b) => b - a).slice(0, 5).reduce((a, b) => a + b, 0),
220
- judgments: scored.map(item => ({ id: item.id, excluded: !!item.result && "excluded" in item.result })) };
353
+ return { teacherIds: [step.id, revision.id], queries: [...first, ...second].filter((item) => !!item) };
221
354
  }
222
355
  catch (error) {
223
356
  stopped = true;
224
357
  throw error;
225
358
  }
226
359
  }));
227
- const failure = settled.find(item => item.status === "rejected");
228
- if (failure)
229
- throw failure.reason;
230
- const queries = settled.flatMap(item => item.status === "fulfilled" && item.value ? [item.value] : []);
231
- if (queries.length !== 10)
232
- return; // Resume only unfinished queries; never repeat completed calls.
360
+ if (problems.size) {
361
+ result.flagged++;
362
+ store.flagReview(example, "evaluation-unresolved", { steps: [...problems] });
363
+ return;
364
+ }
365
+ if (lanes.some(lane => !lane))
366
+ return; // Budget pause: completed operations remain reusable.
367
+ const queries = lanes.flatMap(lane => lane.queries);
233
368
  const selected = selectTrainingQueries(queries);
234
- const attempt = store.startStep("evaluate", evaluation.id, evaluation.request);
235
- store.finishStep("evaluate", evaluation.id, attempt, { result: { sourceId: example.id, inputHash: example.inputHash,
236
- timestamp: example.timestamp, corpusHash: snapshot.corpusHash, corpusReport: snapshot.report, teacherId: teacher.id, queries, selected } });
369
+ save(store, evaluation, { sourceId: example.id, inputHash: example.inputHash, timestamp: example.timestamp,
370
+ corpusHash: snapshot.corpusHash, corpusReport: snapshot.report, teacherIds: lanes.flatMap(lane => lane.teacherIds), queries, selected, review: [] });
371
+ store.clearReview(example.id);
237
372
  result.evaluated++;
238
373
  }
239
374
  finally {
@@ -242,7 +377,7 @@ export async function evaluateTrainingQueries(source, store, config, options, cr
242
377
  };
243
378
  const examples = store.queryExamples(options.threshold);
244
379
  let next = 0;
245
- const workers = await Promise.allSettled(Array.from({ length: Math.min(concurrency, examples.length) }, async () => {
380
+ await drain(Array.from({ length: Math.min(concurrency, examples.length) }, async () => {
246
381
  while (!shouldStop() && (options.maxExamples === undefined || result.examples < options.maxExamples)) {
247
382
  const example = examples[next++];
248
383
  if (!example)
@@ -256,27 +391,28 @@ export async function evaluateTrainingQueries(source, store, config, options, cr
256
391
  }
257
392
  }
258
393
  }));
259
- const failure = workers.find(item => item.status === "rejected");
260
- if (failure)
261
- throw failure.reason; // All workers/snapshots have drained, including on storage failures.
262
394
  return { refreshed, ...result, concurrency, threshold: options.threshold ?? TRAINING_GATE_THRESHOLD,
263
- retrievalMethod: "vector10-bm25-10", callUnit: "retrieval operation or uncached passage judgment" };
395
+ retrievalMethod: "independent-lex10-vec10", callUnit: "teacher completion, retrieval operation or uncached passage judgment" };
264
396
  }
265
397
  export function* exportQueryTraining(store, threshold = TRAINING_GATE_THRESHOLD) {
266
398
  const active = new Map(store.queryExamples(threshold).map(e => [e.id, e]));
267
- const exported = new Set();
268
- for (const evaluation of store.completedEvaluations({ selection: SELECTION_VERSION, retrieval: TRAINING_RETRIEVAL_VERSION })) {
399
+ const unresolved = new Set(store.reviews().map(review => review.sourceId)), exported = new Set();
400
+ for (const evaluation of store.completedEvaluations({ selection: SELECTION_VERSION, retrieval: TRAINING_RETRIEVAL_VERSION, judge: CONTEXT_JUDGE_VERSION })) {
269
401
  const source = active.get(evaluation.sourceId);
270
402
  if (!source || source.inputHash !== evaluation.inputHash || source.timestamp !== evaluation.timestamp || exported.has(source.id))
271
403
  continue;
272
- if (store.step("generate", teacherRequest(source)).id !== evaluation.teacherId)
404
+ exported.add(source.id); // Never fall back to an older accepted corpus after a newer flagged result.
405
+ if (unresolved.has(source.id))
406
+ continue;
407
+ if (!LANES.every(lane => evaluation.teacherIds.includes(store.step("generate", teacherRequest(source, lane)).id)))
273
408
  continue;
274
- exported.add(source.id); // Only the newest corpus evaluation per source.
275
- const provenance = [evaluation.teacherId, ...evaluation.queries.flatMap(q => [q.retrievalId, ...q.judgments?.map(j => j.id) ?? []])];
409
+ // Selection is a local policy over completed scores, not another paid step.
410
+ // This also recovers v2 checkpoints whose old usefulness policy saved null.
411
+ const selected = selectTrainingQueries(evaluation.queries);
412
+ const provenance = [...evaluation.teacherIds, ...evaluation.queries.flatMap(q => [q.retrievalId, ...q.judgments])];
276
413
  yield { stage: "query-training", input: JSON.parse(source.inputJson), inputHash: source.inputHash,
277
- recallProbability: source.recallProbability, threshold,
278
- target: evaluation.selected, source: store.sourceDetails(source.id), evaluation,
279
- splitGroup: trainingHash(source.sessionId), // Also keep identical inputHash groups together across sessions/nodes.
414
+ recallProbability: source.recallProbability, threshold, target: selected, targetPolicy: TARGET_POLICY,
415
+ source: store.sourceDetails(source.id), evaluation: { ...evaluation, selected, review: [] }, splitGroup: trainingHash(source.sessionId),
280
416
  provenance: [...new Set(provenance)].map(id => store.stepRecord(id)) };
281
417
  }
282
418
  }
@@ -1,6 +1,7 @@
1
1
  import type { ChatType } from "./config.js";
2
2
  import type { SessionMessageSpan } from "./session-projector.js";
3
- export declare const TRAINING_RETRIEVAL_VERSION = "qmd-2.10.1-historical-prefix-depth10-v2";
3
+ import type { QueryLane } from "./query-contract.js";
4
+ export declare const TRAINING_RETRIEVAL_VERSION = "qmd-2.10.2-historical-lanes-depth10-v3:complete-excerpt-1200-v1";
4
5
  export declare const TRAINING_SEARCH_OPTIONS: {
5
6
  readonly vector: 10;
6
7
  readonly bm25: 10;
@@ -9,12 +10,18 @@ export declare const TRAINING_SEARCH_OPTIONS: {
9
10
  };
10
11
  export type TrainingHit = {
11
12
  path: string;
13
+ corpus: string;
12
14
  text: string;
13
15
  dates: string[];
14
16
  position: number;
17
+ startLine: number;
18
+ endLine: number;
15
19
  score: number;
16
20
  methods: string[];
17
21
  };
22
+ /** Missing historical evidence is reviewable; unexpected SQLite/I/O failures remain fatal. */
23
+ export declare class HistoricalCorpusUnavailableError extends Error {
24
+ }
18
25
  /** Never infer dates by parsing message bodies: headings can be quoted or forged. */
19
26
  export declare function historicalPrefix(body: string, spans: readonly SessionMessageSpan[] | undefined, cutoff: number): {
20
27
  body: string;
@@ -32,6 +39,6 @@ export declare function historicalTrainingSearch(stateDir: string, chatTypes: re
32
39
  excludedChunks: number;
33
40
  };
34
41
  maxDate: string;
35
- search: (query: string) => Promise<TrainingHit[]>;
42
+ search: (query: string, lane: QueryLane) => Promise<TrainingHit[]>;
36
43
  close: () => Promise<void>;
37
44
  }>;
@@ -1,13 +1,17 @@
1
1
  import { createHash } from "node:crypto";
2
- import { existsSync } from "node:fs";
2
+ import { statSync } from "node:fs";
3
3
  import { join } from "node:path";
4
4
  import { createRequire } from "node:module";
5
5
  import { setImmediate as yieldToEventLoop } from "node:timers/promises";
6
6
  import { readSessionManifest } from "./session-sync.js";
7
7
  import { resolveSessionSource } from "./sources.js";
8
8
  import { trainingCandidates } from "./training-candidates.js";
9
- export const TRAINING_RETRIEVAL_VERSION = "qmd-2.10.1-historical-prefix-depth10-v2";
9
+ import { MEMORY_PASSAGE_VERSION, renderMemoryPassage, duplicateMemoryPassage } from "./memory-passage.js";
10
+ export const TRAINING_RETRIEVAL_VERSION = `qmd-2.10.2-historical-lanes-depth10-v3:${MEMORY_PASSAGE_VERSION}`;
10
11
  export const TRAINING_SEARCH_OPTIONS = { vector: 10, bm25: 10, mergedLimit: null, rerank: false };
12
+ /** Missing historical evidence is reviewable; unexpected SQLite/I/O failures remain fatal. */
13
+ export class HistoricalCorpusUnavailableError extends Error {
14
+ }
11
15
  /** Never infer dates by parsing message bodies: headings can be quoted or forged. */
12
16
  export function historicalPrefix(body, spans, cutoff) {
13
17
  if (!spans?.length || !body.startsWith("# Transcript\n\n") || !Number.isFinite(cutoff))
@@ -47,8 +51,15 @@ export function historicalPrefix(body, spans, cutoff) {
47
51
  * No filesystem projection, live-index mutation, model re-embedding or dependency patch. */
48
52
  export async function historicalTrainingSearch(stateDir, chatTypes, cutoff, openStore) {
49
53
  const indexPath = join(stateDir, "index.sqlite");
50
- if (!existsSync(indexPath))
51
- throw new Error("No QMD index; sync sessions before evaluating training queries");
54
+ try {
55
+ statSync(indexPath);
56
+ }
57
+ catch (error) {
58
+ if (error && typeof error === "object" && "code" in error && error.code === "ENOENT") {
59
+ throw new HistoricalCorpusUnavailableError("No QMD index; sync sessions before evaluating training queries");
60
+ }
61
+ throw error;
62
+ }
52
63
  const manifest = await readSessionManifest(join(stateDir, "sessions-manifest.json"));
53
64
  const source = resolveSessionSource(join(stateDir, "sessions"), chatTypes);
54
65
  const createStore = openStore ?? (await import("@unblocklabs/qmd")).createStore;
@@ -189,16 +200,21 @@ export async function historicalTrainingSearch(stateDir, chatTypes, cutoff, open
189
200
  }
190
201
  };
191
202
  return { corpusHash, report, maxDate,
192
- search: async (query) => {
203
+ search: async (query, lane) => {
193
204
  if (closed)
194
205
  throw new Error("Historical snapshot is closed");
195
206
  if (!report.sessions)
196
207
  return [];
197
- if (!report.chunks)
198
- throw new Error("Historical snapshot has no vectors; refusing a BM25-only evaluation");
208
+ if (lane === "vec" && !report.chunks)
209
+ throw new HistoricalCorpusUnavailableError("Historical snapshot has no vectors; refusing an unresolved vector evaluation");
199
210
  const qmd = await (indexed ??= materialize());
200
- const hits = await trainingCandidates(qmd, query, source.collection, `Historical request made at ${maxDate}. Current, now and latest refer to that timestamp.`);
201
- return hits.map(hit => trainingHit(hit, metadata, cutoff));
211
+ const hits = await trainingCandidates(qmd, query, source.collection, lane);
212
+ const rendered = await Promise.all(hits.map(hit => trainingHit(hit, metadata, cutoff)));
213
+ const distinct = [];
214
+ for (const hit of rendered)
215
+ if (hit && !duplicateMemoryPassage(hit, distinct))
216
+ distinct.push(hit);
217
+ return distinct;
202
218
  }, close: async () => {
203
219
  if (closed)
204
220
  return;
@@ -221,12 +237,20 @@ function historicalChunk(chunk, length) {
221
237
  return Number.isSafeInteger(chunk.pos) && Number.isSafeInteger(chunk.chunk_len) && chunk.pos >= 0 && chunk.chunk_len > 0 &&
222
238
  chunk.pos + chunk.chunk_len <= length;
223
239
  }
224
- function trainingHit(hit, metadata, cutoff) {
225
- const spans = metadata.get(hit.file)?.filter(s => s.start < hit.bestChunkPos + hit.bestChunk.length && s.end > hit.bestChunkPos);
240
+ async function trainingHit(hit, metadata, cutoff) {
241
+ const messages = metadata.get(hit.file);
242
+ const selected = await renderMemoryPassage({ body: hit.body, bestChunk: hit.bestChunk,
243
+ chunkPos: hit.bestChunkPos, chunkLen: hit.bestChunk.length }, messages);
244
+ if (!selected)
245
+ return;
246
+ const spans = messages?.filter(s => s.start < selected.position + (selected.sourceText ?? selected.text).length && s.end > selected.position);
226
247
  if (!spans?.length || spans.some(s => !(Date.parse(s.timestamp) < Math.floor(cutoff / 1000) * 1000)) ||
227
248
  hit.body.slice(hit.bestChunkPos, hit.bestChunkPos + hit.bestChunk.length) !== hit.bestChunk) {
228
249
  throw new Error("QMD returned evidence outside the historical snapshot");
229
250
  }
230
- return { path: hit.file, position: hit.bestChunkPos, text: hit.bestChunk, dates: [...new Set(spans.map(s => s.timestamp))],
251
+ const startLine = hit.body.slice(0, selected.position).split("\n").length;
252
+ const endLine = startLine + (selected.sourceText ?? selected.text).split("\n").length - 1;
253
+ return { path: hit.file, corpus: "sessions", position: selected.position, text: selected.text, startLine, endLine,
254
+ dates: [...new Set(spans.map(s => s.timestamp))],
231
255
  score: hit.score, methods: hit.explain?.methods ?? [] };
232
256
  }