@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.
- package/dist/src/cluster-review.js +1 -1
- package/dist/src/config.d.ts +4 -0
- package/dist/src/config.js +14 -1
- package/dist/src/diagnostics.d.ts +13 -4
- package/dist/src/diagnostics.js +5 -0
- package/dist/src/manager.d.ts +3 -1
- package/dist/src/manager.js +31 -0
- package/dist/src/memory-whisperer.d.ts +2 -1
- package/dist/src/memory-whisperer.js +240 -112
- package/dist/src/mlx-query.d.ts +22 -0
- package/dist/src/mlx-query.js +155 -0
- package/dist/src/people-dossier-review.js +1 -1
- package/dist/src/people-hooks.d.ts +1 -1
- package/dist/src/people-hooks.js +13 -7
- package/dist/src/people-primer-tool.js +1 -1
- package/dist/src/people-primer.js +3 -2
- package/dist/src/plugin.js +10 -5
- package/dist/src/quality-audit.js +86 -81
- package/dist/src/response-audit.js +1 -1
- package/dist/src/response-judge.d.ts +1 -1
- package/dist/src/response-judge.js +18 -15
- package/dist/src/response-stages.d.ts +8 -8
- package/dist/src/response-text.d.ts +5 -0
- package/dist/src/response-text.js +23 -1
- package/dist/src/retrieval-telemetry.d.ts +2 -2
- package/dist/src/retrieval-telemetry.js +1 -1
- package/dist/src/review-tools.js +1 -1
- package/dist/src/skill-whisperer.d.ts +1 -1
- package/dist/src/skill-whisperer.js +76 -63
- package/dist/src/training-candidates.d.ts +9 -1
- package/dist/src/training-candidates.js +12 -7
- package/dist/src/training-gate.js +3 -3
- package/dist/src/training-input.js +2 -23
- package/dist/src/training-judge.js +3 -3
- package/dist/src/training-queries.js +5 -4
- package/dist/src/training-retrieval.d.ts +1 -1
- package/dist/src/training-retrieval.js +136 -93
- package/dist/src/training.js +5 -4
- package/dist/src/typesafe-client.d.ts +15 -0
- package/dist/src/typesafe-client.js +75 -0
- package/dist/src/typesafe-review.d.ts +2 -6
- package/dist/src/typesafe-review.js +49 -55
- package/dist/src/typesafe.d.ts +3 -7
- package/dist/src/typesafe.js +108 -163
- package/dist/src/whisperer-prompt.d.ts +3 -0
- package/dist/src/whisperer-prompt.js +21 -0
- package/docs/configuration.md +12 -2
- package/docs/memory-training.md +5 -0
- package/docs/peoplesql.md +3 -1
- package/docs/response-audit.md +4 -1
- package/docs/retrieval.md +128 -20
- package/openclaw.plugin.json +13 -4
- package/package.json +2 -1
- package/src/mlx-query-worker.py +104 -0
- package/dist/src/typesafe-transport.d.ts +0 -10
- 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"]):
|
|
5
|
+
export declare function registerPeopleHooks(api: OpenClawPluginApi, stores: PeopleStores, config: UnblockMemoryConfig["people"]): Parameters<typeof api.on<"before_prompt_build">>[1] | undefined;
|
package/dist/src/people-hooks.js
CHANGED
|
@@ -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, "&").replace(/</gu, "<").replace(/>/gu, ">");
|
|
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
|
-
|
|
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 ?
|
|
120
|
+
return previous.runId === runId ? peopleContext(previous.contribution) : undefined;
|
|
116
121
|
}
|
|
117
122
|
const blurb = store.getDossierBlurb(person.id);
|
|
118
|
-
const
|
|
123
|
+
const contribution = blurb
|
|
119
124
|
? renderPeopleWhisper(blurb, config.whisperer.maxChars)
|
|
120
125
|
: undefined;
|
|
121
|
-
if (!
|
|
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
|
|
132
|
+
contribution,
|
|
128
133
|
});
|
|
129
|
-
return receipt.runId === runId ?
|
|
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 {
|
|
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
|
|
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");
|
package/dist/src/plugin.js
CHANGED
|
@@ -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 =
|
|
452
|
-
|
|
453
|
-
|
|
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
|
-
|
|
65
|
-
|
|
66
|
-
const
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
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
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
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
|
-
|
|
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-
|
|
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 {
|
|
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-
|
|
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
|
|
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
|
|
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
|
|
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 =
|
|
237
|
-
instructions: { question:
|
|
238
|
-
limits: "Judge substantive relevance, not shared vocabulary.
|
|
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
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
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
|
}
|