@unblocklabs/unblock-memory 0.7.1 → 0.7.3

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 (54) hide show
  1. package/dist/src/analysis.js +36 -25
  2. package/dist/src/config.js +2 -17
  3. package/dist/src/contracts.d.ts +1 -1
  4. package/dist/src/extraction-chunk.d.ts +1 -0
  5. package/dist/src/extraction-chunk.js +1 -0
  6. package/dist/src/extraction-model.js +5 -4
  7. package/dist/src/extraction-runtime.js +2 -7
  8. package/dist/src/extraction-source.js +6 -2
  9. package/dist/src/extraction-store.d.ts +3 -0
  10. package/dist/src/extraction-store.js +14 -10
  11. package/dist/src/extraction-worker.js +31 -15
  12. package/dist/src/manager.d.ts +1 -1
  13. package/dist/src/manager.js +66 -85
  14. package/dist/src/memory-whisperer.js +6 -10
  15. package/dist/src/people-primer-config.js +1 -4
  16. package/dist/src/people-primer.js +6 -8
  17. package/dist/src/people-store.js +9 -1
  18. package/dist/src/people-tools.js +2 -6
  19. package/dist/src/plugin.js +6 -14
  20. package/dist/src/quality-audit.js +11 -2
  21. package/dist/src/query-contract.js +18 -10
  22. package/dist/src/response-audit.js +3 -3
  23. package/dist/src/response-judge.js +1 -2
  24. package/dist/src/response-reviews.d.ts +1 -1
  25. package/dist/src/response-reviews.js +4 -2
  26. package/dist/src/response-store.js +3 -2
  27. package/dist/src/retrieval-telemetry.d.ts +1 -1
  28. package/dist/src/review-tools.d.ts +24 -0
  29. package/dist/src/review-tools.js +22 -26
  30. package/dist/src/runtime.js +2 -1
  31. package/dist/src/session-projector.js +8 -4
  32. package/dist/src/session-sync.js +2 -1
  33. package/dist/src/skill-whisperer.js +51 -24
  34. package/dist/src/training-candidates.d.ts +12 -13
  35. package/dist/src/training-candidates.js +11 -81
  36. package/dist/src/training-gate.js +2 -2
  37. package/dist/src/training-queries.js +5 -2
  38. package/dist/src/training-retrieval.d.ts +15 -2
  39. package/dist/src/training-retrieval.js +63 -39
  40. package/dist/src/training-store.d.ts +2 -8
  41. package/dist/src/training-store.js +15 -20
  42. package/dist/src/typesafe-client.d.ts +5 -0
  43. package/dist/src/typesafe-client.js +4 -0
  44. package/dist/src/typesafe-review.js +4 -6
  45. package/dist/src/typesafe.d.ts +1 -9
  46. package/dist/src/typesafe.js +4 -10
  47. package/dist/src/whisperer-context.d.ts +5 -1
  48. package/dist/src/whisperer-context.js +12 -7
  49. package/dist/src/workspace-path-classifier.js +39 -32
  50. package/docs/configuration.md +3 -2
  51. package/docs/memory-training.md +8 -7
  52. package/docs/retrieval.md +15 -1
  53. package/openclaw.plugin.json +2 -2
  54. package/package.json +6 -4
@@ -47,9 +47,8 @@ export function historicalPrefix(body, spans, cutoff) {
47
47
  return;
48
48
  return { body: body.slice(0, parsed[safe.length]?.span.start ?? body.length), spans: safe.map(s => s.span) };
49
49
  }
50
- /** Fingerprint a read-only source snapshot; build its search index only on demand.
51
- * No filesystem projection, live-index mutation, model re-embedding or dependency patch. */
52
- export async function historicalTrainingSearch(stateDir, chatTypes, cutoff, openStore) {
50
+ /** Capture source bytes once per run; each cutoff still owns its index and native model context. */
51
+ export async function historicalTrainingSource(stateDir, chatTypes) {
53
52
  const indexPath = join(stateDir, "index.sqlite");
54
53
  try {
55
54
  statSync(indexPath);
@@ -62,22 +61,13 @@ export async function historicalTrainingSearch(stateDir, chatTypes, cutoff, open
62
61
  }
63
62
  const manifest = await readSessionManifest(join(stateDir, "sessions-manifest.json"));
64
63
  const source = resolveSessionSource(join(stateDir, "sessions"), chatTypes);
65
- const createStore = openStore ?? (await import("@unblocklabs/qmd")).createStore;
66
- const maxDate = new Date(cutoff).toISOString();
67
- const report = { sessions: 0, chunks: 0, excluded: 0, truncated: 0, excludedChunks: 0 };
68
- const metadata = new Map();
69
- const fingerprint = createHash("sha256").update(JSON.stringify([TRAINING_RETRIEVAL_VERSION, chatTypes.toSorted(), maxDate]));
70
64
  // Use QMD's exact binding, never mix node:sqlite with sqlite-vec.
71
65
  const requireQmd = createRequire(import.meta.resolve("@unblocklabs/qmd"));
72
66
  const Database = requireQmd("better-sqlite3");
73
67
  const sourceDb = new Database(indexPath, { readonly: true, fileMustExist: true });
74
- const documents = [];
75
- let sourceClosed = false, closed = false;
76
- const closeSource = () => { if (!sourceClosed) {
77
- sourceDb.close();
78
- sourceClosed = true;
79
- } documents.length = 0; };
80
- let indexed;
68
+ const sessions = [];
69
+ let excluded = 0;
70
+ let settings;
81
71
  try {
82
72
  const extension = requireQmd("sqlite-vec");
83
73
  if (!extension || typeof extension !== "object" || !("getLoadablePath" in extension) || typeof extension.getLoadablePath !== "function") {
@@ -88,16 +78,13 @@ export async function historicalTrainingSearch(stateDir, chatTypes, cutoff, open
88
78
  throw new Error("QMD sqlite-vec path unavailable");
89
79
  sourceDb.loadExtension(extensionPath);
90
80
  sourceDb.exec("PRAGMA query_only=ON; PRAGMA busy_timeout=5000; BEGIN");
91
- const settings = sourceDb.prepare("SELECT key,value FROM store_config WHERE key='embedding_chunk_strategy'").all();
92
- fingerprint.update(JSON.stringify(settings));
81
+ settings = sourceDb.prepare("SELECT key,value FROM store_config WHERE key='embedding_chunk_strategy'").all();
93
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
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
- // Both passes use this same read transaction. Cached evaluations still
100
- // verify every prefix/vector byte, but never build an index or load a model.
87
+ const vectors = new Map();
101
88
  let yieldAt = performance.now() + 25;
102
89
  for (const session of Object.values(manifest.sessions).sort((a, b) => a.documentPath.localeCompare(b.documentPath))) {
103
90
  if (performance.now() >= yieldAt) {
@@ -106,28 +93,67 @@ export async function historicalTrainingSearch(stateDir, chatTypes, cutoff, open
106
93
  }
107
94
  // Loggie projections can retroactively annotate old text using later revisions.
108
95
  // Workspace files and meetings lack immutable historical content proof here.
109
- if (!chatTypes.includes(session.chatType) || session.provider === "loggie" || session.startedAt >= cutoff) {
110
- report.excluded++;
96
+ if (!chatTypes.includes(session.chatType) || session.provider === "loggie") {
97
+ excluded++;
111
98
  continue;
112
99
  }
113
100
  const row = document.get(source.collection, session.documentPath);
114
101
  if (!row || createHash("sha256").update(row.doc).digest("hex") !== session.projectionHash) {
102
+ excluded++;
103
+ continue;
104
+ }
105
+ let captured = vectors.get(row.hash);
106
+ if (!captured) {
107
+ captured = [];
108
+ for (const chunk of chunks.iterate(row.hash)) {
109
+ if (performance.now() >= yieldAt) {
110
+ await yieldToEventLoop();
111
+ yieldAt = performance.now() + 25;
112
+ }
113
+ captured.push({ ...chunk, embedding: Buffer.from(chunk.embedding) });
114
+ }
115
+ vectors.set(row.hash, captured);
116
+ }
117
+ sessions.push({ session, body: row.doc, chunks: captured });
118
+ }
119
+ }
120
+ finally {
121
+ sourceDb.close();
122
+ }
123
+ return async (cutoff, openStore) => {
124
+ const createStore = openStore ?? (await import("@unblocklabs/qmd")).createStore;
125
+ const maxDate = new Date(cutoff).toISOString();
126
+ const report = { sessions: 0, chunks: 0, excluded, truncated: 0, excludedChunks: 0 };
127
+ const metadata = new Map();
128
+ const fingerprint = createHash("sha256").update(JSON.stringify([TRAINING_RETRIEVAL_VERSION, chatTypes.toSorted(), maxDate]));
129
+ fingerprint.update(JSON.stringify(settings));
130
+ const documents = [];
131
+ let dimensions, closed = false;
132
+ let indexed;
133
+ let yieldAt = performance.now() + 25;
134
+ for (const { session, body, chunks } of sessions) {
135
+ if (performance.now() >= yieldAt) {
136
+ await yieldToEventLoop();
137
+ yieldAt = performance.now() + 25;
138
+ }
139
+ if (session.startedAt >= cutoff) {
115
140
  report.excluded++;
116
141
  continue;
117
142
  }
118
- const prefix = historicalPrefix(row.doc, session.messages, cutoff);
143
+ const prefix = historicalPrefix(body, session.messages, cutoff);
119
144
  if (!prefix) {
120
145
  report.excluded++;
121
146
  continue;
122
147
  }
123
148
  const hash = createHash("sha256").update(prefix.body).digest("hex");
124
- documents.push({ path: session.documentPath, sourceHash: row.hash, hash, body: prefix.body });
149
+ const safeChunks = [];
150
+ documents.push({ path: session.documentPath, hash, body: prefix.body, chunks: safeChunks });
125
151
  metadata.set(`qmd://${source.collection}/${session.documentPath}`, prefix.spans);
126
152
  fingerprint.update(JSON.stringify([session.documentPath, hash]));
127
153
  report.sessions++;
128
154
  if (prefix.spans.length < (session.messages?.length ?? 0))
129
155
  report.truncated++;
130
- for (const chunk of chunks.iterate(row.hash)) {
156
+ for (const chunk of chunks) {
131
157
  if (performance.now() >= yieldAt) {
132
158
  await yieldToEventLoop();
133
159
  yieldAt = performance.now() + 25;
@@ -136,13 +162,14 @@ export async function historicalTrainingSearch(stateDir, chatTypes, cutoff, open
136
162
  report.excludedChunks++;
137
163
  continue;
138
164
  }
139
- const bytes = Buffer.from(chunk.embedding);
165
+ const bytes = chunk.embedding;
140
166
  if (bytes.length % 4 || !bytes.length)
141
167
  throw new Error("Invalid historical embedding");
142
168
  const size = bytes.length / 4;
143
169
  dimensions ??= size;
144
170
  if (dimensions !== size)
145
171
  throw new Error("Mixed historical embedding dimensions");
172
+ safeChunks.push(chunk);
146
173
  fingerprint.update(JSON.stringify([chunk.seq, chunk.pos, chunk.chunk_len, chunk.model, chunk.embed_fingerprint])).update(bytes);
147
174
  report.chunks++;
148
175
  }
@@ -175,14 +202,13 @@ export async function historicalTrainingSearch(stateDir, chatTypes, cutoff, open
175
202
  }
176
203
  qmd.internal.insertContent(document.hash, document.body, maxDate);
177
204
  qmd.internal.insertDocument(source.collection, document.path, "Transcript", document.hash, maxDate, maxDate);
178
- for (const chunk of chunks.iterate(document.sourceHash)) {
205
+ for (const chunk of document.chunks) {
179
206
  if (performance.now() >= yieldAt) {
180
207
  await yieldToEventLoop();
181
208
  yieldAt = performance.now() + 25;
182
209
  }
183
- if (!historicalChunk(chunk, document.body.length))
184
- continue;
185
- const bytes = Buffer.from(chunk.embedding), vector = new Float32Array(bytes.length / 4);
210
+ const bytes = Buffer.from(chunk.embedding.buffer, chunk.embedding.byteOffset, chunk.embedding.byteLength);
211
+ const vector = new Float32Array(bytes.length / 4);
186
212
  for (let i = 0; i < vector.length; i++)
187
213
  vector[i] = bytes.readFloatLE(i * 4);
188
214
  qmd.internal.insertEmbedding(document.hash, chunk.seq, chunk.pos, vector, chunk.model, maxDate, 1, chunk.embed_fingerprint, chunk.chunk_len);
@@ -195,9 +221,6 @@ export async function historicalTrainingSearch(stateDir, chatTypes, cutoff, open
195
221
  await qmd?.close();
196
222
  throw error;
197
223
  }
198
- finally {
199
- closeSource();
200
- }
201
224
  };
202
225
  return { corpusHash, report, maxDate,
203
226
  search: async (query, lane) => {
@@ -224,14 +247,15 @@ export async function historicalTrainingSearch(stateDir, chatTypes, cutoff, open
224
247
  await indexed.then(qmd => qmd.close(), () => { });
225
248
  }
226
249
  finally {
227
- closeSource();
250
+ documents.length = 0;
228
251
  }
229
252
  } };
230
- }
231
- catch (error) {
232
- closeSource();
233
- throw error;
234
- }
253
+ };
254
+ }
255
+ /** Standalone calls get a fresh source capture; evaluation runs reuse their own capture. */
256
+ export async function historicalTrainingSearch(stateDir, chatTypes, cutoff, openStore) {
257
+ const source = await historicalTrainingSource(stateDir, chatTypes);
258
+ return source(cutoff, openStore);
235
259
  }
236
260
  function historicalChunk(chunk, length) {
237
261
  return Number.isSafeInteger(chunk.pos) && Number.isSafeInteger(chunk.chunk_len) && chunk.pos >= 0 && chunk.chunk_len > 0 &&
@@ -85,15 +85,9 @@ export declare class TrainingStore {
85
85
  error: string;
86
86
  }): void;
87
87
  retry(includeAmbiguous: boolean, ids: readonly string[]): number;
88
- activeExamples(): TrainingSourceExample[];
89
- queryExamples(threshold?: number): {
88
+ queryExamples(threshold?: number): (TrainingSourceExample & {
90
89
  recallProbability: number;
91
- id: string;
92
- inputHash: string;
93
- inputJson: string;
94
- sessionId: string;
95
- timestamp: number;
96
- }[];
90
+ })[];
97
91
  step<S extends keyof TrainingStepResults>(stage: S, parameters: Record<string, unknown>): {
98
92
  id: string;
99
93
  stage: S;
@@ -136,20 +136,21 @@ export class TrainingStore {
136
136
  this.#db.prepare("UPDATE training_examples SET active=0 WHERE session_id=?").run(sessionId);
137
137
  const counts = { added: 0, changed: 0, unchanged: 0, retired: 0 };
138
138
  const retained = new Set();
139
+ const input = this.#db.prepare("INSERT OR IGNORE INTO training_inputs VALUES (?,?,?)");
140
+ const source = this.#db.prepare(`INSERT INTO training_examples VALUES (?,?,?,?,?,?,1)
141
+ ON CONFLICT(session_id,event_seq) DO UPDATE SET timestamp=excluded.timestamp,input_hash=excluded.input_hash,
142
+ context_limited=excluded.context_limited,active=1`);
143
+ const gate = this.#db.prepare(`INSERT OR IGNORE INTO training_gates
144
+ (id,input_hash,prompt_version,requested_model,status) VALUES (?,?,?,?,'pending')`);
139
145
  for (const example of examples) {
140
146
  const old = previous.get(example.seq);
141
147
  if (!old && (existingOnly || example.timestamp < since || example.timestamp >= until))
142
148
  continue;
143
149
  retained.add(example.seq);
144
- this.#db.prepare("INSERT OR IGNORE INTO training_inputs VALUES (?,?,?)")
145
- .run(example.inputHash, TRAINING_PREPARATION, JSON.stringify(example.input));
146
- this.#db.prepare(`INSERT INTO training_examples VALUES (?,?,?,?,?,?,1)
147
- ON CONFLICT(session_id,event_seq) DO UPDATE SET timestamp=excluded.timestamp,input_hash=excluded.input_hash,
148
- context_limited=excluded.context_limited,active=1`)
149
- .run(trainingHash([this.#nodeId, this.#agentId, sessionId, example.seq]), sessionId, example.seq, example.timestamp, example.inputHash, Number(example.contextLimited));
150
+ input.run(example.inputHash, TRAINING_PREPARATION, JSON.stringify(example.input));
151
+ source.run(trainingHash([this.#nodeId, this.#agentId, sessionId, example.seq]), sessionId, example.seq, example.timestamp, example.inputHash, Number(example.contextLimited));
150
152
  const id = trainingHash([example.inputHash, TRAINING_GATE_VERSION, TRAINING_GATE_MODEL]);
151
- this.#db.prepare(`INSERT OR IGNORE INTO training_gates (id,input_hash,prompt_version,requested_model,status) VALUES (?,?,?,?,'pending')`)
152
- .run(id, example.inputHash, TRAINING_GATE_VERSION, TRAINING_GATE_MODEL);
153
+ gate.run(id, example.inputHash, TRAINING_GATE_VERSION, TRAINING_GATE_MODEL);
153
154
  if (!old)
154
155
  counts.added++;
155
156
  else if (old.input_hash !== example.inputHash || old.active !== 1)
@@ -211,21 +212,15 @@ export class TrainingStore {
211
212
  return count + Number(gates.changes) + Number(steps.changes);
212
213
  }, 0));
213
214
  }
214
- activeExamples() {
215
- return this.#db.prepare(`SELECT e.id,e.input_hash inputHash,i.input_json inputJson,e.session_id sessionId,e.timestamp
216
- FROM training_examples e JOIN training_inputs i ON i.hash=e.input_hash
217
- WHERE e.active=1 AND i.preparation=? ORDER BY e.timestamp DESC,e.id`).all(TRAINING_PREPARATION);
218
- }
219
215
  queryExamples(threshold = TRAINING_GATE_THRESHOLD) {
220
216
  if (!Number.isFinite(threshold) || threshold < 0 || threshold > 1)
221
217
  throw new Error("Threshold must be between 0 and 1");
222
- const probabilities = new Map(this.#db.prepare(`SELECT g.input_hash,g.probability FROM training_gates g
223
- WHERE ${this.#scope} AND g.status='complete' AND g.probability>=?`)
224
- .all(TRAINING_GATE_VERSION, TRAINING_GATE_MODEL, TRAINING_PREPARATION, threshold).map(row => [String(row.input_hash), Number(row.probability)]));
225
- return this.activeExamples().flatMap(example => {
226
- const recallProbability = probabilities.get(example.inputHash);
227
- return recallProbability === undefined ? [] : [{ ...example, recallProbability }];
228
- });
218
+ return this.#db.prepare(`SELECT e.id,e.input_hash inputHash,i.input_json inputJson,e.session_id sessionId,e.timestamp,
219
+ g.probability recallProbability FROM training_examples e JOIN training_inputs i ON i.hash=e.input_hash
220
+ JOIN training_gates g ON g.input_hash=e.input_hash
221
+ WHERE e.active=1 AND i.preparation=? AND g.prompt_version=? AND g.requested_model=?
222
+ AND g.status='complete' AND g.probability>=? ORDER BY e.timestamp DESC,e.id`)
223
+ .all(TRAINING_PREPARATION, TRAINING_GATE_VERSION, TRAINING_GATE_MODEL, threshold);
229
224
  }
230
225
  step(stage, parameters) {
231
226
  const request = { ...parameters, recipe: TRAINING_RECIPE_VERSION };
@@ -1,5 +1,10 @@
1
+ import { Type } from "typebox";
1
2
  import type { UnblockMemoryConfig } from "./config.js";
2
3
  export declare const TYPESAFE_MODEL = "jev-1.13.0";
4
+ export declare const TYPESAFE_NOUL_SCHEMA: Type.TObject<{
5
+ type: Type.TLiteral<"noul">;
6
+ noul: Type.TNumber;
7
+ }>;
3
8
  export declare class TypeSafeRequestError extends Error {
4
9
  readonly code: "timeout" | "cancelled" | "http_error" | "invalid_response" | "network_error";
5
10
  readonly status?: number | undefined;
@@ -1,6 +1,10 @@
1
1
  import { readFile } from "node:fs/promises";
2
2
  import { parseEnv } from "node:util";
3
+ import { Type } from "typebox";
3
4
  export const TYPESAFE_MODEL = "jev-1.13.0";
5
+ export const TYPESAFE_NOUL_SCHEMA = Type.Object({
6
+ type: Type.Literal("noul"), noul: Type.Number({ minimum: 0, maximum: 1 }),
7
+ });
4
8
  export class TypeSafeRequestError extends Error {
5
9
  code;
6
10
  status;
@@ -1,7 +1,7 @@
1
1
  import { Type } from "typebox";
2
2
  import { Value } from "typebox/value";
3
3
  import { backgroundWordCount, PEOPLE_BACKGROUND_MAX_WORDS } from "./people-background.js";
4
- import { requestTypeSafe } from "./typesafe-client.js";
4
+ import { requestTypeSafe, TYPESAFE_NOUL_SCHEMA } from "./typesafe-client.js";
5
5
  export { TYPESAFE_MODEL as TYPESAFE_REVIEW_MODEL } from "./typesafe-client.js";
6
6
  const relationSchema = Type.Object({ answers: Type.Object({ relation: Type.Object({
7
7
  type: Type.Literal("choice"),
@@ -61,8 +61,8 @@ export async function reviewTypeSafeClaim(params) {
61
61
  let background;
62
62
  if (params.personBackground) {
63
63
  const schema = Type.Object({ answers: Type.Object({
64
- backgroundOnly: Type.Object({ type: Type.Literal("noul"), noul: Type.Number({ minimum: 0, maximum: 1 }) }),
65
- explicitSupport: Type.Object({ type: Type.Literal("noul"), noul: Type.Number({ minimum: 0, maximum: 1 }) }),
64
+ backgroundOnly: TYPESAFE_NOUL_SCHEMA,
65
+ explicitSupport: TYPESAFE_NOUL_SCHEMA,
66
66
  }) });
67
67
  if (!Value.Check(schema, payload))
68
68
  throw new Error("TypeSafe returned an invalid background review");
@@ -73,9 +73,7 @@ export async function reviewTypeSafeClaim(params) {
73
73
  needsReview: answer.choice !== "supports" || answer.confidence < 0.9 ||
74
74
  (background !== undefined && (background.backgroundOnly < 0.9 || background.explicitSupport < 0.9)) };
75
75
  }
76
- const nouls = Type.Object({ answers: Type.Record(Type.String(), Type.Object({
77
- type: Type.Literal("noul"), noul: Type.Number({ minimum: 0, maximum: 1 }),
78
- })) });
76
+ const nouls = Type.Object({ answers: Type.Record(Type.String(), TYPESAFE_NOUL_SCHEMA) });
79
77
  /** Directional coverage, not topic similarity. Bounded at six comparisons of four ranked candidates. */
80
78
  export async function reviewMemoryRedundancy(params) {
81
79
  if (params.excerpts.length > 4)
@@ -3,6 +3,7 @@ import type { QueryConversation } from "./query-contract.js";
3
3
  export declare function selectTypeSafeSkill(params: {
4
4
  apiKey: string;
5
5
  timeoutMs: number;
6
+ signal?: AbortSignal;
6
7
  currentRequest: string;
7
8
  history: readonly {
8
9
  role: "user" | "assistant";
@@ -82,12 +83,3 @@ export declare function judgeMemoryPassage(request: ReturnType<typeof memoryUsef
82
83
  output_tokens: number;
83
84
  } | null;
84
85
  }>;
85
- /** One HTTP request per candidate; result order matches input order. */
86
- export declare function judgeTypeSafeMemories(params: {
87
- apiKey: string;
88
- timeoutMs: number;
89
- signal: AbortSignal;
90
- conversation: QueryConversation;
91
- asOf: string;
92
- candidates: readonly MemoryPassage[];
93
- }): Promise<number[]>;
@@ -1,6 +1,6 @@
1
1
  import { Type } from "typebox";
2
2
  import { Value } from "typebox/value";
3
- import { requestTypeSafe, TypeSafeRequestError, TYPESAFE_MODEL } from "./typesafe-client.js";
3
+ import { requestTypeSafe, TypeSafeRequestError, TYPESAFE_MODEL, TYPESAFE_NOUL_SCHEMA } from "./typesafe-client.js";
4
4
  import { MEMORY_PASSAGE_CHARS } from "./memory-passage.js";
5
5
  const SKILL_MIN_USEFULNESS = 0.7;
6
6
  /** Select from trusted candidates; never accept a provider-generated path or skill name. */
@@ -26,7 +26,7 @@ export async function selectTypeSafeSkill(params) {
26
26
  /** One skill per request; rank the comparable usefulness probabilities in code. */
27
27
  async function judgeTypeSafeSkill(params) {
28
28
  let payload;
29
- payload = await requestTypeSafe({ apiKey: params.apiKey, timeoutMs: params.timeoutMs }, { currentRequest: params.currentRequest, history: params.history,
29
+ payload = await requestTypeSafe(params, { currentRequest: params.currentRequest, history: params.history,
30
30
  candidate: { name: params.candidate.name, description: params.candidate.description } }, { useful: {
31
31
  type: "noul",
32
32
  instructions: {
@@ -51,9 +51,7 @@ async function judgeTypeSafeSkill(params) {
51
51
  return payload.answers.useful.noul;
52
52
  }
53
53
  const memoryAnswersSchema = Type.Object({
54
- answers: Type.Record(Type.String(), Type.Object({
55
- type: Type.Literal("noul"), noul: Type.Number({ minimum: 0, maximum: 1 }),
56
- })),
54
+ answers: Type.Record(Type.String(), TYPESAFE_NOUL_SCHEMA),
57
55
  });
58
56
  export const QUALITY_JUDGE_VERSION = "jev-1.13.0:quality-v3-isolated";
59
57
  /** These are indicators for review, never authorization to delete or rewrite. */
@@ -137,7 +135,7 @@ export function memoryUsefulnessRequest(conversation, candidate, asOf) {
137
135
  }
138
136
  const memoryJudgmentSchema = Type.Object({
139
137
  model: Type.Literal(TYPESAFE_MODEL),
140
- answers: Type.Object({ memory_0: Type.Object({ type: Type.Literal("noul"), noul: Type.Number({ minimum: 0, maximum: 1 }) }) }, { additionalProperties: false }),
138
+ answers: Type.Object({ memory_0: TYPESAFE_NOUL_SCHEMA }, { additionalProperties: false }),
141
139
  usage: Type.Optional(Type.Object({ input_tokens: Type.Integer({ minimum: 0 }), output_tokens: Type.Integer({ minimum: 0 }) })),
142
140
  });
143
141
  function parseMemoryJudgment(payload) {
@@ -150,7 +148,3 @@ function parseMemoryJudgment(payload) {
150
148
  export async function judgeMemoryPassage(request, params) {
151
149
  return parseMemoryJudgment(await requestTypeSafe(params, request.state, request.questions));
152
150
  }
153
- /** One HTTP request per candidate; result order matches input order. */
154
- export async function judgeTypeSafeMemories(params) {
155
- return Promise.all(params.candidates.map(async (candidate) => (await judgeMemoryPassage(memoryUsefulnessRequest(params.conversation, candidate, params.asOf), params)).probability));
156
- }
@@ -3,4 +3,8 @@ export declare function messageText(message: unknown): {
3
3
  role: "user" | "assistant";
4
4
  text: string;
5
5
  } | undefined;
6
- export declare function buildSkillWhispererQuery(prompt: string, messages: readonly unknown[], historyMessages: number): string;
6
+ export declare function recentSkillMessages(messages: readonly unknown[], limit: number): {
7
+ role: "user" | "assistant";
8
+ text: string;
9
+ }[];
10
+ export declare function buildSkillWhispererQuery(prompt: string, history: readonly NonNullable<ReturnType<typeof messageText>>[]): string;
@@ -11,11 +11,16 @@ export function messageText(message) {
11
11
  }).join("\n").trim() : "";
12
12
  return text ? { role: message.role, text } : undefined;
13
13
  }
14
- export function buildSkillWhispererQuery(prompt, messages, historyMessages) {
15
- const availableHistory = messages.flatMap(message => {
16
- const parsed = messageText(message);
17
- return parsed ? [`${parsed.role}: ${parsed.text}`] : [];
18
- });
19
- const history = historyMessages === 0 ? [] : availableHistory.slice(-historyMessages);
20
- return [...history, `user: ${prompt.trim()}`].join("\n\n").slice(-MAX_SKILL_QUERY_CHARS);
14
+ export function recentSkillMessages(messages, limit) {
15
+ const history = [];
16
+ for (let i = messages.length - 1; i >= 0 && history.length < limit; i--) {
17
+ const parsed = messageText(messages[i]);
18
+ if (parsed)
19
+ history.push(parsed);
20
+ }
21
+ return history.reverse();
22
+ }
23
+ export function buildSkillWhispererQuery(prompt, history) {
24
+ return [...history.map(message => `${message.role}: ${message.text}`), `user: ${prompt.trim()}`]
25
+ .join("\n\n").slice(-MAX_SKILL_QUERY_CHARS);
21
26
  }
@@ -2,40 +2,47 @@ import fs from "node:fs/promises";
2
2
  import path from "node:path";
3
3
  import { isPathStrictlyInside } from "openclaw/plugin-sdk/file-access-runtime";
4
4
  import { readMemoryArtifactProvenance } from "openclaw/plugin-sdk/memory-core-host-runtime-core";
5
- export const classifyWorkspaceMemoryPaths = async (params) => await Promise.all(params.relativePaths.map(async (relativePath) => {
5
+ export const classifyWorkspaceMemoryPaths = async (params) => {
6
+ if (!params.relativePaths.length)
7
+ return [];
6
8
  let workspacePath;
7
- let filePath;
8
9
  try {
9
- [workspacePath, filePath] = await Promise.all([
10
- fs.realpath(params.workspaceDir),
11
- fs.realpath(path.resolve(params.workspaceDir, relativePath)),
12
- ]);
10
+ workspacePath = await fs.realpath(params.workspaceDir);
13
11
  }
14
12
  catch {
15
- return { relativePath, originClass: "untrusted" };
13
+ return params.relativePaths.map(relativePath => ({ relativePath, originClass: "untrusted" }));
16
14
  }
17
- if (!isPathStrictlyInside(workspacePath, filePath)) {
18
- return { relativePath, originClass: "untrusted" };
19
- }
20
- const workspaceRelativePath = path.relative(workspacePath, filePath);
21
- const segments = workspaceRelativePath.split(path.sep);
22
- const curatedRoot = segments.length === 1 &&
23
- ["MEMORY.md", "memory.md", "USER.md"].includes(segments[0]);
24
- if ((segments.length === 1 && ["DREAMS.md", "dreams.md"].includes(segments[0])) ||
25
- (segments[0] === "memory" && ["dreaming", ".dreams"].includes(segments[1]))) {
26
- return { relativePath, originClass: "system" };
27
- }
28
- const isWorkspaceMemory = curatedRoot ||
29
- (segments[0] === "memory" && segments.at(-1)?.endsWith(".md") === true);
30
- const normalizedPath = workspaceRelativePath.replaceAll(path.sep, "/");
31
- const recorded = isWorkspaceMemory
32
- ? await readMemoryArtifactProvenance({
33
- workspaceDir: params.workspaceDir,
34
- relativePath: normalizedPath,
35
- })
36
- : undefined;
37
- return {
38
- relativePath,
39
- originClass: recorded?.originClass ?? (isWorkspaceMemory ? "agent" : "untrusted"),
40
- };
41
- }));
15
+ return await Promise.all(params.relativePaths.map(async (relativePath) => {
16
+ let filePath;
17
+ try {
18
+ filePath = await fs.realpath(path.resolve(params.workspaceDir, relativePath));
19
+ }
20
+ catch {
21
+ return { relativePath, originClass: "untrusted" };
22
+ }
23
+ if (!isPathStrictlyInside(workspacePath, filePath)) {
24
+ return { relativePath, originClass: "untrusted" };
25
+ }
26
+ const workspaceRelativePath = path.relative(workspacePath, filePath);
27
+ const segments = workspaceRelativePath.split(path.sep);
28
+ const curatedRoot = segments.length === 1 &&
29
+ ["MEMORY.md", "memory.md", "USER.md"].includes(segments[0]);
30
+ if ((segments.length === 1 && ["DREAMS.md", "dreams.md"].includes(segments[0])) ||
31
+ (segments[0] === "memory" && ["dreaming", ".dreams"].includes(segments[1]))) {
32
+ return { relativePath, originClass: "system" };
33
+ }
34
+ const isWorkspaceMemory = curatedRoot ||
35
+ (segments[0] === "memory" && segments.at(-1)?.endsWith(".md") === true);
36
+ const normalizedPath = workspaceRelativePath.replaceAll(path.sep, "/");
37
+ const recorded = isWorkspaceMemory
38
+ ? await readMemoryArtifactProvenance({
39
+ workspaceDir: params.workspaceDir,
40
+ relativePath: normalizedPath,
41
+ })
42
+ : undefined;
43
+ return {
44
+ relativePath,
45
+ originClass: recorded?.originClass ?? (isWorkspaceMemory ? "agent" : "untrusted"),
46
+ };
47
+ }));
48
+ };
@@ -86,7 +86,8 @@ The plugin reads only `TYPESAFE_API_KEY` from the environment. Standalone QMD al
86
86
  supports `TYPESAFE_API_KEY_FILE`; these are separate credential resolvers.
87
87
  `typesafe.timeoutMs` is not a universal total deadline: People Primer and dossier
88
88
  save review use `peoplePrimer.timeoutMs` per request; Memory Whisperer has its
89
- own overall budget.
89
+ own overall budget. Skill Whisperer's fixed three-second total budget includes
90
+ credential resolution, retrieval and judging, even when its provider timeout is longer.
90
91
 
91
92
  ## Example profiles
92
93
 
@@ -214,7 +215,7 @@ contract; these tables explain their effects.
214
215
  | `typesafe.apiKey` | Unset | Explicit inline key; mutually exclusive with key file; prefer file |
215
216
  | `typesafe.apiKeyFile` | Unset | Absolute raw-key or dotenv file, reread at credential resolution; explicit missing file never falls back to a different key |
216
217
  | `typesafe.timeoutMs` | `1500` | 1–10,000 per request for Skill/Memory Whisperer, quality/claim/cluster review and response audit; **primer and dossier save use `peoplePrimer.timeoutMs` instead** |
217
- | `skillWhisperer.enabled` | `false` | Requires explicit skills corpus and appropriate host hook access |
218
+ | `skillWhisperer.enabled` | `false` | Requires explicit skills corpus and appropriate host hook access; fixed 3-second total deadline |
218
219
  | `skillWhisperer.historyMessages` | `5` | Nonnegative integer; prior visible messages used for routing |
219
220
  | `skillWhisperer.minScore` | `0.5` | 0–1, **local vector fallback only**, ignored for TypeSafe shortlist admission |
220
221
  | `skillWhisperer.cooldownTurns` | `10` | Nonnegative user-turn count; no fallback to weaker cooling-down alternatives |
@@ -70,9 +70,11 @@ continue without retrying failed requests. Storage/lease failures still stop the
70
70
  In-flight work drains and persists before snapshots close. Dry runs make no inference calls.
71
71
 
72
72
  Cached evaluations still validate the exact historical text and vector fingerprint,
73
- but do not rebuild a QMD index. The index is built only on the first uncached search,
74
- from the same read-only SQLite transaction used to compute that fingerprint.
75
- Concurrent queries share that index; changed corpus content still invalidates caches.
73
+ but do not rebuild a QMD index. Each evaluation run captures immutable source text
74
+ and vector bytes once, then closes the source reader. Each exact cutoff derives its
75
+ own fingerprint and builds a private index only on its first uncached search.
76
+ Concurrent queries at that cutoff share the index; later runs capture fresh source
77
+ bytes, so changed corpus content still invalidates caches.
76
78
 
77
79
  ## Input and historical boundaries
78
80
 
@@ -99,10 +101,9 @@ unknown/future dates. Never infer dates from quoted headings. Copy existing vect
99
101
  only for complete chunks within the safe prefix; BM25 sees that same prefix.
100
102
  Validate returned passages/dates again. No reembedding or temporary transcripts.
101
103
 
102
- QMD cannot independently set per-method depth through its public search API.
103
- A small discovery adapter retains its tokenization, FTS-highlight chunk selection,
104
- source-aware dedup and installed chunk helpers, but requests ten from the selected
105
- backend and omits query-conditioned scoring. It does not rewrite QMD. Training and
104
+ QMD's public `discoverCandidates` API owns tokenization, FTS-highlight chunk
105
+ selection and source-aware deduplication. A small adapter requests ten from the
106
+ selected backend and omits query-conditioned scoring. Training and
106
107
  runtime share complete-excerpt rendering, including the 1,200-character cap;
107
108
  oversized matches are discarded rather than truncated.
108
109
 
package/docs/retrieval.md CHANGED
@@ -380,6 +380,12 @@ for the trusted LAN/loopback only; use TLS or a private tunnel across untrusted
380
380
  Visible conversation text is sent to this endpoint, never tool outputs/thinking.
381
381
  Approve only corpora suitable for every audience of the agent.
382
382
 
383
+ The packaged `src/mlx-query-worker.py` is a standalone JSON-lines worker for
384
+ separately managed model-serving integrations, not a Gateway subprocess or HTTP
385
+ server. It is retained with `query_contract.py` and the pinned tokenizer assets;
386
+ the latter also define the training-data contract. A host service owns any adapter
387
+ between that private worker protocol and the HTTP API below.
388
+
383
389
  `POST /generate` accepts `{id, conversation: {history, currentRequest}}` and returns
384
390
  `{response: {id, text}}`, where `text` is JSON with exactly two nonempty strings,
385
391
  `lex` and `vec`. The request ID must match. Redirects, malformed/oversized responses,
@@ -464,6 +470,13 @@ successful siblings can still qualify. If all fail, no hint is emitted. It does
464
470
  not switch to vector-only selection. There are no automatic HTTP retries. Other
465
471
  credential-file read errors likewise produce a warning and no hint.
466
472
 
473
+ The complete skill contribution has a fixed three-second deadline, including
474
+ credential resolution, retrieval and provider calls. A slow skill lookup cannot
475
+ hold up ready memory or people hints beyond that budget. Session teardown,
476
+ superseding turns and Gateway shutdown cancel its wait and provider requests;
477
+ shared indexing/embedding work can finish for other callers, but cannot trigger
478
+ late Jev calls or hints for the cancelled run.
479
+
467
480
  **Privacy:** enabled TypeSafe selection sends up to 12,000 characters of current
468
481
  prompt/recent user-assistant text, plus the shortlisted names/descriptions, to
469
482
  `api.typesafe.ai`. Source-path fields, full skill procedures, tool-result messages,
@@ -476,7 +489,8 @@ The defaults use five prior messages, a vector-only score threshold of `0.5`,
476
489
  and a ten-turn cooldown. A skill is cooling down after either a suggestion or a
477
490
  successful direct `read` of its indexed `SKILL.md`. When the selected
478
491
  skill is cooling down, no hint is emitted; Skill Whisperer does not fall through
479
- to a weaker match. Cooldown state is per session and intentionally resets with
492
+ to a weaker match. When every shortlisted skill is cooling down, Jev is skipped.
493
+ Cooldown state is per session and intentionally resets with
480
494
  the Gateway. Shell-command reads are not tracked.
481
495
 
482
496
  The `skills` corpus shares the existing QMD store and warm embedding model but
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "id": "unblock-memory",
3
3
  "name": "Unblock Memory",
4
- "version": "0.7.1",
4
+ "version": "0.7.3",
5
5
  "description": "Indexes, retrieves, and analyzes configured workspace memory with existing QMD vectors.",
6
6
  "kind": "memory",
7
7
  "activation": { "onStartup": true },
@@ -67,7 +67,7 @@
67
67
  "help": "Shared provider gate for opted-in whisperers, people primer/save review, evidence/quality reviews and response audits. Does not enable features or approve corpora by itself. Ordinary search and People Whisperer injection stay local."
68
68
  },
69
69
  "typesafe.apiKey": { "label": "TypeSafe API Key", "sensitive": true },
70
- "typesafe.timeoutMs": { "label": "TypeSafe Request Timeout", "help": "Per-request timeout, not the whole operation. People primer/dossier review use peoplePrimer.timeoutMs instead; Memory Whisperer also has its own total deadline." },
70
+ "typesafe.timeoutMs": { "label": "TypeSafe Request Timeout", "help": "Per-request timeout, not the whole operation. People primer/dossier review use peoplePrimer.timeoutMs instead; Memory Whisperer has its own total deadline, and Skill Whisperer has a fixed 3-second total budget." },
71
71
  "typesafe.apiKeyFile": {
72
72
  "label": "TypeSafe Key File",
73
73
  "help": "Absolute path to a plaintext API key or a dotenv file containing TYPESAFE_API_KEY. Prefer this over storing a key in config."