@unblocklabs/unblock-memory 0.3.26 → 0.3.28

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 (56) hide show
  1. package/dist/src/cluster-review.js +1 -1
  2. package/dist/src/config.d.ts +4 -0
  3. package/dist/src/config.js +14 -1
  4. package/dist/src/diagnostics.d.ts +13 -4
  5. package/dist/src/diagnostics.js +5 -0
  6. package/dist/src/manager.d.ts +3 -1
  7. package/dist/src/manager.js +31 -0
  8. package/dist/src/memory-whisperer.d.ts +2 -1
  9. package/dist/src/memory-whisperer.js +240 -112
  10. package/dist/src/mlx-query.d.ts +22 -0
  11. package/dist/src/mlx-query.js +155 -0
  12. package/dist/src/people-dossier-review.js +1 -1
  13. package/dist/src/people-hooks.d.ts +1 -1
  14. package/dist/src/people-hooks.js +13 -7
  15. package/dist/src/people-primer-tool.js +1 -1
  16. package/dist/src/people-primer.js +3 -2
  17. package/dist/src/plugin.js +10 -5
  18. package/dist/src/quality-audit.js +86 -81
  19. package/dist/src/response-audit.js +1 -1
  20. package/dist/src/response-judge.d.ts +1 -1
  21. package/dist/src/response-judge.js +18 -15
  22. package/dist/src/response-stages.d.ts +8 -8
  23. package/dist/src/response-text.d.ts +5 -0
  24. package/dist/src/response-text.js +23 -1
  25. package/dist/src/retrieval-telemetry.d.ts +2 -2
  26. package/dist/src/retrieval-telemetry.js +1 -1
  27. package/dist/src/review-tools.js +1 -1
  28. package/dist/src/skill-whisperer.d.ts +1 -1
  29. package/dist/src/skill-whisperer.js +76 -63
  30. package/dist/src/training-candidates.d.ts +9 -1
  31. package/dist/src/training-candidates.js +12 -7
  32. package/dist/src/training-gate.js +3 -3
  33. package/dist/src/training-input.js +2 -23
  34. package/dist/src/training-judge.js +3 -3
  35. package/dist/src/training-queries.js +5 -4
  36. package/dist/src/training-retrieval.d.ts +1 -1
  37. package/dist/src/training-retrieval.js +136 -93
  38. package/dist/src/training.js +5 -4
  39. package/dist/src/typesafe-client.d.ts +15 -0
  40. package/dist/src/typesafe-client.js +75 -0
  41. package/dist/src/typesafe-review.d.ts +2 -6
  42. package/dist/src/typesafe-review.js +49 -55
  43. package/dist/src/typesafe.d.ts +3 -7
  44. package/dist/src/typesafe.js +108 -163
  45. package/dist/src/whisperer-prompt.d.ts +3 -0
  46. package/dist/src/whisperer-prompt.js +21 -0
  47. package/docs/configuration.md +12 -2
  48. package/docs/memory-training.md +5 -0
  49. package/docs/peoplesql.md +3 -1
  50. package/docs/response-audit.md +4 -1
  51. package/docs/retrieval.md +128 -20
  52. package/openclaw.plugin.json +13 -4
  53. package/package.json +2 -1
  54. package/src/mlx-query-worker.py +104 -0
  55. package/dist/src/typesafe-transport.d.ts +0 -10
  56. package/dist/src/typesafe-transport.js +0 -26
@@ -0,0 +1,22 @@
1
+ import type { UnblockMemoryConfig } from "./config.js";
2
+ type Conversation = {
3
+ history: {
4
+ role: "user" | "assistant";
5
+ content: string;
6
+ }[];
7
+ currentRequest: string;
8
+ };
9
+ /** Preserve whole visible messages and the complete current request, never tool/thinking text. */
10
+ export declare function queryConversation(prompt: string, messages: readonly unknown[], historyMessages: number): Conversation;
11
+ export declare class MlxQueryGenerator {
12
+ #private;
13
+ private readonly config;
14
+ /** Gateway hook and tool registries can evaluate/register this plugin separately. */
15
+ static shared(config: NonNullable<UnblockMemoryConfig["memoryWhisperer"]["mlx"]>): MlxQueryGenerator;
16
+ constructor(config: NonNullable<UnblockMemoryConfig["memoryWhisperer"]["mlx"]>);
17
+ get closed(): boolean;
18
+ start(): Promise<void>;
19
+ generate(conversation: Conversation, signal: AbortSignal): Promise<string[]>;
20
+ close(): void;
21
+ }
22
+ export {};
@@ -0,0 +1,155 @@
1
+ import { spawn } from "node:child_process";
2
+ import { randomUUID } from "node:crypto";
3
+ import { existsSync } from "node:fs";
4
+ import { fileURLToPath } from "node:url";
5
+ import { createInterface } from "node:readline";
6
+ import { abortable } from "./abortable.js";
7
+ import { messageText } from "./whisperer-context.js";
8
+ import { conversationUserText } from "./response-text.js";
9
+ /** Preserve whole visible messages and the complete current request, never tool/thinking text. */
10
+ export function queryConversation(prompt, messages, historyMessages) {
11
+ const currentRequest = conversationUserText(prompt)?.text ?? "";
12
+ const visible = messages.flatMap(message => {
13
+ const text = messageText(message);
14
+ const content = text?.role === "user" ? conversationUserText(text.text)?.text : text?.text;
15
+ return text && content ? [{ role: text.role, content }] : [];
16
+ });
17
+ if (visible.at(-1)?.role === "user" && visible.at(-1)?.content === currentRequest)
18
+ visible.pop();
19
+ const history = historyMessages ? visible.slice(-historyMessages) : [];
20
+ while (history.length && Buffer.byteLength(JSON.stringify({ history, currentRequest })) > 24_000)
21
+ history.shift();
22
+ if (!currentRequest)
23
+ throw new Error("Missing or unparseable current request");
24
+ if (Buffer.byteLength(JSON.stringify({ history, currentRequest })) > 24_000) {
25
+ throw new Error("Query input exceeds context budget");
26
+ }
27
+ return { history, currentRequest };
28
+ }
29
+ export class MlxQueryGenerator {
30
+ config;
31
+ /** Gateway hook and tool registries can evaluate/register this plugin separately. */
32
+ static shared(config) {
33
+ const scope = globalThis;
34
+ const symbol = Symbol.for("unblock-memory.mlx-workers.v1");
35
+ const workers = (scope[symbol] ??= new Map());
36
+ const key = JSON.stringify([config.pythonPath, config.modelPath]);
37
+ let worker = workers.get(key);
38
+ if (!worker || worker.closed) {
39
+ worker = new MlxQueryGenerator(config);
40
+ workers.set(key, worker);
41
+ }
42
+ return worker;
43
+ }
44
+ #child;
45
+ #ready;
46
+ #closed = false;
47
+ #retryAfter = 0;
48
+ #pending = new Map();
49
+ constructor(config) {
50
+ this.config = config;
51
+ }
52
+ get closed() { return this.#closed; }
53
+ start() {
54
+ if (this.#closed)
55
+ return Promise.reject(new Error("Query worker stopped"));
56
+ if (this.#ready)
57
+ return this.#ready;
58
+ if (Date.now() < this.#retryAfter)
59
+ return Promise.reject(new Error("Query worker restarting"));
60
+ const adjacent = new URL("./mlx-query-worker.py", import.meta.url);
61
+ const script = existsSync(adjacent) ? adjacent : new URL("../../src/mlx-query-worker.py", import.meta.url);
62
+ const child = spawn(this.config.pythonPath, ["-u", fileURLToPath(script), this.config.modelPath], {
63
+ stdio: "pipe", env: { ...process.env, HF_HUB_OFFLINE: "1", TRANSFORMERS_OFFLINE: "1", TOKENIZERS_PARALLELISM: "false" },
64
+ });
65
+ this.#child = child;
66
+ // No source text from Python warnings/exceptions enters Gateway logs.
67
+ child.stderr.resume();
68
+ const lines = createInterface({ input: child.stdout });
69
+ this.#ready = new Promise((resolve, reject) => {
70
+ const timer = setTimeout(() => child.kill(), 60_000);
71
+ const fail = () => {
72
+ clearTimeout(timer);
73
+ lines.close();
74
+ reject(new Error("Query worker unavailable"));
75
+ if (this.#child !== child)
76
+ return;
77
+ this.#child = undefined;
78
+ this.#ready = undefined;
79
+ this.#retryAfter = Date.now() + 2000;
80
+ for (const request of this.#pending.values())
81
+ request.reject(new Error("Query worker unavailable"));
82
+ this.#pending.clear();
83
+ };
84
+ child.once("error", fail);
85
+ child.once("close", fail);
86
+ child.stdin.on("error", () => child.kill());
87
+ lines.on("line", line => {
88
+ try {
89
+ const value = JSON.parse(line);
90
+ if (!value || typeof value !== "object")
91
+ throw new Error("Invalid worker response");
92
+ if ("ready" in value && value.ready === true) {
93
+ clearTimeout(timer);
94
+ resolve();
95
+ return;
96
+ }
97
+ if (!("id" in value) || typeof value.id !== "string")
98
+ throw new Error("Missing worker request ID");
99
+ const request = this.#pending.get(value.id);
100
+ if (!request)
101
+ return; // Cancelled request; never surface late results.
102
+ this.#pending.delete(value.id);
103
+ if (!("text" in value) || typeof value.text !== "string") {
104
+ request.reject(new Error("Query generation failed"));
105
+ return;
106
+ }
107
+ let queries;
108
+ try {
109
+ queries = JSON.parse(value.text).queries;
110
+ }
111
+ catch { /* reject below */ }
112
+ const usable = Array.isArray(queries) ? [...new Set(queries
113
+ .filter((query) => typeof query === "string")
114
+ .map(query => query.trim()).filter(Boolean))] : [];
115
+ if (!usable.length) {
116
+ request.reject(new Error("No usable generated queries"));
117
+ return;
118
+ }
119
+ request.resolve(usable);
120
+ }
121
+ catch {
122
+ child.kill();
123
+ }
124
+ });
125
+ });
126
+ return this.#ready;
127
+ }
128
+ async generate(conversation, signal) {
129
+ await abortable(this.start(), signal);
130
+ signal.throwIfAborted();
131
+ if (this.#pending.size >= 8)
132
+ throw new Error("Query worker busy");
133
+ const id = randomUUID();
134
+ const child = this.#child;
135
+ const result = new Promise((resolve, reject) => this.#pending.set(id, { resolve, reject }));
136
+ const cancel = () => {
137
+ this.#pending.get(id)?.reject(new Error("Query generation cancelled"));
138
+ this.#pending.delete(id);
139
+ if (child.stdin.writable)
140
+ child.stdin.write(JSON.stringify({ id, cancel: true }) + "\n");
141
+ };
142
+ signal.addEventListener("abort", cancel, { once: true });
143
+ child.stdin.write(JSON.stringify({ id, conversation }) + "\n");
144
+ try {
145
+ return await result;
146
+ }
147
+ finally {
148
+ signal.removeEventListener("abort", cancel);
149
+ }
150
+ }
151
+ close() {
152
+ this.#closed = true;
153
+ this.#child?.kill();
154
+ }
155
+ }
@@ -1,6 +1,6 @@
1
1
  import { resolveAgentIdentity } from "openclaw/plugin-sdk/agent-runtime";
2
2
  import { abortable } from "./abortable.js";
3
- import { resolveTypeSafeApiKey } from "./typesafe.js";
3
+ import { resolveTypeSafeApiKey } from "./typesafe-client.js";
4
4
  /** Review the injected blurb using only exact indexed references already on its claims. */
5
5
  export async function reviewPersonDossier(params) {
6
6
  const unavailable = (reason) => ({ status: "unavailable", needsReview: true, reason });
@@ -2,4 +2,4 @@ import type { OpenClawPluginApi } from "openclaw/plugin-sdk/plugin-entry";
2
2
  import type { UnblockMemoryConfig } from "./config.js";
3
3
  import type { PeopleStores } from "./people-store.js";
4
4
  export declare function renderPeopleWhisper(blurb: string, maxChars: number): string | undefined;
5
- export declare function registerPeopleHooks(api: OpenClawPluginApi, stores: PeopleStores, config: UnblockMemoryConfig["people"]): void;
5
+ export declare function registerPeopleHooks(api: OpenClawPluginApi, stores: PeopleStores, config: UnblockMemoryConfig["people"]): Parameters<typeof api.on<"before_prompt_build">>[1] | undefined;
@@ -20,6 +20,11 @@ export function renderPeopleWhisper(blurb, maxChars) {
20
20
  const normalized = blurb.trim();
21
21
  return normalized ? normalized.slice(0, maxChars) : undefined;
22
22
  }
23
+ function peopleContext(contribution) {
24
+ // Escape only at output: durable receipts retain the original dossier text.
25
+ const text = contribution.replace(/&/gu, "&amp;").replace(/</gu, "&lt;").replace(/>/gu, "&gt;");
26
+ return { appendContext: `<people>${text}</people>` };
27
+ }
23
28
  export function registerPeopleHooks(api, stores, config) {
24
29
  const threadByRun = new Map();
25
30
  const pendingThreadByIdentity = new Map();
@@ -83,7 +88,7 @@ export function registerPeopleHooks(api, stores, config) {
83
88
  });
84
89
  if (!config.whisperer.enabled)
85
90
  return;
86
- api.on("before_prompt_build", (_event, context) => {
91
+ const beforePrompt = (_event, context) => {
87
92
  if (context.trigger !== "user" || context.messageProvider !== "slack")
88
93
  return;
89
94
  const sessionKey = nonBlank(context.sessionKey);
@@ -112,30 +117,31 @@ export function registerPeopleHooks(api, stores, config) {
112
117
  return;
113
118
  const previous = store.getWhisperReceipt(threadKey, person.id);
114
119
  if (previous) {
115
- return previous.runId === runId ? { prependContext: previous.contribution } : undefined;
120
+ return previous.runId === runId ? peopleContext(previous.contribution) : undefined;
116
121
  }
117
122
  const blurb = store.getDossierBlurb(person.id);
118
- const prependContext = blurb
123
+ const contribution = blurb
119
124
  ? renderPeopleWhisper(blurb, config.whisperer.maxChars)
120
125
  : undefined;
121
- if (!prependContext)
126
+ if (!contribution)
122
127
  return;
123
128
  const receipt = store.recordWhisperReceipt({
124
129
  threadKey,
125
130
  personId: person.id,
126
131
  runId,
127
- contribution: prependContext,
132
+ contribution,
128
133
  });
129
- return receipt.runId === runId ? { prependContext: receipt.contribution } : undefined;
134
+ return receipt.runId === runId ? peopleContext(receipt.contribution) : undefined;
130
135
  }
131
136
  catch (error) {
132
137
  api.logger.warn(`unblock-memory people whisperer lookup failed: ${String(error)}`);
133
138
  return;
134
139
  }
135
- });
140
+ };
136
141
  api.on("agent_end", (event, context) => {
137
142
  const runId = nonBlank(event.runId) ?? nonBlank(context.runId);
138
143
  if (runId)
139
144
  threadByRun.delete(runId);
140
145
  });
146
+ return beforePrompt;
141
147
  }
@@ -2,7 +2,7 @@ import { Type } from "typebox";
2
2
  import { Value } from "typebox/value";
3
3
  import { jsonResult, resolveAgentIdentity } from "openclaw/plugin-sdk/agent-runtime";
4
4
  import { getContext } from "./tool-context.js";
5
- import { resolveTypeSafeApiKey } from "./typesafe.js";
5
+ import { resolveTypeSafeApiKey } from "./typesafe-client.js";
6
6
  import { primePersonDossier } from "./people-primer.js";
7
7
  import { abortable } from "./abortable.js";
8
8
  import { backgroundWordCount, PEOPLE_BACKGROUND_MAX_WORDS } from "./people-background.js";
@@ -1,7 +1,8 @@
1
1
  import { createHash } from "node:crypto";
2
2
  import { Type } from "typebox";
3
3
  import { Value } from "typebox/value";
4
- import { askTypeSafeReview, TYPESAFE_REVIEW_MODEL } from "./typesafe-review.js";
4
+ import { requestTypeSafe } from "./typesafe-client.js";
5
+ import { TYPESAFE_REVIEW_MODEL } from "./typesafe-review.js";
5
6
  import { abortable } from "./abortable.js";
6
7
  const VERSION = "people-primer-background-v4";
7
8
  const MAX_EXCERPT_CHARS = 6000;
@@ -112,7 +113,7 @@ export async function primePersonDossier(params) {
112
113
  cached++;
113
114
  else {
114
115
  requests++;
115
- payload = await askTypeSafeReview({ apiKey: params.apiKey, timeoutMs: config.timeoutMs, signal }, state, questions);
116
+ payload = await requestTypeSafe({ apiKey: params.apiKey, timeoutMs: config.timeoutMs, signal }, state, questions);
116
117
  signal.throwIfAborted();
117
118
  if (!valid(payload) || !Value.Check(answerSchema, payload))
118
119
  throw new Error("Invalid primer judgments");
@@ -2,7 +2,7 @@ import { Type } from "typebox";
2
2
  import { Value } from "typebox/value";
3
3
  import { jsonResult } from "openclaw/plugin-sdk/agent-runtime";
4
4
  import { resolveConfig } from "./config.js";
5
- import { resolveTypeSafeApiKey } from "./typesafe.js";
5
+ import { resolveTypeSafeApiKey } from "./typesafe-client.js";
6
6
  import { registerPeopleHooks } from "./people-hooks.js";
7
7
  import { PeopleStores } from "./people-store.js";
8
8
  import { registerPeopleTools } from "./people-tools.js";
@@ -10,6 +10,7 @@ import { registerPeoplePrimerTool } from "./people-primer-tool.js";
10
10
  import { QmdMemoryRuntime } from "./runtime.js";
11
11
  import { registerSkillWhisperer } from "./skill-whisperer.js";
12
12
  import { registerMemoryWhisperer } from "./memory-whisperer.js";
13
+ import { registerWhispererPrompt } from "./whisperer-prompt.js";
13
14
  import { getContext } from "./tool-context.js";
14
15
  import { WhispererDiagnostics } from "./diagnostics.js";
15
16
  import { registerReviewTools } from "./review-tools.js";
@@ -438,19 +439,23 @@ export function registerUnblockMemory(api) {
438
439
  }));
439
440
  api.on("gateway_stop", () => runtime.stopSessionSyncSchedule());
440
441
  }
442
+ let peopleWhisperer;
441
443
  if (config.people.enabled) {
442
444
  const peopleStores = new PeopleStores({
443
445
  maxOpenTodos: config.people.todos.maxOpen,
444
446
  maxBlurbChars: config.people.whisperer.maxChars,
445
447
  });
446
- registerPeopleHooks(api, peopleStores, config.people);
448
+ peopleWhisperer = registerPeopleHooks(api, peopleStores, config.people);
447
449
  registerPeopleTools(api, peopleStores, config, runtime);
448
450
  registerPeoplePrimerTool(api, runtime, peopleStores, config);
449
451
  api.on("gateway_stop", () => peopleStores.closeAll());
450
452
  }
451
- const diagnostics = new WhispererDiagnostics();
452
- registerSkillWhisperer(api, runtime, config.skillWhisperer, config.typesafe, diagnostics);
453
- registerMemoryWhisperer(api, runtime, config.memoryWhisperer, config.typesafe, diagnostics);
453
+ const diagnostics = WhispererDiagnostics.shared();
454
+ registerWhispererPrompt(api, {
455
+ memory: registerMemoryWhisperer(api, runtime, config.memoryWhisperer, config.typesafe, diagnostics),
456
+ skill: registerSkillWhisperer(api, runtime, config.skillWhisperer, config.typesafe, diagnostics),
457
+ people: peopleWhisperer,
458
+ });
454
459
  api.registerTool((ctx) => createSearchTool(runtime, ctx), { names: ["memory_search"] });
455
460
  api.registerTool((ctx) => createGetTool(runtime, ctx), { names: ["memory_get"] });
456
461
  api.registerTool((ctx) => createSyncSessionsTool(runtime, ctx), {
@@ -3,7 +3,6 @@ import { parseSafeVirtualPath } from "./sources.js";
3
3
  import { judgeTypeSafeQuality, QUALITY_JUDGE_VERSION } from "./typesafe.js";
4
4
  import { qualityTriage } from "./quality-triage.js";
5
5
  const MAX_CHUNK_CHARS = 6000;
6
- const BATCH_SIZE = 4;
7
6
  /** A formatting clue, never proof that JSON or structured data is worthless. */
8
7
  export function qualityStructure(text) {
9
8
  if (!text.trim())
@@ -61,89 +60,95 @@ export async function auditQualityPage(params) {
61
60
  AND cv.seq = ? AND cv.pos = ? AND cv.chunk_len = ?`);
62
61
  const page = rows.slice(0, limit);
63
62
  try {
64
- for (let offset = 0; offset < page.length; offset += BATCH_SIZE) {
65
- check();
66
- const batch = page.slice(offset, offset + BATCH_SIZE).map(row => {
67
- const source = sources.get(row.collection);
68
- const text = row.doc.slice(row.pos, row.pos + row.chunk_len);
69
- const fingerprint = chunkFingerprint(text);
70
- const cacheKey = chunkFingerprint(JSON.stringify([QUALITY_JUDGE_VERSION, source.kind, fingerprint]));
71
- const eligible = Boolean(parseSafeVirtualPath(`qmd://${source.collection}/${row.path}`, sources)) && row.pos >= 0 && row.chunk_len > 0 &&
72
- row.pos + row.chunk_len <= row.doc.length;
73
- const structure = qualityStructure(text);
74
- const judgment = eligible && text.length <= MAX_CHUNK_CHARS
75
- ? curation.qualityJudgment(cacheKey) : undefined;
76
- return { row, source, text, fingerprint, cacheKey, structure, judgment, eligible };
77
- });
78
- const missing = [...new Map(batch.filter(item => item.eligible && item.text.length <= MAX_CHUNK_CHARS &&
79
- item.structure !== "empty" && !item.judgment).map(item => [item.cacheKey, item])).values()];
80
- const answers = await judgeTypeSafeQuality({
81
- apiKey: params.apiKey, timeoutMs: params.timeoutMs, signal,
82
- chunks: missing.map(item => ({ text: item.text, sourceKind: item.source.kind === "sessions" ? "sessions" : "files" })),
83
- });
63
+ check();
64
+ const batch = page.map(row => {
65
+ const source = sources.get(row.collection);
66
+ const text = row.doc.slice(row.pos, row.pos + row.chunk_len);
67
+ const fingerprint = chunkFingerprint(text);
68
+ const cacheKey = chunkFingerprint(JSON.stringify([QUALITY_JUDGE_VERSION, source.kind, fingerprint]));
69
+ const eligible = Boolean(parseSafeVirtualPath(`qmd://${source.collection}/${row.path}`, sources)) && row.pos >= 0 && row.chunk_len > 0 &&
70
+ row.pos + row.chunk_len <= row.doc.length;
71
+ const structure = qualityStructure(text);
72
+ const judgment = eligible && text.length <= MAX_CHUNK_CHARS
73
+ ? curation.qualityJudgment(cacheKey) : undefined;
74
+ return { row, source, text, fingerprint, cacheKey, structure, judgment, eligible };
75
+ });
76
+ const missing = [...new Map(batch.filter(item => item.eligible && item.text.length <= MAX_CHUNK_CHARS &&
77
+ item.structure !== "empty" && !item.judgment).map(item => [item.cacheKey, item])).values()];
78
+ const answers = await Promise.allSettled(missing.map(async (item) => (await judgeTypeSafeQuality({
79
+ apiKey: params.apiKey, timeoutMs: params.timeoutMs, signal,
80
+ chunks: [{ text: item.text, sourceKind: item.source.kind === "sessions" ? "sessions" : "files" }],
81
+ }))[0]));
82
+ check();
83
+ const fresh = new Map();
84
+ for (const [index, answer] of answers.entries()) {
85
+ if (answer.status !== "fulfilled")
86
+ continue;
87
+ const { cacheKey, row } = missing[index];
88
+ if (!current.get(row.document_id, row.collection, row.path, row.hash, row.seq, row.pos, row.chunk_len))
89
+ continue;
90
+ fresh.set(cacheKey, answer.value);
91
+ // Keep successful sibling judgments even if the cursor stops at a failed candidate.
92
+ curation.cacheQualityJudgment(cacheKey, answer.value);
93
+ judged++;
94
+ }
95
+ for (const item of batch) {
84
96
  check();
85
- const fresh = new Map(missing.map((item, index) => [item.cacheKey, answers[index]]));
86
- judged += answers.length;
87
- for (const item of batch) {
88
- check();
89
- const { row, source, text, fingerprint, cacheKey, structure } = item;
90
- const advance = () => { next = { documentId: row.document_id, seq: row.seq }; };
91
- scanned++;
92
- if (!item.eligible || !parseSafeVirtualPath(`qmd://${source.collection}/${row.path}`, sources) ||
93
- !current.get(row.document_id, row.collection, row.path, row.hash, row.seq, row.pos, row.chunk_len)) {
94
- skippedStale++;
95
- advance();
96
- continue;
97
- }
98
- if (text.length > MAX_CHUNK_CHARS) {
99
- skippedOversized++;
100
- advance();
101
- continue;
102
- }
103
- const judgment = structure === "empty"
104
- ? { noise: 1, evidence: 0 } : item.judgment ?? fresh.get(cacheKey);
105
- if (!judgment)
106
- throw new Error("missing quality judgment");
107
- if (item.judgment)
108
- cached++;
109
- else if (structure !== "empty")
110
- curation.cacheQualityJudgment(cacheKey, judgment);
111
- if (structure !== "empty" && structure !== "encoded_message" && judgment.noise < params.minNoise) {
112
- advance();
113
- continue;
114
- }
115
- const reason = structure === "empty" ? "empty_content" :
116
- structure === "encoded_message" ? "possible_double_encoded_message" :
117
- structure === "serialized_message" ? "possible_serialized_message" : "possible_ingestion_noise";
118
- const startLine = row.doc.slice(0, row.pos).split("\n").length;
119
- const endLine = startLine + text.split("\n").length - 1;
120
- const task = curation.addTask({
121
- type: "quality_review", corpus: source.corpus, collection: source.collection,
122
- path: row.path, reason, contentFingerprint: fingerprint,
123
- detail: JSON.stringify({
124
- path: `qmd://${source.collection}/${row.path}`, from: startLine, to: endLine,
125
- excerpt: text.slice(0, 400), excerptTruncated: text.length > 400,
126
- indicator: structure === "empty" ? "deterministic_empty" :
127
- structure === "encoded_message" ? "deterministic_encoding" : "typesafe",
128
- ...judgment, policy: QUALITY_JUDGE_VERSION,
129
- triage: qualityTriage(judgment.noise, judgment.evidence, structure === "encoded_message"),
130
- instruction: "Inspect original source and ingestion before acting. Verify source/index after any authorized repair. Never manually edit generated session projections.",
131
- }),
132
- });
133
- flagged++;
97
+ const { row, source, text, fingerprint, cacheKey, structure } = item;
98
+ const advance = () => { next = { documentId: row.document_id, seq: row.seq }; };
99
+ scanned++;
100
+ if (!item.eligible || !parseSafeVirtualPath(`qmd://${source.collection}/${row.path}`, sources) ||
101
+ !current.get(row.document_id, row.collection, row.path, row.hash, row.seq, row.pos, row.chunk_len)) {
102
+ skippedStale++;
134
103
  advance();
135
- if (task.status !== "pending")
136
- continue;
137
- const triage = qualityTriage(judgment.noise, judgment.evidence, structure === "encoded_message");
138
- const key = JSON.stringify([source.collection, reason, triage]);
139
- const group = groups.get(key) ?? {
140
- corpus: source.corpus, source: source.configuredPath, reason, triage, pending: 0, examples: [],
141
- };
142
- group.pending++;
143
- if (group.examples.length < 3)
144
- group.examples.push(task);
145
- groups.set(key, group);
104
+ continue;
146
105
  }
106
+ if (text.length > MAX_CHUNK_CHARS) {
107
+ skippedOversized++;
108
+ advance();
109
+ continue;
110
+ }
111
+ const judgment = structure === "empty"
112
+ ? { noise: 1, evidence: 0 } : item.judgment ?? fresh.get(cacheKey);
113
+ if (!judgment)
114
+ throw new Error("missing quality judgment");
115
+ if (item.judgment)
116
+ cached++;
117
+ if (structure !== "empty" && structure !== "encoded_message" && judgment.noise < params.minNoise) {
118
+ advance();
119
+ continue;
120
+ }
121
+ const reason = structure === "empty" ? "empty_content" :
122
+ structure === "encoded_message" ? "possible_double_encoded_message" :
123
+ structure === "serialized_message" ? "possible_serialized_message" : "possible_ingestion_noise";
124
+ const startLine = row.doc.slice(0, row.pos).split("\n").length;
125
+ const endLine = startLine + text.split("\n").length - 1;
126
+ const task = curation.addTask({
127
+ type: "quality_review", corpus: source.corpus, collection: source.collection,
128
+ path: row.path, reason, contentFingerprint: fingerprint,
129
+ detail: JSON.stringify({
130
+ path: `qmd://${source.collection}/${row.path}`, from: startLine, to: endLine,
131
+ excerpt: text.slice(0, 400), excerptTruncated: text.length > 400,
132
+ indicator: structure === "empty" ? "deterministic_empty" :
133
+ structure === "encoded_message" ? "deterministic_encoding" : "typesafe",
134
+ ...judgment, policy: QUALITY_JUDGE_VERSION,
135
+ triage: qualityTriage(judgment.noise, judgment.evidence, structure === "encoded_message"),
136
+ instruction: "Inspect original source and ingestion before acting. Verify source/index after any authorized repair. Never manually edit generated session projections.",
137
+ }),
138
+ });
139
+ flagged++;
140
+ advance();
141
+ if (task.status !== "pending")
142
+ continue;
143
+ const triage = qualityTriage(judgment.noise, judgment.evidence, structure === "encoded_message");
144
+ const key = JSON.stringify([source.collection, reason, triage]);
145
+ const group = groups.get(key) ?? {
146
+ corpus: source.corpus, source: source.configuredPath, reason, triage, pending: 0, examples: [],
147
+ };
148
+ group.pending++;
149
+ if (group.examples.length < 3)
150
+ group.examples.push(task);
151
+ groups.set(key, group);
147
152
  }
148
153
  return result("ok", rows.length <= limit);
149
154
  }
@@ -1,6 +1,6 @@
1
1
  import { createHash } from "node:crypto";
2
2
  import { abortable } from "./abortable.js";
3
- import { resolveTypeSafeApiKey } from "./typesafe.js";
3
+ import { resolveTypeSafeApiKey } from "./typesafe-client.js";
4
4
  import { RESPONSE_EXTRACTOR_VERSION, ResponseTranscriptReader } from "./response-episodes.js";
5
5
  import { RESPONSE_RUBRIC_VERSION, RESPONSE_STAGE_VERSIONS, judgeMemoryOpportunity } from "./response-judge.js";
6
6
  import { ResponseAuditStore } from "./response-store.js";
@@ -6,7 +6,7 @@ export declare const RESPONSE_STAGE_VERSIONS: {
6
6
  readonly feedback: "feedback-v9";
7
7
  readonly sentiment: "sentiment-v10";
8
8
  readonly retrospective: "retrospective-v9";
9
- readonly memory: "memory-v1";
9
+ readonly memory: "memory-v2-isolated";
10
10
  };
11
11
  declare const qualitySchema: Type.TObject<{
12
12
  answers: Type.TObject<{
@@ -1,8 +1,9 @@
1
1
  import { Type } from "typebox";
2
2
  import { Value } from "typebox/value";
3
- import { askTypeSafeReview, TYPESAFE_REVIEW_MODEL } from "./typesafe-review.js";
3
+ import { requestTypeSafe } from "./typesafe-client.js";
4
+ import { TYPESAFE_REVIEW_MODEL } from "./typesafe-review.js";
4
5
  export const RESPONSE_RUBRIC_VERSION = `${TYPESAFE_REVIEW_MODEL}:response-v10`;
5
- export const RESPONSE_STAGE_VERSIONS = { quality: "quality-v9", feedback: "feedback-v9", sentiment: "sentiment-v10", retrospective: "retrospective-v9", memory: "memory-v1" };
6
+ export const RESPONSE_STAGE_VERSIONS = { quality: "quality-v9", feedback: "feedback-v9", sentiment: "sentiment-v10", retrospective: "retrospective-v9", memory: "memory-v2-isolated" };
6
7
  const probability = Type.Number({ minimum: 0, maximum: 1 });
7
8
  const noul = Type.Object({ type: Type.Literal("noul"), noul: probability });
8
9
  const score = Type.Object({ type: Type.Literal("score"), score: Type.Number({ minimum: 0, maximum: 3 }),
@@ -73,7 +74,7 @@ export async function judgeResponse(episode, params, sentimentEnabled = true, ca
73
74
  contextLimited: episode.contextLimited, evidenceLimit: "Visible conversation only. Artifacts, tool results and external facts are not provided." };
74
75
  if (!cache?.quality)
75
76
  cache?.begin(["quality"]);
76
- const qualityPayload = cache?.quality ? { answers: cache.quality } : await askTypeSafeReview(params, state, {
77
+ const qualityPayload = cache?.quality ? { answers: cache.quality } : await requestTypeSafe(params, state, {
77
78
  underdelivery: { type: "noul", instructions: { question: "Does the visible `answer` CLEARLY underdeliver on what the human explicitly asked for in `request`, given `before`?",
78
79
  required: "Identify a concrete unmet requirement, wrong product, explicit constraint violation, materially shallow answer or unjustified deferral. Judge delivery, not politeness or writing style.",
79
80
  exclusions: "Missing verification of unseen work is uncertainty, not failure. Necessary clarification, legitimate safety/approval boundaries, honest blockers and newly added requirements are not underdelivery. Do not infer a failure from memory-search counts or unseen tool activity.", trust } },
@@ -142,7 +143,7 @@ export async function judgeResponse(episode, params, sentimentEnabled = true, ca
142
143
  };
143
144
  const questions = Object.fromEntries(Object.entries(feedbackQuestions).filter(([key]) => Object.hasOwn(sentimentSchema.properties, key) ? needSentiment : needFeedback));
144
145
  cache?.begin([...(needFeedback ? ["feedback"] : []), ...(needSentiment ? ["sentiment"] : [])]);
145
- const feedbackPayload = await askTypeSafeReview(params, { ...state, feedback: episode.feedback }, questions);
146
+ const feedbackPayload = await requestTypeSafe(params, { ...state, feedback: episode.feedback }, questions);
146
147
  const base = needFeedback ? feedbackPayload : { answers: cache.feedback };
147
148
  if (!Value.Check(feedbackSchema, base))
148
149
  throw new Error("Invalid response feedback judgment");
@@ -188,7 +189,7 @@ const retrospectiveSchema = Type.Object({ answers: Type.Object({ correction: nou
188
189
  /** Later evidence is kept in a third request and never changes the original grade. */
189
190
  export async function judgeResponseFollowup(episode, params) {
190
191
  const later = ["complete", "partial"].includes(episode.followup.status) ? episode.followup.messages : [];
191
- const payload = await askTypeSafeReview(params, { before: episode.before, originalRequest: episode.request, originalAnswer: episode.answer,
192
+ const payload = await requestTypeSafe(params, { before: episode.before, originalRequest: episode.request, originalAnswer: episode.answer,
192
193
  humanReply: episode.feedback, nextAssistantResponse: later, evidenceStatus: episode.followup.status }, {
193
194
  outcome: { type: "choice", instructions: { question: "What does the available conversation establish about delivery of the ORIGINAL request by originalAnswer?",
194
195
  method: "Match each complaint or admission to a concrete requirement or claim in originalRequest/originalAnswer, using before only to resolve existing requirements. Grade the original answer, not the later repair. A concrete shortfall takes precedence over praise.",
@@ -233,16 +234,18 @@ export async function judgeResponseFollowup(episode, params) {
233
234
  return { status: episode.followup.status, judgment: payload.answers };
234
235
  }
235
236
  export async function judgeMemoryOpportunity(episode, candidates, params) {
236
- const questions = Object.fromEntries(candidates.map((_c, i) => [`candidate_${i}`, { type: "noul",
237
- instructions: { question: `Would the information in \`candidates[${i}]\` materially address the specific context gap expressed in \`feedback\` about \`answer\`?`,
238
- limits: "Judge substantive relevance, not shared vocabulary. These are CURRENT indexed excerpts; their presence does not prove historical availability, truth, or agent fault.", trust } }]));
237
+ const questions = { candidate_0: { type: "noul",
238
+ instructions: { question: "Would the information in `candidates[0]` materially address the specific context gap expressed in `feedback` about `answer`?",
239
+ limits: "Judge substantive relevance, not shared vocabulary. This is a CURRENT indexed excerpt; its presence does not prove historical availability, truth, or agent fault.", trust } } };
239
240
  if (!candidates.length)
240
241
  return [];
241
- const payload = await askTypeSafeReview(params, { request: episode.request, answer: episode.answer,
242
- feedback: episode.feedback, candidates: candidates.map(c => ({ text: c.text })) }, questions);
243
- const schema = Type.Object({ answers: Type.Object(Object.fromEntries(candidates.map((_c, i) => [`candidate_${i}`, noul]))) });
244
- if (!Value.Check(schema, payload))
245
- throw new Error("Invalid memory opportunity judgment");
246
- return candidates.map((c, i) => ({ path: c.path, hash: c.hash, usefulness: payload.answers[`candidate_${i}`].noul,
247
- basis: "current_index_only; historical availability and retrieval exposure unknown" }));
242
+ const schema = Type.Object({ answers: Type.Object({ candidate_0: noul }, { additionalProperties: false }) });
243
+ return Promise.all(candidates.map(async (candidate) => {
244
+ const payload = await requestTypeSafe(params, { request: episode.request, answer: episode.answer,
245
+ feedback: episode.feedback, candidates: [{ text: candidate.text }] }, questions);
246
+ if (!Value.Check(schema, payload))
247
+ throw new Error("Invalid memory opportunity judgment");
248
+ return { path: candidate.path, hash: candidate.hash, usefulness: payload.answers.candidate_0.noul,
249
+ basis: "current_index_only; historical availability and retrieval exposure unknown" };
250
+ }));
248
251
  }